diff --git a/.gitignore b/.gitignore index 25a10b9deb..cca1f312af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Rust target/ **/*.rs.bk +/tasks/track_memory_allocations/allocs_linter.yaml # Wasm **/*.wasm diff --git a/Cargo.lock b/Cargo.lock index facb3ab9d3..defd5ecde1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2763,11 +2763,13 @@ dependencies = [ "mimalloc-safe", "oxc_allocator", "oxc_formatter", + "oxc_linter", "oxc_minifier", "oxc_parser", "oxc_semantic", "oxc_tasks_common", "oxc_transformer", + "rustc-hash", "saphyr", ] diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index 0250a9288e..b5e3068253 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -17,6 +17,7 @@ description.workspace = true default = [] ruledocs = ["oxc_macros/ruledocs"] # Enables the `ruledocs` feature for conditional compilation force_test_reporter = [] +track_allocations = [] [lints] workspace = true @@ -81,4 +82,3 @@ url = { workspace = true } [dev-dependencies] insta = { workspace = true } markdown = { workspace = true } - diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs index 4596bce29b..5cad0abb8d 100644 --- a/crates/oxc_linter/src/context/host.rs +++ b/crates/oxc_linter/src/context/host.rs @@ -516,6 +516,34 @@ impl<'a> ContextHost<'a> { } } + /// Run one native rule's callbacks for allocation profiling, without linter setup or teardown. + /// + /// Uses the same eligibility and AST type filters as the regular rule runner. + #[cfg(feature = "track_allocations")] + pub fn run_rule_for_allocations(self: Rc, rule: &RuleEnum, severity: AllowWarnDeny) { + let run_info = rule.run_info(); + let ast_types = rule.types_info(); + if rule.is_tsgolint_rule() + || (run_info == crate::RuleRunFunctionsImplemented::Run + && ast_types.is_some_and(|types| !self.semantic().nodes().contains_any(types))) + || !rule.should_run(&self) + { + return; + } + + let ctx = self.spawn(rule, severity); + if run_info.is_run_once_implemented() { + rule.run_once::(&ctx, None); + } + if run_info.is_run_implemented() { + for node in ctx.nodes() { + if ast_types.is_none_or(|types| types.has(node.kind().ty())) { + rule.run::(node, &ctx, None); + } + } + } + } + /// Creates a new [`LintContext`] for testing purposes only. #[cfg(test)] pub(crate) fn spawn_for_test(self: Rc) -> LintContext<'a> { diff --git a/tasks/track_memory_allocations/Cargo.toml b/tasks/track_memory_allocations/Cargo.toml index 9c5a9b79b0..3c409ad05e 100644 --- a/tasks/track_memory_allocations/Cargo.toml +++ b/tasks/track_memory_allocations/Cargo.toml @@ -19,6 +19,7 @@ doctest = false [dependencies] oxc_allocator = { workspace = true, features = ["track_allocations"] } oxc_formatter = { workspace = true } +oxc_linter = { workspace = true, optional = true, features = ["track_allocations"] } oxc_minifier = { workspace = true } oxc_parser = { workspace = true } oxc_semantic = { workspace = true } @@ -27,8 +28,10 @@ oxc_transformer = { workspace = true } humansize = { workspace = true } mimalloc-safe = { workspace = true } +rustc-hash = { workspace = true, optional = true } saphyr = { workspace = true } [features] +linter = ["dep:oxc_linter", "dep:rustc-hash"] # Sentinel flag that should only get enabled if we're running under `--all-features` is_all_features = [] diff --git a/tasks/track_memory_allocations/README.md b/tasks/track_memory_allocations/README.md index 44a17ac571..f0c559cb05 100644 --- a/tasks/track_memory_allocations/README.md +++ b/tasks/track_memory_allocations/README.md @@ -4,6 +4,35 @@ This task keeps track of the number of system allocations as well as arena alloc Update the snapshots with `cargo allocs`. +## Optional per-rule linter tracking + +Run `cargo run -p oxc_track_memory_allocations --profile coverage --features linter` +to write only `tasks/track_memory_allocations/allocs_linter.yaml`, grouped by file +and plugin/rule name. This is opt-in: normal `cargo allocs` runs and CI do not run +it, including CI builds using `--all-features`. + +Linter-enabled builds do not measure or rewrite the normal stage snapshots: +the linter enables additional semantic-analysis features that change their +allocation counts. Use `cargo allocs` without the feature for those snapshots. + +The generated report is gitignored. Within each file, rules are sorted by +`sys alloc bytes` descending, then `sys allocs` descending, then rule name. +Values reflect the current run without snapshot tolerances. File size is omitted +from the per-rule metrics. + +Only native rule callbacks (`should_run`, `run_once`, and `run`) are measured, +with the regular eligibility and AST type filters. Parsing, semantic analysis, +configuration, context construction, and context teardown are excluded. Type-aware +rules implemented by tsgolint are omitted. Skipped native rules have zero activity +unless their `should_run` callback allocates. + +Rules run individually in rule-ID order, rather than interleaving their node +callbacks as the production runner does. They share a fresh per-file context. +Diagnostic storage growth is charged to the rule that triggers it, and lazy shared +work (such as React Compiler analysis) is charged to its first consumer. Diagnostics +remain alive until all rules finish. For these measurements, `arena size` is the +arena growth during that rule rather than the cumulative arena size. + ## Metrics Recorded per file and per stage (parser, semantic, transformer, minifier, formatter): diff --git a/tasks/track_memory_allocations/src/lib.rs b/tasks/track_memory_allocations/src/lib.rs index 27833ee146..902f34cac8 100644 --- a/tasks/track_memory_allocations/src/lib.rs +++ b/tasks/track_memory_allocations/src/lib.rs @@ -15,6 +15,9 @@ use oxc_transformer::{TransformOptions, Transformer}; use std::alloc::{GlobalAlloc, Layout}; use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +#[cfg(feature = "linter")] +mod linter; + #[global_allocator] static GLOBAL: TrackedAllocator = TrackedAllocator; @@ -300,6 +303,11 @@ fn test() { pub fn run() -> Result<(), io::Error> { let files = TestFiles::complicated(); + #[cfg(feature = "linter")] + if !cfg!(feature = "is_all_features") { + return linter::run(&files); + } + let mut parser_entries = Vec::new(); let mut semantic_entries = Vec::new(); let mut transformer_entries = Vec::new(); diff --git a/tasks/track_memory_allocations/src/linter.rs b/tasks/track_memory_allocations/src/linter.rs new file mode 100644 index 0000000000..a9da0c8fea --- /dev/null +++ b/tasks/track_memory_allocations/src/linter.rs @@ -0,0 +1,73 @@ +use std::{fmt::Write as _, fs, io, path::Path, rc::Rc, sync::Arc}; + +use oxc_allocator::Allocator; +use oxc_linter::{ + ConfigStore, ConfigStoreBuilder, ContextSubHost, ContextSubHostOptions, ExternalPluginStore, + LintOptions, ModuleRecord, rules::ContextHost, +}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_tasks_common::{TestFiles, project_root}; +use rustc_hash::FxHashMap; + +use super::{record_stats_in, render_file_stats}; + +pub fn run(files: &TestFiles) -> io::Result<()> { + let mut external_plugin_store = ExternalPluginStore::default(); + let config = ConfigStoreBuilder::all().build(&mut external_plugin_store).unwrap(); + let config_store = ConfigStore::new(config, FxHashMap::default(), external_plugin_store); + let snapshot_path = project_root().join("tasks/track_memory_allocations/allocs_linter.yaml"); + let mut out = String::new(); + + for file in files.files() { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &file.source_text, file.source_type).parse(); + assert!(parsed.diagnostics.is_empty()); + let path = Path::new(&file.file_name); + let semantic = SemanticBuilder::new_linter().build(&parsed.program).semantic; + let module_record = Arc::new(ModuleRecord::new(path, &parsed.module_record, &semantic)); + let state = config_store.resolve(path); + let host = Rc::new(ContextHost::new( + path, + vec![ContextSubHost::new(semantic, module_record, 0, ContextSubHostOptions::default())], + &allocator, + LintOptions::default(), + state.config, + )); + + let mut entries = Vec::new(); + for (rule, severity) in state.rules.iter().filter(|(rule, _)| !rule.is_tsgolint_rule()) { + let rule_name = format!("{}/{}", rule.plugin_name(), rule.name()); + let arena_before = allocator.used_bytes(); + let ((), mut stats) = record_stats_in(&allocator, || { + Rc::clone(&host).run_rule_for_allocations(rule, *severity); + }); + stats.arena_used_bytes = allocator.used_bytes().saturating_sub(arena_before); + entries.push((rule_name, stats)); + } + + entries.sort_unstable_by(|(left_name, left), (right_name, right)| { + right + .counters + .heap + .alloc_bytes + .cmp(&left.counters.heap.alloc_bytes) + .then_with(|| right.counters.heap.allocs.cmp(&left.counters.heap.allocs)) + .then_with(|| left_name.cmp(right_name)) + }); + + writeln!(out, "{}:", file.file_name).unwrap(); + for (rule_name, stats) in entries { + let mut metrics = stats.metrics(file.source_text.len()); + metrics.retain(|metric| metric.label != "file size"); + let mut rule_out = String::new(); + render_file_stats(&mut rule_out, &rule_name, &metrics, None); + for line in rule_out.lines() { + writeln!(out, " {line}").unwrap(); + } + } + out.push('\n'); + } + + fs::write(snapshot_path, out) +}