From 9b9298d61bf22d9c19e4790044925f62f240d7b7 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 9 Aug 2026 16:39:45 -0700 Subject: [PATCH 01/62] add check suppression accounting --- crates/no-mistakes/src/check.rs | 11 +- crates/no-mistakes/src/check_parallel.rs | 3 + crates/no-mistakes/src/check_runner.rs | 3 +- .../no-mistakes/src/check_runner/results.rs | 94 +++++++++++- .../no-mistakes/src/check_runner/run_all.rs | 12 ++ crates/no-mistakes/src/codebase/rules/mod.rs | 4 + crates/no-mistakes/src/codebase/rules/run.rs | 1 + .../src/codebase/rules/run/prepared.rs | 3 + .../codebase/rules/run/prepared/execution.rs | 5 +- .../src/codebase/rules/run/standalone.rs | 1 + .../src/codebase/rules/suppression.rs | 141 ++++++++++++++++++ .../analyze_project/context/check_run.rs | 2 + crates/no-mistakes/src/napi_api/cli_parity.rs | 3 +- crates/no-mistakes/src/napi_api/options.rs | 1 + .../no-mistakes/src/napi_api/tests/check.rs | 26 ++++ .../src/react_traits/analyze/file.rs | 1 + .../src/react_traits/pipeline/check.rs | 1 + .../src/react_traits/pipeline/run/tests.rs | 1 + .../pipeline/run_with_facts/tests.rs | 1 + .../src/react_traits/report/text/tests.rs | 3 + .../src/react_traits/report/types.rs | 8 + docs/cli/check.md | 5 +- docs/rules/README.md | 7 +- .../suppression-accounting/.no-mistakes.yml | 3 + .../check/suppression-accounting/src/a.ts | 1 + .../check/suppression-accounting/src/b.ts | 4 + .../suppression-accounting/tsconfig.json | 3 + packages/no-mistakes/report-types.d.ts | 14 ++ packages/no-mistakes/traversal-types.d.ts | 2 + 29 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 fixtures/check/suppression-accounting/.no-mistakes.yml create mode 100644 fixtures/check/suppression-accounting/src/a.ts create mode 100644 fixtures/check/suppression-accounting/src/b.ts create mode 100644 fixtures/check/suppression-accounting/tsconfig.json diff --git a/crates/no-mistakes/src/check.rs b/crates/no-mistakes/src/check.rs index 4d2535c17..a951c16c4 100644 --- a/crates/no-mistakes/src/check.rs +++ b/crates/no-mistakes/src/check.rs @@ -29,6 +29,10 @@ pub(crate) struct CheckArgs { /// Shorthand for --format json. #[arg(long, global = true, conflicts_with = "format")] json: bool, + /// Include deterministic accounting for findings hidden by no-mistakes + /// suppression directives. Disabled by default to preserve existing output. + #[arg(long, global = true)] + include_suppressed: bool, /// Legacy programmatic timing switch. CLI timing flags are root-global. #[arg(skip)] timings: bool, @@ -48,7 +52,12 @@ pub(crate) fn run(args: CheckArgs) -> Result { ); let cwd = std::env::current_dir().context("cwd must be accessible")?; let root = resolve_root(&args.root, &cwd); - let results = check_runner::run_all(root, args.config, args.tsconfig)?; + let results = check_runner::run_all_with_suppressed( + root, + args.config, + args.tsconfig, + args.include_suppressed, + )?; record_missing_check_timings(&results); no_mistakes::invocation::commit_timeout()?; for warning in &results.warnings { diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 9e843557b..108956ad6 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -52,6 +52,7 @@ pub(crate) struct DomainCheckInputs<'a> { Option<&'a no_mistakes::codebase::ci_workflows::ParsedWorkflowSet>, pub(crate) tsconfig_gate_project_inputs: Option<&'a no_mistakes::codebase::rules::tsconfig_gate_coverage::ProjectSourceInputs>, + pub(crate) defer_suppression: bool, } pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults { @@ -82,6 +83,7 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults let vitest_projects = inputs.vitest_projects; let workflow_documents = inputs.workflow_documents; let tsconfig_gate_project_inputs = inputs.tsconfig_gate_project_inputs; + let defer_suppression = inputs.defer_suppression; let ((react, queues), (rules, (integration, (codebase, filesystem_rules)))) = rayon::join( || { @@ -122,6 +124,7 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults prepared_tsconfig_catalog, inferred_roots: Some(inferred_roots), sources: Some(&rule_sources), + defer_suppression, }, dependency_graph.as_deref(), ) diff --git a/crates/no-mistakes/src/check_runner.rs b/crates/no-mistakes/src/check_runner.rs index d528edb2d..822feac6e 100644 --- a/crates/no-mistakes/src/check_runner.rs +++ b/crates/no-mistakes/src/check_runner.rs @@ -7,7 +7,8 @@ mod results; mod run_all; pub(crate) use results::{complete_domain_checks, empty_results, json_value, CheckResults}; -pub(crate) use run_all::run_all; +#[allow(unused_imports)] +pub(crate) use run_all::{run_all, run_all_with_suppressed}; #[cfg(test)] mod tests; diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index e626d2f21..69e6d8be0 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -18,6 +18,7 @@ pub(crate) struct FinalizeInput<'a> { pub(crate) discover_duration: Duration, pub(crate) facts_duration: Duration, pub(crate) completed: CompletedDomainChecks, + pub(crate) include_suppressed: bool, } pub(crate) struct CheckResults { @@ -28,6 +29,7 @@ pub(crate) struct CheckResults { pub(crate) codebase: Vec, pub(crate) warnings: Vec, pub(crate) advisories: Vec, + pub(crate) suppressed: Vec, pub(crate) timings: Vec<(&'static str, Duration)>, } @@ -63,12 +65,13 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result) -> Result; 1]) -> CheckResults { codebase: Vec::new(), warnings, advisories: Vec::new(), + suppressed: Vec::new(), timings: vec![ ("discover", Duration::ZERO), ("parse_extract", Duration::ZERO), @@ -146,6 +227,7 @@ pub(crate) fn json_value(results: &CheckResults) -> serde_json::Value { codebase, warnings, advisories, + suppressed, timings, } = results; let _ = timings; @@ -158,6 +240,10 @@ pub(crate) fn json_value(results: &CheckResults) -> serde_json::Value { "warnings": warnings, "advisories": advisories, }); + if !suppressed.is_empty() { + value["suppressed"] = serde_json::to_value(suppressed) + .expect("suppression accounting serialization never fails"); + } // Dependency feature unification can switch serde_json maps from sorted // storage to insertion-ordered storage. Keep the public report stable in // either configuration, including keys in nested finding objects. diff --git a/crates/no-mistakes/src/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs index f5ddf91df..e78e40381 100644 --- a/crates/no-mistakes/src/check_runner/run_all.rs +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -8,10 +8,20 @@ use anyhow::{Context, Result}; use enabled::{fact_plan, integration_configured}; use std::path::PathBuf; +#[allow(dead_code)] pub(crate) fn run_all( root: PathBuf, config_path: Option, tsconfig_path: Option, +) -> Result { + run_all_with_suppressed(root, config_path, tsconfig_path, false) +} + +pub(crate) fn run_all_with_suppressed( + root: PathBuf, + config_path: Option, + tsconfig_path: Option, + include_suppressed: bool, ) -> Result { let root = root.canonicalize().unwrap_or(root); let session = no_mistakes::codebase::analysis_session::AnalysisSession::new( @@ -165,6 +175,7 @@ pub(crate) fn run_all( vitest_projects: prepared.vitest_projects.as_ref(), workflow_documents: prepared.workflow_documents.as_deref(), tsconfig_gate_project_inputs: prepared.tsconfig_gate_project_inputs.as_ref(), + defer_suppression: true, }); no_mistakes::invocation::check_timeout()?; results::finalize_domain_checks(results::FinalizeInput { @@ -184,5 +195,6 @@ pub(crate) fn run_all( codebase, filesystem_rules, ))?, + include_suppressed, }) } diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 1811dff15..a3358f1a8 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -81,6 +81,10 @@ pub use run::{ pub use vitest_project_catalog::{prepare_vitest_project_catalog, PreparedVitestProjectCatalog}; pub(crate) use file_matching::matching_files; +#[doc(hidden)] +pub use suppression::{ + suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, +}; pub(crate) use suppression::{ suppress_rule_findings, suppress_rule_findings_with_source, suppress_rule_findings_with_sources, suppress_rule_findings_with_sources_except, diff --git a/crates/no-mistakes/src/codebase/rules/run.rs b/crates/no-mistakes/src/codebase/rules/run.rs index 0fcd5b28a..43dbaa402 100644 --- a/crates/no-mistakes/src/codebase/rules/run.rs +++ b/crates/no-mistakes/src/codebase/rules/run.rs @@ -77,6 +77,7 @@ pub fn run_check_with_facts_and_playwright( prepared_tsconfig_catalog: &prepared_tsconfig_catalog, inferred_roots: None, sources: Some(&sources), + defer_suppression: false, }) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared.rs b/crates/no-mistakes/src/codebase/rules/run/prepared.rs index f5f09c597..ae58c7da0 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -31,6 +31,9 @@ pub struct PreparedRulesCheck<'a> { pub prepared_tsconfig_catalog: &'a crate::codebase::ts_resolver::TsConfigCatalog, pub inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, pub sources: Option<&'a crate::codebase::ts_source::SourceStore>, + /// Aggregate `check` defers suppression until every domain can share one + /// SourceStore-aware adapter and produce optional accounting. + pub defer_suppression: bool, } /// Shared-config entry point used by the aggregate `check` command. diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index be66ce132..3e2fbdb2e 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -22,6 +22,7 @@ pub(super) fn run( prepared_tsconfig_catalog, inferred_roots, sources, + defer_suppression, } = inputs; if !any_codebase_rule_enabled(config) { return Ok(Vec::new()); @@ -167,7 +168,9 @@ pub(super) fn run( inferred_roots, ); findings.extend(graph_findings?); - suppress_findings(root, &mut findings, sources); + if !defer_suppression { + suppress_findings(root, &mut findings, sources); + } sort_findings(&mut findings); Ok(findings) } diff --git a/crates/no-mistakes/src/codebase/rules/run/standalone.rs b/crates/no-mistakes/src/codebase/rules/run/standalone.rs index 067cbace1..1869c2bec 100644 --- a/crates/no-mistakes/src/codebase/rules/run/standalone.rs +++ b/crates/no-mistakes/src/codebase/rules/run/standalone.rs @@ -101,6 +101,7 @@ pub(super) fn run_check( prepared_tsconfig_catalog: &prepared_tsconfig_catalog, inferred_roots: Some(&inferred_roots), sources: Some(&sources), + defer_suppression: false, }) } diff --git a/crates/no-mistakes/src/codebase/rules/suppression.rs b/crates/no-mistakes/src/codebase/rules/suppression.rs index ccd3c9b53..7fde038e1 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -1,4 +1,5 @@ use super::RuleFinding; +use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -27,6 +28,94 @@ pub(crate) fn suppress_rule_findings_with_source(findings: &mut Vec findings.retain(|finding| !finding_is_suppressed(source, finding)); } +/// A domain finding projected into the common suppression contract. +/// +/// The check runner creates these only after all domain analyzers have consumed +/// the request-scoped `SourceStore`; this keeps suppression from introducing a +/// second source-read path. +pub struct SuppressionTarget<'a> { + pub domain: &'static str, + pub rule: &'a str, + pub file: &'a str, + pub line: Option, + pub reason: &'a str, +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SuppressedFinding { + pub domain: String, + pub rule: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + /// The deterministic diagnostic that would have been emitted. + pub reason: String, + pub directive: SuppressionDirective, +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SuppressionDirective { + pub kind: SuppressionDirectiveKind, + pub line: usize, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SuppressionDirectiveKind { + File, + Line, + NextLine, +} + +/// Retain unsuppressed findings and return deterministic accounting for the +/// findings removed by a valid directive. A missing source is deliberately not +/// treated as a suppression: it must not hide a diagnostic. +pub fn suppress_domain_findings_with_sources( + root: &Path, + findings: &mut Vec, + sources: &crate::codebase::ts_source::SourceStore, + describe: impl Fn(&T) -> SuppressionTarget<'_>, +) -> Vec { + let lexical_root = crate::codebase::ts_source::normalize_discovery_path(root); + let mut cached_sources: HashMap>> = HashMap::new(); + let mut suppressed = Vec::new(); + findings.retain(|finding| { + let target = describe(finding); + let source = cached_sources + .entry(target.file.to_string()) + .or_insert_with(|| { + let (candidate, is_absolute) = + finding_source_candidate(&lexical_root, target.file, true)?; + let path = if is_absolute { + sources.trusted_regular_path(&candidate) + } else { + sources.validated_regular_path(&lexical_root, &candidate) + }?; + super::read_source(sources, &path) + }); + let Some(directive) = source + .as_deref() + .and_then(|source| matching_directive(source, target.rule, target.line)) + else { + return true; + }; + suppressed.push(SuppressedFinding { + domain: target.domain.to_string(), + rule: target.rule.to_string(), + file: target.file.to_string(), + line: target.line, + reason: target.reason.to_string(), + directive, + }); + false + }); + suppressed.sort(); + suppressed.dedup(); + suppressed +} + fn suppress_rule_findings_inner( root: &Path, findings: &mut Vec, @@ -110,3 +199,55 @@ fn finding_is_suppressed(source: &str, finding: &RuleFinding) -> bool { || crate::codebase::ts_source::has_disable_line_comment(source, line, &finding.rule) }) } + +fn matching_directive( + source: &str, + rule: &str, + line: Option, +) -> Option { + if crate::codebase::ts_source::has_disable_file_comment(source, rule) { + return Some(SuppressionDirective { + kind: SuppressionDirectiveKind::File, + line: file_directive_line(source, rule), + }); + } + let line = u32::try_from(line?).ok()?; + if crate::codebase::ts_source::has_disable_line_comment(source, line, rule) { + return Some(SuppressionDirective { + kind: SuppressionDirectiveKind::Line, + line: line as usize, + }); + } + crate::codebase::ts_source::has_disable_comment(source, line, rule).then_some( + SuppressionDirective { + kind: SuppressionDirectiveKind::NextLine, + line: line.saturating_sub(1) as usize, + }, + ) +} + +fn file_directive_line(source: &str, rule: &str) -> usize { + source + .trim_start_matches('\u{FEFF}') + .lines() + .enumerate() + .find_map(|(index, line)| { + let trimmed = line.trim(); + let directive = trimmed + .strip_prefix("//") + .or_else(|| trimmed.strip_prefix('#')) + .or_else(|| trimmed.strip_prefix("--"))? + .trim(); + let rest = directive.strip_prefix("no-mistakes-disable-file ")?; + directive_rule_part_matches(rest.trim(), rule).then_some(index + 1) + }) + // `has_disable_file_comment` already established that a valid directive + // exists. This fallback is only for unusual leading block-comment forms. + .unwrap_or(1) +} + +fn directive_rule_part_matches(rule_part: &str, rule: &str) -> bool { + rule_part.strip_prefix(rule).is_some_and(|suffix| { + suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with(char::is_whitespace) + }) +} diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs index 2e296a2a5..a8156c78a 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs @@ -104,6 +104,7 @@ impl SharedCheckContext { .prepared .tsconfig_gate_project_inputs .as_ref(), + defer_suppression: false, }); let completed = crate::check_runner::complete_domain_checks(( react, @@ -149,6 +150,7 @@ impl SharedCheckContext { codebase: completed.codebase.findings, warnings, advisories, + suppressed: Vec::new(), }) } } diff --git a/crates/no-mistakes/src/napi_api/cli_parity.rs b/crates/no-mistakes/src/napi_api/cli_parity.rs index 5a790c665..a471ba5e2 100644 --- a/crates/no-mistakes/src/napi_api/cli_parity.rs +++ b/crates/no-mistakes/src/napi_api/cli_parity.rs @@ -39,10 +39,11 @@ pub(crate) fn fetches_json_impl(options_json: String) -> napi::Result { pub(crate) fn check_json_impl(options_json: String) -> napi::Result { let options = parse_options::(&options_json)?; let root = resolve_project_root(options.root.as_deref()).map_err(to_napi_error)?; - let results = crate::check_runner::run_all( + let results = crate::check_runner::run_all_with_suppressed( root, options.config.map(PathBuf::from), options.tsconfig.map(PathBuf::from), + options.include_suppressed, ) .map_err(to_napi_error)?; to_pretty_json(&crate::check_runner::json_value(&results)) diff --git a/crates/no-mistakes/src/napi_api/options.rs b/crates/no-mistakes/src/napi_api/options.rs index 449964eb2..29311fae7 100644 --- a/crates/no-mistakes/src/napi_api/options.rs +++ b/crates/no-mistakes/src/napi_api/options.rs @@ -19,6 +19,7 @@ pub(crate) struct ProjectOptions { pub(crate) roots: Vec, pub(crate) depth: Option, pub(crate) assert_no_fetch: bool, + pub(crate) include_suppressed: bool, pub(crate) direction: Option, /// `react usages` target component (`path` or `path#Symbol`). pub(crate) target: Option, diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index bafdba3c4..13de31a17 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -45,6 +45,32 @@ fn check_json_returns_global_check_report() { assert!(value["warnings"].as_array().unwrap().is_empty()); } +#[test] +fn check_json_optionally_accounts_for_suppressed_ordinary_rule_findings() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-accounting"); + let baseline = check_json_impl(json!({ "root": root }).to_string()).unwrap(); + let baseline: serde_json::Value = serde_json::from_str(&baseline).unwrap(); + assert!(baseline.get("suppressed").is_none()); + assert!(baseline["codebase"].as_array().is_some_and(Vec::is_empty)); + + let audit = check_json_impl( + json!({ + "root": root, + "includeSuppressed": true, + }) + .to_string(), + ) + .unwrap(); + let audit: serde_json::Value = serde_json::from_str(&audit).unwrap(); + assert_eq!(audit["codebase"], json!([])); + assert_eq!(audit["suppressed"][0]["domain"], "codebase"); + assert_eq!(audit["suppressed"][0]["rule"], "unique-exports"); + assert_eq!(audit["suppressed"][0]["line"], 2); + assert_eq!(audit["suppressed"][0]["directive"]["kind"], "nextLine"); + assert_eq!(audit["suppressed"][0]["directive"]["line"], 1); +} + #[test] fn check_json_returns_warnings_for_skipped_configured_check() { let options = json!({ diff --git a/crates/no-mistakes/src/react_traits/analyze/file.rs b/crates/no-mistakes/src/react_traits/analyze/file.rs index 11cfd3f1e..2818fa4d6 100644 --- a/crates/no-mistakes/src/react_traits/analyze/file.rs +++ b/crates/no-mistakes/src/react_traits/analyze/file.rs @@ -117,6 +117,7 @@ fn analyze_program_inner( file: f.file.clone(), exported_name: f.cached_function.clone(), shape: Some(format!("{} {}", f.method, f.path)), + line: f.line, }) .collect(); diff --git a/crates/no-mistakes/src/react_traits/pipeline/check.rs b/crates/no-mistakes/src/react_traits/pipeline/check.rs index 533c32ccf..59d8af1e9 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check.rs @@ -118,6 +118,7 @@ fn assert_no_fetch_violations( file: facts.file.clone(), rule: "assert-no-fetch".to_string(), detail: facts.fetches.first().and_then(|f| f.shape.clone()), + line: facts.fetches.first().map(|f| f.line), }); } } diff --git a/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs index 2a3640e33..284ac9119 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs @@ -149,6 +149,7 @@ fn aggregate_children_skips_repeated_refs_and_unreadable_children() { file: child.file.clone(), exported_name: None, shape: None, + line: 1, }); let mut cache = HashMap::from([(root.join("app/components/Child.tsx"), vec![child])]); let agg = aggregate_children(&parent, &mut cache, &root, &mut HashSet::new()); diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs index 476c7fc00..319061ae7 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs @@ -75,6 +75,7 @@ fn run_analyze_inner_with_facts_uses_root_matches_and_cached_children() { file: child.file.clone(), exported_name: None, shape: None, + line: 1, }); let shared = facts(vec![(parent_path, vec![parent]), (child_path, vec![child])]); let file_config = FileConfig { diff --git a/crates/no-mistakes/src/react_traits/report/text/tests.rs b/crates/no-mistakes/src/react_traits/report/text/tests.rs index 2379b7f1b..56b34fcaf 100644 --- a/crates/no-mistakes/src/react_traits/report/text/tests.rs +++ b/crates/no-mistakes/src/react_traits/report/text/tests.rs @@ -66,6 +66,7 @@ fn print_results_with_fetches() { file: "app/components/Fetcher.tsx".to_string(), exported_name: None, shape: Some("GET /api/users".to_string()), + line: 1, }]; print_results(&[facts], 0); } @@ -77,6 +78,7 @@ fn print_violations_outputs_violations() { file: "app/components/Fetcher.tsx".to_string(), rule: "assert-no-fetch".to_string(), detail: Some("GET /api/users".to_string()), + line: Some(1), }]; print_violations(&violations); } @@ -88,6 +90,7 @@ fn print_violations_no_detail() { file: "app/components/Fetcher.tsx".to_string(), rule: "assert-no-fetch".to_string(), detail: None, + line: None, }]; print_violations(&violations); } diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index 23656cd85..224e18d40 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -37,6 +37,10 @@ pub struct FetchCall { pub file: String, pub exported_name: Option, pub shape: Option, + /// Source location retained for check suppression. It is intentionally not + /// part of the public analyze report, preserving that report's schema. + #[serde(skip)] + pub line: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -112,6 +116,10 @@ pub struct Violation { pub file: String, pub rule: String, pub detail: Option, + /// Internal location for the aggregate check suppression adapter. Direct + /// React check output remains byte-for-byte compatible. + #[serde(skip)] + pub line: Option, } #[derive(Default, Deserialize)] diff --git a/docs/cli/check.md b/docs/cli/check.md index a49703e2f..754c15929 100644 --- a/docs/cli/check.md +++ b/docs/cli/check.md @@ -11,6 +11,8 @@ rules. `check` runs React, queue, integration, filesystem, Playwright, unique export, and codebase rules that are enabled in config. Key options: `--root`, `--config`, `--tsconfig`, `--format`, and `--json`. +`--include-suppressed` is opt-in and adds deterministic directive accounting to +JSON/YAML output without changing the default report schema. The root-global `--timings` and `--verbose-timings` flags work here and on every other CLI leaf. Verbose mode implies timings, includes rule/graph/Playwright sub-phases and work counts, and marks overlapping check-domain spans as @@ -22,4 +24,5 @@ and [configuration](../configuration/README.md). If a configured check cannot run, `check` prints a warning to stderr, includes it in structured output as `warnings`, and exits nonzero. -Node API: `check(options)`. +Node API: `check({ includeSuppressed: true })` exposes the same optional +`suppressed` accounting. diff --git a/docs/rules/README.md b/docs/rules/README.md index 8b08443b4..dc5fd0e9f 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -82,4 +82,9 @@ export { handler as GET }; ``` Top-of-file opt-outs use `no-mistakes-disable-file`. Line suppressions require -rules to report line numbers. +rules to report line numbers. `no-mistakes check --include-suppressed` is an +opt-in audit view: it adds a deterministic `suppressed` array containing the +domain, rule ID, finding location/reason, and matching directive kind/line. +Unknown rule IDs, malformed directives, and unused directives are ignored; +they never hide a finding. File directives apply even when a finding has no +line, while line and next-line directives require an exact finding location. diff --git a/fixtures/check/suppression-accounting/.no-mistakes.yml b/fixtures/check/suppression-accounting/.no-mistakes.yml new file mode 100644 index 000000000..177df01d8 --- /dev/null +++ b/fixtures/check/suppression-accounting/.no-mistakes.yml @@ -0,0 +1,3 @@ +rules: + - rule: unique-exports + scope: repository diff --git a/fixtures/check/suppression-accounting/src/a.ts b/fixtures/check/suppression-accounting/src/a.ts new file mode 100644 index 000000000..cf206194f --- /dev/null +++ b/fixtures/check/suppression-accounting/src/a.ts @@ -0,0 +1 @@ +export const shared = 1; diff --git a/fixtures/check/suppression-accounting/src/b.ts b/fixtures/check/suppression-accounting/src/b.ts new file mode 100644 index 000000000..7fb3f22e6 --- /dev/null +++ b/fixtures/check/suppression-accounting/src/b.ts @@ -0,0 +1,4 @@ +// no-mistakes-disable-next-line unique-exports: public compatibility alias +export function shared() { + return 2; +} diff --git a/fixtures/check/suppression-accounting/tsconfig.json b/fixtures/check/suppression-accounting/tsconfig.json new file mode 100644 index 000000000..1ab84030a --- /dev/null +++ b/fixtures/check/suppression-accounting/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "target": "ES2022", "module": "ESNext" } +} diff --git a/packages/no-mistakes/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index b3f2389cf..0c67c8eae 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -30,6 +30,20 @@ export interface CheckReport { codebase: unknown[]; warnings: string[]; advisories: unknown[]; + /** Present only when `includeSuppressed` is requested and directives matched. */ + suppressed?: SuppressedFinding[]; +} + +export interface SuppressedFinding { + domain: "react" | "queues" | "rules" | "integration" | "codebase"; + rule: string; + file: string; + line?: number; + reason: string; + directive: { + kind: "file" | "line" | "nextLine"; + line: number; + }; } export interface QueueReport { diff --git a/packages/no-mistakes/traversal-types.d.ts b/packages/no-mistakes/traversal-types.d.ts index 2b6005b94..f23a11a0d 100644 --- a/packages/no-mistakes/traversal-types.d.ts +++ b/packages/no-mistakes/traversal-types.d.ts @@ -201,6 +201,8 @@ export interface ProjectOptions { roots?: string[]; depth?: number; assertNoFetch?: boolean; + /** Add deterministic accounting for findings hidden by no-mistakes directives. */ + includeSuppressed?: boolean; direction?: "deps" | "dependents" | "both"; /** `reactUsages` target component (`path` or `path#Symbol`). */ target?: string; From 7cf8c5307c42448eab577e2512d11cc3286e2f33 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 9 Aug 2026 16:49:00 -0700 Subject: [PATCH 02/62] cover check suppression adapters --- .../no-mistakes/src/check_runner/results.rs | 6 +- .../no-mistakes/src/check_runner/run_all.rs | 4 +- .../src/codebase/rules/suppression.rs | 57 ++++++------------- .../codebase/ts_source/disable_comments.rs | 42 ++++++++++++-- .../src/codebase/ts_source/tests.rs | 34 ++++++++++- .../analyze_project/context/check_run.rs | 1 + .../no-mistakes/src/napi_api/tests/check.rs | 46 +++++++++++++++ .../suppression-integration/.no-mistakes.yml | 7 +++ .../helpers/openai.mts | 1 + .../tests/uses-openai.test.mts | 7 +++ .../suppression-integration/vitest.config.mts | 7 +++ .../check/suppression-queues/.no-mistakes.yml | 7 +++ .../check/suppression-queues/src/queues.ts | 8 +++ .../check/suppression-react/.no-mistakes.yml | 3 + .../check/suppression-react/app/Fetcher.tsx | 5 ++ 15 files changed, 184 insertions(+), 51 deletions(-) create mode 100644 fixtures/check/suppression-integration/.no-mistakes.yml create mode 100644 fixtures/check/suppression-integration/helpers/openai.mts create mode 100644 fixtures/check/suppression-integration/tests/uses-openai.test.mts create mode 100644 fixtures/check/suppression-integration/vitest.config.mts create mode 100644 fixtures/check/suppression-queues/.no-mistakes.yml create mode 100644 fixtures/check/suppression-queues/src/queues.ts create mode 100644 fixtures/check/suppression-react/.no-mistakes.yml create mode 100644 fixtures/check/suppression-react/app/Fetcher.tsx diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index 69e6d8be0..1e70be913 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -30,6 +30,7 @@ pub(crate) struct CheckResults { pub(crate) warnings: Vec, pub(crate) advisories: Vec, pub(crate) suppressed: Vec, + pub(crate) include_suppressed: bool, pub(crate) timings: Vec<(&'static str, Duration)>, } @@ -191,6 +192,7 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result; 1]) -> CheckResults { warnings, advisories: Vec::new(), suppressed: Vec::new(), + include_suppressed: false, timings: vec![ ("discover", Duration::ZERO), ("parse_extract", Duration::ZERO), @@ -228,6 +231,7 @@ pub(crate) fn json_value(results: &CheckResults) -> serde_json::Value { warnings, advisories, suppressed, + include_suppressed, timings, } = results; let _ = timings; @@ -240,7 +244,7 @@ pub(crate) fn json_value(results: &CheckResults) -> serde_json::Value { "warnings": warnings, "advisories": advisories, }); - if !suppressed.is_empty() { + if *include_suppressed { value["suppressed"] = serde_json::to_value(suppressed) .expect("suppression accounting serialization never fails"); } diff --git a/crates/no-mistakes/src/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs index e78e40381..2bb28ae79 100644 --- a/crates/no-mistakes/src/check_runner/run_all.rs +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -111,7 +111,9 @@ pub(crate) fn run_all_with_suppressed( filesystem_rules_enabled, no_mistakes::playwright::rules::configured(config), ) { - return Ok(empty_results([None])); + let mut results = empty_results([None]); + results.include_suppressed = include_suppressed; + return Ok(results); } let (views, discover_duration) = no_mistakes::diagnostics::measure_if_enabled( "discovery", diff --git a/crates/no-mistakes/src/codebase/rules/suppression.rs b/crates/no-mistakes/src/codebase/rules/suppression.rs index 7fde038e1..6d86ef558 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -205,49 +205,24 @@ fn matching_directive( rule: &str, line: Option, ) -> Option { - if crate::codebase::ts_source::has_disable_file_comment(source, rule) { - return Some(SuppressionDirective { + use crate::codebase::ts_source::DisableDirective; + + match crate::codebase::ts_source::matching_disable_directive( + source, + line.and_then(|line| u32::try_from(line).ok()), + rule, + )? { + DisableDirective::File { line } => Some(SuppressionDirective { kind: SuppressionDirectiveKind::File, - line: file_directive_line(source, rule), - }); - } - let line = u32::try_from(line?).ok()?; - if crate::codebase::ts_source::has_disable_line_comment(source, line, rule) { - return Some(SuppressionDirective { + line: line as usize, + }), + DisableDirective::Line { line } => Some(SuppressionDirective { kind: SuppressionDirectiveKind::Line, line: line as usize, - }); - } - crate::codebase::ts_source::has_disable_comment(source, line, rule).then_some( - SuppressionDirective { + }), + DisableDirective::NextLine { line } => Some(SuppressionDirective { kind: SuppressionDirectiveKind::NextLine, - line: line.saturating_sub(1) as usize, - }, - ) -} - -fn file_directive_line(source: &str, rule: &str) -> usize { - source - .trim_start_matches('\u{FEFF}') - .lines() - .enumerate() - .find_map(|(index, line)| { - let trimmed = line.trim(); - let directive = trimmed - .strip_prefix("//") - .or_else(|| trimmed.strip_prefix('#')) - .or_else(|| trimmed.strip_prefix("--"))? - .trim(); - let rest = directive.strip_prefix("no-mistakes-disable-file ")?; - directive_rule_part_matches(rest.trim(), rule).then_some(index + 1) - }) - // `has_disable_file_comment` already established that a valid directive - // exists. This fallback is only for unusual leading block-comment forms. - .unwrap_or(1) -} - -fn directive_rule_part_matches(rule_part: &str, rule: &str) -> bool { - rule_part.strip_prefix(rule).is_some_and(|suffix| { - suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with(char::is_whitespace) - }) + line: line as usize, + }), + } } diff --git a/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs b/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs index a0df93f1b..9335b1946 100644 --- a/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs +++ b/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs @@ -135,10 +135,42 @@ pub fn has_disable_line_comment(source: &str, stmt_line: u32, rule_id: &str) -> /// - `// no-mistakes-disable-file : ` /// - `// no-mistakes-disable-file ` pub fn has_disable_file_comment(source: &str, rule_id: &str) -> bool { + disable_file_directive_line(source, rule_id).is_some() +} + +/// Returns exact provenance for a supported suppression directive. +/// +/// This is the single directive parser used by both filtering and the +/// aggregate check audit report, so accounting cannot fabricate a line number. +pub fn matching_disable_directive( + source: &str, + finding_line: Option, + rule_id: &str, +) -> Option { + if let Some(line) = disable_file_directive_line(source, rule_id) { + return Some(DisableDirective::File { line }); + } + let line = finding_line?; + if has_disable_line_comment(source, line, rule_id) { + return Some(DisableDirective::Line { line }); + } + has_disable_comment(source, line, rule_id).then_some(DisableDirective::NextLine { + line: line.saturating_sub(1), + }) +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DisableDirective { + File { line: u32 }, + Line { line: u32 }, + NextLine { line: u32 }, +} + +fn disable_file_directive_line(source: &str, rule_id: &str) -> Option { let mut in_block_comment = false; let mut saw_hash_attribute = false; - for line in source.trim_start_matches('\u{FEFF}').lines() { + for (index, line) in source.trim_start_matches('\u{FEFF}').lines().enumerate() { let mut rest = line.trim(); loop { @@ -167,23 +199,23 @@ pub fn has_disable_file_comment(source: &str, rule_id: &str) -> bool { let comment_prefix_is_slash = rest.starts_with("//"); saw_hash_attribute |= hash_attribute_comment_line(rest); let Some(rest) = leading_comment_text(rest) else { - return false; + return None; }; let Some(after_directive) = rest.strip_prefix("no-mistakes-disable-file ") else { break; }; if saw_hash_attribute && comment_prefix_is_slash { - return false; + return None; } let rule_part = after_directive.trim(); if rule_part_matches(rule_part, rule_id) { - return true; + return Some((index + 1) as u32); } break; } } - false + None } fn hash_attribute_comment_line(line: &str) -> bool { diff --git a/crates/no-mistakes/src/codebase/ts_source/tests.rs b/crates/no-mistakes/src/codebase/ts_source/tests.rs index ed982e686..7ad5138fa 100644 --- a/crates/no-mistakes/src/codebase/ts_source/tests.rs +++ b/crates/no-mistakes/src/codebase/ts_source/tests.rs @@ -2,9 +2,9 @@ use super::{ deadline_checked_paths, discover_files, discover_files_from_visible, discover_source_files, discover_source_files_from_visible, discover_visible_paths, format_parse_diagnostic, git_visible_files, has_disable_comment, has_disable_file_comment, has_disable_line_comment, - is_skipped_dir, is_test_file, line_number, normalize_discovery_path, parse_git_tagged_paths, - relative_slash_path, starts_with_use_client, static_property_key_name, unwrap_ts_wrappers, - walk_files, FrozenPathRemapper, + is_skipped_dir, is_test_file, line_number, matching_disable_directive, + normalize_discovery_path, parse_git_tagged_paths, relative_slash_path, starts_with_use_client, + static_property_key_name, unwrap_ts_wrappers, walk_files, FrozenPathRemapper, }; use oxc_allocator::Allocator; use oxc_ast::ast::{Expression, ObjectPropertyKind, Statement}; @@ -18,6 +18,34 @@ mod discovery_preserve; mod gitignore; mod source_and_discovery; +#[test] +fn matching_disable_directive_reports_exact_supported_provenance() { + assert_eq!( + matching_disable_directive( + "/* notice */\n// no-mistakes-disable-file my-rule\nconst value = 1", + Some(3), + "my-rule", + ), + Some(super::DisableDirective::File { line: 2 }) + ); + assert_eq!( + matching_disable_directive( + "value(); // no-mistakes-disable-line my-rule", + Some(1), + "my-rule" + ), + Some(super::DisableDirective::Line { line: 1 }) + ); + assert_eq!( + matching_disable_directive( + "// no-mistakes-disable-next-line my-rule\nvalue()", + Some(2), + "my-rule" + ), + Some(super::DisableDirective::NextLine { line: 1 }) + ); +} + #[test] fn frozen_path_remapper_keeps_symlink_namespace_deterministic() { let root = diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs index a8156c78a..9125bc498 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs @@ -151,6 +151,7 @@ impl SharedCheckContext { warnings, advisories, suppressed: Vec::new(), + include_suppressed: false, }) } } diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 13de31a17..396de5250 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -71,6 +71,52 @@ fn check_json_optionally_accounts_for_suppressed_ordinary_rule_findings() { assert_eq!(audit["suppressed"][0]["directive"]["line"], 1); } +#[test] +fn check_json_audit_mode_includes_an_empty_suppression_array() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check-runner/empty"); + let baseline = check_json_impl(json!({ "root": root }).to_string()).unwrap(); + let baseline: serde_json::Value = serde_json::from_str(&baseline).unwrap(); + assert!(baseline.get("suppressed").is_none()); + + let audit = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); + let audit: serde_json::Value = serde_json::from_str(&audit).unwrap(); + assert_eq!(audit["suppressed"], json!([])); +} + +#[test] +fn check_json_accounts_for_react_queue_and_integration_adapters() { + let fixtures = [ + ("suppression-react", "react", "assert-no-fetch", "nextLine"), + ("suppression-queues", "queues", "queues-check", "file"), + ( + "suppression-integration", + "integration", + "integration-test-no-mocks", + "file", + ), + ]; + for (fixture, domain, rule, directive_kind) in fixtures { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check") + .join(fixture); + let output = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!( + value["suppressed"] + .as_array() + .is_some_and(|findings| findings.iter().any(|finding| { + finding["domain"] == domain + && finding["rule"] == rule + && finding["directive"]["kind"] == directive_kind + })), + "{fixture}: {value}" + ); + } +} + #[test] fn check_json_returns_warnings_for_skipped_configured_check() { let options = json!({ diff --git a/fixtures/check/suppression-integration/.no-mistakes.yml b/fixtures/check/suppression-integration/.no-mistakes.yml new file mode 100644 index 000000000..95ba65bb2 --- /dev/null +++ b/fixtures/check/suppression-integration/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + projects: + unit: + integration_suites: + unit: [aws] diff --git a/fixtures/check/suppression-integration/helpers/openai.mts b/fixtures/check/suppression-integration/helpers/openai.mts new file mode 100644 index 000000000..c86fead04 --- /dev/null +++ b/fixtures/check/suppression-integration/helpers/openai.mts @@ -0,0 +1 @@ +export const callOpenAI = /* no-mistakes: integration=openai */ async () => 'ok' diff --git a/fixtures/check/suppression-integration/tests/uses-openai.test.mts b/fixtures/check/suppression-integration/tests/uses-openai.test.mts new file mode 100644 index 000000000..6ffdf1b15 --- /dev/null +++ b/fixtures/check/suppression-integration/tests/uses-openai.test.mts @@ -0,0 +1,7 @@ +// no-mistakes-disable-file integration-test-no-mocks: policy fixture +import { test } from 'vitest' +import { callOpenAI } from '../helpers/openai.mts' + +test('uses a disallowed integration', async () => { + await callOpenAI() +}) diff --git a/fixtures/check/suppression-integration/vitest.config.mts b/fixtures/check/suppression-integration/vitest.config.mts new file mode 100644 index 000000000..9decf082b --- /dev/null +++ b/fixtures/check/suppression-integration/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [{ test: { name: 'unit', include: ['tests/**/*.test.mts'] } }], + }, +}) diff --git a/fixtures/check/suppression-queues/.no-mistakes.yml b/fixtures/check/suppression-queues/.no-mistakes.yml new file mode 100644 index 000000000..acb68c1a0 --- /dev/null +++ b/fixtures/check/suppression-queues/.no-mistakes.yml @@ -0,0 +1,7 @@ +projects: + queues: + type: library + root: . + queues: + enqueues: [src/queues.ts] + workers: [src/queues.ts] diff --git a/fixtures/check/suppression-queues/src/queues.ts b/fixtures/check/suppression-queues/src/queues.ts new file mode 100644 index 000000000..f980a5ff2 --- /dev/null +++ b/fixtures/check/suppression-queues/src/queues.ts @@ -0,0 +1,8 @@ +// no-mistakes-disable-file queues-check: unmatched topology is intentional +import { Queue, Worker } from 'bullmq'; + +export const queue = new Queue('lonely'); +export const enqueue = () => queue.add('missing-worker', {}); +export const worker = new Worker('lonely', async (job) => { + if (job.name === 'missing-producer') return job.data; +}); diff --git a/fixtures/check/suppression-react/.no-mistakes.yml b/fixtures/check/suppression-react/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/suppression-react/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/suppression-react/app/Fetcher.tsx b/fixtures/check/suppression-react/app/Fetcher.tsx new file mode 100644 index 000000000..35761db58 --- /dev/null +++ b/fixtures/check/suppression-react/app/Fetcher.tsx @@ -0,0 +1,5 @@ +export default async function Fetcher() { + // no-mistakes-disable-next-line assert-no-fetch: intentional fixture fetch + await fetch('/api/users'); + return
; +} From eaa51cb23f2bafc18f12b5d6fd1e6d7901054522 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 9 Aug 2026 16:58:27 -0700 Subject: [PATCH 03/62] defer adapter suppression to check runner --- .../src/codebase/unique_exports/collector.rs | 11 +---------- .../no-mistakes/src/codebase/unique_exports/scan.rs | 6 ++---- .../no-mistakes/src/codebase/unique_exports/types.rs | 1 + crates/no-mistakes/src/napi_api/tests/check.rs | 2 +- crates/no-mistakes/src/react_traits/report/types.rs | 3 --- fixtures/check/suppression-react/app/Fetcher.tsx | 2 +- 6 files changed, 6 insertions(+), 19 deletions(-) diff --git a/crates/no-mistakes/src/codebase/unique_exports/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index cc1ed669d..0e3ae4fc7 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/collector.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/collector.rs @@ -1,9 +1,8 @@ pub(super) use super::origin::find_target_export_origin; use super::origin::{origin_for_export, resolve_export_source}; -use super::{ExportBucket, ExportOccurrence, ExportOrigin, SourceFile, RULE_ID}; +use super::{ExportBucket, ExportOccurrence, ExportOrigin, SourceFile}; use crate::codebase::symbols::export_kind_str; use crate::codebase::ts_resolver::{normalize_path, ImportResolverFacade}; -use crate::codebase::ts_source::has_disable_comment; use crate::codebase::ts_symbols::{Export, ExportKind}; use crate::codebase::workspaces::WorkspaceMap; use std::collections::{HashMap, HashSet}; @@ -31,13 +30,6 @@ pub(super) fn collect_file_exports( memo.insert(path, out.clone()); return out; }; - if file.disabled { - visiting.remove(&path); - let out = Vec::new(); - memo.insert(path, out.clone()); - return out; - } - let mut out = Vec::new(); for export in &file.symbols.exports { if should_skip_export(file, export) { @@ -132,6 +124,5 @@ pub(super) fn collect_file_exports( pub(super) fn should_skip_export(file: &SourceFile, export: &Export) -> bool { export.name == "default" - || has_disable_comment(&file.source, export.line, RULE_ID) || super::nextjs::is_framework_export(&file.rel, &export.name, file.is_nextjs_project) } diff --git a/crates/no-mistakes/src/codebase/unique_exports/scan.rs b/crates/no-mistakes/src/codebase/unique_exports/scan.rs index 95537c674..a7ebed54f 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/scan.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/scan.rs @@ -35,12 +35,10 @@ pub(super) fn collect_source_files_from_facts( anyhow::bail!("missing source facts for {}", path.display()); }; let disabled = has_disable_file_comment(&source, RULE_ID); - if !disabled { - if let Some(error) = &facts.parse_error { + let symbols = if let Some(error) = &facts.parse_error { + if !disabled { anyhow::bail!("failed to parse {}: {error}", path.display()); } - } - let symbols = if disabled { Default::default() } else { let Some(symbols) = facts.symbols.clone() else { diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index ddf0a6a6d..fe95be232 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -23,6 +23,7 @@ pub struct UniqueExportFinding { pub(super) struct SourceFile { pub(super) path: PathBuf, pub(super) rel: String, + #[allow(dead_code)] // retained for standalone diagnostics and fixture construction pub(super) source: String, pub(super) symbols: std::sync::Arc, pub(super) disabled: bool, diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 396de5250..1a2dfb7bd 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -87,7 +87,7 @@ fn check_json_audit_mode_includes_an_empty_suppression_array() { #[test] fn check_json_accounts_for_react_queue_and_integration_adapters() { let fixtures = [ - ("suppression-react", "react", "assert-no-fetch", "nextLine"), + ("suppression-react", "react", "assert-no-fetch", "file"), ("suppression-queues", "queues", "queues-check", "file"), ( "suppression-integration", diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index 224e18d40..dd36ae056 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -37,9 +37,6 @@ pub struct FetchCall { pub file: String, pub exported_name: Option, pub shape: Option, - /// Source location retained for check suppression. It is intentionally not - /// part of the public analyze report, preserving that report's schema. - #[serde(skip)] pub line: usize, } diff --git a/fixtures/check/suppression-react/app/Fetcher.tsx b/fixtures/check/suppression-react/app/Fetcher.tsx index 35761db58..5edfdf093 100644 --- a/fixtures/check/suppression-react/app/Fetcher.tsx +++ b/fixtures/check/suppression-react/app/Fetcher.tsx @@ -1,5 +1,5 @@ +// no-mistakes-disable-file assert-no-fetch: intentional fixture fetch export default async function Fetcher() { - // no-mistakes-disable-next-line assert-no-fetch: intentional fixture fetch await fetch('/api/users'); return
; } From f842f2e4a1e8fb7a7dc27a777e59e9e60c4d49d0 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:20:02 -0700 Subject: [PATCH 04/62] fix: preserve suppression audit parity --- crates/no-mistakes/src/check_parallel.rs | 1 + .../no-mistakes/src/check_runner/results.rs | 21 +++++++++++++-- crates/no-mistakes/src/check_runner/tests.rs | 1 + .../candidate_index/tests.rs | 1 + .../rules/filesystem_dispatch/entrypoints.rs | 1 + .../rules/filesystem_dispatch/execute.rs | 26 ++++++++++++------- .../rules/filesystem_dispatch/tests.rs | 5 ++++ crates/no-mistakes/src/fetch/visit_helpers.rs | 3 ++- crates/no-mistakes/src/fetch/visitor/tests.rs | 12 +++++++++ crates/no-mistakes/src/napi_api/tests.rs | 8 ++++-- .../no-mistakes/src/napi_api/tests/check.rs | 26 ++++++++++++++++++- .../src/react_traits/report/types.rs | 3 +++ .../suppression-filesystem/.no-mistakes.yml | 3 +++ .../suppression-filesystem/src/placeholder.ts | 1 + .../check/suppression-react/app/Fetcher.tsx | 2 +- packages/no-mistakes/report-types.d.ts | 2 +- 16 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 fixtures/check/suppression-filesystem/.no-mistakes.yml create mode 100644 fixtures/check/suppression-filesystem/src/placeholder.ts diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 108956ad6..fef1dac6d 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -179,6 +179,7 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults workflow_documents, tsconfig_gate_project_inputs, config_path: config_path.as_deref(), + defer_suppression, }, Some(facts), ) diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index 1e70be913..b8205259a 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -73,7 +73,7 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result) -> Result) -> Result { pub workflow_documents: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, pub tsconfig_gate_project_inputs: Option<&'a tsconfig_gate_coverage::ProjectSourceInputs>, pub config_path: Option<&'a Path>, + /// Aggregate `check` applies SourceStore-backed suppression once after all + /// domains finish so it can report optional directive accounting. + pub defer_suppression: bool, } #[doc(hidden)] @@ -71,6 +74,7 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( workflow_documents, tsconfig_gate_project_inputs, config_path, + defer_suppression, } = prepared; let acc = Mutex::new(Vec::new()); let metadata_files = metadata::metadata_files(root, config, files, snapshot); @@ -106,16 +110,18 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( for (_, result) in results { findings.extend(result?); } - suppress_rule_findings_with_sources_except( - root, - &mut findings, - &sources, - &[ - RUST_MAX_LINES_PER_FILE, - RUST_NO_INLINE_TESTS, - RUST_NO_INLINE_ALLOWS, - ], - ); + if !defer_suppression { + suppress_rule_findings_with_sources_except( + root, + &mut findings, + &sources, + &[ + RUST_MAX_LINES_PER_FILE, + RUST_NO_INLINE_TESTS, + RUST_NO_INLINE_ALLOWS, + ], + ); + } super::super::sort_findings(&mut findings); Ok(findings) } diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index 0c8019446..f31b3ca40 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -106,6 +106,7 @@ fn prepared_dispatch_rejects_tsconfig_gate_without_workflow_documents() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: Some(&config_path), + defer_suppression: false, }, ) .unwrap_err(); @@ -213,6 +214,7 @@ fn enabling_mermaid_validation_preserves_existing_markdown_findings() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: Some(&config_path), + defer_suppression: false, }, ) .unwrap() @@ -399,6 +401,7 @@ fn aggregate_drops_exclusive_rust_sources_without_global_suppression_rereads() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, + defer_suppression: false, }, ) .unwrap(); @@ -453,6 +456,7 @@ comparisons: workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, + defer_suppression: false, }, ) .unwrap(); @@ -521,6 +525,7 @@ fn aggregate_finding_and_suppression_share_one_physical_read() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, + defer_suppression: false, }, ) .unwrap(); diff --git a/crates/no-mistakes/src/fetch/visit_helpers.rs b/crates/no-mistakes/src/fetch/visit_helpers.rs index a42bd11c7..39485c048 100644 --- a/crates/no-mistakes/src/fetch/visit_helpers.rs +++ b/crates/no-mistakes/src/fetch/visit_helpers.rs @@ -1,3 +1,4 @@ +use crate::codebase::ts_source::byte_offset_to_line; use crate::fetch::cache_opts::{ cache_wrapper_name, extract_fetch_cache_options, infer_cached_wrapper_name, }; @@ -21,7 +22,7 @@ pub fn try_extract_fetch<'a>( let mut method = "GET".to_string(); let mut cached = false; let mut cache_kind = CacheKind::None; - let line = visitor.source[..expr.span().start as usize].lines().count() + 1; + let line = byte_offset_to_line(visitor.source, expr.span().start as usize) as usize; let (path, raw_path, is_dynamic, is_unsupported) = if let Some(arg) = expr.arguments.first() { let result = extract_url_from_argument(arg, visitor.source); diff --git a/crates/no-mistakes/src/fetch/visitor/tests.rs b/crates/no-mistakes/src/fetch/visitor/tests.rs index ad20fb4d3..c49a3369e 100644 --- a/crates/no-mistakes/src/fetch/visitor/tests.rs +++ b/crates/no-mistakes/src/fetch/visitor/tests.rs @@ -67,6 +67,18 @@ fn anonymous_default_function_declaration_keeps_fetch_visible_inside_body() { .unwrap(); } +#[test] +fn fetch_call_uses_its_actual_one_based_source_line() { + let source = "export default async function Fetcher() {\n // directive\n await fetch('/api/visible');\n}"; + crate::ast::with_program(Path::new("fixture.ts"), source, |program, source| { + let mut visitor = FetchVisitor::new(source, "fixture.ts", false, false); + visitor.visit_program(program); + assert_eq!(visitor.fetches.len(), 1); + assert_eq!(visitor.fetches[0].line, 3); + }) + .unwrap(); +} + #[test] fn namespace_import_is_tracked_as_shadowed() { let source = "import * as Fetcher from './fetcher';\nFetcher.fetch('/api/hidden');"; diff --git a/crates/no-mistakes/src/napi_api/tests.rs b/crates/no-mistakes/src/napi_api/tests.rs index 91fc19715..1c81e34db 100644 --- a/crates/no-mistakes/src/napi_api/tests.rs +++ b/crates/no-mistakes/src/napi_api/tests.rs @@ -382,11 +382,15 @@ fn react_json_functions_return_reports() { .to_string(); let output = react_analyze_json_impl(options).unwrap(); let value: serde_json::Value = serde_json::from_str(&output).unwrap(); - assert!(value + let fetching = value .as_array() .unwrap() .iter() - .any(|entry| entry["name"] == "FetchingComponent")); + .find(|entry| entry["name"] == "FetchingComponent") + .expect("fixture must expose its fetching component"); + // Suppression needs the internal source location, but React analysis must + // not gain a location field without an explicit public DTO change. + assert!(fetching["fetches"][0].get("line").is_none()); let root = crate::codebase::ts_resolver::normalize_path( &PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 1a2dfb7bd..ee2928e56 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -87,8 +87,14 @@ fn check_json_audit_mode_includes_an_empty_suppression_array() { #[test] fn check_json_accounts_for_react_queue_and_integration_adapters() { let fixtures = [ - ("suppression-react", "react", "assert-no-fetch", "file"), + ("suppression-react", "react", "assert-no-fetch", "nextLine"), ("suppression-queues", "queues", "queues-check", "file"), + ( + "suppression-filesystem", + "filesystem", + "no-empty-or-comments-only-files", + "file", + ), ( "suppression-integration", "integration", @@ -117,6 +123,24 @@ fn check_json_accounts_for_react_queue_and_integration_adapters() { } } +#[test] +fn check_json_records_react_next_line_directive_at_the_fetch_location() { + let root = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check/suppression-react"); + let output = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + let finding = value["suppressed"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["domain"] == "react") + .unwrap_or_else(|| panic!("missing React suppression: {value}")); + assert_eq!(finding["line"], 3); + assert_eq!(finding["directive"]["kind"], "nextLine"); + assert_eq!(finding["directive"]["line"], 2); +} + #[test] fn check_json_returns_warnings_for_skipped_configured_check() { let options = json!({ diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index dd36ae056..0e96c296d 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -37,6 +37,9 @@ pub struct FetchCall { pub file: String, pub exported_name: Option, pub shape: Option, + /// Internal location for aggregate-check suppression. Direct React reports + /// intentionally retain their established JSON schema. + #[serde(skip)] pub line: usize, } diff --git a/fixtures/check/suppression-filesystem/.no-mistakes.yml b/fixtures/check/suppression-filesystem/.no-mistakes.yml new file mode 100644 index 000000000..2f68f82ca --- /dev/null +++ b/fixtures/check/suppression-filesystem/.no-mistakes.yml @@ -0,0 +1,3 @@ +rules: + - rule: no-empty-or-comments-only-files + scope: repository diff --git a/fixtures/check/suppression-filesystem/src/placeholder.ts b/fixtures/check/suppression-filesystem/src/placeholder.ts new file mode 100644 index 000000000..cdbdfa47c --- /dev/null +++ b/fixtures/check/suppression-filesystem/src/placeholder.ts @@ -0,0 +1 @@ +// no-mistakes-disable-file no-empty-or-comments-only-files: intentional empty fixture diff --git a/fixtures/check/suppression-react/app/Fetcher.tsx b/fixtures/check/suppression-react/app/Fetcher.tsx index 5edfdf093..35761db58 100644 --- a/fixtures/check/suppression-react/app/Fetcher.tsx +++ b/fixtures/check/suppression-react/app/Fetcher.tsx @@ -1,5 +1,5 @@ -// no-mistakes-disable-file assert-no-fetch: intentional fixture fetch export default async function Fetcher() { + // no-mistakes-disable-next-line assert-no-fetch: intentional fixture fetch await fetch('/api/users'); return
; } diff --git a/packages/no-mistakes/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index 0c67c8eae..b16ed0faa 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -35,7 +35,7 @@ export interface CheckReport { } export interface SuppressedFinding { - domain: "react" | "queues" | "rules" | "integration" | "codebase"; + domain: "react" | "queues" | "rules" | "filesystem" | "integration" | "codebase"; rule: string; file: string; line?: number; From fa57f5227a62a94dd243073383b6086633f98c5f Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:51:26 -0700 Subject: [PATCH 05/62] fix: satisfy suppression audit quality gates --- crates/no-mistakes/src/benchmark_support.rs | 3 +- crates/no-mistakes/src/check_runner.rs | 3 +- .../no-mistakes/src/check_runner/results.rs | 108 +++-------------- .../src/check_runner/results/suppression.rs | 111 ++++++++++++++++++ .../no-mistakes/src/check_runner/run_all.rs | 9 -- crates/no-mistakes/src/check_runner/tests.rs | 8 ++ .../tests/integration_gitignore.rs | 3 +- crates/no-mistakes/src/codebase/rules/mod.rs | 26 +--- .../src/codebase/rules/source_access.rs | 11 ++ .../src/codebase/rules/suppression.rs | 95 +-------------- .../codebase/rules/suppression/accounting.rs | 92 +++++++++++++++ .../codebase/ts_source/disable_comments.rs | 35 +----- .../ts_source/disable_comments/directives.rs | 27 +++++ .../src/codebase/unique_exports/scan.rs | 1 - .../unique_exports/scan/test_support.rs | 1 - .../codebase/unique_exports/tests/origin.rs | 1 - .../src/codebase/unique_exports/types.rs | 2 - 17 files changed, 287 insertions(+), 249 deletions(-) create mode 100644 crates/no-mistakes/src/check_runner/results/suppression.rs create mode 100644 crates/no-mistakes/src/codebase/rules/source_access.rs create mode 100644 crates/no-mistakes/src/codebase/rules/suppression/accounting.rs create mode 100644 crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs diff --git a/crates/no-mistakes/src/benchmark_support.rs b/crates/no-mistakes/src/benchmark_support.rs index 7db0db811..4ac316b3e 100644 --- a/crates/no-mistakes/src/benchmark_support.rs +++ b/crates/no-mistakes/src/benchmark_support.rs @@ -128,7 +128,8 @@ pub fn high_fanout_finalization_signature( /// Run every configured `check` domain and serialize the stable public report. pub fn check_json(root: &Path) -> Result { crate::ast::with_request_parse_cache(|| { - let results = crate::check_runner::run_all(root.to_path_buf(), None, None)?; + let results = + crate::check_runner::run_all_with_suppressed(root.to_path_buf(), None, None, false)?; Ok(serde_json::to_string(&crate::check_runner::json_value( &results, ))?) diff --git a/crates/no-mistakes/src/check_runner.rs b/crates/no-mistakes/src/check_runner.rs index 822feac6e..dd5b43e8f 100644 --- a/crates/no-mistakes/src/check_runner.rs +++ b/crates/no-mistakes/src/check_runner.rs @@ -7,8 +7,7 @@ mod results; mod run_all; pub(crate) use results::{complete_domain_checks, empty_results, json_value, CheckResults}; -#[allow(unused_imports)] -pub(crate) use run_all::{run_all, run_all_with_suppressed}; +pub(crate) use run_all::run_all_with_suppressed; #[cfg(test)] mod tests; diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index b8205259a..dc3301273 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -8,6 +8,8 @@ use no_mistakes::queue::CheckFinding; use no_mistakes::react_traits; use std::time::Duration; +mod suppression; + pub(crate) struct FinalizeInput<'a> { pub(crate) root: &'a std::path::Path, pub(crate) config: &'a no_mistakes::config::v2::NoMistakesConfig, @@ -86,96 +88,16 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result { + pub(super) root: &'a std::path::Path, + pub(super) sources: &'a SourceStore, + pub(super) react: &'a mut Vec, + pub(super) queues: &'a mut Vec, + pub(super) rules: &'a mut Vec, + pub(super) filesystem: &'a mut Vec, + pub(super) integration: &'a mut Vec, + pub(super) codebase: &'a mut Vec, +} + +pub(super) fn apply(input: Inputs<'_>) -> Vec { + let Inputs { + root, + sources, + react, + queues, + rules, + filesystem, + integration, + codebase, + } = input; + let mut suppressed = Vec::new(); + suppressed.extend(suppress_domain_findings_with_sources( + root, + react, + sources, + |finding| SuppressionTarget { + domain: "react", + rule: &finding.rule, + file: &finding.file, + line: finding.line, + reason: finding + .detail + .as_deref() + .unwrap_or("component fetch assertion failed"), + }, + )); + suppressed.extend(suppress_domain_findings_with_sources( + root, + queues, + sources, + |finding| SuppressionTarget { + domain: "queues", + rule: "queues-check", + file: &finding.file, + line: Some(finding.line), + reason: &finding.message, + }, + )); + suppress_rules(root, sources, rules, "rules", &mut suppressed); + suppress_rules(root, sources, filesystem, "filesystem", &mut suppressed); + suppressed.extend(suppress_domain_findings_with_sources( + root, + integration, + sources, + |finding| SuppressionTarget { + domain: "integration", + rule: "integration-test-no-mocks", + file: &finding.file, + line: Some(finding.line as usize), + reason: &finding.message, + }, + )); + suppressed.extend(suppress_domain_findings_with_sources( + root, + codebase, + sources, + |finding| SuppressionTarget { + domain: "codebase", + rule: &finding.rule, + file: &finding.file, + line: Some(finding.line as usize), + reason: &finding.message, + }, + )); + suppressed.sort(); + suppressed.dedup(); + suppressed +} + +fn suppress_rules( + root: &std::path::Path, + sources: &SourceStore, + findings: &mut Vec, + domain: &'static str, + suppressed: &mut Vec, +) { + suppressed.extend(suppress_domain_findings_with_sources( + root, + findings, + sources, + |finding| SuppressionTarget { + domain, + rule: &finding.rule, + file: &finding.file, + line: Some(finding.line), + reason: &finding.message, + }, + )); +} diff --git a/crates/no-mistakes/src/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs index 2bb28ae79..7a9a08273 100644 --- a/crates/no-mistakes/src/check_runner/run_all.rs +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -8,15 +8,6 @@ use anyhow::{Context, Result}; use enabled::{fact_plan, integration_configured}; use std::path::PathBuf; -#[allow(dead_code)] -pub(crate) fn run_all( - root: PathBuf, - config_path: Option, - tsconfig_path: Option, -) -> Result { - run_all_with_suppressed(root, config_path, tsconfig_path, false) -} - pub(crate) fn run_all_with_suppressed( root: PathBuf, config_path: Option, diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 89ee72919..0f1f2f734 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -21,6 +21,14 @@ mod prepared_parser_cache; mod prepared_tsconfig; mod tsconfig_catalog; +fn run_all( + root: PathBuf, + config_path: Option, + tsconfig_path: Option, +) -> anyhow::Result { + super::run_all_with_suppressed(root, config_path, tsconfig_path, false) +} + fn aggregate_html_id_rule_composition(name: &str) -> Vec { let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/playwright/html-id-rule-composition") diff --git a/crates/no-mistakes/src/check_runner/tests/integration_gitignore.rs b/crates/no-mistakes/src/check_runner/tests/integration_gitignore.rs index 79fcafd64..a89319fb7 100644 --- a/crates/no-mistakes/src/check_runner/tests/integration_gitignore.rs +++ b/crates/no-mistakes/src/check_runner/tests/integration_gitignore.rs @@ -1,4 +1,5 @@ -use super::super::{prepared, run_all}; +use super::super::prepared; +use super::run_all; use std::path::{Path, PathBuf}; use std::process::Command; diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index a3358f1a8..48908f6a2 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -52,6 +52,7 @@ pub mod workspace_package_cycles; pub mod filesystem_dispatch; pub(crate) mod path_filter; mod run; +mod source_access; mod suppression; use serde::Serialize; @@ -68,11 +69,10 @@ pub use filesystem_dispatch::{ }; pub use ids::*; #[doc(hidden)] -pub use run::canonical_graph_plan; -#[doc(hidden)] -pub use run::canonical_graph_requires_full_file_universe; -#[doc(hidden)] -pub use run::run_check_with_config_facts_playwright_and_graph; +pub use run::{ + canonical_graph_plan, canonical_graph_requires_full_file_universe, + run_check_with_config_facts_playwright_and_graph, +}; pub use run::{ run_check, run_check_with_config_and_facts_and_playwright, run_check_with_facts, run_check_with_facts_and_playwright, PreparedRulesCheck, @@ -81,6 +81,7 @@ pub use run::{ pub use vitest_project_catalog::{prepare_vitest_project_catalog, PreparedVitestProjectCatalog}; pub(crate) use file_matching::matching_files; +pub(crate) use source_access::{read_source, source_store_for_files}; #[doc(hidden)] pub use suppression::{ suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, @@ -90,21 +91,6 @@ pub(crate) use suppression::{ suppress_rule_findings_with_sources, suppress_rule_findings_with_sources_except, }; -pub(crate) fn source_store_for_files( - files: &[PathBuf], -) -> std::sync::Arc { - std::sync::Arc::new(crate::codebase::ts_source::SourceStore::new( - std::sync::Arc::new(crate::codebase::ts_source::FileInventory::from_paths(files)), - )) -} - -pub(crate) fn read_source( - sources: &crate::codebase::ts_source::SourceStore, - path: &Path, -) -> Option> { - sources.read_path(path).ok() -} - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] pub struct RuleFinding { diff --git a/crates/no-mistakes/src/codebase/rules/source_access.rs b/crates/no-mistakes/src/codebase/rules/source_access.rs new file mode 100644 index 000000000..e75668a73 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/source_access.rs @@ -0,0 +1,11 @@ +use crate::codebase::ts_source::{FileInventory, SourceStore}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(crate) fn source_store_for_files(files: &[PathBuf]) -> Arc { + Arc::new(SourceStore::new(Arc::new(FileInventory::from_paths(files)))) +} + +pub(crate) fn read_source(sources: &SourceStore, path: &Path) -> Option> { + sources.read_path(path).ok() +} diff --git a/crates/no-mistakes/src/codebase/rules/suppression.rs b/crates/no-mistakes/src/codebase/rules/suppression.rs index 6d86ef558..344906cf2 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -1,8 +1,13 @@ use super::RuleFinding; -use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; +mod accounting; +pub use accounting::{ + suppress_domain_findings_with_sources, SuppressedFinding, SuppressionDirective, + SuppressionDirectiveKind, SuppressionTarget, +}; + pub(crate) fn suppress_rule_findings(root: &Path, findings: &mut Vec) { suppress_rule_findings_inner(root, findings, None, &[]); } @@ -28,94 +33,6 @@ pub(crate) fn suppress_rule_findings_with_source(findings: &mut Vec findings.retain(|finding| !finding_is_suppressed(source, finding)); } -/// A domain finding projected into the common suppression contract. -/// -/// The check runner creates these only after all domain analyzers have consumed -/// the request-scoped `SourceStore`; this keeps suppression from introducing a -/// second source-read path. -pub struct SuppressionTarget<'a> { - pub domain: &'static str, - pub rule: &'a str, - pub file: &'a str, - pub line: Option, - pub reason: &'a str, -} - -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SuppressedFinding { - pub domain: String, - pub rule: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub line: Option, - /// The deterministic diagnostic that would have been emitted. - pub reason: String, - pub directive: SuppressionDirective, -} - -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SuppressionDirective { - pub kind: SuppressionDirectiveKind, - pub line: usize, -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum SuppressionDirectiveKind { - File, - Line, - NextLine, -} - -/// Retain unsuppressed findings and return deterministic accounting for the -/// findings removed by a valid directive. A missing source is deliberately not -/// treated as a suppression: it must not hide a diagnostic. -pub fn suppress_domain_findings_with_sources( - root: &Path, - findings: &mut Vec, - sources: &crate::codebase::ts_source::SourceStore, - describe: impl Fn(&T) -> SuppressionTarget<'_>, -) -> Vec { - let lexical_root = crate::codebase::ts_source::normalize_discovery_path(root); - let mut cached_sources: HashMap>> = HashMap::new(); - let mut suppressed = Vec::new(); - findings.retain(|finding| { - let target = describe(finding); - let source = cached_sources - .entry(target.file.to_string()) - .or_insert_with(|| { - let (candidate, is_absolute) = - finding_source_candidate(&lexical_root, target.file, true)?; - let path = if is_absolute { - sources.trusted_regular_path(&candidate) - } else { - sources.validated_regular_path(&lexical_root, &candidate) - }?; - super::read_source(sources, &path) - }); - let Some(directive) = source - .as_deref() - .and_then(|source| matching_directive(source, target.rule, target.line)) - else { - return true; - }; - suppressed.push(SuppressedFinding { - domain: target.domain.to_string(), - rule: target.rule.to_string(), - file: target.file.to_string(), - line: target.line, - reason: target.reason.to_string(), - directive, - }); - false - }); - suppressed.sort(); - suppressed.dedup(); - suppressed -} - fn suppress_rule_findings_inner( root: &Path, findings: &mut Vec, diff --git a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs new file mode 100644 index 000000000..3f98a0aa0 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -0,0 +1,92 @@ +use super::{finding_source_candidate, matching_directive}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; + +/// A domain finding projected into the common suppression contract. +/// +/// The check runner creates these only after all domain analyzers have consumed +/// the request-scoped `SourceStore`; this keeps suppression from introducing a +/// second source-read path. +pub struct SuppressionTarget<'a> { + pub domain: &'static str, + pub rule: &'a str, + pub file: &'a str, + pub line: Option, + pub reason: &'a str, +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SuppressedFinding { + pub domain: String, + pub rule: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + /// The deterministic diagnostic that would have been emitted. + pub reason: String, + pub directive: SuppressionDirective, +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SuppressionDirective { + pub kind: SuppressionDirectiveKind, + pub line: usize, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SuppressionDirectiveKind { + File, + Line, + NextLine, +} + +/// Retain unsuppressed findings and return deterministic accounting for the +/// findings removed by a valid directive. A missing source is deliberately not +/// treated as a suppression: it must not hide a diagnostic. +pub fn suppress_domain_findings_with_sources( + root: &Path, + findings: &mut Vec, + sources: &crate::codebase::ts_source::SourceStore, + describe: impl Fn(&T) -> SuppressionTarget<'_>, +) -> Vec { + let lexical_root = crate::codebase::ts_source::normalize_discovery_path(root); + let mut cached_sources = HashMap::new(); + let mut suppressed = Vec::new(); + findings.retain(|finding| { + let target = describe(finding); + let source = cached_sources + .entry(target.file.to_string()) + .or_insert_with(|| { + let (candidate, is_absolute) = + finding_source_candidate(&lexical_root, target.file, true)?; + let path = if is_absolute { + sources.trusted_regular_path(&candidate) + } else { + sources.validated_regular_path(&lexical_root, &candidate) + }?; + super::super::read_source(sources, &path) + }); + let Some(directive) = source + .as_deref() + .and_then(|source| matching_directive(source, target.rule, target.line)) + else { + return true; + }; + suppressed.push(SuppressedFinding { + domain: target.domain.to_string(), + rule: target.rule.to_string(), + file: target.file.to_string(), + line: target.line, + reason: target.reason.to_string(), + directive, + }); + false + }); + suppressed.sort(); + suppressed.dedup(); + suppressed +} diff --git a/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs b/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs index 9335b1946..308864133 100644 --- a/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs +++ b/crates/no-mistakes/src/codebase/ts_source/disable_comments.rs @@ -138,34 +138,6 @@ pub fn has_disable_file_comment(source: &str, rule_id: &str) -> bool { disable_file_directive_line(source, rule_id).is_some() } -/// Returns exact provenance for a supported suppression directive. -/// -/// This is the single directive parser used by both filtering and the -/// aggregate check audit report, so accounting cannot fabricate a line number. -pub fn matching_disable_directive( - source: &str, - finding_line: Option, - rule_id: &str, -) -> Option { - if let Some(line) = disable_file_directive_line(source, rule_id) { - return Some(DisableDirective::File { line }); - } - let line = finding_line?; - if has_disable_line_comment(source, line, rule_id) { - return Some(DisableDirective::Line { line }); - } - has_disable_comment(source, line, rule_id).then_some(DisableDirective::NextLine { - line: line.saturating_sub(1), - }) -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum DisableDirective { - File { line: u32 }, - Line { line: u32 }, - NextLine { line: u32 }, -} - fn disable_file_directive_line(source: &str, rule_id: &str) -> Option { let mut in_block_comment = false; let mut saw_hash_attribute = false; @@ -198,9 +170,7 @@ fn disable_file_directive_line(source: &str, rule_id: &str) -> Option { let comment_prefix_is_slash = rest.starts_with("//"); saw_hash_attribute |= hash_attribute_comment_line(rest); - let Some(rest) = leading_comment_text(rest) else { - return None; - }; + let rest = leading_comment_text(rest)?; let Some(after_directive) = rest.strip_prefix("no-mistakes-disable-file ") else { break; }; @@ -259,3 +229,6 @@ fn rule_part_matches(rule_part: &str, rule_id: &str) -> bool { suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with(char::is_whitespace) }) } +#[path = "disable_comments/directives.rs"] +mod directives; +pub use directives::{matching_disable_directive, DisableDirective}; diff --git a/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs b/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs new file mode 100644 index 000000000..7fc33cce4 --- /dev/null +++ b/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs @@ -0,0 +1,27 @@ +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DisableDirective { + File { line: u32 }, + Line { line: u32 }, + NextLine { line: u32 }, +} + +/// Returns exact provenance for a supported suppression directive. +/// +/// This is the single directive parser used by both filtering and the +/// aggregate check audit report, so accounting cannot fabricate a line number. +pub fn matching_disable_directive( + source: &str, + finding_line: Option, + rule_id: &str, +) -> Option { + if let Some(line) = super::disable_file_directive_line(source, rule_id) { + return Some(DisableDirective::File { line }); + } + let line = finding_line?; + if super::has_disable_line_comment(source, line, rule_id) { + return Some(DisableDirective::Line { line }); + } + super::has_disable_comment(source, line, rule_id).then_some(DisableDirective::NextLine { + line: line.saturating_sub(1), + }) +} diff --git a/crates/no-mistakes/src/codebase/unique_exports/scan.rs b/crates/no-mistakes/src/codebase/unique_exports/scan.rs index a7ebed54f..63216e4ba 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/scan.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/scan.rs @@ -51,7 +51,6 @@ pub(super) fn collect_source_files_from_facts( rel: relative_slash_path(root, path), disabled, is_nextjs_project: nextjs_projects.contains_file(path), - source: source.to_string(), symbols, }); } diff --git a/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs b/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs index f9277515d..dcf23f02c 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs @@ -30,7 +30,6 @@ pub(crate) fn collect_source_files(root: &Path, files: &[PathBuf]) -> Result SourceFile { SourceFile { path: root.join(rel), rel: rel.to_string(), - source: source.to_string(), symbols: crate::codebase::ts_symbols::extract_symbols(source, false) .unwrap() .into(), diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index fe95be232..2449e8a09 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -23,8 +23,6 @@ pub struct UniqueExportFinding { pub(super) struct SourceFile { pub(super) path: PathBuf, pub(super) rel: String, - #[allow(dead_code)] // retained for standalone diagnostics and fixture construction - pub(super) source: String, pub(super) symbols: std::sync::Arc, pub(super) disabled: bool, pub(super) is_nextjs_project: bool, From 2e06912b6307a6d9caaa1ec119a3604be443e1fe Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 10:19:02 -0700 Subject: [PATCH 06/62] fix: preserve standalone unique export suppression --- .../src/check_runner/tests/architecture.rs | 2 +- crates/no-mistakes/src/check_tasks.rs | 2 +- .../src/codebase/unique_exports.rs | 1 + .../src/codebase/unique_exports/collector.rs | 10 ++++- .../src/codebase/unique_exports/origin.rs | 2 +- .../src/codebase/unique_exports/scan.rs | 3 ++ .../unique_exports/scan/test_support.rs | 2 + .../src/codebase/unique_exports/tests.rs | 8 ++-- .../codebase/unique_exports/tests/origin.rs | 41 +++++++++++++++++++ .../tests/shared_facts_disable.rs | 2 +- .../src/codebase/unique_exports/types.rs | 2 + .../src/codebase/unique_exports/with_facts.rs | 10 ++++- .../unique_exports/with_facts/prepared.rs | 10 +++++ .../with_facts/prepared/aggregate.rs | 32 +++++++++++++++ .../src/barrel.ts | 2 + .../src/source.ts | 2 + 16 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs create mode 100644 fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts create mode 100644 fixtures/codebase/unique-exports-suppressed-origin/src/source.ts diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index d016c7deb..24588e706 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -30,7 +30,7 @@ fn aggregate_check_injects_prepared_config_into_every_domain() { "run_check_with_config_facts_playwright_and_graph", "queue::analyze_project_with_prepared_facts_and_catalog_and_session", "integration_tests::check_with_prepared_facts_catalog_and_session", - "unique_exports::analyze_project_with_prepared_facts_catalog_and_inferred_and_session", + "unique_exports::analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check", "run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts", ] { assert!( diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index a9417949b..b1f02245c 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -154,7 +154,7 @@ pub(crate) fn run_codebase_check_with_catalog( no_mistakes::diagnostics::TimingKind::Parallel, || -> Result<_> { Ok(if enabled { - unique_exports::analyze_project_with_prepared_facts_catalog_and_inferred_and_session( + unique_exports::analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check( root, config, prepared_tsconfig_catalog, diff --git a/crates/no-mistakes/src/codebase/unique_exports.rs b/crates/no-mistakes/src/codebase/unique_exports.rs index 9f75f6ccc..eb0abffa0 100644 --- a/crates/no-mistakes/src/codebase/unique_exports.rs +++ b/crates/no-mistakes/src/codebase/unique_exports.rs @@ -26,6 +26,7 @@ pub use with_facts::{ pub use with_facts::{ analyze_project_with_prepared_facts_and_inferred_and_session, analyze_project_with_prepared_facts_catalog_and_inferred_and_session, + analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check, }; pub const RULE_ID: &str = "unique-exports"; diff --git a/crates/no-mistakes/src/codebase/unique_exports/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index 0e3ae4fc7..d9aa9b1f1 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/collector.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/collector.rs @@ -1,8 +1,9 @@ pub(super) use super::origin::find_target_export_origin; use super::origin::{origin_for_export, resolve_export_source}; -use super::{ExportBucket, ExportOccurrence, ExportOrigin, SourceFile}; +use super::{ExportBucket, ExportOccurrence, ExportOrigin, SourceFile, RULE_ID}; use crate::codebase::symbols::export_kind_str; use crate::codebase::ts_resolver::{normalize_path, ImportResolverFacade}; +use crate::codebase::ts_source::has_disable_comment; use crate::codebase::ts_symbols::{Export, ExportKind}; use crate::codebase::workspaces::WorkspaceMap; use std::collections::{HashMap, HashSet}; @@ -30,6 +31,12 @@ pub(super) fn collect_file_exports( memo.insert(path, out.clone()); return out; }; + if file.disabled && !file.defer_suppression { + visiting.remove(&path); + let out = Vec::new(); + memo.insert(path, out.clone()); + return out; + } let mut out = Vec::new(); for export in &file.symbols.exports { if should_skip_export(file, export) { @@ -124,5 +131,6 @@ pub(super) fn collect_file_exports( pub(super) fn should_skip_export(file: &SourceFile, export: &Export) -> bool { export.name == "default" + || (!file.defer_suppression && has_disable_comment(&file.source, export.line, RULE_ID)) || super::nextjs::is_framework_export(&file.rel, &export.name, file.is_nextjs_project) } diff --git a/crates/no-mistakes/src/codebase/unique_exports/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/origin.rs index 032a44eb7..7045d3885 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/origin.rs @@ -42,7 +42,7 @@ impl OriginSearch<'_, R> { self.visiting.remove(&target); return None; }; - if file.disabled { + if file.disabled && !file.defer_suppression { self.visiting.remove(&target); return None; } diff --git a/crates/no-mistakes/src/codebase/unique_exports/scan.rs b/crates/no-mistakes/src/codebase/unique_exports/scan.rs index 63216e4ba..d8928f0aa 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/scan.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/scan.rs @@ -24,6 +24,7 @@ pub(super) fn collect_source_files_from_facts( root: &Path, files: &[PathBuf], shared: &crate::codebase::check_facts::CheckFactMap, + defer_suppression: bool, ) -> Result> { let nextjs_projects = NextJsProjectLookup::new(root, files, shared.files()); let mut source_files = Vec::new(); @@ -49,7 +50,9 @@ pub(super) fn collect_source_files_from_facts( source_files.push(SourceFile { path: normalize_path(path), rel: relative_slash_path(root, path), + source: source.to_string(), disabled, + defer_suppression, is_nextjs_project: nextjs_projects.contains_file(path), symbols, }); diff --git a/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs b/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs index dcf23f02c..c197ba118 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/scan/test_support.rs @@ -28,7 +28,9 @@ pub(crate) fn collect_source_files(root: &Path, files: &[PathBuf]) -> Result SourceFile { SourceFile { path: root.join(rel), rel: rel.to_string(), + source: source.to_string(), symbols: crate::codebase::ts_symbols::extract_symbols(source, false) .unwrap() .into(), disabled: false, + defer_suppression: false, is_nextjs_project: false, } } +fn suppression_origin_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/codebase/unique-exports-suppressed-origin") +} + +fn fixture_source_file(root: &Path, rel: &str, defer_suppression: bool) -> SourceFile { + let source = std::fs::read_to_string(root.join(rel)).unwrap(); + let mut file = source_file(root, rel, &source); + file.disabled = crate::codebase::ts_source::has_disable_file_comment(&source, RULE_ID); + file.defer_suppression = defer_suppression; + file +} + fn find_origin( target: &Path, imported: &str, @@ -97,3 +112,29 @@ export type { MissingType } from './missing'\n", let missing_type = find_origin(&reexport_path, "MissingType", &files, &resolver, &workspace); assert_eq!(missing_type.bucket, ExportBucket::Type); } + +#[test] +fn deferred_suppression_preserves_reexport_origin_identity() { + let root = suppression_origin_fixture(); + let source = fixture_source_file(&root, "src/source.ts", true); + let barrel = fixture_source_file(&root, "src/barrel.ts", true); + let barrel_path = barrel.path.clone(); + let files = HashMap::from([(source.path.clone(), source), (barrel.path.clone(), barrel)]); + let tsconfig = crate::codebase::ts_resolver::TsConfig { + dir: root.clone(), + paths: Vec::new(), + paths_dir: root.clone(), + base_url: None, + }; + let resolver = ImportResolver::new(&tsconfig); + + let origin = find_origin( + &barrel_path, + "Shared", + &files, + &resolver, + &WorkspaceMap::default(), + ); + + assert_eq!(origin.file, "src/source.ts"); +} diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests/shared_facts_disable.rs b/crates/no-mistakes/src/codebase/unique_exports/tests/shared_facts_disable.rs index 03e7add15..f1dea04fe 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests/shared_facts_disable.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests/shared_facts_disable.rs @@ -15,7 +15,7 @@ fn source_files_from_facts_skips_disabled_parse_errors() { }, ); - let source_files = scan::collect_source_files_from_facts(&root, &files, &facts).unwrap(); + let source_files = scan::collect_source_files_from_facts(&root, &files, &facts, false).unwrap(); assert_eq!(source_files.len(), 1); assert!(source_files[0].disabled); diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index 2449e8a09..d58a96b9e 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -23,8 +23,10 @@ pub struct UniqueExportFinding { pub(super) struct SourceFile { pub(super) path: PathBuf, pub(super) rel: String, + pub(super) source: String, pub(super) symbols: std::sync::Arc, pub(super) disabled: bool, + pub(super) defer_suppression: bool, pub(super) is_nextjs_project: bool, } diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs index 8ced830b4..6df13a753 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs @@ -17,6 +17,7 @@ pub use prepared::{ pub use prepared::{ analyze_project_with_prepared_facts_and_inferred_and_session, analyze_project_with_prepared_facts_catalog_and_inferred_and_session, + analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check, }; pub fn analyze_project_with_facts( @@ -42,6 +43,7 @@ struct ProjectRootsAnalysis<'a> { shared: &'a CheckFactMap, project_roots: Vec, options: UniqueExportsOptions, + defer_suppression: bool, inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, } @@ -56,6 +58,7 @@ fn analyze_project_roots_with_facts( shared, project_roots, options, + defer_suppression, inferred_roots, } = inputs; if project_roots.is_empty() { @@ -94,7 +97,12 @@ fn analyze_project_roots_with_facts( .collect::>(); let workspace = workspaces::load_from_files_with_session(root, &workspace_files, Some(session)) .unwrap_or_default(); - let source_files = super::scan::collect_source_files_from_facts(root, &symbol_files, shared)?; + let source_files = super::scan::collect_source_files_from_facts( + root, + &symbol_files, + shared, + defer_suppression, + )?; if let Some(catalog) = resolution.catalog { let resolver = crate::codebase::ts_resolver::ScopedImportResolver::new_in_session( catalog, diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs index 517a2f711..0c3a52d47 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs @@ -7,6 +7,9 @@ use crate::codebase::unique_exports::{UniqueExportFinding, RULE_ID}; use anyhow::Result; use std::path::Path; +mod aggregate; +pub use aggregate::analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check; + #[derive(Clone, Copy, Default)] pub(super) struct PreparedResolution<'a> { pub(super) tsconfig_path: Option<&'a Path>, @@ -32,6 +35,7 @@ pub fn analyze_project_with_config_and_facts( shared, None, &session, + false, ) } @@ -53,6 +57,7 @@ pub fn analyze_project_with_prepared_facts( shared, None, &session, + false, ) } @@ -94,6 +99,7 @@ pub fn analyze_project_with_prepared_facts_and_inferred_and_session( shared, Some(inferred_roots), session, + false, ) } @@ -117,6 +123,7 @@ pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session( shared, Some(inferred_roots), session, + false, ) } @@ -127,6 +134,7 @@ fn analyze_project_with_optional_prepared_facts( shared: &CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, session: &AnalysisSession, + defer_suppression: bool, ) -> Result> { let normalized_root = normalize_path(root); let root = normalized_root.as_path(); @@ -154,6 +162,7 @@ fn analyze_project_with_optional_prepared_facts( shared, project_roots, options, + defer_suppression, inferred_roots, })?); } @@ -178,6 +187,7 @@ fn analyze_project_with_optional_prepared_facts( shared, project_roots, options: config.rule_options(RULE_ID), + defer_suppression, inferred_roots, }) } diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs new file mode 100644 index 000000000..4c832e597 --- /dev/null +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs @@ -0,0 +1,32 @@ +use super::{analyze_project_with_optional_prepared_facts, PreparedResolution}; +use crate::codebase::analysis_session::AnalysisSession; +use crate::codebase::check_facts::CheckFactMap; +use crate::codebase::config::Config; +use crate::codebase::unique_exports::UniqueExportFinding; +use anyhow::Result; +use std::path::Path; + +/// Analyze aggregate check facts while deferring directive filtering to the +/// check runner's request-wide SourceStore-backed suppression pass. +#[doc(hidden)] +pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check( + root: &Path, + config: &Config, + tsconfig_catalog: &crate::codebase::ts_resolver::TsConfigCatalog, + shared: &CheckFactMap, + inferred_roots: &crate::codebase::config::InferredRoots, + session: &AnalysisSession, +) -> Result> { + analyze_project_with_optional_prepared_facts( + root, + config, + PreparedResolution { + catalog: Some(tsconfig_catalog), + ..Default::default() + }, + shared, + Some(inferred_roots), + session, + true, + ) +} diff --git a/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts b/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts new file mode 100644 index 000000000..8c7487c1e --- /dev/null +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts @@ -0,0 +1,2 @@ +// This re-export must retain the suppressed source's identity during aggregate auditing. +export { Shared } from './source'; diff --git a/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts b/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts new file mode 100644 index 000000000..ae55bd626 --- /dev/null +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts @@ -0,0 +1,2 @@ +// no-mistakes-disable-file unique-exports: this source is intentionally suppressed +export const Shared = 1; From 8b70a743aeca6225742bc994eb1569a0c417cf93 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 10:28:02 -0700 Subject: [PATCH 07/62] test: cover line suppression accounting --- crates/no-mistakes/src/napi_api/tests/check.rs | 5 +++++ fixtures/check/suppression-accounting/src/c.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 fixtures/check/suppression-accounting/src/c.ts diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index ee2928e56..85cb68e22 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -64,11 +64,16 @@ fn check_json_optionally_accounts_for_suppressed_ordinary_rule_findings() { .unwrap(); let audit: serde_json::Value = serde_json::from_str(&audit).unwrap(); assert_eq!(audit["codebase"], json!([])); + assert_eq!(audit["suppressed"].as_array().unwrap().len(), 2); assert_eq!(audit["suppressed"][0]["domain"], "codebase"); assert_eq!(audit["suppressed"][0]["rule"], "unique-exports"); assert_eq!(audit["suppressed"][0]["line"], 2); assert_eq!(audit["suppressed"][0]["directive"]["kind"], "nextLine"); assert_eq!(audit["suppressed"][0]["directive"]["line"], 1); + assert_eq!(audit["suppressed"][1]["file"], "src/c.ts"); + assert_eq!(audit["suppressed"][1]["line"], 1); + assert_eq!(audit["suppressed"][1]["directive"]["kind"], "line"); + assert_eq!(audit["suppressed"][1]["directive"]["line"], 1); } #[test] diff --git a/fixtures/check/suppression-accounting/src/c.ts b/fixtures/check/suppression-accounting/src/c.ts new file mode 100644 index 000000000..58f3707ac --- /dev/null +++ b/fixtures/check/suppression-accounting/src/c.ts @@ -0,0 +1 @@ +export const shared = 3; // no-mistakes-disable-line unique-exports: retained legacy name From 5c2da05b09cd61e848131eec2c83b18a177846fc Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 10:59:28 -0700 Subject: [PATCH 08/62] fix: defer suppression accounting at rule boundaries --- .../src/check_runner/results/suppression.rs | 66 ++++++++++++++----- .../rules/filesystem_dispatch/execute.rs | 10 ++- .../rules/filesystem_dispatch/run_rule.rs | 1 + .../codebase/rules/rust_max_lines_per_file.rs | 12 +++- .../src/codebase/rules/rust_rules_combined.rs | 21 +++++- .../rules/rust_rules_combined/scan.rs | 51 +++++++++++--- .../src/codebase/unique_exports/collector.rs | 6 ++ .../src/codebase/unique_exports/findings.rs | 14 +++- .../src/codebase/unique_exports/types.rs | 3 + .../no-mistakes/src/napi_api/tests/check.rs | 53 +++++++++++++++ .../src/react_traits/pipeline/check.rs | 1 + .../src/react_traits/report/text/tests.rs | 2 + .../src/react_traits/report/types.rs | 5 ++ .../.no-mistakes.yml | 3 + .../app/Fetcher.tsx | 6 ++ .../.no-mistakes.yml | 3 + .../suppression-rust-combined/src/lib.rs | 3 + .../.no-mistakes.yml | 3 + .../suppression-unique-canonical/src/a.ts | 2 + .../suppression-unique-canonical/src/b.ts | 1 + .../tsconfig.json | 1 + 21 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 fixtures/check/suppression-react-multiple/.no-mistakes.yml create mode 100644 fixtures/check/suppression-react-multiple/app/Fetcher.tsx create mode 100644 fixtures/check/suppression-rust-combined/.no-mistakes.yml create mode 100644 fixtures/check/suppression-rust-combined/src/lib.rs create mode 100644 fixtures/check/suppression-unique-canonical/.no-mistakes.yml create mode 100644 fixtures/check/suppression-unique-canonical/src/a.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/b.ts create mode 100644 fixtures/check/suppression-unique-canonical/tsconfig.json diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index ff9046984..fce93d733 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -31,21 +31,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { codebase, } = input; let mut suppressed = Vec::new(); - suppressed.extend(suppress_domain_findings_with_sources( - root, - react, - sources, - |finding| SuppressionTarget { - domain: "react", - rule: &finding.rule, - file: &finding.file, - line: finding.line, - reason: finding - .detail - .as_deref() - .unwrap_or("component fetch assertion failed"), - }, - )); + suppress_react(root, sources, react, &mut suppressed); suppressed.extend(suppress_domain_findings_with_sources( root, queues, @@ -89,6 +75,56 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { suppressed } +/// A component-level React diagnostic covers every local fetch. Preserve its +/// single stable public finding unless all of those fetches are suppressed. +fn suppress_react( + root: &std::path::Path, + sources: &SourceStore, + findings: &mut Vec, + suppressed: &mut Vec, +) { + findings.retain(|finding| { + let lines = if finding.suppression_lines.is_empty() { + vec![finding.line] + } else { + finding + .suppression_lines + .iter() + .copied() + .map(Some) + .collect() + }; + let mut locations = lines + .into_iter() + .map(|line| react_traits::Violation { + line, + suppression_lines: Vec::new(), + ..finding.clone() + }) + .collect::>(); + suppressed.extend(suppress_domain_findings_with_sources( + root, + &mut locations, + sources, + react_target, + )); + !locations.is_empty() + }); +} + +fn react_target(finding: &react_traits::Violation) -> SuppressionTarget<'_> { + SuppressionTarget { + domain: "react", + rule: &finding.rule, + file: &finding.file, + line: finding.line, + reason: finding + .detail + .as_deref() + .unwrap_or("component fetch assertion failed"), + } +} + fn suppress_rules( root: &std::path::Path, sources: &SourceStore, diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs index 88e3e1504..910dd4302 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs @@ -12,6 +12,7 @@ struct RuleRunInputs<'a> { vitest_catalog: Option<&'a super::super::PreparedVitestProjectCatalog>, sources: &'a std::sync::Arc, facts: Option<&'a crate::codebase::check_facts::CheckFactMap>, + defer_suppression: bool, workflow_documents: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, tsconfig_gate_project_inputs: Option<&'a tsconfig_gate_coverage::ProjectSourceInputs>, config_path: Option<&'a Path>, @@ -97,6 +98,7 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( vitest_catalog, sources: &sources, facts, + defer_suppression, workflow_documents, tsconfig_gate_project_inputs, config_path, @@ -127,7 +129,7 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( } fn run_enabled_rules(inputs: &RuleRunInputs<'_>) { - macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources($id, $call, inputs.root, inputs.config, inputs.candidates.candidates($id), inputs.sources, inputs.facts); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; spawn_special_rules(scope, inputs); }); }; } + macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources($id, $call, inputs.root, inputs.config, inputs.candidates.candidates($id), inputs.sources, inputs.facts, inputs.defer_suppression); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; spawn_special_rules(scope, inputs); }); }; } crate::filesystem_rules!(run_rules); } @@ -139,6 +141,7 @@ fn spawn_special_rules<'a>(scope: &rayon::Scope<'a>, inputs: &'a RuleRunInputs<' vitest_catalog, sources, facts: _, + defer_suppression, workflow_documents, tsconfig_gate_project_inputs, config_path, @@ -148,13 +151,14 @@ fn spawn_special_rules<'a>(scope: &rayon::Scope<'a>, inputs: &'a RuleRunInputs<' } = *inputs; markdown_dispatch::spawn(scope, root, config, candidates, markdown_facts, acc); if registry::rust_rules_enabled(config) { - scope.spawn(|_| { - let result = rust_rules_combined::check_with_files_and_sources( + scope.spawn(move |_| { + let result = rust_rules_combined::check_with_files_sources_and_deferred_suppression( root, config, candidates.rust_candidates(), candidates.exclusive_rust_candidates(), sources, + defer_suppression, ); acc.lock() .expect("mutex poisoned") diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs index fea38e021..d09b4b4a3 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs @@ -12,6 +12,7 @@ pub(super) fn run_rule_with_sources( files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, facts: Option<&crate::codebase::check_facts::CheckFactMap>, + _defer_suppression: bool, ) -> Result> { match rule_id { AGENTS_MD_MAX_SIZE => { diff --git a/crates/no-mistakes/src/codebase/rules/rust_max_lines_per_file.rs b/crates/no-mistakes/src/codebase/rules/rust_max_lines_per_file.rs index 8a1590831..4e3b42b39 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_max_lines_per_file.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_max_lines_per_file.rs @@ -94,7 +94,17 @@ pub(crate) fn check_source( content: &str, limit: usize, ) -> Option { - if has_disable_file_comment(content, RULE_ID) { + check_source_with_deferred_suppression(path, root, content, limit, false) +} + +pub(crate) fn check_source_with_deferred_suppression( + path: &Path, + root: &Path, + content: &str, + limit: usize, + defer_suppression: bool, +) -> Option { + if !defer_suppression && has_disable_file_comment(content, RULE_ID) { return None; } let code_lines = count_code_lines(content); diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs index a1d419262..03f37d2a7 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs @@ -34,6 +34,24 @@ pub(crate) fn check_with_files_and_sources( all_files: &[PathBuf], exclusive_files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, +) -> Result> { + check_with_files_sources_and_deferred_suppression( + root, + config, + all_files, + exclusive_files, + sources, + false, + ) +} + +pub(crate) fn check_with_files_sources_and_deferred_suppression( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], + exclusive_files: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Result> { let mut work = BTreeMap::::new(); add_max_lines_work(root, config, all_files, &mut work)?; @@ -43,12 +61,13 @@ pub(crate) fn check_with_files_and_sources( let mut findings: Vec = work .par_iter() .flat_map(|(path, work)| { - scan::scan_file( + scan::scan_file_with_deferred_suppression( root, path, work, exclusive_files.binary_search(path).is_ok(), sources, + defer_suppression, ) }) .collect(); diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs index 30d0936ed..10d8ee766 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs @@ -6,17 +6,34 @@ pub(super) fn scan_file( work: &RustWork, exclusive: bool, sources: &crate::codebase::ts_source::SourceStore, +) -> Vec { + scan_file_with_deferred_suppression(root, path, work, exclusive, sources, false) +} + +pub(super) fn scan_file_with_deferred_suppression( + root: &Path, + path: &Path, + work: &RustWork, + exclusive: bool, + sources: &crate::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Vec { if exclusive { let Ok(content) = std::fs::read_to_string(path) else { return Vec::new(); }; - return scan_file_with_source(root, path, work, &content); + return scan_file_with_source_and_deferred_suppression( + root, + path, + work, + &content, + defer_suppression, + ); } let Some(content) = super::super::read_source(sources, path) else { return Vec::new(); }; - scan_file_with_source(root, path, work, &content) + scan_file_with_source_and_deferred_suppression(root, path, work, &content, defer_suppression) } pub(super) fn scan_file_with_source( @@ -24,18 +41,34 @@ pub(super) fn scan_file_with_source( path: &Path, work: &RustWork, content: &str, +) -> Vec { + scan_file_with_source_and_deferred_suppression(root, path, work, content, false) +} + +pub(super) fn scan_file_with_source_and_deferred_suppression( + root: &Path, + path: &Path, + work: &RustWork, + content: &str, + defer_suppression: bool, ) -> Vec { let mut findings = Vec::new(); for limit in &work.max_limits { - if let Some(finding) = rust_max_lines_per_file::check_source(path, root, content, *limit) { + if let Some(finding) = rust_max_lines_per_file::check_source_with_deferred_suppression( + path, + root, + content, + *limit, + defer_suppression, + ) { findings.push(finding); } } - let inline_tests_enabled = - work.inline_tests && !has_disable_file_comment(content, RUST_NO_INLINE_TESTS); - let inline_allows_enabled = - work.inline_allows && !has_disable_file_comment(content, RUST_NO_INLINE_ALLOWS); + let inline_tests_enabled = work.inline_tests + && (defer_suppression || !has_disable_file_comment(content, RUST_NO_INLINE_TESTS)); + let inline_allows_enabled = work.inline_allows + && (defer_suppression || !has_disable_file_comment(content, RUST_NO_INLINE_ALLOWS)); let needs_inline_tests_parse = inline_tests_enabled && content.contains("cfg") && content.contains("test"); let needs_inline_allows_parse = inline_allows_enabled && content.contains("allow"); @@ -55,7 +88,9 @@ pub(super) fn scan_file_with_source( } let mut findings = dedup_findings(findings); - super::super::suppress_rule_findings_with_source(&mut findings, content); + if !defer_suppression { + super::super::suppress_rule_findings_with_source(&mut findings, content); + } findings } diff --git a/crates/no-mistakes/src/codebase/unique_exports/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index d9aa9b1f1..1935a1753 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/collector.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/collector.rs @@ -62,6 +62,8 @@ pub(super) fn collect_file_exports( occurrence.file = file.rel.clone(); occurrence.line = export.line; occurrence.kind = export_kind_str(&export.kind).to_string(); + occurrence.suppressed = + file.disabled || has_disable_comment(&file.source, export.line, RULE_ID); if !super::nextjs::is_framework_export( &occurrence.file, &occurrence.name, @@ -108,6 +110,8 @@ pub(super) fn collect_file_exports( line: export.line, kind: export_kind_str(&export.kind).to_string(), origin, + suppressed: file.disabled + || has_disable_comment(&file.source, export.line, RULE_ID), }); } _ => { @@ -119,6 +123,8 @@ pub(super) fn collect_file_exports( line: export.line, kind: export_kind_str(&export.kind).to_string(), origin: origin_for_export(file, export, bucket), + suppressed: file.disabled + || has_disable_comment(&file.source, export.line, RULE_ID), }); } } diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 8855cd543..83b1deb59 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -31,8 +31,18 @@ pub(super) fn unique_export_findings( if unique_occurrences.len() < 2 { continue; } - let first = &unique_occurrences[0]; - for duplicate in unique_occurrences.iter().skip(1) { + // In aggregate mode preserve standalone semantics: a suppressed + // occurrence must not turn an unsuppressed export into a duplicate. + // Still emit the suppressed occurrence so finalization can account for + // its directive. + let first = unique_occurrences + .iter() + .find(|occurrence| !occurrence.suppressed) + .unwrap_or(&unique_occurrences[0]); + for duplicate in unique_occurrences + .iter() + .filter(|item| !std::ptr::eq(*item, first)) + { findings.push(UniqueExportFinding { rule: RULE_ID.to_string(), file: duplicate.file.clone(), diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index d58a96b9e..a4b3493da 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -79,6 +79,9 @@ pub(super) struct ExportOccurrence { pub(super) line: u32, pub(super) kind: String, pub(super) origin: ExportOrigin, + /// Deferred aggregate analysis needs this to keep a suppressed occurrence + /// from becoming the canonical export for a visible duplicate. + pub(super) suppressed: bool, } #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 85cb68e22..81caca197 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -146,6 +146,59 @@ fn check_json_records_react_next_line_directive_at_the_fetch_location() { assert_eq!(finding["directive"]["line"], 2); } +#[test] +fn check_json_keeps_unsuppressed_duplicate_when_suppressed_export_sorts_first() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-unique-canonical"); + let baseline: serde_json::Value = + serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) + .unwrap(); + assert!(baseline["codebase"].as_array().is_some_and(Vec::is_empty)); + + let audit: serde_json::Value = serde_json::from_str( + &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), + ) + .unwrap(); + assert!(audit["codebase"].as_array().is_some_and(Vec::is_empty)); + assert!(audit["suppressed"].as_array().is_some_and(|items| items + .iter() + .any(|item| { item["rule"] == "unique-exports" && item["file"] == "src/a.ts" }))); +} + +#[test] +fn check_json_does_not_hide_later_react_fetch_after_first_is_suppressed() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-react-multiple"); + let output = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(!value["react"].as_array().unwrap().is_empty(), "{value}"); + assert!(value["suppressed"].as_array().is_some_and(|items| items + .iter() + .any(|item| { item["domain"] == "react" && item["line"] == 3 }))); +} + +#[test] +fn check_json_accounts_for_suppressed_combined_rust_rule() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-rust-combined"); + let baseline: serde_json::Value = + serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) + .unwrap(); + assert!(baseline["rules"].as_array().is_some_and(Vec::is_empty)); + + let audit: serde_json::Value = serde_json::from_str( + &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), + ) + .unwrap(); + assert!(audit["rules"].as_array().is_some_and(Vec::is_empty)); + assert!(audit["suppressed"] + .as_array() + .is_some_and(|items| items.iter().any(|item| { + item["domain"] == "filesystem" && item["rule"] == "rust-no-inline-allows" + }))); +} + #[test] fn check_json_returns_warnings_for_skipped_configured_check() { let options = json!({ diff --git a/crates/no-mistakes/src/react_traits/pipeline/check.rs b/crates/no-mistakes/src/react_traits/pipeline/check.rs index 59d8af1e9..1e528290c 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check.rs @@ -119,6 +119,7 @@ fn assert_no_fetch_violations( rule: "assert-no-fetch".to_string(), detail: facts.fetches.first().and_then(|f| f.shape.clone()), line: facts.fetches.first().map(|f| f.line), + suppression_lines: facts.fetches.iter().map(|fetch| fetch.line).collect(), }); } } diff --git a/crates/no-mistakes/src/react_traits/report/text/tests.rs b/crates/no-mistakes/src/react_traits/report/text/tests.rs index 56b34fcaf..997de2624 100644 --- a/crates/no-mistakes/src/react_traits/report/text/tests.rs +++ b/crates/no-mistakes/src/react_traits/report/text/tests.rs @@ -79,6 +79,7 @@ fn print_violations_outputs_violations() { rule: "assert-no-fetch".to_string(), detail: Some("GET /api/users".to_string()), line: Some(1), + suppression_lines: vec![1], }]; print_violations(&violations); } @@ -91,6 +92,7 @@ fn print_violations_no_detail() { rule: "assert-no-fetch".to_string(), detail: None, line: None, + suppression_lines: Vec::new(), }]; print_violations(&violations); } diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index 0e96c296d..2f24450e4 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -120,6 +120,11 @@ pub struct Violation { /// React check output remains byte-for-byte compatible. #[serde(skip)] pub line: Option, + /// All local fetch locations represented by this component-level + /// diagnostic. Aggregate suppression uses these without changing the + /// stable direct React report schema. + #[serde(skip)] + pub suppression_lines: Vec, } #[derive(Default, Deserialize)] diff --git a/fixtures/check/suppression-react-multiple/.no-mistakes.yml b/fixtures/check/suppression-react-multiple/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/suppression-react-multiple/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/suppression-react-multiple/app/Fetcher.tsx b/fixtures/check/suppression-react-multiple/app/Fetcher.tsx new file mode 100644 index 000000000..6efd1bc8a --- /dev/null +++ b/fixtures/check/suppression-react-multiple/app/Fetcher.tsx @@ -0,0 +1,6 @@ +export default async function Fetcher() { + // no-mistakes-disable-next-line assert-no-fetch: first call is intentional + await fetch('/api/first'); + await fetch('/api/second'); + return
; +} diff --git a/fixtures/check/suppression-rust-combined/.no-mistakes.yml b/fixtures/check/suppression-rust-combined/.no-mistakes.yml new file mode 100644 index 000000000..fc96c1038 --- /dev/null +++ b/fixtures/check/suppression-rust-combined/.no-mistakes.yml @@ -0,0 +1,3 @@ +rules: + - rule: rust-no-inline-allows + scope: repository diff --git a/fixtures/check/suppression-rust-combined/src/lib.rs b/fixtures/check/suppression-rust-combined/src/lib.rs new file mode 100644 index 000000000..d3a151da4 --- /dev/null +++ b/fixtures/check/suppression-rust-combined/src/lib.rs @@ -0,0 +1,3 @@ +// no-mistakes-disable-file rust-no-inline-allows: compatibility exception +#[allow(dead_code)] +fn retained() {} diff --git a/fixtures/check/suppression-unique-canonical/.no-mistakes.yml b/fixtures/check/suppression-unique-canonical/.no-mistakes.yml new file mode 100644 index 000000000..177df01d8 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/.no-mistakes.yml @@ -0,0 +1,3 @@ +rules: + - rule: unique-exports + scope: repository diff --git a/fixtures/check/suppression-unique-canonical/src/a.ts b/fixtures/check/suppression-unique-canonical/src/a.ts new file mode 100644 index 000000000..e0fb439b9 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/a.ts @@ -0,0 +1,2 @@ +// no-mistakes-disable-file unique-exports: compatibility export +export const shared = 1; diff --git a/fixtures/check/suppression-unique-canonical/src/b.ts b/fixtures/check/suppression-unique-canonical/src/b.ts new file mode 100644 index 000000000..0ffb8aff2 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/b.ts @@ -0,0 +1 @@ +export const shared = 2; diff --git a/fixtures/check/suppression-unique-canonical/tsconfig.json b/fixtures/check/suppression-unique-canonical/tsconfig.json new file mode 100644 index 000000000..0bfe0a4c7 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"ESNext"}} From fcf06dccdda8807db720170d1eba69fb38f96577 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 11:06:03 -0700 Subject: [PATCH 09/62] fix: defer remaining aggregate rule suppressions --- .../src/codebase/rules/agents_md_max_size.rs | 18 ++++++- .../agents_md_max_size_budget.rs | 25 ++++++++-- .../rules/filesystem_dispatch/run_rule.rs | 10 +++- .../codebase/rules/nextjs_no_api_routes.rs | 11 ++++- .../rules/nextjs_no_api_routes/aggregate.rs | 22 +++++++-- .../src/codebase/rules/nextjs_no_caching.rs | 32 +++++++++--- .../codebase/rules/run/prepared/execution.rs | 49 +++++++++---------- .../rules/server_route_client_boundary.rs | 28 ++++++++--- .../server_route_client_boundary/execution.rs | 1 + .../reachable.rs | 22 +++++++-- .../with_facts.rs | 10 ++-- .../with_facts/graph.rs | 1 + 12 files changed, 171 insertions(+), 58 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs index 03da6f985..42354255d 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs @@ -110,6 +110,16 @@ pub(crate) fn check_with_files_and_sources( config: &NoMistakesConfig, all_files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, +) -> Result> { + check_with_files_sources_and_deferred_suppression(root, config, all_files, sources, false) +} + +pub(crate) fn check_with_files_sources_and_deferred_suppression( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Result> { let mut findings = Vec::new(); for rule in config.rule_applications(RULE_ID) { @@ -129,7 +139,13 @@ pub(crate) fn check_with_files_and_sources( .cloned() .collect(); let files = super::path_filter::filter_rule_files(root, config, rule, &files)?; - findings.extend(scan_with_sources(root, &opts, &files, sources)?); + findings.extend(scan_with_sources( + root, + &opts, + &files, + sources, + defer_suppression, + )?); } super::sort_findings(&mut findings); Ok(findings) diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs index a7f39057c..26cd89169 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; pub(super) fn scan(root: &Path, opts: &Options, files: &[PathBuf]) -> Result> { let sources = crate::codebase::rules::source_store_for_files(files); - scan_with_sources(root, opts, files, &sources) + scan_with_sources(root, opts, files, &sources, false) } pub(super) fn scan_with_sources( @@ -15,6 +15,7 @@ pub(super) fn scan_with_sources( opts: &Options, files: &[PathBuf], sources: &SourceStore, + defer_suppression: bool, ) -> Result> { let max_lines = opts.max_lines.unwrap_or(DEFAULT_MAX_LINES); let max_chars = opts.max_chars.unwrap_or(DEFAULT_MAX_CHARS); @@ -24,7 +25,14 @@ pub(super) fn scan_with_sources( let Some(content) = crate::codebase::rules::read_source(sources, path) else { return Vec::new(); }; - check_content(path, root, max_lines, max_chars, &content) + check_content_with_deferred_suppression( + path, + root, + max_lines, + max_chars, + &content, + defer_suppression, + ) }) .collect(); findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.message.cmp(&b.message))); @@ -57,7 +65,18 @@ pub(super) fn check_content( max_chars: usize, content: &str, ) -> Vec { - if has_disable_file_comment(content, RULE_ID) { + check_content_with_deferred_suppression(path, root, max_lines, max_chars, content, false) +} + +fn check_content_with_deferred_suppression( + path: &Path, + root: &Path, + max_lines: usize, + max_chars: usize, + content: &str, + defer_suppression: bool, +) -> Vec { + if !defer_suppression && has_disable_file_comment(content, RULE_ID) { return Vec::new(); } let file = relative_slash_path(root, path); diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs index d09b4b4a3..bd6475bc4 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs @@ -12,11 +12,17 @@ pub(super) fn run_rule_with_sources( files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, facts: Option<&crate::codebase::check_facts::CheckFactMap>, - _defer_suppression: bool, + defer_suppression: bool, ) -> Result> { match rule_id { AGENTS_MD_MAX_SIZE => { - agents_md_max_size::check_with_files_and_sources(root, config, files, sources) + agents_md_max_size::check_with_files_sources_and_deferred_suppression( + root, + config, + files, + sources, + defer_suppression, + ) } FINITE_SET_CONSISTENCY => finite_set_consistency::check_with_files_sources_and_facts( root, diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs index 34a3ae962..65bd27cda 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs @@ -8,7 +8,10 @@ use anyhow::Result; use std::path::{Path, PathBuf}; mod aggregate; -pub(crate) use aggregate::{check_with_facts, check_with_facts_and_inferred}; +#[allow(unused_imports)] +pub(crate) use aggregate::{ + check_with_facts, check_with_facts_and_inferred, check_with_facts_for_aggregate, +}; pub const RULE_ID: &str = "nextjs-no-api-routes"; @@ -36,8 +39,12 @@ fn finding_for_file( target_roots: &[PathBuf], path: &Path, source: &str, + defer_suppression: bool, ) -> Option { - if has_disable_file_comment(source, RULE_ID) || has_disable_line_comment(source, 1, RULE_ID) { + if !defer_suppression + && (has_disable_file_comment(source, RULE_ID) + || has_disable_line_comment(source, 1, RULE_ID)) + { return None; } if !target_roots diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs index 466353cac..59025ffa1 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs @@ -9,7 +9,7 @@ pub(crate) fn check_with_facts( config: &NoMistakesConfig, shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result> { - check_with_optional_inferred(root, config, shared, None) + check_with_optional_inferred(root, config, shared, None, false) } pub(crate) fn check_with_facts_and_inferred( @@ -18,7 +18,17 @@ pub(crate) fn check_with_facts_and_inferred( shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: &crate::codebase::config::InferredRoots, ) -> Result> { - check_with_optional_inferred(root, config, shared, Some(inferred_roots)) + check_with_optional_inferred(root, config, shared, Some(inferred_roots), false) +} + +pub(crate) fn check_with_facts_for_aggregate( + root: &Path, + config: &NoMistakesConfig, + shared: &crate::codebase::check_facts::CheckFactMap, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, +) -> Result> { + check_with_optional_inferred(root, config, shared, inferred_roots, defer_suppression) } fn check_with_optional_inferred( @@ -26,6 +36,7 @@ fn check_with_optional_inferred( config: &NoMistakesConfig, shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, ) -> Result> { let root = crate::codebase::ts_resolver::normalize_path(root); let target_roots = target_roots(&root, config, inferred_roots); @@ -55,6 +66,7 @@ fn check_with_optional_inferred( |item| item.path, |item| item.source, inferred_roots, + defer_suppression, ) } @@ -98,6 +110,7 @@ pub(super) fn check_files( |item| item.path.as_path(), |item| item.source.as_ref(), None, + false, ) } @@ -108,6 +121,7 @@ fn check_items( path_for: impl Fn(&T) -> &Path + Sync, source_for: impl Fn(&T) -> &str + Sync, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, ) -> Result> where T: Sync, @@ -135,7 +149,9 @@ where let source = source_for(item); filter .is_match(path) - .then(|| finding_for_file(root, &target_roots, path, source)) + .then(|| { + finding_for_file(root, &target_roots, path, source, defer_suppression) + }) .flatten() }) .collect::>(), diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs index fac55f3e7..2ebbb2866 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs @@ -42,7 +42,7 @@ pub(crate) fn check_with_facts( config: &NoMistakesConfig, shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result> { - check_with_optional_inferred(root, config, shared, None) + check_with_optional_inferred(root, config, shared, None, false) } pub(crate) fn check_with_facts_and_inferred( @@ -51,7 +51,17 @@ pub(crate) fn check_with_facts_and_inferred( shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: &crate::codebase::config::InferredRoots, ) -> Result> { - check_with_optional_inferred(root, config, shared, Some(inferred_roots)) + check_with_optional_inferred(root, config, shared, Some(inferred_roots), false) +} + +pub(crate) fn check_with_facts_for_aggregate( + root: &Path, + config: &NoMistakesConfig, + shared: &crate::codebase::check_facts::CheckFactMap, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, +) -> Result> { + check_with_optional_inferred(root, config, shared, inferred_roots, defer_suppression) } fn check_with_optional_inferred( @@ -59,6 +69,7 @@ fn check_with_optional_inferred( config: &NoMistakesConfig, shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, ) -> Result> { let root = crate::codebase::ts_resolver::normalize_path(root); let mut findings = Vec::new(); @@ -96,7 +107,13 @@ fn check_with_optional_inferred( path.display() ); }; - findings.extend(findings_for_file(&root, path, source, cache_facts)); + findings.extend(findings_for_file( + &root, + path, + source, + cache_facts, + defer_suppression, + )); } } super::sort_findings(&mut findings); @@ -135,7 +152,7 @@ fn check_files( let Ok(cache_facts) = extract(path, &source) else { return Vec::new(); }; - findings_for_file(root, path, &source, &cache_facts) + findings_for_file(root, path, &source, &cache_facts, false) }) .collect::>(), ); @@ -149,14 +166,17 @@ fn findings_for_file( path: &Path, source: &str, cache_facts: &[NextjsCachingFinding], + defer_suppression: bool, ) -> Vec { - if has_disable_file_comment(source, RULE_ID) { + if !defer_suppression && has_disable_file_comment(source, RULE_ID) { return Vec::new(); } let file = relative_slash_path(root, path); cache_facts .iter() - .filter(|finding| !has_disable_comment(source, finding.line as u32, RULE_ID)) + .filter(|finding| { + defer_suppression || !has_disable_comment(source, finding.line as u32, RULE_ID) + }) .map(|finding| RuleFinding { rule: RULE_ID.to_string(), file: file.clone(), diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index 3e2fbdb2e..4c68fa037 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -92,44 +92,39 @@ pub(super) fn run( shared, dependency_graph.expect("dynamic-import rule requires canonical graph"), &session, + defer_suppression, ) }, )?); } if rule_enabled(config, SERVER_ROUTE_CLIENT_BOUNDARY) { - let boundary_findings = match inferred_roots { - Some(inferred_roots) => server_route_client_boundary::check_with_facts_and_inferred( - root, - config, - shared, - inferred_roots, - ), - None => server_route_client_boundary::check_with_facts(root, config, shared), - }?; + let boundary_findings = server_route_client_boundary::check_with_facts_for_aggregate( + root, + config, + shared, + inferred_roots, + defer_suppression, + )?; findings.extend(boundary_findings); } if rule_enabled(config, NEXTJS_NO_API_ROUTES) { - let api_route_findings = match inferred_roots { - Some(inferred_roots) => nextjs_no_api_routes::check_with_facts_and_inferred( - root, - config, - shared, - inferred_roots, - ), - None => nextjs_no_api_routes::check_with_facts(root, config, shared), - }?; + let api_route_findings = nextjs_no_api_routes::check_with_facts_for_aggregate( + root, + config, + shared, + inferred_roots, + defer_suppression, + )?; findings.extend(api_route_findings); } if rule_enabled(config, NEXTJS_NO_CACHING) { - findings.extend(match inferred_roots { - Some(inferred_roots) => nextjs_no_caching::check_with_facts_and_inferred( - root, - config, - shared, - inferred_roots, - ), - None => nextjs_no_caching::check_with_facts(root, config, shared), - }?); + findings.extend(nextjs_no_caching::check_with_facts_for_aggregate( + root, + config, + shared, + inferred_roots, + defer_suppression, + )?); } if rule_enabled(config, REQUIRE_STORYBOOK_STORIES) { findings.extend(storybook_findings( diff --git a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs index 3dc77b78c..1449a1b52 100644 --- a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs +++ b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs @@ -20,6 +20,7 @@ pub const RULE_ID: &str = "server-route-client-boundary"; pub(crate) struct FileFacts { has_server_route_shape: bool, client_call_lines: Vec, + disabled: bool, } pub(crate) fn extract_program( @@ -29,11 +30,8 @@ pub(crate) fn extract_program( ) -> FileFacts { FileFacts { has_server_route_shape: ast::has_server_like_route_call_from_program(path, source, program), - client_call_lines: if has_disable_file_comment(source, RULE_ID) { - Vec::new() - } else { - ast::client_call_lines_from_program(source, program) - }, + client_call_lines: ast::client_call_lines_from_program(source, program), + disabled: has_disable_file_comment(source, RULE_ID), } } @@ -59,7 +57,7 @@ pub(crate) fn check_with_facts( config: &NoMistakesConfig, shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result> { - check_with_optional_inferred(root, config, shared, None) + check_with_optional_inferred(root, config, shared, None, false) } pub(crate) fn check_with_facts_and_inferred( @@ -68,7 +66,17 @@ pub(crate) fn check_with_facts_and_inferred( shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: &crate::codebase::config::InferredRoots, ) -> Result> { - check_with_optional_inferred(root, config, shared, Some(inferred_roots)) + check_with_optional_inferred(root, config, shared, Some(inferred_roots), false) +} + +pub(crate) fn check_with_facts_for_aggregate( + root: &Path, + config: &NoMistakesConfig, + shared: &crate::codebase::check_facts::CheckFactMap, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, +) -> Result> { + check_with_optional_inferred(root, config, shared, inferred_roots, defer_suppression) } fn check_with_optional_inferred( @@ -76,6 +84,7 @@ fn check_with_optional_inferred( config: &NoMistakesConfig, shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, ) -> Result> { let root = crate::codebase::ts_resolver::normalize_path(root); let mut facts = Vec::new(); @@ -95,6 +104,7 @@ fn check_with_optional_inferred( |item| item.path, |item| item.facts, inferred_roots, + defer_suppression, ) } @@ -105,6 +115,7 @@ pub(super) fn check_items( path_for: impl Fn(&T) -> &Path + Sync, facts_for: impl Fn(&T) -> &FileFacts + Sync, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, ) -> Result> where T: Sync, @@ -153,6 +164,9 @@ where }) .flat_map(|item| { let path = path_for(item); + if !defer_suppression && facts_for(item).disabled { + return Vec::new(); + } client_findings_for_file(root, path, facts_for(item)) }) .collect::>(), diff --git a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/execution.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/execution.rs index 1e7ed058a..80b2ebde8 100644 --- a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/execution.rs @@ -47,5 +47,6 @@ pub(super) fn check_files( |item| item.path.as_path(), |item| &item.facts, None, + false, ) } diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs index a26d891c7..584b94382 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs @@ -32,6 +32,16 @@ pub(super) fn collect( test_file: &Path, mocks: &HashSet, dependency_cache: &DashMap>>, +) -> Result { + collect_with_deferred_suppression(ctx, test_file, mocks, dependency_cache, false) +} + +pub(super) fn collect_with_deferred_suppression( + ctx: ReachableContext<'_>, + test_file: &Path, + mocks: &HashSet, + dependency_cache: &DashMap>>, + defer_suppression: bool, ) -> Result { let test_reachable = dependency_cache .entry(test_file.to_path_buf()) @@ -74,7 +84,7 @@ pub(super) fn collect( file_facts.source.as_deref(), file_facts.dynamic_imports.as_ref(), ) { - if has_disable_file_comment(source, RULE_ID) { + if !defer_suppression && has_disable_file_comment(source, RULE_ID) { continue; } let mut local_findings = Vec::new(); @@ -90,7 +100,9 @@ pub(super) fn collect( findings: &mut local_findings, }; for import in &facts.dynamic_imports { - if !has_disable_comment(source, import.line as u32, RULE_ID) { + if defer_suppression + || !has_disable_comment(source, import.line as u32, RULE_ID) + { collect_outcome( &mut result, evaluate_dynamic_import(&check_context, import.clone()), @@ -101,7 +113,7 @@ pub(super) fn collect( } } let cached = get_or_cache_file(file, ctx.file_cache)?; - if has_disable_file_comment(&cached.source, RULE_ID) { + if !defer_suppression && has_disable_file_comment(&cached.source, RULE_ID) { continue; } let mut local_findings = Vec::new(); @@ -117,7 +129,9 @@ pub(super) fn collect( findings: &mut local_findings, }; for import in &cached.dynamic_imports { - if has_disable_comment(&cached.source, import.line as u32, RULE_ID) { + if !defer_suppression + && has_disable_comment(&cached.source, import.line as u32, RULE_ID) + { continue; } collect_outcome( diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index 3b3d7596c..e0570e20e 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -61,6 +61,7 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( shared: &CheckFactMap, graph: &DepGraph, session: &std::sync::Arc, + defer_suppression: bool, ) -> Result> { let files = shared.files().to_vec(); let visible_files = files.iter().cloned().collect::>(); @@ -102,7 +103,7 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( let Some(source) = file_facts.source.as_deref() else { anyhow::bail!("missing source facts for {}", file.display()); }; - if has_disable_file_comment(source, RULE_ID) { + if !defer_suppression && has_disable_file_comment(source, RULE_ID) { return Ok(PerTestResult { direct_findings: Vec::new(), reachable_findings: Vec::new(), @@ -144,12 +145,14 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( findings: &mut local_findings, }; for import in &facts.dynamic_imports { - if !has_disable_comment(source, import.line as u32, RULE_ID) { + if defer_suppression + || !has_disable_comment(source, import.line as u32, RULE_ID) + { check_dynamic_import(&mut check_context, import.clone()); } } } - let reachable = reachable::collect( + let reachable = reachable::collect_with_deferred_suppression( reachable::ReachableContext { root, config, @@ -163,6 +166,7 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( &file, &mocks, &dependency_cache, + defer_suppression, )?; Ok(PerTestResult { direct_findings: local_findings, diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs index 62be31ebd..6bd163aa5 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs @@ -36,5 +36,6 @@ pub(crate) fn check_with_prepared_facts_and_session( shared, &graph, session, + false, ) } From 748c98c75d9179c6b1c35b2db8f29ae0b244d1f1 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 11:08:25 -0700 Subject: [PATCH 10/62] fix: defer storybook suppression in aggregate checks --- .../rules/require_storybook_stories.rs | 3 +- .../require_storybook_stories/prepared.rs | 32 ++++++++++++++++++- .../rules/require_storybook_stories/runner.rs | 10 ++++-- .../require_storybook_stories/selection.rs | 3 +- .../tests/coverage_helpers.rs | 1 + .../codebase/rules/run/prepared/execution.rs | 1 + .../rules/run/prepared/execution/helpers.rs | 29 ++++++----------- 7 files changed, 55 insertions(+), 24 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs index 718341c6d..fb4f0b742 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs @@ -22,6 +22,7 @@ use coverage_graph::{dynamic_or_mock_boundary_files, transitive_covered_componen use findings::{namespace_import_findings, stale_or_blank_allow_findings}; pub(crate) use prepared::{ check_with_prepared_facts_and_inferred_and_session, check_with_prepared_facts_and_session, + check_with_prepared_facts_for_aggregate, }; use selection::{component_disabled, file_disabled, selected_components}; use types::{GlobMatcher, Options}; @@ -101,7 +102,7 @@ fn check_with_facts_and_catalog( &visible_files, &session, ); - runner::check_with_resolver(root, config, shared, &resolver, inferred_roots) + runner::check_with_resolver(root, config, shared, &resolver, inferred_roots, false) } fn tsconfig_catalog( diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs index d9a7a95f0..108684476 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs @@ -19,6 +19,7 @@ pub(crate) fn check_with_prepared_facts_and_session( shared, None, session, + false, ) } @@ -37,6 +38,27 @@ pub(crate) fn check_with_prepared_facts_and_inferred_and_session( shared, Some(inferred_roots), session, + false, + ) +} + +pub(crate) fn check_with_prepared_facts_for_aggregate( + root: &Path, + config: &NoMistakesConfig, + prepared_tsconfig_catalog: &TsConfigCatalog, + shared: &CheckFactMap, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, + session: &AnalysisSession, + defer_suppression: bool, +) -> Result> { + check_with_optional_inferred( + root, + config, + prepared_tsconfig_catalog, + shared, + inferred_roots, + session, + defer_suppression, ) } @@ -47,6 +69,7 @@ fn check_with_optional_inferred( shared: &CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, session: &AnalysisSession, + defer_suppression: bool, ) -> Result> { let visible_files = shared .files() @@ -55,5 +78,12 @@ fn check_with_optional_inferred( .collect::>(); let resolver = ScopedImportResolver::new_in_session(prepared_tsconfig_catalog, &visible_files, session); - check_with_resolver(root, config, shared, &resolver, inferred_roots) + check_with_resolver( + root, + config, + shared, + &resolver, + inferred_roots, + defer_suppression, + ) } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs index 82eadd105..e50ec91c2 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs @@ -21,6 +21,7 @@ struct RuleCheck<'a> { shared: &'a CheckFactMap, resolver: &'a dyn ImportResolution, inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, + defer_suppression: bool, } pub(super) fn check_with_resolver( @@ -29,6 +30,7 @@ pub(super) fn check_with_resolver( shared: &CheckFactMap, resolver: &dyn ImportResolution, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + defer_suppression: bool, ) -> Result> { let root = normalize_path(root); let mut findings = Vec::new(); @@ -53,6 +55,7 @@ pub(super) fn check_with_resolver( shared, resolver, inferred_roots, + defer_suppression, })?); } sort_findings(&mut findings); @@ -68,6 +71,7 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { shared, resolver, inferred_roots, + defer_suppression, } = inputs; let opts: Options = rule.rule_options(); let mut inferred_roots = inferred_roots.cloned().unwrap_or_default(); @@ -87,6 +91,7 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { &include, &exclude, &test_filter, + defer_suppression, ) .into_iter() .filter(|component| rule_filter.is_match(&component.file)) @@ -141,8 +146,9 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { } if opts.allow_components.contains_key(&component.key) || allow_files.is_match(&component.project_file) - || file_disabled(shared, &component.file) - || component_disabled(shared, &component.file, component.line) + || (!defer_suppression + && (file_disabled(shared, &component.file) + || component_disabled(shared, &component.file, component.line))) { continue; } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs index 44c74570d..3b3d97587 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs @@ -20,6 +20,7 @@ pub(super) fn selected_components( include: &GlobMatcher, exclude: &GlobMatcher, test_filter: &crate::codebase::test_filter::TestFileFilter, + defer_suppression: bool, ) -> Vec { let mut components = Vec::new(); let scoped_files = shared.files().iter().collect::>(); @@ -61,7 +62,7 @@ pub(super) fn selected_components( continue; } let line = export_line(facts, &component.name).unwrap_or(1) as usize; - if component_disabled(shared, path, line) { + if !defer_suppression && component_disabled(shared, path, line) { continue; } components.push(Component { diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs index b03973e88..52844e4f8 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs @@ -384,6 +384,7 @@ fn selection_and_transitive_helpers_cover_skip_paths() { &include, &exclude, &test_filter, + false, ); assert_eq!( selected diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index 4c68fa037..4b2d0f39a 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -134,6 +134,7 @@ pub(super) fn run( shared, inferred_roots, &session, + defer_suppression, )?); } if crate::playwright::rules::configured(config) { diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs index 784871612..f6ee86871 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs @@ -7,26 +7,17 @@ pub(super) fn storybook_findings( shared: &crate::codebase::check_facts::CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, session: &std::sync::Arc, + defer_suppression: bool, ) -> Result> { - match inferred_roots { - Some(inferred_roots) => { - require_storybook_stories::check_with_prepared_facts_and_inferred_and_session( - root, - config, - prepared_tsconfig_catalog, - shared, - inferred_roots, - session, - ) - } - None => require_storybook_stories::check_with_prepared_facts_and_session( - root, - config, - prepared_tsconfig_catalog, - shared, - session, - ), - } + require_storybook_stories::check_with_prepared_facts_for_aggregate( + root, + config, + prepared_tsconfig_catalog, + shared, + inferred_roots, + session, + defer_suppression, + ) } pub(super) fn suppress_findings( From f842b4ebaf549c5b6efdd5c0ff9700c4ce26e6da Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 11:10:32 -0700 Subject: [PATCH 11/62] test: cover aggregate API route deferral --- .../src/codebase/rules/nextjs_no_api_routes/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs index c20adc336..b90cebfd0 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs @@ -202,7 +202,7 @@ fn route_matching_rejects_paths_outside_target_roots() { let target_roots = vec![root.join("web")]; let outside = root.join("other/app/api/users/route.ts"); - assert!(finding_for_file(&root, &target_roots, &outside, "").is_none()); + assert!(finding_for_file(&root, &target_roots, &outside, "", false).is_none()); assert!(!is_nextjs_api_route(&outside, &target_roots)); } @@ -212,6 +212,6 @@ fn route_matching_rejects_non_route_paths_inside_target_roots() { let target_roots = vec![root.join("web")]; let inside = root.join("web/app/page.tsx"); - assert!(finding_for_file(&root, &target_roots, &inside, "").is_none()); + assert!(finding_for_file(&root, &target_roots, &inside, "", false).is_none()); assert!(!is_nextjs_api_route(&inside, &target_roots)); } From 85b7036252ade73a0d20407b055427f3a13f95db Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 11:39:45 -0700 Subject: [PATCH 12/62] test: cover aggregate suppression contracts --- .../src/check_runner/tests/architecture.rs | 2 +- .../tests/call_literals_regressions.rs | 3 + .../codebase/rules/nextjs_no_api_routes.rs | 5 +- .../rules/nextjs_no_api_routes/aggregate.rs | 17 -- .../rules/nextjs_no_api_routes/tests.rs | 8 + .../src/codebase/rules/nextjs_no_caching.rs | 17 -- .../codebase/rules/nextjs_no_caching/tests.rs | 8 + .../rules/require_storybook_stories.rs | 5 +- .../require_storybook_stories/prepared.rs | 37 ---- .../rules/require_storybook_stories/runner.rs | 6 +- .../require_storybook_stories/selection.rs | 23 ++- .../tests/coverage_helpers.rs | 6 +- .../rules/server_route_client_boundary.rs | 17 -- .../server_route_client_boundary/tests.rs | 9 + .../no-mistakes/src/napi_api/tests/check.rs | 171 ++++++++++++++++++ .../.no-mistakes.yml | 5 + .../aggregate-agents-md-max-size/AGENTS.md | 3 + .../.no-mistakes.yml | 9 + .../web/pages/api/legacy.ts | 4 + .../.no-mistakes.yml | 9 + .../web/app/page.ts | 4 + .../.no-mistakes.yml | 15 ++ .../web/components/ComponentSuppressed.tsx | 4 + .../web/components/FileSuppressed.tsx | 4 + .../web/stories/empty.stories.tsx | 3 + .../.no-mistakes.yml | 11 ++ .../backend/api/client.ts | 4 + .../backend/api/users.ts | 4 + .../.no-mistakes.yml | 7 + .../src/leaf.mts | 1 + .../src/reachable.mts | 4 + .../tests/direct.test.mts | 6 + .../tests/reachable.test.mts | 6 + .../vitest.config.mts | 5 + 34 files changed, 336 insertions(+), 106 deletions(-) create mode 100644 fixtures/check/aggregate-agents-md-max-size/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-agents-md-max-size/AGENTS.md create mode 100644 fixtures/check/aggregate-nextjs-no-api-routes/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-nextjs-no-api-routes/web/pages/api/legacy.ts create mode 100644 fixtures/check/aggregate-nextjs-no-caching/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts create mode 100644 fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-require-storybook-stories/web/components/ComponentSuppressed.tsx create mode 100644 fixtures/check/aggregate-require-storybook-stories/web/components/FileSuppressed.tsx create mode 100644 fixtures/check/aggregate-require-storybook-stories/web/stories/empty.stories.tsx create mode 100644 fixtures/check/aggregate-server-route-client-boundary/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-server-route-client-boundary/backend/api/client.ts create mode 100644 fixtures/check/aggregate-server-route-client-boundary/backend/api/users.ts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/leaf.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/reachable.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/direct.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/reachable.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports/vitest.config.mts diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index 24588e706..5656e7248 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -257,7 +257,7 @@ fn aggregate_rule_coordinator_delegates_variant_dispatch() { assert!(execution.contains("mod helpers;")); assert!(execution.contains("use helpers::{storybook_findings, suppress_findings};")); assert!(helpers.contains("pub(super) fn storybook_findings(")); - assert!(helpers.contains("check_with_prepared_facts_and_inferred_and_session")); + assert!(helpers.contains("check_with_prepared_facts_for_aggregate")); assert_eq!(storybook_block.matches("storybook_findings(").count(), 1); assert!(storybook_block.contains("prepared_tsconfig_catalog")); assert!(!storybook_block.contains("prepared_tsconfig,")); diff --git a/crates/no-mistakes/src/codebase/rules/finite_set_consistency/tests/call_literals_regressions.rs b/crates/no-mistakes/src/codebase/rules/finite_set_consistency/tests/call_literals_regressions.rs index a2175758c..1e5cd0f7b 100644 --- a/crates/no-mistakes/src/codebase/rules/finite_set_consistency/tests/call_literals_regressions.rs +++ b/crates/no-mistakes/src/codebase/rules/finite_set_consistency/tests/call_literals_regressions.rs @@ -18,6 +18,9 @@ fn call_first_string_arguments_allow_line_suppression_at_the_dynamic_call() { #[test] fn suppressed_dynamic_calls_do_not_skip_static_set_comparisons() { + // Suppression only removes the extraction diagnostic after comparison; + // retaining the suppressed call's static value is what makes both + // comparison findings sound. let root = call_literal_fixture_root("suppressed-static-mismatch"); let files = vec![root.join("schedules.mts"), root.join("registry.mts")]; diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs index 65bd27cda..fccd44456 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs @@ -8,10 +8,7 @@ use anyhow::Result; use std::path::{Path, PathBuf}; mod aggregate; -#[allow(unused_imports)] -pub(crate) use aggregate::{ - check_with_facts, check_with_facts_and_inferred, check_with_facts_for_aggregate, -}; +pub(crate) use aggregate::check_with_facts_for_aggregate; pub const RULE_ID: &str = "nextjs-no-api-routes"; diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs index 59025ffa1..09e4628d8 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/aggregate.rs @@ -4,23 +4,6 @@ use anyhow::{bail, Context, Result}; use rayon::prelude::*; use std::path::{Path, PathBuf}; -pub(crate) fn check_with_facts( - root: &Path, - config: &NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, -) -> Result> { - check_with_optional_inferred(root, config, shared, None, false) -} - -pub(crate) fn check_with_facts_and_inferred( - root: &Path, - config: &NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, - inferred_roots: &crate::codebase::config::InferredRoots, -) -> Result> { - check_with_optional_inferred(root, config, shared, Some(inferred_roots), false) -} - pub(crate) fn check_with_facts_for_aggregate( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs index b90cebfd0..6dea2bd12 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs @@ -3,6 +3,14 @@ use crate::codebase::check_facts::{CheckFactMap, CheckFileFacts}; use crate::config::v2::schema::{Project, ProjectType, RuleDef}; use std::collections::HashMap; +fn check_with_facts( + root: &Path, + config: &NoMistakesConfig, + facts: &CheckFactMap, +) -> anyhow::Result> { + check_with_facts_for_aggregate(root, config, facts, None, false) +} + fn fixture() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../test-cases/codebase-analysis/no-nextjs-api-routes/fixture") diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs index 2ebbb2866..0265000c4 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching.rs @@ -37,23 +37,6 @@ pub fn check(root: &Path, config: &NoMistakesConfig) -> Result> check_files(&root, config, &files) } -pub(crate) fn check_with_facts( - root: &Path, - config: &NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, -) -> Result> { - check_with_optional_inferred(root, config, shared, None, false) -} - -pub(crate) fn check_with_facts_and_inferred( - root: &Path, - config: &NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, - inferred_roots: &crate::codebase::config::InferredRoots, -) -> Result> { - check_with_optional_inferred(root, config, shared, Some(inferred_roots), false) -} - pub(crate) fn check_with_facts_for_aggregate( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs index 16629f22e..fd21e233a 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs @@ -1,4 +1,12 @@ use super::*; + +fn check_with_facts( + root: &Path, + config: &NoMistakesConfig, + facts: &CheckFactMap, +) -> anyhow::Result> { + check_with_facts_for_aggregate(root, config, facts, None, false) +} use crate::codebase::check_facts::{CheckFactMap, CheckFileFacts}; use crate::config::v2::schema::{Project, ProjectType, RuleDef}; use std::collections::HashMap; diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs index fb4f0b742..1a48f46f7 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs @@ -20,10 +20,7 @@ use config::effective_story_patterns; use coverage::{all_react_component_keys, directly_covered_components, reachable_story_files}; use coverage_graph::{dynamic_or_mock_boundary_files, transitive_covered_components}; use findings::{namespace_import_findings, stale_or_blank_allow_findings}; -pub(crate) use prepared::{ - check_with_prepared_facts_and_inferred_and_session, check_with_prepared_facts_and_session, - check_with_prepared_facts_for_aggregate, -}; +pub(crate) use prepared::check_with_prepared_facts_for_aggregate; use selection::{component_disabled, file_disabled, selected_components}; use types::{GlobMatcher, Options}; diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs index 108684476..4b43ce687 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs @@ -5,43 +5,6 @@ use anyhow::Result; use std::collections::HashSet; use std::path::Path; -pub(crate) fn check_with_prepared_facts_and_session( - root: &Path, - config: &NoMistakesConfig, - prepared_tsconfig_catalog: &TsConfigCatalog, - shared: &CheckFactMap, - session: &AnalysisSession, -) -> Result> { - check_with_optional_inferred( - root, - config, - prepared_tsconfig_catalog, - shared, - None, - session, - false, - ) -} - -pub(crate) fn check_with_prepared_facts_and_inferred_and_session( - root: &Path, - config: &NoMistakesConfig, - prepared_tsconfig_catalog: &TsConfigCatalog, - shared: &CheckFactMap, - inferred_roots: &crate::codebase::config::InferredRoots, - session: &AnalysisSession, -) -> Result> { - check_with_optional_inferred( - root, - config, - prepared_tsconfig_catalog, - shared, - Some(inferred_roots), - session, - false, - ) -} - pub(crate) fn check_with_prepared_facts_for_aggregate( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs index e50ec91c2..d4075e5f4 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs @@ -87,11 +87,13 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { root, project_root, shared, - &opts, + super::selection::SelectionOptions { + options: &opts, + defer_suppression, + }, &include, &exclude, &test_filter, - defer_suppression, ) .into_iter() .filter(|component| rule_filter.is_match(&component.file)) diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs index 3b3d97587..6eb6e311d 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/selection.rs @@ -12,16 +12,22 @@ use crate::codebase::ts_symbols::ExportKind; use std::collections::HashSet; use std::path::Path; +pub(super) struct SelectionOptions<'a> { + pub(super) options: &'a Options, + pub(super) defer_suppression: bool, +} + pub(super) fn selected_components( root: &Path, project_root: &Path, shared: &CheckFactMap, - opts: &Options, + selection: SelectionOptions<'_>, include: &GlobMatcher, exclude: &GlobMatcher, test_filter: &crate::codebase::test_filter::TestFileFilter, - defer_suppression: bool, ) -> Vec { + let opts = selection.options; + let defer_suppression = selection.defer_suppression; let mut components = Vec::new(); let scoped_files = shared.files().iter().collect::>(); for (path, facts) in &shared.ts { @@ -49,7 +55,7 @@ pub(super) fn selected_components( let Some(react) = facts.react.as_ref() else { continue; }; - if should_skip_file(facts, opts, explicit) { + if should_skip_file(facts, opts, explicit, defer_suppression) { continue; } for component in react.components.iter() { @@ -82,11 +88,18 @@ pub(super) fn selected_components( components } -fn should_skip_file(facts: &CheckFileFacts, opts: &Options, explicit: bool) -> bool { +fn should_skip_file( + facts: &CheckFileFacts, + opts: &Options, + explicit: bool, + defer_suppression: bool, +) -> bool { let Some(source) = facts.source.as_deref() else { return false; }; - has_disable_file_comment(source, RULE_ID) + // Aggregate checks must retain file-disabled components until the shared + // suppression projection records their findings in the optional audit. + (!defer_suppression && has_disable_file_comment(source, RULE_ID)) || (!explicit && !opts.required_props.is_empty() && !source_has_required_prop(source, opts)) } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs index 52844e4f8..0dc455729 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs @@ -380,11 +380,13 @@ fn selection_and_transitive_helpers_cover_skip_paths() { &root, project_root, &shared, - &opts, + selection::SelectionOptions { + options: &opts, + defer_suppression: false, + }, &include, &exclude, &test_filter, - false, ); assert_eq!( selected diff --git a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs index 1449a1b52..2564ddd5e 100644 --- a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs +++ b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs @@ -52,23 +52,6 @@ pub fn check(root: &Path, config: &NoMistakesConfig) -> Result> check_files(&root, config, &files) } -pub(crate) fn check_with_facts( - root: &Path, - config: &NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, -) -> Result> { - check_with_optional_inferred(root, config, shared, None, false) -} - -pub(crate) fn check_with_facts_and_inferred( - root: &Path, - config: &NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, - inferred_roots: &crate::codebase::config::InferredRoots, -) -> Result> { - check_with_optional_inferred(root, config, shared, Some(inferred_roots), false) -} - pub(crate) fn check_with_facts_for_aggregate( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs index 98090fd0c..eb61b0189 100644 --- a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs @@ -1,4 +1,13 @@ use super::*; +use crate::codebase::check_facts::CheckFactMap; + +fn check_with_facts( + root: &Path, + config: &NoMistakesConfig, + facts: &CheckFactMap, +) -> anyhow::Result> { + check_with_facts_for_aggregate(root, config, facts, None, false) +} use crate::config::v2::schema::{Project, ProjectType, RuleDef, RuleScope}; fn fixture(name: &str) -> PathBuf { diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 81caca197..8b524ffc9 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -1,6 +1,51 @@ use super::*; use serde_json::json; +fn static_check_fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check") + .join(name) +} + +fn baseline_and_audit(name: &str) -> (serde_json::Value, serde_json::Value) { + let root = static_check_fixture(name); + let baseline: serde_json::Value = + serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) + .unwrap(); + let audit: serde_json::Value = serde_json::from_str( + &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), + ) + .unwrap(); + let mut comparable = audit.clone(); + comparable + .as_object_mut() + .expect("check report is an object") + .remove("suppressed"); + assert_eq!(baseline, comparable, "audit changed a visible report field"); + (baseline, audit) +} + +fn assert_suppression(audit: &serde_json::Value, expected: &serde_json::Value) { + let domain = expected["domain"].as_str().unwrap(); + let rule = expected["rule"].as_str().unwrap(); + let file = expected["file"].as_str().unwrap(); + let line = expected["line"].as_u64().unwrap(); + let finding = audit["suppressed"] + .as_array() + .unwrap() + .iter() + .find(|finding| { + finding["domain"] == domain + && finding["rule"] == rule + && finding["file"] == file + && finding["line"] == line + }) + .unwrap_or_else(|| panic!("missing suppression {domain}/{rule} {file}:{line}: {audit}")); + assert_eq!(finding["reason"], expected["reason"]); + assert_eq!(finding["directive"]["kind"], expected["directiveKind"]); + assert_eq!(finding["directive"]["line"], expected["directiveLine"]); +} + #[test] fn check_json_reports_tracked_artifacts_below_source_skip_directories() { let fixture = crate::test_support::materialize_gitignore_fixture("banned-paths-source-skips"); @@ -76,6 +121,132 @@ fn check_json_optionally_accounts_for_suppressed_ordinary_rule_findings() { assert_eq!(audit["suppressed"][1]["directive"]["line"], 1); } +#[test] +fn check_json_preserves_nextjs_caching_report_when_auditing_suppression() { + let (_, audit) = baseline_and_audit("aggregate-nextjs-no-caching"); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "nextjs-no-caching", + "file": "web/app/page.ts", + "line": 3, + "directiveKind": "nextLine", + "directiveLine": 2, + "reason": "fetch cache: \"force-cache\" is disabled; use uncached request-time data", + }), + ); +} + +#[test] +fn check_json_preserves_nextjs_api_report_when_auditing_suppression() { + let (_, audit) = baseline_and_audit("aggregate-nextjs-no-api-routes"); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "nextjs-no-api-routes", + "file": "web/pages/api/legacy.ts", + "line": 1, + "directiveKind": "line", + "directiveLine": 1, + "reason": "Next.js API/server routes are disabled; move server endpoints out of the Next.js app", + }), + ); +} + +#[test] +fn check_json_preserves_direct_and_reachable_dynamic_import_reports_when_auditing() { + let (_, audit) = baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports"); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "test-no-unmocked-dynamic-imports", + "file": "src/reachable.mts", + "line": 3, + "directiveKind": "nextLine", + "directiveLine": 2, + "reason": "dynamic import dependency `src/leaf.mts` must be mocked", + }), + ); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "test-no-unmocked-dynamic-imports", + "file": "tests/direct.test.mts", + "line": 5, + "directiveKind": "nextLine", + "directiveLine": 4, + "reason": "dynamic import dependency `src/leaf.mts` must be mocked", + }), + ); +} + +#[test] +fn check_json_preserves_server_boundary_report_when_auditing_suppression() { + let (_, audit) = baseline_and_audit("aggregate-server-route-client-boundary"); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "server-route-client-boundary", + "file": "backend/api/client.ts", + "line": 4, + "directiveKind": "file", + "directiveLine": 1, + "reason": "client HTTP call is in a server route folder; move request clients out of route definition folders or narrow server route globs so AST route extraction stays unambiguous", + }), + ); +} + +#[test] +fn check_json_preserves_agents_size_report_when_auditing_suppression() { + let (_, audit) = baseline_and_audit("aggregate-agents-md-max-size"); + assert_suppression( + &audit, + &json!({ + "domain": "filesystem", + "rule": "agents-md-max-size", + "file": "AGENTS.md", + "line": 1, + "directiveKind": "file", + "directiveLine": 1, + "reason": "3 lines (max 2) - trim to keep agent context lean", + }), + ); +} + +#[test] +fn check_json_preserves_storybook_file_and_component_reports_when_auditing_suppression() { + let (_, audit) = baseline_and_audit("aggregate-require-storybook-stories"); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "require-storybook-stories", + "file": "web/components/ComponentSuppressed.tsx", + "line": 2, + "directiveKind": "nextLine", + "directiveLine": 1, + "reason": "React component `ComponentSuppressed` is selected for Storybook coverage but no reachable story imports it or a parent component that renders it. Add a Storybook story, add an accepted colocated test when `allow_colocated_tests` is enabled, render it through a covered parent component, exclude it from `require-storybook-stories`, or add a documented no-mistakes disable comment.", + }), + ); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "require-storybook-stories", + "file": "web/components/FileSuppressed.tsx", + "line": 2, + "directiveKind": "file", + "directiveLine": 1, + "reason": "React component `FileSuppressed` is selected for Storybook coverage but no reachable story imports it or a parent component that renders it. Add a Storybook story, add an accepted colocated test when `allow_colocated_tests` is enabled, render it through a covered parent component, exclude it from `require-storybook-stories`, or add a documented no-mistakes disable comment.", + }), + ); +} + #[test] fn check_json_audit_mode_includes_an_empty_suppression_array() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check-runner/empty"); diff --git a/fixtures/check/aggregate-agents-md-max-size/.no-mistakes.yml b/fixtures/check/aggregate-agents-md-max-size/.no-mistakes.yml new file mode 100644 index 000000000..6d5075a79 --- /dev/null +++ b/fixtures/check/aggregate-agents-md-max-size/.no-mistakes.yml @@ -0,0 +1,5 @@ +rules: + - rule: agents-md-max-size + scope: repository + options: + maxLines: 2 diff --git a/fixtures/check/aggregate-agents-md-max-size/AGENTS.md b/fixtures/check/aggregate-agents-md-max-size/AGENTS.md new file mode 100644 index 000000000..2189c5830 --- /dev/null +++ b/fixtures/check/aggregate-agents-md-max-size/AGENTS.md @@ -0,0 +1,3 @@ +// no-mistakes-disable-file agents-md-max-size: this policy file is intentionally long +line one +line two diff --git a/fixtures/check/aggregate-nextjs-no-api-routes/.no-mistakes.yml b/fixtures/check/aggregate-nextjs-no-api-routes/.no-mistakes.yml new file mode 100644 index 000000000..f179de3b6 --- /dev/null +++ b/fixtures/check/aggregate-nextjs-no-api-routes/.no-mistakes.yml @@ -0,0 +1,9 @@ +projects: + web: + type: nextjs + root: web + +rules: + - rule: nextjs-no-api-routes + projects: + - web diff --git a/fixtures/check/aggregate-nextjs-no-api-routes/web/pages/api/legacy.ts b/fixtures/check/aggregate-nextjs-no-api-routes/web/pages/api/legacy.ts new file mode 100644 index 000000000..7ae3134d7 --- /dev/null +++ b/fixtures/check/aggregate-nextjs-no-api-routes/web/pages/api/legacy.ts @@ -0,0 +1,4 @@ +// no-mistakes-disable-line nextjs-no-api-routes: legacy endpoint is retained during migration +export default function handler() { + return { ok: true } +} diff --git a/fixtures/check/aggregate-nextjs-no-caching/.no-mistakes.yml b/fixtures/check/aggregate-nextjs-no-caching/.no-mistakes.yml new file mode 100644 index 000000000..baec72254 --- /dev/null +++ b/fixtures/check/aggregate-nextjs-no-caching/.no-mistakes.yml @@ -0,0 +1,9 @@ +projects: + web: + type: nextjs + root: web + +rules: + - rule: nextjs-no-caching + projects: + - web diff --git a/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts b/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts new file mode 100644 index 000000000..4d8a097d0 --- /dev/null +++ b/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts @@ -0,0 +1,4 @@ +export async function loadUser() { + // no-mistakes-disable-next-line nextjs-no-caching: request data must stay uncached here + return fetch('/api/user', { cache: 'force-cache' }) +} diff --git a/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml new file mode 100644 index 000000000..096f097fe --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml @@ -0,0 +1,15 @@ +version: 2 + +projects: + web: + type: nextjs + root: web + +rules: + - rule: require-storybook-stories + projects: + - web + options: + stories: + - stories/**/*.stories.tsx + includeAllReactNamedExports: true diff --git a/fixtures/check/aggregate-require-storybook-stories/web/components/ComponentSuppressed.tsx b/fixtures/check/aggregate-require-storybook-stories/web/components/ComponentSuppressed.tsx new file mode 100644 index 000000000..9e1c2b0a4 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-stories/web/components/ComponentSuppressed.tsx @@ -0,0 +1,4 @@ +// no-mistakes-disable-next-line require-storybook-stories: covered by the parent application shell +export function ComponentSuppressed() { + return
component suppressed
+} diff --git a/fixtures/check/aggregate-require-storybook-stories/web/components/FileSuppressed.tsx b/fixtures/check/aggregate-require-storybook-stories/web/components/FileSuppressed.tsx new file mode 100644 index 000000000..d010fc963 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-stories/web/components/FileSuppressed.tsx @@ -0,0 +1,4 @@ +// no-mistakes-disable-file require-storybook-stories: covered by the parent application shell +export function FileSuppressed() { + return
file suppressed
+} diff --git a/fixtures/check/aggregate-require-storybook-stories/web/stories/empty.stories.tsx b/fixtures/check/aggregate-require-storybook-stories/web/stories/empty.stories.tsx new file mode 100644 index 000000000..6b8bd7171 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-stories/web/stories/empty.stories.tsx @@ -0,0 +1,3 @@ +export const Empty = { + render: () =>
empty
, +} diff --git a/fixtures/check/aggregate-server-route-client-boundary/.no-mistakes.yml b/fixtures/check/aggregate-server-route-client-boundary/.no-mistakes.yml new file mode 100644 index 000000000..703ea4066 --- /dev/null +++ b/fixtures/check/aggregate-server-route-client-boundary/.no-mistakes.yml @@ -0,0 +1,11 @@ +projects: + backend: + type: server + root: backend + routes: + - api/** + +rules: + - rule: server-route-client-boundary + projects: + - backend diff --git a/fixtures/check/aggregate-server-route-client-boundary/backend/api/client.ts b/fixtures/check/aggregate-server-route-client-boundary/backend/api/client.ts new file mode 100644 index 000000000..82eccca13 --- /dev/null +++ b/fixtures/check/aggregate-server-route-client-boundary/backend/api/client.ts @@ -0,0 +1,4 @@ +// no-mistakes-disable-file server-route-client-boundary: client is colocated during migration +import axios from 'axios' + +axios.get('/api/users') diff --git a/fixtures/check/aggregate-server-route-client-boundary/backend/api/users.ts b/fixtures/check/aggregate-server-route-client-boundary/backend/api/users.ts new file mode 100644 index 000000000..030cc05e0 --- /dev/null +++ b/fixtures/check/aggregate-server-route-client-boundary/backend/api/users.ts @@ -0,0 +1,4 @@ +import express from 'express' + +const app = express() +app.get('/api/users', () => undefined) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/.no-mistakes.yml b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/.no-mistakes.yml new file mode 100644 index 000000000..d4abed644 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/leaf.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/leaf.mts new file mode 100644 index 000000000..1e9853c5f --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/leaf.mts @@ -0,0 +1 @@ +export const leaf = true diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/reachable.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/reachable.mts new file mode 100644 index 000000000..669c3a856 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/src/reachable.mts @@ -0,0 +1,4 @@ +export function loadReachable() { + // no-mistakes-disable-next-line test-no-unmocked-dynamic-imports: reachable import is intentional + return import('./leaf.mts') +} diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/direct.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/direct.test.mts new file mode 100644 index 000000000..6205e75ad --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/direct.test.mts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test('direct dynamic import is intentionally unmocked', async () => { + // no-mistakes-disable-next-line test-no-unmocked-dynamic-imports: this direct import is intentional + await import('../src/leaf.mts') +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/reachable.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/reachable.test.mts new file mode 100644 index 000000000..4ecb59547 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/tests/reachable.test.mts @@ -0,0 +1,6 @@ +import { test } from 'vitest' +import { loadReachable } from '../src/reachable.mts' + +test('reachable dynamic import is intentionally unmocked', () => { + loadReachable() +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/vitest.config.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/vitest.config.mts new file mode 100644 index 000000000..1f2d81198 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports/vitest.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { include: ['tests/**/*.test.mts'] }, +}) From 667e8b663321eee24d2d9ad3da691bd29097bd37 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 11:50:14 -0700 Subject: [PATCH 13/62] fix: satisfy strict clippy for suppression pipeline --- .../agents_md_max_size_budget.rs | 12 +----- .../rules/agents_md_max_size/tests.rs | 4 +- .../rules/filesystem_dispatch/execute.rs | 2 +- .../rules/filesystem_dispatch/run_rule.rs | 37 +++++++++++------- .../codebase/rules/run/prepared/execution.rs | 18 +++++---- .../src/codebase/rules/rust_rules_combined.rs | 17 --------- .../rules/rust_rules_combined/scan.rs | 19 ---------- .../rules/rust_rules_combined/tests.rs | 38 +++++++++++++++---- .../rules/test_no_unmocked_dynamic_imports.rs | 5 ++- .../with_facts.rs | 28 ++++++++++---- .../with_facts/graph.rs | 11 +++--- 11 files changed, 95 insertions(+), 96 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs index 26cd89169..1368d39a9 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs @@ -58,17 +58,7 @@ pub(super) fn scan_advisories_with_sources( Ok(advisories) } -pub(super) fn check_content( - path: &Path, - root: &Path, - max_lines: usize, - max_chars: usize, - content: &str, -) -> Vec { - check_content_with_deferred_suppression(path, root, max_lines, max_chars, content, false) -} - -fn check_content_with_deferred_suppression( +pub(super) fn check_content_with_deferred_suppression( path: &Path, root: &Path, max_lines: usize, diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs index a1874fce9..9d4ea1523 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs @@ -1,4 +1,4 @@ -use super::agents_md_max_size_budget::{check_content, count_lines}; +use super::agents_md_max_size_budget::{check_content_with_deferred_suppression, count_lines}; use super::*; use crate::config::v2::{ schema::{RuleDef, RuleScope}, @@ -14,7 +14,7 @@ fn check_file( let Ok(content) = std::fs::read_to_string(path) else { return Vec::new(); }; - check_content(path, root, max_lines, max_chars, &content) + check_content_with_deferred_suppression(path, root, max_lines, max_chars, &content, false) } fn config_with_rule(yaml: &str) -> NoMistakesConfig { diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs index 910dd4302..7feb79c2b 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs @@ -129,7 +129,7 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( } fn run_enabled_rules(inputs: &RuleRunInputs<'_>) { - macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources($id, $call, inputs.root, inputs.config, inputs.candidates.candidates($id), inputs.sources, inputs.facts, inputs.defer_suppression); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; spawn_special_rules(scope, inputs); }); }; } + macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources(run_rule::RunRuleRequest { rule_id: $id, fallback: $call, root: inputs.root, config: inputs.config, files: inputs.candidates.candidates($id), sources: inputs.sources, facts: inputs.facts, defer_suppression: inputs.defer_suppression }); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; spawn_special_rules(scope, inputs); }); }; } crate::filesystem_rules!(run_rules); } diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs index bd6475bc4..8f7c8a378 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs @@ -1,19 +1,28 @@ use super::*; -pub(super) fn run_rule_with_sources( - rule_id: &str, - fallback: fn( - &Path, - &crate::config::v2::NoMistakesConfig, - &[PathBuf], - ) -> Result>, - root: &Path, - config: &crate::config::v2::NoMistakesConfig, - files: &[PathBuf], - sources: &crate::codebase::ts_source::SourceStore, - facts: Option<&crate::codebase::check_facts::CheckFactMap>, - defer_suppression: bool, -) -> Result> { +pub(super) struct RunRuleRequest<'a> { + pub(super) rule_id: &'a str, + pub(super) fallback: + fn(&Path, &crate::config::v2::NoMistakesConfig, &[PathBuf]) -> Result>, + pub(super) root: &'a Path, + pub(super) config: &'a crate::config::v2::NoMistakesConfig, + pub(super) files: &'a [PathBuf], + pub(super) sources: &'a crate::codebase::ts_source::SourceStore, + pub(super) facts: Option<&'a crate::codebase::check_facts::CheckFactMap>, + pub(super) defer_suppression: bool, +} + +pub(super) fn run_rule_with_sources(request: RunRuleRequest<'_>) -> Result> { + let RunRuleRequest { + rule_id, + fallback, + root, + config, + files, + sources, + facts, + defer_suppression, + } = request; match rule_id { AGENTS_MD_MAX_SIZE => { agents_md_max_size::check_with_files_sources_and_deferred_suppression( diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index 4b2d0f39a..fb101ec1f 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -85,14 +85,16 @@ pub(super) fn run( "rules.test_no_unmocked_dynamic_imports", || { test_no_unmocked_dynamic_imports::check_with_prepared_facts_graph_and_session( - root, - config, - prepared_tsconfig, - prepared_tsconfig_catalog, - shared, - dependency_graph.expect("dynamic-import rule requires canonical graph"), - &session, - defer_suppression, + test_no_unmocked_dynamic_imports::PreparedFactsGraphRequest { + root, + config, + tsconfig_catalog: prepared_tsconfig_catalog, + shared, + graph: dependency_graph + .expect("dynamic-import rule requires canonical graph"), + session: &session, + defer_suppression, + }, ) }, )?); diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs index 03f37d2a7..7528e8107 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs @@ -28,23 +28,6 @@ pub(super) struct RustWork { pub(super) inline_allows: bool, } -pub(crate) fn check_with_files_and_sources( - root: &Path, - config: &NoMistakesConfig, - all_files: &[PathBuf], - exclusive_files: &[PathBuf], - sources: &crate::codebase::ts_source::SourceStore, -) -> Result> { - check_with_files_sources_and_deferred_suppression( - root, - config, - all_files, - exclusive_files, - sources, - false, - ) -} - pub(crate) fn check_with_files_sources_and_deferred_suppression( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs index 10d8ee766..bf913c280 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs @@ -1,15 +1,5 @@ use super::*; -pub(super) fn scan_file( - root: &Path, - path: &Path, - work: &RustWork, - exclusive: bool, - sources: &crate::codebase::ts_source::SourceStore, -) -> Vec { - scan_file_with_deferred_suppression(root, path, work, exclusive, sources, false) -} - pub(super) fn scan_file_with_deferred_suppression( root: &Path, path: &Path, @@ -36,15 +26,6 @@ pub(super) fn scan_file_with_deferred_suppression( scan_file_with_source_and_deferred_suppression(root, path, work, &content, defer_suppression) } -pub(super) fn scan_file_with_source( - root: &Path, - path: &Path, - work: &RustWork, - content: &str, -) -> Vec { - scan_file_with_source_and_deferred_suppression(root, path, work, content, false) -} - pub(super) fn scan_file_with_source_and_deferred_suppression( root: &Path, path: &Path, diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs index 9c816e119..42c975cd1 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs @@ -1,10 +1,12 @@ -use super::scan::{scan_file, scan_file_with_source}; +use super::scan::{ + scan_file_with_deferred_suppression, scan_file_with_source_and_deferred_suppression, +}; use super::*; use crate::config::v2::schema::{RuleDef, RuleScope}; fn scan_test_file(root: &Path, path: &Path, work: &RustWork) -> Vec { let sources = crate::codebase::rules::source_store_for_files(&[path.to_path_buf()]); - scan::scan_file(root, path, work, false, &sources) + scan::scan_file_with_deferred_suppression(root, path, work, false, &sources, false) } fn config_with_rule(rule: &str) -> NoMistakesConfig { @@ -44,7 +46,10 @@ fn scan_file_returns_empty_for_unreadable_file() { }; let sources = crate::codebase::rules::source_store_for_files(std::slice::from_ref(&missing)); - assert!(scan_file(&root, &missing, &work, true, &sources).is_empty()); + assert!( + scan_file_with_deferred_suppression(&root, &missing, &work, true, &sources, false) + .is_empty() + ); } #[test] @@ -72,7 +77,10 @@ fn combined_scan_applies_line_suppression_before_releasing_source() { }; let source = "// no-mistakes-disable-next-line rust-no-inline-allows\n#[allow(dead_code)]\nfn hidden() {}\n"; - assert!(scan_file_with_source(&root, &path, &work, source).is_empty()); + assert!( + scan_file_with_source_and_deferred_suppression(&root, &path, &work, source, false) + .is_empty() + ); } #[test] @@ -86,13 +94,27 @@ fn exclusive_sources_are_not_retained_and_overlapping_sources_are_memoized() { let files = vec![path]; let exclusive_sources = crate::codebase::rules::source_store_for_files(&files); - let exclusive = - check_with_files_and_sources(&root, &config, &files, &files, &exclusive_sources).unwrap(); + let exclusive = check_with_files_sources_and_deferred_suppression( + &root, + &config, + &files, + &files, + &exclusive_sources, + false, + ) + .unwrap(); assert_eq!(exclusive_sources.physical_read_count(), 0); let overlapping_sources = crate::codebase::rules::source_store_for_files(&files); - let overlapping = - check_with_files_and_sources(&root, &config, &files, &[], &overlapping_sources).unwrap(); + let overlapping = check_with_files_sources_and_deferred_suppression( + &root, + &config, + &files, + &[], + &overlapping_sources, + false, + ) + .unwrap(); assert_eq!(overlapping_sources.physical_read_count(), 1); assert_eq!(exclusive, overlapping); } diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs index 27d7dfc81..b5cc5fab8 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs @@ -17,9 +17,10 @@ pub(crate) use standalone::{ check_inner, matching_test_files_with_filter, remap_resolved_path, resolve_mock_specifiers, }; use std::path::Path; -pub(crate) use with_facts::check_with_prepared_facts_graph_and_session; pub use with_facts::{check_with_facts, check_with_prepared_facts}; - +pub(crate) use with_facts::{ + check_with_prepared_facts_graph_and_session, PreparedFactsGraphRequest, +}; pub const RULE_ID: &str = "test-no-unmocked-dynamic-imports"; pub fn check( diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index e0570e20e..1e9e760ce 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -53,16 +53,28 @@ pub fn check_with_prepared_facts( check_with_prepared_facts_and_session(root, config, tsconfig, &catalog, shared, &session) } +pub(crate) struct PreparedFactsGraphRequest<'a> { + pub(crate) root: &'a Path, + pub(crate) config: &'a NoMistakesConfig, + pub(crate) tsconfig_catalog: &'a crate::codebase::ts_resolver::TsConfigCatalog, + pub(crate) shared: &'a CheckFactMap, + pub(crate) graph: &'a DepGraph, + pub(crate) session: &'a std::sync::Arc, + pub(crate) defer_suppression: bool, +} + pub(crate) fn check_with_prepared_facts_graph_and_session( - root: &Path, - config: &NoMistakesConfig, - _tsconfig: &TsConfig, - tsconfig_catalog: &crate::codebase::ts_resolver::TsConfigCatalog, - shared: &CheckFactMap, - graph: &DepGraph, - session: &std::sync::Arc, - defer_suppression: bool, + request: PreparedFactsGraphRequest<'_>, ) -> Result> { + let PreparedFactsGraphRequest { + root, + config, + tsconfig_catalog, + shared, + graph, + session, + defer_suppression, + } = request; let files = shared.files().to_vec(); let visible_files = files.iter().cloned().collect::>(); // Dynamic-import policy is filesystem-scoped even when another consumer diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs index 6bd163aa5..c925c3913 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs @@ -1,4 +1,4 @@ -use super::check_with_prepared_facts_graph_and_session; +use super::{check_with_prepared_facts_graph_and_session, PreparedFactsGraphRequest}; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::dependencies::graph::{DepGraph, GraphBuildPlan}; use crate::codebase::ts_resolver::{TsConfig, TsConfigCatalog}; @@ -28,14 +28,13 @@ pub(crate) fn check_with_prepared_facts_and_session( session.clone(), ) })?; - check_with_prepared_facts_graph_and_session( + check_with_prepared_facts_graph_and_session(PreparedFactsGraphRequest { root, config, - tsconfig, tsconfig_catalog, shared, - &graph, + graph: &graph, session, - false, - ) + defer_suppression: false, + }) } From bde2a856b6a06029379a6d906256723c378e70a7 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 12:20:17 -0700 Subject: [PATCH 14/62] fix: close suppression review gaps --- crates/no-mistakes/src/check_parallel.rs | 2 +- crates/no-mistakes/src/check_runner.rs | 2 +- .../src/codebase/rules/banned_paths/tests.rs | 3 +- crates/no-mistakes/src/codebase/rules/mod.rs | 4 +- .../rules/require_storybook_stories.rs | 15 ++++- .../rules/require_storybook_stories/config.rs | 3 +- .../require_storybook_stories/prepared.rs | 40 ++++++------- .../rules/require_storybook_stories/runner.rs | 6 +- .../tests/coverage_rule_cases.rs | 17 +++++- crates/no-mistakes/src/codebase/rules/run.rs | 6 +- .../src/codebase/rules/run/prepared.rs | 14 +++-- .../codebase/rules/run/prepared/execution.rs | 12 ++-- .../rules/run/prepared/execution/helpers.rs | 43 +++++++++----- .../src/codebase/rules/run/standalone.rs | 2 +- .../src/codebase/rules/rust_rules_combined.rs | 11 +--- .../rules/rust_rules_combined/scan.rs | 13 ----- .../rules/rust_rules_combined/tests.rs | 7 +-- .../src/codebase/rules/suppression.rs | 6 +- .../src/codebase/rules/suppression_tests.rs | 4 ++ .../config.rs | 34 +++++++++-- .../config/filter.rs | 16 ++++- .../config/prepared.rs | 10 +++- .../config/prepared_tests.rs | 9 ++- .../config/tests.rs | 2 +- .../with_facts.rs | 4 +- .../with_facts/graph.rs | 2 + .../ts_source/disable_comments/directives.rs | 11 ++-- .../src/codebase/unique_exports/collector.rs | 13 +++-- .../analyze_project/context/check_run.rs | 58 +++++-------------- .../context/scope_project_reports.rs | 1 + .../src/napi_api/analyze_project/tests.rs | 22 +++++++ crates/no-mistakes/src/napi_api/tests.rs | 12 ++++ .../no-mistakes/src/napi_api/tests/check.rs | 29 +++++++++- .../.no-mistakes.yml | 3 + .../app/Fetcher.tsx | 6 ++ .../suppression-unique-canonical/src/a.ts | 3 +- packages/no-mistakes/report-types.d.ts | 2 +- 37 files changed, 283 insertions(+), 164 deletions(-) create mode 100644 fixtures/check/suppression-directive-precedence/.no-mistakes.yml create mode 100644 fixtures/check/suppression-directive-precedence/app/Fetcher.tsx diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index fef1dac6d..01876c646 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -123,7 +123,7 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults prepared_tsconfig, prepared_tsconfig_catalog, inferred_roots: Some(inferred_roots), - sources: Some(&rule_sources), + sources: rule_sources.as_ref(), defer_suppression, }, dependency_graph.as_deref(), diff --git a/crates/no-mistakes/src/check_runner.rs b/crates/no-mistakes/src/check_runner.rs index dd5b43e8f..a032d32d1 100644 --- a/crates/no-mistakes/src/check_runner.rs +++ b/crates/no-mistakes/src/check_runner.rs @@ -3,7 +3,7 @@ mod fact_collection; pub(crate) mod finite_set_plan; mod graph_plan; pub(crate) mod prepared; -mod results; +pub(crate) mod results; mod run_all; pub(crate) use results::{complete_domain_checks, empty_results, json_value, CheckResults}; diff --git a/crates/no-mistakes/src/codebase/rules/banned_paths/tests.rs b/crates/no-mistakes/src/codebase/rules/banned_paths/tests.rs index 6e42883db..1f811106c 100644 --- a/crates/no-mistakes/src/codebase/rules/banned_paths/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/banned_paths/tests.rs @@ -89,7 +89,8 @@ fn respects_rule_include_and_suppression() { root.join("other/pages/index.tsx"), ]; let mut findings = check_with_files(&root, &config, &files).unwrap(); - super::super::suppress_rule_findings(&root, &mut findings); + let sources = super::super::source_store_for_files(&files); + super::super::suppress_rule_findings_with_sources(&root, &mut findings, &sources); assert_eq!(findings.len(), 1); assert_eq!(findings[0].file, "web/pages/index.tsx"); } diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 48908f6a2..5d1108931 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -87,8 +87,8 @@ pub use suppression::{ suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, }; pub(crate) use suppression::{ - suppress_rule_findings, suppress_rule_findings_with_source, - suppress_rule_findings_with_sources, suppress_rule_findings_with_sources_except, + suppress_rule_findings_with_source, suppress_rule_findings_with_sources, + suppress_rule_findings_with_sources_except, }; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs index 1a48f46f7..e9a71320d 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs @@ -20,7 +20,7 @@ use config::effective_story_patterns; use coverage::{all_react_component_keys, directly_covered_components, reachable_story_files}; use coverage_graph::{dynamic_or_mock_boundary_files, transitive_covered_components}; use findings::{namespace_import_findings, stale_or_blank_allow_findings}; -pub(crate) use prepared::check_with_prepared_facts_for_aggregate; +pub(crate) use prepared::{check_with_prepared_facts_for_aggregate, PreparedStorybookCheck}; use selection::{component_disabled, file_disabled, selected_components}; use types::{GlobMatcher, Options}; @@ -77,7 +77,7 @@ pub fn check( ); let sources = snapshot.source_store_for(root); let catalog = tsconfig_catalog(root, config, tsconfig_path, &visible_paths, &sources)?; - check_with_facts_and_catalog(root, config, &facts, &catalog, None) + check_with_facts_and_catalog(root, config, &facts, &catalog, None, &sources) } fn check_with_facts_and_catalog( @@ -86,6 +86,7 @@ fn check_with_facts_and_catalog( shared: &CheckFactMap, catalog: &crate::codebase::ts_resolver::TsConfigCatalog, inferred_roots: Option<&crate::codebase::config::InferredRoots>, + sources: &crate::codebase::ts_source::SourceStore, ) -> Result> { let session = crate::codebase::analysis_session::AnalysisSession::new(crate::diagnostics::current()); @@ -99,7 +100,15 @@ fn check_with_facts_and_catalog( &visible_files, &session, ); - runner::check_with_resolver(root, config, shared, &resolver, inferred_roots, false) + runner::check_with_resolver( + root, + config, + shared, + &resolver, + inferred_roots, + false, + sources, + ) } fn tsconfig_catalog( diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/config.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/config.rs index 02bace140..fce632d14 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/config.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/config.rs @@ -10,6 +10,7 @@ pub(super) fn effective_story_patterns( project_root: &Path, config: &NoMistakesConfig, opts: &Options, + sources: &crate::codebase::ts_source::SourceStore, ) -> Vec { if !opts.stories.is_empty() { return opts.stories.clone(); @@ -18,7 +19,7 @@ pub(super) fn effective_story_patterns( if let Some(configs) = config.tests.storybook.configs.as_ref() { for config_path in configs.values() { let config_path = resolve_storybook_config_path(root, project_root, &config_path); - let Ok(source) = std::fs::read_to_string(&config_path) else { + let Some(source) = crate::codebase::rules::read_source(sources, &config_path) else { continue; }; let base = config_path.parent().unwrap_or(project_root); diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs index 4b43ce687..70b427263 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/prepared.rs @@ -5,16 +5,25 @@ use anyhow::Result; use std::collections::HashSet; use std::path::Path; +pub(crate) struct PreparedStorybookCheck<'a> { + pub(crate) root: &'a Path, + pub(crate) config: &'a NoMistakesConfig, + pub(crate) prepared_tsconfig_catalog: &'a TsConfigCatalog, + pub(crate) shared: &'a CheckFactMap, + pub(crate) inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, + pub(crate) session: &'a AnalysisSession, + pub(crate) defer_suppression: bool, + pub(crate) sources: &'a crate::codebase::ts_source::SourceStore, +} + pub(crate) fn check_with_prepared_facts_for_aggregate( - root: &Path, - config: &NoMistakesConfig, - prepared_tsconfig_catalog: &TsConfigCatalog, - shared: &CheckFactMap, - inferred_roots: Option<&crate::codebase::config::InferredRoots>, - session: &AnalysisSession, - defer_suppression: bool, + input: PreparedStorybookCheck<'_>, ) -> Result> { - check_with_optional_inferred( + check_with_optional_inferred(input) +} + +fn check_with_optional_inferred(input: PreparedStorybookCheck<'_>) -> Result> { + let PreparedStorybookCheck { root, config, prepared_tsconfig_catalog, @@ -22,18 +31,8 @@ pub(crate) fn check_with_prepared_facts_for_aggregate( inferred_roots, session, defer_suppression, - ) -} - -fn check_with_optional_inferred( - root: &Path, - config: &NoMistakesConfig, - prepared_tsconfig_catalog: &TsConfigCatalog, - shared: &CheckFactMap, - inferred_roots: Option<&crate::codebase::config::InferredRoots>, - session: &AnalysisSession, - defer_suppression: bool, -) -> Result> { + sources, + } = input; let visible_files = shared .files() .iter() @@ -48,5 +47,6 @@ fn check_with_optional_inferred( &resolver, inferred_roots, defer_suppression, + sources, ) } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs index d4075e5f4..9f54fb6d4 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs @@ -22,6 +22,7 @@ struct RuleCheck<'a> { resolver: &'a dyn ImportResolution, inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, defer_suppression: bool, + sources: &'a crate::codebase::ts_source::SourceStore, } pub(super) fn check_with_resolver( @@ -31,6 +32,7 @@ pub(super) fn check_with_resolver( resolver: &dyn ImportResolution, inferred_roots: Option<&crate::codebase::config::InferredRoots>, defer_suppression: bool, + sources: &crate::codebase::ts_source::SourceStore, ) -> Result> { let root = normalize_path(root); let mut findings = Vec::new(); @@ -56,6 +58,7 @@ pub(super) fn check_with_resolver( resolver, inferred_roots, defer_suppression, + sources, })?); } sort_findings(&mut findings); @@ -72,13 +75,14 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { resolver, inferred_roots, defer_suppression, + sources, } = inputs; let opts: Options = rule.rule_options(); let mut inferred_roots = inferred_roots.cloned().unwrap_or_default(); let rule_filter = RulePathFilter::new_with_inferred(root, config, rule, &mut inferred_roots)?; let include = GlobMatcher::new(&opts.include)?; let exclude = GlobMatcher::new(&opts.exclude)?; - let story_patterns = effective_story_patterns(root, project_root, config, &opts); + let story_patterns = effective_story_patterns(root, project_root, config, &opts, sources); let stories = GlobMatcher::new(&story_patterns)?; let allow_files = GlobMatcher::new(opts.allow_files.keys())?; let test_filter = crate::codebase::test_filter::TestFileFilter::new(root, config); diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs index cbefb8aaf..8d02049c8 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs @@ -153,12 +153,20 @@ fn config_helpers_cover_tsconfig_and_storybook_fallbacks() { let mut missing = crate::config::v2::NoMistakesConfig::default(); missing.tests.storybook.configs = Some(StringOrList::One(".storybook/missing.ts".to_string())); - let patterns = - config::effective_story_patterns(&root, &root, &missing, &types::Options::default()); + let missing_sources = crate::codebase::rules::source_store_for_files(&visible); + let patterns = config::effective_story_patterns( + &root, + &root, + &missing, + &types::Options::default(), + &missing_sources, + ); assert_eq!(patterns, vec!["**/*.stories.{ts,tsx,js,jsx}"]); let story_root = fixture("defaults"); let config_path = story_root.join(".storybook/main.ts"); + let absolute_sources = + crate::codebase::rules::source_store_for_files(std::slice::from_ref(&config_path)); let mut absolute = crate::config::v2::NoMistakesConfig::default(); absolute.tests.storybook.configs = Some(StringOrList::One(config_path.to_string_lossy().to_string())); @@ -167,10 +175,14 @@ fn config_helpers_cover_tsconfig_and_storybook_fallbacks() { &story_root, &absolute, &types::Options::default(), + &absolute_sources, ); assert_eq!(patterns, vec!["storybook/**/*.stories.tsx"]); let fallback_root = fixture("single-story-config"); + let fallback_config_path = fallback_root.join(".storybook/main.ts"); + let fallback_sources = + crate::codebase::rules::source_store_for_files(std::slice::from_ref(&fallback_config_path)); let mut root_relative = crate::config::v2::NoMistakesConfig::default(); root_relative.tests.storybook.configs = Some(StringOrList::One(".storybook/main.ts".to_string())); @@ -179,6 +191,7 @@ fn config_helpers_cover_tsconfig_and_storybook_fallbacks() { &fallback_root.join("web"), &root_relative, &types::Options::default(), + &fallback_sources, ); assert_eq!( patterns, diff --git a/crates/no-mistakes/src/codebase/rules/run.rs b/crates/no-mistakes/src/codebase/rules/run.rs index 43dbaa402..4e3f562bc 100644 --- a/crates/no-mistakes/src/codebase/rules/run.rs +++ b/crates/no-mistakes/src/codebase/rules/run.rs @@ -1,8 +1,8 @@ use super::{ forbidden_dependencies, nextjs_no_api_routes, nextjs_no_caching, require_storybook_stories, required_entrypoint_reachability, rule_enabled, server_route_client_boundary, sort_findings, - suppress_rule_findings, suppress_rule_findings_with_sources, test_no_unmocked_dynamic_imports, - RuleFinding, FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, + suppress_rule_findings_with_sources, test_no_unmocked_dynamic_imports, RuleFinding, + FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, REQUIRED_ENTRYPOINT_REACHABILITY, REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, }; @@ -76,7 +76,7 @@ pub fn run_check_with_facts_and_playwright( prepared_tsconfig: &prepared_tsconfig, prepared_tsconfig_catalog: &prepared_tsconfig_catalog, inferred_roots: None, - sources: Some(&sources), + sources: &sources, defer_suppression: false, }) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared.rs b/crates/no-mistakes/src/codebase/rules/run/prepared.rs index ae58c7da0..a1d59242e 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -1,11 +1,10 @@ use super::{ any_codebase_rule_enabled, forbidden_dependencies, nextjs_no_api_routes, nextjs_no_caching, require_storybook_stories, required_entrypoint_reachability, rule_enabled, - server_route_client_boundary, sort_findings, suppress_rule_findings, - suppress_rule_findings_with_sources, test_no_unmocked_dynamic_imports, RuleFinding, - FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, - REQUIRED_ENTRYPOINT_REACHABILITY, REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, - TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, + server_route_client_boundary, sort_findings, suppress_rule_findings_with_sources, + test_no_unmocked_dynamic_imports, RuleFinding, FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, + NEXTJS_NO_CACHING, REQUIRED_ENTRYPOINT_REACHABILITY, REQUIRE_STORYBOOK_STORIES, + SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, }; use crate::codebase::dependencies::graph::{DepGraph, GraphBuildPlan}; use anyhow::Result; @@ -30,7 +29,10 @@ pub struct PreparedRulesCheck<'a> { pub prepared_tsconfig: &'a crate::codebase::ts_resolver::TsConfig, pub prepared_tsconfig_catalog: &'a crate::codebase::ts_resolver::TsConfigCatalog, pub inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, - pub sources: Option<&'a crate::codebase::ts_source::SourceStore>, + /// The request-owned source store. Aggregate callers must pass the same + /// store used for discovery and fact collection; standalone callers build + /// one store for their own request before entering this prepared path. + pub sources: &'a crate::codebase::ts_source::SourceStore, /// Aggregate `check` defers suppression until every domain can share one /// SourceStore-aware adapter and produce optional accounting. pub defer_suppression: bool, diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index fb101ec1f..9bd56e912 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -3,7 +3,7 @@ use super::*; mod graph_rules; mod helpers; use graph_rules::graph_rule_findings; -use helpers::{storybook_findings, suppress_findings}; +use helpers::{storybook_findings, suppress_findings, StorybookFindingsRequest}; pub(super) fn run( inputs: PreparedRulesCheck<'_>, @@ -80,6 +80,8 @@ pub(super) fn run( None }; let mut findings = Vec::new(); + // Aggregate callers derive this once and pass Some(inferred_roots) through + // every prepared rule adapter. if rule_enabled(config, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS) { findings.extend(crate::perf_trace::trace( "rules.test_no_unmocked_dynamic_imports", @@ -93,6 +95,7 @@ pub(super) fn run( graph: dependency_graph .expect("dynamic-import rule requires canonical graph"), session: &session, + sources, defer_suppression, }, ) @@ -129,15 +132,16 @@ pub(super) fn run( )?); } if rule_enabled(config, REQUIRE_STORYBOOK_STORIES) { - findings.extend(storybook_findings( + findings.extend(storybook_findings(StorybookFindingsRequest { root, config, prepared_tsconfig_catalog, shared, inferred_roots, - &session, + session: &session, defer_suppression, - )?); + sources, + })?); } if crate::playwright::rules::configured(config) { findings.extend(crate::perf_trace::trace( diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs index f6ee86871..1d8cdbf34 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs @@ -1,15 +1,18 @@ use super::*; -pub(super) fn storybook_findings( - root: &Path, - config: &crate::config::v2::NoMistakesConfig, - prepared_tsconfig_catalog: &crate::codebase::ts_resolver::TsConfigCatalog, - shared: &crate::codebase::check_facts::CheckFactMap, - inferred_roots: Option<&crate::codebase::config::InferredRoots>, - session: &std::sync::Arc, - defer_suppression: bool, -) -> Result> { - require_storybook_stories::check_with_prepared_facts_for_aggregate( +pub(super) struct StorybookFindingsRequest<'a> { + pub(super) root: &'a Path, + pub(super) config: &'a crate::config::v2::NoMistakesConfig, + pub(super) prepared_tsconfig_catalog: &'a crate::codebase::ts_resolver::TsConfigCatalog, + pub(super) shared: &'a crate::codebase::check_facts::CheckFactMap, + pub(super) inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, + pub(super) session: &'a std::sync::Arc, + pub(super) defer_suppression: bool, + pub(super) sources: &'a crate::codebase::ts_source::SourceStore, +} + +pub(super) fn storybook_findings(input: StorybookFindingsRequest<'_>) -> Result> { + let StorybookFindingsRequest { root, config, prepared_tsconfig_catalog, @@ -17,16 +20,26 @@ pub(super) fn storybook_findings( inferred_roots, session, defer_suppression, + sources, + } = input; + require_storybook_stories::check_with_prepared_facts_for_aggregate( + require_storybook_stories::PreparedStorybookCheck { + root, + config, + prepared_tsconfig_catalog, + shared, + inferred_roots, + session, + defer_suppression, + sources, + }, ) } pub(super) fn suppress_findings( root: &Path, findings: &mut Vec, - sources: Option<&crate::codebase::ts_source::SourceStore>, + sources: &crate::codebase::ts_source::SourceStore, ) { - match sources { - Some(sources) => suppress_rule_findings_with_sources(root, findings, sources), - None => suppress_rule_findings(root, findings), - } + suppress_rule_findings_with_sources(root, findings, sources); } diff --git a/crates/no-mistakes/src/codebase/rules/run/standalone.rs b/crates/no-mistakes/src/codebase/rules/run/standalone.rs index 1869c2bec..6e9157741 100644 --- a/crates/no-mistakes/src/codebase/rules/run/standalone.rs +++ b/crates/no-mistakes/src/codebase/rules/run/standalone.rs @@ -100,7 +100,7 @@ pub(super) fn run_check( prepared_tsconfig: &prepared_tsconfig, prepared_tsconfig_catalog: &prepared_tsconfig_catalog, inferred_roots: Some(&inferred_roots), - sources: Some(&sources), + sources: &sources, defer_suppression: false, }) } diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs index 7528e8107..658360607 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs @@ -32,7 +32,7 @@ pub(crate) fn check_with_files_sources_and_deferred_suppression( root: &Path, config: &NoMistakesConfig, all_files: &[PathBuf], - exclusive_files: &[PathBuf], + _exclusive_files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, defer_suppression: bool, ) -> Result> { @@ -44,14 +44,7 @@ pub(crate) fn check_with_files_sources_and_deferred_suppression( let mut findings: Vec = work .par_iter() .flat_map(|(path, work)| { - scan::scan_file_with_deferred_suppression( - root, - path, - work, - exclusive_files.binary_search(path).is_ok(), - sources, - defer_suppression, - ) + scan::scan_file_with_deferred_suppression(root, path, work, sources, defer_suppression) }) .collect(); super::sort_findings(&mut findings); diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs index bf913c280..8374c3a3f 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/scan.rs @@ -4,22 +4,9 @@ pub(super) fn scan_file_with_deferred_suppression( root: &Path, path: &Path, work: &RustWork, - exclusive: bool, sources: &crate::codebase::ts_source::SourceStore, defer_suppression: bool, ) -> Vec { - if exclusive { - let Ok(content) = std::fs::read_to_string(path) else { - return Vec::new(); - }; - return scan_file_with_source_and_deferred_suppression( - root, - path, - work, - &content, - defer_suppression, - ); - } let Some(content) = super::super::read_source(sources, path) else { return Vec::new(); }; diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs index 42c975cd1..4baab1c65 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs @@ -6,7 +6,7 @@ use crate::config::v2::schema::{RuleDef, RuleScope}; fn scan_test_file(root: &Path, path: &Path, work: &RustWork) -> Vec { let sources = crate::codebase::rules::source_store_for_files(&[path.to_path_buf()]); - scan::scan_file_with_deferred_suppression(root, path, work, false, &sources, false) + scan::scan_file_with_deferred_suppression(root, path, work, &sources, false) } fn config_with_rule(rule: &str) -> NoMistakesConfig { @@ -47,8 +47,7 @@ fn scan_file_returns_empty_for_unreadable_file() { let sources = crate::codebase::rules::source_store_for_files(std::slice::from_ref(&missing)); assert!( - scan_file_with_deferred_suppression(&root, &missing, &work, true, &sources, false) - .is_empty() + scan_file_with_deferred_suppression(&root, &missing, &work, &sources, false).is_empty() ); } @@ -103,7 +102,7 @@ fn exclusive_sources_are_not_retained_and_overlapping_sources_are_memoized() { false, ) .unwrap(); - assert_eq!(exclusive_sources.physical_read_count(), 0); + assert_eq!(exclusive_sources.physical_read_count(), 1); let overlapping_sources = crate::codebase::rules::source_store_for_files(&files); let overlapping = check_with_files_sources_and_deferred_suppression( diff --git a/crates/no-mistakes/src/codebase/rules/suppression.rs b/crates/no-mistakes/src/codebase/rules/suppression.rs index 344906cf2..2a4cf2af4 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -8,10 +8,6 @@ pub use accounting::{ SuppressionDirectiveKind, SuppressionTarget, }; -pub(crate) fn suppress_rule_findings(root: &Path, findings: &mut Vec) { - suppress_rule_findings_inner(root, findings, None, &[]); -} - pub(crate) fn suppress_rule_findings_with_sources_except( root: &Path, findings: &mut Vec, @@ -33,7 +29,7 @@ pub(crate) fn suppress_rule_findings_with_source(findings: &mut Vec findings.retain(|finding| !finding_is_suppressed(source, finding)); } -fn suppress_rule_findings_inner( +pub(super) fn suppress_rule_findings_inner( root: &Path, findings: &mut Vec, request_sources: Option<&crate::codebase::ts_source::SourceStore>, diff --git a/crates/no-mistakes/src/codebase/rules/suppression_tests.rs b/crates/no-mistakes/src/codebase/rules/suppression_tests.rs index 66c3488e7..ae3a66162 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression_tests.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression_tests.rs @@ -1,5 +1,9 @@ use super::*; +fn suppress_rule_findings(root: &std::path::Path, findings: &mut Vec) { + super::suppression::suppress_rule_findings_inner(root, findings, None, &[]); +} + #[test] fn shared_suppression_only_reads_repo_relative_paths() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs index 867d7d940..45e00923d 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs @@ -39,25 +39,37 @@ fn precompute_setup_data_from_config_files( root: &Path, config_files: &[ConfigFile], ) -> Result> { - precompute_setup_data_from_config_files_inner(root, config_files, None) + precompute_setup_data_from_config_files_inner(root, config_files, None, None) } fn precompute_setup_data_from_config_files_from_visible( root: &Path, config_files: &[ConfigFile], visible_files: &std::collections::HashSet, + sources: &crate::codebase::ts_source::SourceStore, ) -> Result> { - precompute_setup_data_from_config_files_inner(root, config_files, Some(visible_files)) + precompute_setup_data_from_config_files_inner( + root, + config_files, + Some(visible_files), + Some(sources), + ) } fn precompute_setup_data_from_config_files_inner( root: &Path, config_files: &[ConfigFile], visible_files: Option<&std::collections::HashSet>, + sources: Option<&crate::codebase::ts_source::SourceStore>, ) -> Result> { let mut result = Vec::new(); for config_file in config_files { - let source = std::fs::read_to_string(&config_file.path)?; + let source = match sources { + Some(sources) => crate::codebase::rules::read_source(sources, &config_file.path) + .ok_or_else(|| anyhow::anyhow!("failed to read {}", config_file.path.display()))? + .to_string(), + None => std::fs::read_to_string(&config_file.path)?, + }; let base = config_file.path.parent().unwrap_or(root); let includes = normalize_matcher_patterns(root, base, config_file.includes(&source)); let excludes = normalize_matcher_patterns( @@ -70,8 +82,12 @@ fn precompute_setup_data_from_config_files_inner( include_regex: build_regexes(&extract_test_regexes(&source))?, exclude: build_globset(&excludes)?, }; - let setup_files = - setup_files_from_configs_inner(root, vec![config_file.path.clone()], visible_files)?; + let setup_files = setup_files_from_configs_inner( + root, + vec![config_file.path.clone()], + visible_files, + sources, + )?; result.push(ConfigSetupData { filter, setup_files, @@ -119,10 +135,16 @@ fn setup_files_from_configs_inner( root: &Path, config_files: Vec, visible_files: Option<&std::collections::HashSet>, + sources: Option<&crate::codebase::ts_source::SourceStore>, ) -> Result> { let mut files = Vec::new(); for config_file in config_files { - let source = std::fs::read_to_string(&config_file)?; + let source = match sources { + Some(sources) => crate::codebase::rules::read_source(sources, &config_file) + .ok_or_else(|| anyhow::anyhow!("failed to read {}", config_file.display()))? + .to_string(), + None => std::fs::read_to_string(&config_file)?, + }; let base = config_file.parent().unwrap_or(root); let mut setups = extract_test_property_strings(&source, "setupFiles"); setups.extend(extract_property_strings(&source, "setupFiles")); diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/filter.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/filter.rs index c4e20d5cf..26513083d 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/filter.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/filter.rs @@ -47,13 +47,27 @@ pub(super) fn test_filter_from_config_files( root: &Path, config: &NoMistakesConfig, config_files: &[ConfigFile], +) -> Result { + test_filter_from_config_files_with_sources(root, config, config_files, None) +} + +pub(super) fn test_filter_from_config_files_with_sources( + root: &Path, + config: &NoMistakesConfig, + config_files: &[ConfigFile], + sources: Option<&crate::codebase::ts_source::SourceStore>, ) -> Result { let (mut includes, mut excludes) = rule_test_project_globs(root, config)?; let has_rule_target_includes = !includes.is_empty(); let mut include_regex = Vec::new(); let mut config_includes = Vec::new(); for config_file in config_files { - let source = std::fs::read_to_string(&config_file.path)?; + let source = match sources { + Some(sources) => crate::codebase::rules::read_source(sources, &config_file.path) + .ok_or_else(|| anyhow::anyhow!("failed to read {}", config_file.path.display()))? + .to_string(), + None => std::fs::read_to_string(&config_file.path)?, + }; let base = config_file.path.parent().unwrap_or(root); config_includes.extend(super::normalize_matcher_patterns( root, diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared.rs index ba8eca739..104f54e51 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared.rs @@ -1,5 +1,4 @@ use super::discovery::config_files_from_visible; -use super::filter::test_filter_from_config_files; use super::{ConfigSetupData, TestFilter}; use crate::config::v2::NoMistakesConfig; use anyhow::Result; @@ -25,6 +24,7 @@ pub(in super::super) fn prepare_from_visible( root: &Path, config: &NoMistakesConfig, visible_files: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, ) -> Result { let config_files = config_files_from_visible(root, config, visible_files); let visible_files = visible_files @@ -32,11 +32,17 @@ pub(in super::super) fn prepare_from_visible( .map(|path| crate::codebase::ts_resolver::normalize_path(path)) .collect::>(); Ok(PreparedConfig { - test_filter: test_filter_from_config_files(root, config, &config_files)?, + test_filter: super::filter::test_filter_from_config_files_with_sources( + root, + config, + &config_files, + Some(sources), + )?, setup_data: super::precompute_setup_data_from_config_files_from_visible( root, &config_files, &visible_files, + sources, )?, }) } diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs index 7ec1a7aff..f3fa11fd6 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs @@ -17,7 +17,9 @@ fn prepared_config_globs_only_expand_aggregate_candidates() { // `jest.config.mjs` also exists and matches the configured glob. Omitting it // from the aggregate candidates must keep its matcher and setup files out. - let prepared = prepare_from_visible(&root, &config, &[selected_config]).unwrap(); + let sources = + crate::codebase::rules::source_store_for_files(std::slice::from_ref(&selected_config)); + let prepared = prepare_from_visible(&root, &config, &[selected_config], &sources).unwrap(); assert!(prepared .test_filter() @@ -31,7 +33,7 @@ fn prepared_config_globs_only_expand_aggregate_candidates() { fn aggregate_rule_uses_prepared_config_without_standalone_discovery() { let source = include_str!("../with_facts.rs"); - assert!(source.contains("config::prepare_from_visible(root, config, &files)")); + assert!(source.contains("config::prepare_from_visible(root, config, &files, sources)")); assert!(!source.contains("config::test_filter(")); assert!(!source.contains("config::precompute_setup_data(")); assert!(!source.contains("discover_files(")); @@ -47,7 +49,8 @@ fn pass4b_prepared_setup_files_drop_ignored_candidate_and_keep_visible_fallback( let mut config = NoMistakesConfig::default(); config.tests.vitest.configs = Some(StringOrList::One("dynamic/vitest.config.ts".to_string())); - let prepared = prepare_from_visible(&root, &config, &visible).unwrap(); + let sources = crate::codebase::rules::source_store_for_files(&visible); + let prepared = prepare_from_visible(&root, &config, &visible, &sources).unwrap(); assert_eq!( prepared.setup_data()[0].setup_files, diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/tests.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/tests.rs index 8ebaf8665..6c9480215 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/tests.rs @@ -1,7 +1,7 @@ use super::*; fn setup_files_from_configs(root: &Path, config_files: Vec) -> Result> { - setup_files_from_configs_inner(root, config_files, None) + setup_files_from_configs_inner(root, config_files, None, None) } use crate::config::v2::schema::{Project, RuleDef}; diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index 1e9e760ce..84078e597 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -60,6 +60,7 @@ pub(crate) struct PreparedFactsGraphRequest<'a> { pub(crate) shared: &'a CheckFactMap, pub(crate) graph: &'a DepGraph, pub(crate) session: &'a std::sync::Arc, + pub(crate) sources: &'a crate::codebase::ts_source::SourceStore, pub(crate) defer_suppression: bool, } @@ -73,6 +74,7 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( shared, graph, session, + sources, defer_suppression, } = request; let files = shared.files().to_vec(); @@ -87,7 +89,7 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( }); let prepared = crate::perf_trace::trace("test_no_unmocked_dynamic_imports.prepare_config", || { - config::prepare_from_visible(root, config, &files) + config::prepare_from_visible(root, config, &files, sources) })?; let test_files = matching_test_files_with_filter(root, &files, prepared.test_filter()); let setup_data = prepared.setup_data(); diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs index c925c3913..aaaaba5cb 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs @@ -28,6 +28,7 @@ pub(crate) fn check_with_prepared_facts_and_session( session.clone(), ) })?; + let sources = crate::codebase::rules::source_store_for_files(shared.files()); check_with_prepared_facts_graph_and_session(PreparedFactsGraphRequest { root, config, @@ -35,6 +36,7 @@ pub(crate) fn check_with_prepared_facts_and_session( shared, graph: &graph, session, + sources: &sources, defer_suppression: false, }) } diff --git a/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs b/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs index 7fc33cce4..72d5ca8ed 100644 --- a/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs +++ b/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs @@ -18,10 +18,11 @@ pub fn matching_disable_directive( return Some(DisableDirective::File { line }); } let line = finding_line?; - if super::has_disable_line_comment(source, line, rule_id) { - return Some(DisableDirective::Line { line }); + if super::has_disable_comment(source, line, rule_id) { + return Some(DisableDirective::NextLine { + line: line.saturating_sub(1), + }); } - super::has_disable_comment(source, line, rule_id).then_some(DisableDirective::NextLine { - line: line.saturating_sub(1), - }) + super::has_disable_line_comment(source, line, rule_id) + .then_some(DisableDirective::Line { line }) } diff --git a/crates/no-mistakes/src/codebase/unique_exports/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index 1935a1753..7452e6a8c 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/collector.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/collector.rs @@ -3,7 +3,7 @@ use super::origin::{origin_for_export, resolve_export_source}; use super::{ExportBucket, ExportOccurrence, ExportOrigin, SourceFile, RULE_ID}; use crate::codebase::symbols::export_kind_str; use crate::codebase::ts_resolver::{normalize_path, ImportResolverFacade}; -use crate::codebase::ts_source::has_disable_comment; +use crate::codebase::ts_source::{has_disable_comment, has_disable_line_comment}; use crate::codebase::ts_symbols::{Export, ExportKind}; use crate::codebase::workspaces::WorkspaceMap; use std::collections::{HashMap, HashSet}; @@ -62,8 +62,9 @@ pub(super) fn collect_file_exports( occurrence.file = file.rel.clone(); occurrence.line = export.line; occurrence.kind = export_kind_str(&export.kind).to_string(); - occurrence.suppressed = - file.disabled || has_disable_comment(&file.source, export.line, RULE_ID); + occurrence.suppressed = file.disabled + || has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID); if !super::nextjs::is_framework_export( &occurrence.file, &occurrence.name, @@ -111,7 +112,8 @@ pub(super) fn collect_file_exports( kind: export_kind_str(&export.kind).to_string(), origin, suppressed: file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID), + || has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID), }); } _ => { @@ -124,7 +126,8 @@ pub(super) fn collect_file_exports( kind: export_kind_str(&export.kind).to_string(), origin: origin_for_export(file, export, bucket), suppressed: file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID), + || has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID), }); } } diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs index 9125bc498..1ecc1f1dd 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs @@ -56,10 +56,9 @@ impl SharedCheckContext { facts: &crate::codebase::check_facts::CheckFactMap, dependency_graph: Option<&std::sync::Arc>, session: std::sync::Arc, + include_suppressed: bool, ) -> Result { use crate::check_parallel::{run_domain_checks, DomainCheckInputs}; - use crate::codebase::rules::agents_md_max_size::advisories_with_files_and_sources; - if self.fact_files.is_empty() && self.graph_files.is_empty() && !self.filesystem_rules_enabled @@ -104,7 +103,7 @@ impl SharedCheckContext { .prepared .tsconfig_gate_project_inputs .as_ref(), - defer_suppression: false, + defer_suppression: true, }); let completed = crate::check_runner::complete_domain_checks(( react, @@ -114,44 +113,19 @@ impl SharedCheckContext { codebase, filesystem_rules, ))?; - let mut rules = completed.rules.findings; - rules.extend(completed.filesystem_rules.findings); - let warnings = [ - completed.react.warning, - completed.queues.warning, - completed.rules.warning, - completed.integration.warning, - completed.codebase.warning, - completed.filesystem_rules.warning, - ] - .into_iter() - .flatten() - .collect(); - let advisories = if self.filesystem_rules_enabled { - advisories_with_files_and_sources(&self.root, config, &self.fs_files, &sources)? - } else { - Vec::new() - }; - Ok(crate::check_runner::CheckResults { - timings: vec![ - ("discover", std::time::Duration::ZERO), - ("parse_extract", std::time::Duration::ZERO), - ("react", completed.react.duration), - ("queues", completed.queues.duration), - ("rules", completed.rules.duration), - ("integration", completed.integration.duration), - ("codebase", completed.codebase.duration), - ("filesystem_rules", completed.filesystem_rules.duration), - ], - react: completed.react.findings, - queues: completed.queues.findings, - rules, - integration: completed.integration.findings, - codebase: completed.codebase.findings, - warnings, - advisories, - suppressed: Vec::new(), - include_suppressed: false, - }) + crate::check_runner::results::finalize_domain_checks( + crate::check_runner::results::FinalizeInput { + root: &self.root, + config, + filesystem_files: &self.fs_files, + sources: &sources, + filesystem_rules_enabled: self.filesystem_rules_enabled, + react_warning: None, + discover_duration: std::time::Duration::ZERO, + facts_duration: std::time::Duration::ZERO, + completed, + include_suppressed, + }, + ) } } diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_project_reports.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_project_reports.rs index afb064251..1c8d66fe4 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_project_reports.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_project_reports.rs @@ -30,6 +30,7 @@ impl PreparedScope { &self.check_facts, dependency_graph.as_ref(), self.traversal.session_arc(), + parsed.include_suppressed, )?)) } _ => unreachable!("project report types are checked before dispatch"), diff --git a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs index 12f09ce86..2316a72a8 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs @@ -90,6 +90,28 @@ fn analyze_project_dynamic_import_check_respects_filesystem_skips_with_standalon assert!(finding.get("target").is_none()); } +#[test] +fn analyze_project_check_applies_shared_suppression_accounting() { + let root = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check/suppression-react"); + let output = analyze_project_json_impl( + json!({ + "root": root, + "reports": [{ "type": "check", "includeSuppressed": true }] + }) + .to_string(), + ) + .unwrap(); + let result: Value = serde_json::from_str(&output).unwrap(); + let report = &result["reports"][0]["result"]; + assert!(report["react"].as_array().is_some_and(Vec::is_empty)); + assert!(report["suppressed"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["domain"] == "react" && item["rule"] == "assert-no-fetch") + })); +} + #[test] fn analyze_project_reachability_check_uses_full_graph_with_standalone_parity() { let root = check_runner_fixture("required-reachability-ignores-filesystem-skip"); diff --git a/crates/no-mistakes/src/napi_api/tests.rs b/crates/no-mistakes/src/napi_api/tests.rs index 1c81e34db..0db9982c1 100644 --- a/crates/no-mistakes/src/napi_api/tests.rs +++ b/crates/no-mistakes/src/napi_api/tests.rs @@ -388,6 +388,9 @@ fn react_json_functions_return_reports() { .iter() .find(|entry| entry["name"] == "FetchingComponent") .expect("fixture must expose its fetching component"); + assert!(fetching["fetches"] + .as_array() + .is_some_and(|fetches| !fetches.is_empty())); // Suppression needs the internal source location, but React analysis must // not gain a location field without an explicit public DTO change. assert!(fetching["fetches"][0].get("line").is_none()); @@ -459,6 +462,15 @@ fn invalid_options_return_napi_errors() { assert!(error.reason.contains("Failed to read plan")); } +#[test] +fn project_options_default_include_suppressed_when_omitted() { + let options = parse_options::( + &json!({ "root": fixture_root("simple"), "files": ["a.mts"] }).to_string(), + ) + .unwrap(); + assert!(!options.include_suppressed); +} + #[test] fn option_parsers_cover_all_supported_values() { for relationship in [ diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 8b524ffc9..1e2e45f78 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -317,6 +317,24 @@ fn check_json_records_react_next_line_directive_at_the_fetch_location() { assert_eq!(finding["directive"]["line"], 2); } +#[test] +fn check_json_uses_filter_precedence_for_overlapping_directives() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-directive-precedence"); + let output = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + let finding = value["suppressed"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["domain"] == "react") + .unwrap_or_else(|| panic!("missing React suppression: {value}")); + assert_eq!(finding["line"], 4); + assert_eq!(finding["directive"]["kind"], "nextLine"); + assert_eq!(finding["directive"]["line"], 3); +} + #[test] fn check_json_keeps_unsuppressed_duplicate_when_suppressed_export_sorts_first() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -331,9 +349,14 @@ fn check_json_keeps_unsuppressed_duplicate_when_suppressed_export_sorts_first() ) .unwrap(); assert!(audit["codebase"].as_array().is_some_and(Vec::is_empty)); - assert!(audit["suppressed"].as_array().is_some_and(|items| items - .iter() - .any(|item| { item["rule"] == "unique-exports" && item["file"] == "src/a.ts" }))); + assert!(audit["suppressed"] + .as_array() + .is_some_and(|items| items.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "src/a.ts" + && item["directive"]["kind"] == "line" + && item["directive"]["line"] == 1 + }))); } #[test] diff --git a/fixtures/check/suppression-directive-precedence/.no-mistakes.yml b/fixtures/check/suppression-directive-precedence/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/suppression-directive-precedence/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/suppression-directive-precedence/app/Fetcher.tsx b/fixtures/check/suppression-directive-precedence/app/Fetcher.tsx new file mode 100644 index 000000000..66d2e0955 --- /dev/null +++ b/fixtures/check/suppression-directive-precedence/app/Fetcher.tsx @@ -0,0 +1,6 @@ +export default async function Fetcher() { + // The next-line directive wins when both supported directives cover one finding. + // no-mistakes-disable-next-line assert-no-fetch: next-line is authoritative + await fetch('/api/first'); // no-mistakes-disable-line assert-no-fetch: same-line also matches + return
; +} diff --git a/fixtures/check/suppression-unique-canonical/src/a.ts b/fixtures/check/suppression-unique-canonical/src/a.ts index e0fb439b9..541792bca 100644 --- a/fixtures/check/suppression-unique-canonical/src/a.ts +++ b/fixtures/check/suppression-unique-canonical/src/a.ts @@ -1,2 +1 @@ -// no-mistakes-disable-file unique-exports: compatibility export -export const shared = 1; +export const shared = 1; // no-mistakes-disable-line unique-exports: compatibility export diff --git a/packages/no-mistakes/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index b16ed0faa..4a7e4baf6 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -30,7 +30,7 @@ export interface CheckReport { codebase: unknown[]; warnings: string[]; advisories: unknown[]; - /** Present only when `includeSuppressed` is requested and directives matched. */ + /** Present when `includeSuppressed` is requested; empty when no directives matched. */ suppressed?: SuppressedFinding[]; } From 94be44fcebd97be955d1d283a6b2f2d1344bb38c Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 12:59:23 -0700 Subject: [PATCH 15/62] fix: preserve suppressed parse failures --- .../test_no_unmocked_dynamic_imports/with_facts.rs | 13 +++++++------ crates/no-mistakes/src/napi_api/tests/check.rs | 14 ++++++++++++++ .../.no-mistakes.yml | 7 +++++++ .../src/leaf.mts | 1 + .../tests/direct.test.mts | 5 +++++ .../tests/disabled.test.mts | 6 ++++++ .../vitest.config.mts | 5 +++++ 7 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/leaf.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/direct.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/vitest.config.mts diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index 84078e597..a911ef991 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -22,6 +22,7 @@ mod tsconfig_catalog; pub(crate) use graph::check_with_prepared_facts_and_session; +#[derive(Default)] struct PerTestResult { direct_findings: Vec, reachable_findings: Vec, @@ -117,12 +118,12 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( let Some(source) = file_facts.source.as_deref() else { anyhow::bail!("missing source facts for {}", file.display()); }; - if !defer_suppression && has_disable_file_comment(source, RULE_ID) { - return Ok(PerTestResult { - direct_findings: Vec::new(), - reachable_findings: Vec::new(), - covered_reachable_imports: HashSet::new(), - }); + // A file-disabled parse error cannot yield findings for + // audit mode, but must not abort unrelated test files. + if has_disable_file_comment(source, RULE_ID) + && (file_facts.parse_error.is_some() || !defer_suppression) + { + return Ok(PerTestResult::default()); } if let Some(error) = &file_facts.parse_error { anyhow::bail!("failed to parse {}: {error}", file.display()); diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 1e2e45f78..e382bb142 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -184,6 +184,20 @@ fn check_json_preserves_direct_and_reachable_dynamic_import_reports_when_auditin ); } +#[test] +fn check_json_skips_file_disabled_parse_errors_without_losing_other_dynamic_imports() { + let (baseline, audit) = + baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error"); + assert!(baseline["warnings"].as_array().is_some_and(Vec::is_empty)); + assert!(baseline["rules"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["rule"] == "test-no-unmocked-dynamic-imports" + && finding["file"] == "tests/direct.test.mts" + }) + })); + assert_eq!(audit["suppressed"], json!([])); +} + #[test] fn check_json_preserves_server_boundary_report_when_auditing_suppression() { let (_, audit) = baseline_and_audit("aggregate-server-route-client-boundary"); diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/.no-mistakes.yml b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/.no-mistakes.yml new file mode 100644 index 000000000..d4abed644 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/leaf.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/leaf.mts new file mode 100644 index 000000000..1e9853c5f --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/leaf.mts @@ -0,0 +1 @@ +export const leaf = true diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/direct.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/direct.test.mts new file mode 100644 index 000000000..439dc6b72 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/direct.test.mts @@ -0,0 +1,5 @@ +import { test } from 'vitest' + +test('unmocked import remains reportable', async () => { + await import('../src/leaf.mts') +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled.test.mts new file mode 100644 index 000000000..d501306f8 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled.test.mts @@ -0,0 +1,6 @@ +// no-mistakes-disable-file test-no-unmocked-dynamic-imports: malformed legacy test +import { test } from 'vitest' + +test('malformed', () => { + const broken = { +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/vitest.config.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/vitest.config.mts new file mode 100644 index 000000000..1f2d81198 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/vitest.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { include: ['tests/**/*.test.mts'] }, +}) From 22d923f766ad030b7196a5feff8e6e9283526316 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 14:13:18 -0700 Subject: [PATCH 16/62] fix: close suppression review gaps --- .../no-mistakes/src/check_runner/results.rs | 2 + .../src/check_runner/results/suppression.rs | 38 +++-- .../check_runner/results/suppression_tests.rs | 25 +++ .../filesystem_dispatch/candidate_helpers.rs | 28 ---- .../filesystem_dispatch/candidate_index.rs | 30 +--- .../candidate_index/tests.rs | 25 --- .../rules/filesystem_dispatch/execute.rs | 1 - .../rules/require_storybook_stories.rs | 25 ++- .../rules/require_storybook_stories/runner.rs | 28 +++- .../src/codebase/rules/rust_rules_combined.rs | 1 - .../rules/rust_rules_combined/tests.rs | 4 +- .../config/discovery/visible.rs | 4 +- .../with_facts.rs | 117 +++++-------- .../with_facts/graph.rs | 4 +- .../with_facts/per_test.rs | 121 ++++++++++++++ .../with_facts/tsconfig_catalog.rs | 6 +- .../src/codebase/unique_exports/collector.rs | 34 +++- .../src/codebase/unique_exports/findings.rs | 12 +- .../src/codebase/unique_exports/origin.rs | 43 ++++- .../src/codebase/unique_exports/tests.rs | 9 + .../src/codebase/unique_exports/types.rs | 5 + .../analyze_project/context/check_run.rs | 4 +- .../src/napi_api/analyze_project/tests.rs | 15 ++ .../no-mistakes/src/napi_api/tests/check.rs | 108 ++---------- .../src/napi_api/tests/check_suppression.rs | 157 ++++++++++++++++++ crates/no-mistakes/src/react_traits/mod.rs | 4 +- .../src/react_traits/pipeline/check.rs | 39 ++++- .../src/react_traits/pipeline/run.rs | 10 ++ .../react_traits/pipeline/run_with_facts.rs | 11 ++ .../src/react_traits/report/text/tests.rs | 2 + .../src/react_traits/report/types.rs | 13 ++ .../aggregate-agents-md-max-size/AGENTS.md | 2 +- .../.gitignore | 1 + .../.no-mistakes.yml | 7 + .../src/leaf.mts | 1 + .../tests/visible.test.mts | 5 + .../web/app/page.ts | 4 +- .../.no-mistakes.yml | 2 + .../src/other.mts | 1 + .../tests/disabled-mock.test.mts | 9 + .../suppression-react-multiple/app/Child.tsx | 4 + .../app/Fetcher.tsx | 5 +- .../.no-mistakes.yml | 7 +- .../shared/suppressed-origin.ts | 5 + .../shared/type-origin.ts | 1 + .../suppression-unique-canonical/src/c.ts | 1 + .../src/chained-visible.ts | 2 + .../src/named-barrel.ts | 2 + .../src/type-barrel.ts | 2 + .../src/type-visible.ts | 1 + .../src/wild-barrel.ts | 1 + 51 files changed, 674 insertions(+), 314 deletions(-) create mode 100644 crates/no-mistakes/src/check_runner/results/suppression_tests.rs create mode 100644 crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs create mode 100644 crates/no-mistakes/src/napi_api/tests/check_suppression.rs create mode 100644 fixtures/check/aggregate-dynamic-import-gitignored-config/.gitignore create mode 100644 fixtures/check/aggregate-dynamic-import-gitignored-config/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-dynamic-import-gitignored-config/src/leaf.mts create mode 100644 fixtures/check/aggregate-dynamic-import-gitignored-config/tests/visible.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/other.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled-mock.test.mts create mode 100644 fixtures/check/suppression-react-multiple/app/Child.tsx create mode 100644 fixtures/check/suppression-unique-canonical/shared/suppressed-origin.ts create mode 100644 fixtures/check/suppression-unique-canonical/shared/type-origin.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/c.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/chained-visible.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/named-barrel.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/type-barrel.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/type-visible.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/wild-barrel.ts diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index dc3301273..943a29519 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -9,6 +9,8 @@ use no_mistakes::react_traits; use std::time::Duration; mod suppression; +#[cfg(test)] +mod suppression_tests; pub(crate) struct FinalizeInput<'a> { pub(crate) root: &'a std::path::Path, diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index fce93d733..e70044d3f 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -77,31 +77,43 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { /// A component-level React diagnostic covers every local fetch. Preserve its /// single stable public finding unless all of those fetches are suppressed. -fn suppress_react( +pub(super) fn suppress_react( root: &std::path::Path, sources: &SourceStore, findings: &mut Vec, suppressed: &mut Vec, ) { findings.retain(|finding| { - let lines = if finding.suppression_lines.is_empty() { - vec![finding.line] - } else { + let mut locations = if !finding.suppression_targets.is_empty() { + finding + .suppression_targets + .iter() + .map(|target| react_traits::Violation { + file: target.file.clone(), + line: Some(target.line), + suppression_lines: Vec::new(), + suppression_targets: Vec::new(), + ..finding.clone() + }) + .collect() + } else if !finding.suppression_lines.is_empty() { finding .suppression_lines .iter() - .copied() - .map(Some) + .map(|line| react_traits::Violation { + line: Some(*line), + suppression_lines: Vec::new(), + suppression_targets: Vec::new(), + ..finding.clone() + }) .collect() - }; - let mut locations = lines - .into_iter() - .map(|line| react_traits::Violation { - line, + } else { + vec![react_traits::Violation { suppression_lines: Vec::new(), + suppression_targets: Vec::new(), ..finding.clone() - }) - .collect::>(); + }] + }; suppressed.extend(suppress_domain_findings_with_sources( root, &mut locations, diff --git a/crates/no-mistakes/src/check_runner/results/suppression_tests.rs b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs new file mode 100644 index 000000000..89afee3b9 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs @@ -0,0 +1,25 @@ +use super::suppression::suppress_react; +use no_mistakes::codebase::ts_source::VisiblePathSnapshot; +use no_mistakes::react_traits::Violation; +use std::path::PathBuf; + +#[test] +fn line_less_react_findings_are_not_dropped_by_suppression_adapter() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-react-multiple"); + let snapshot = VisiblePathSnapshot::new(&root); + let sources = snapshot.source_store_for(&root); + let mut findings = vec![Violation { + component: "Fetcher".to_string(), + file: "app/Fetcher.tsx".to_string(), + rule: "assert-no-fetch".to_string(), + detail: None, + line: None, + suppression_lines: Vec::new(), + suppression_targets: Vec::new(), + }]; + let mut suppressed = Vec::new(); + suppress_react(&root, &sources, &mut findings, &mut suppressed); + assert_eq!(findings.len(), 1); + assert!(suppressed.is_empty()); +} diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_helpers.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_helpers.rs index 55f4980cd..393342285 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_helpers.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_helpers.rs @@ -2,38 +2,10 @@ use std::borrow::Cow; use std::collections::HashSet; use std::path::{Path, PathBuf}; -use crate::codebase::rules::{ - BANNED_PATHS, BANNED_RENAMED_FILES, CONFIG_PATH_REFERENCES, DOC_CONSISTENCY, - FILE_EXTENSION_POLICY, FINITE_SET_CONSISTENCY, INTEGRATION_TEST_NO_MOCKS, - NO_EMPTY_OR_COMMENTS_ONLY_FILES, NO_GIT_IDENTITY_MUTATION, REQUIRED_COMPANION_IMPORTS, - SHELLCHECK_RUNNER, STRUCTURED_CONFIG_POLICY, TEST_EMAIL_DOMAIN_POLICY, -}; - pub(super) fn is_rust_path(path: &Path) -> bool { path.extension().and_then(|extension| extension.to_str()) == Some("rs") } -// Rules that may read Rust directly or emit a Rust-path finding whose -// suppression check must read the source from the shared store. -pub(super) fn rule_can_consume_rust_source(rule_id: &str) -> bool { - matches!( - rule_id, - BANNED_PATHS - | BANNED_RENAMED_FILES - | CONFIG_PATH_REFERENCES - | DOC_CONSISTENCY - | FILE_EXTENSION_POLICY - | FINITE_SET_CONSISTENCY - | INTEGRATION_TEST_NO_MOCKS - | NO_EMPTY_OR_COMMENTS_ONLY_FILES - | NO_GIT_IDENTITY_MUTATION - | REQUIRED_COMPANION_IMPORTS - | SHELLCHECK_RUNNER - | STRUCTURED_CONFIG_POLICY - | TEST_EMAIL_DOMAIN_POLICY - ) -} - pub(super) fn normalized_paths(paths: &[PathBuf]) -> Cow<'_, [PathBuf]> { let already_normalized = paths.windows(2).all(|pair| pair[0] < pair[1]) && paths.iter().all(|path| { diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index.rs index c1f786420..0850b5040 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index.rs @@ -5,13 +5,11 @@ use crate::codebase::rules::{ RUST_MAX_LINES_PER_FILE, RUST_NO_INLINE_ALLOWS, RUST_NO_INLINE_TESTS, }; use crate::config::v2::NoMistakesConfig; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use super::candidate_helpers::{ - is_rust_path, markdown_inventory_path_allowed, normalized_paths, rule_can_consume_rust_source, -}; +use super::candidate_helpers::{is_rust_path, markdown_inventory_path_allowed, normalized_paths}; /// Immutable, request-scoped candidates for every enabled filesystem rule. /// @@ -21,7 +19,6 @@ use super::candidate_helpers::{ pub(super) struct RuleCandidateIndex { by_rule: BTreeMap<&'static str, Arc>>, rust: Arc>, - exclusive_rust: Arc>, } impl RuleCandidateIndex { @@ -164,28 +161,9 @@ impl RuleCandidateIndex { .collect::>(); rust.sort(); rust.dedup(); - let rust_rule_ids = [ - RUST_MAX_LINES_PER_FILE, - RUST_NO_INLINE_TESTS, - RUST_NO_INLINE_ALLOWS, - ]; - let non_rust = by_rule - .iter() - .filter(|(rule_id, _)| { - !rust_rule_ids.contains(rule_id) && rule_can_consume_rust_source(rule_id) - }) - .flat_map(|(_, paths)| paths.iter().filter(|path| is_rust_path(path)).cloned()) - .collect::>(); - let exclusive_rust = rust - .iter() - .filter(|path| !non_rust.contains(*path)) - .cloned() - .collect(); - Self { by_rule, rust: Arc::new(rust), - exclusive_rust: Arc::new(exclusive_rust), } } @@ -200,10 +178,6 @@ impl RuleCandidateIndex { &self.rust } - pub(super) fn exclusive_rust_candidates(&self) -> &[PathBuf] { - &self.exclusive_rust - } - pub(super) fn all_candidates(&self) -> impl Iterator { self.by_rule.values().flat_map(|paths| paths.iter()) } diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs index 96f711188..71c1263fd 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs @@ -88,10 +88,6 @@ fn rust_exclusivity_tracks_enabled_non_rust_candidate_overlap() { &files, Some(Arc::clone(&files)), ); - assert_eq!( - exclusive.exclusive_rust_candidates(), - std::slice::from_ref(&rust_file) - ); let agents = exclusive .by_rule .get(super::super::AGENTS_MD_MAX_SIZE) @@ -104,27 +100,6 @@ fn rust_exclusivity_tracks_enabled_non_rust_candidate_overlap() { .unwrap(); assert!(Arc::ptr_eq(&agents, &workflows)); assert!(Arc::ptr_eq(&agents, &files)); - - let overlapping = NoMistakesConfig { - rules: vec![ - rust_rule, - RuleDef { - rule: super::super::NO_EMPTY_OR_COMMENTS_ONLY_FILES.to_string(), - scope: Some(RuleScope::Repository), - ..Default::default() - }, - ], - ..Default::default() - }; - let shared = RuleCandidateIndex::prepare_with_inventory( - &root, - &overlapping, - &files, - &files, - &files, - None, - ); - assert!(shared.exclusive_rust_candidates().is_empty()); } #[test] diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs index 7feb79c2b..d2779f130 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs @@ -156,7 +156,6 @@ fn spawn_special_rules<'a>(scope: &rayon::Scope<'a>, inputs: &'a RuleRunInputs<' root, config, candidates.rust_candidates(), - candidates.exclusive_rust_candidates(), sources, defer_suppression, ); diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs index e9a71320d..5a7c42a34 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs @@ -1,5 +1,8 @@ use super::RuleFinding; -use crate::codebase::check_facts::{CheckFactMap, CheckFactPlan}; +use crate::codebase::check_facts::{ + collect_check_facts_with_graph_files_playwright_sources_and_session, CheckFactMap, + CheckFactPlan, +}; use crate::config::v2::schema::NoMistakesConfig; use anyhow::Result; use std::collections::HashSet; @@ -56,16 +59,20 @@ pub fn check( config: &NoMistakesConfig, tsconfig_path: Option<&Path>, ) -> Result> { - let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(root); + let session = + crate::codebase::analysis_session::AnalysisSession::new(crate::diagnostics::current()); + let snapshot = session.visible_paths(root); let visible_paths = snapshot.paths_for(root); let files = crate::codebase::ts_source::discover_files_from_visible( root, &config.filesystem.skip_directories, &visible_paths, ); - let facts = crate::codebase::check_facts::collect_check_facts( + let sources = snapshot.source_store_for(root); + let facts = collect_check_facts_with_graph_files_playwright_sources_and_session( + &session, root, - files, + (files, Vec::new()), CheckFactPlan { react: true, symbols: true, @@ -74,10 +81,11 @@ pub fn check( source: true, ..Default::default() }, + None, + std::sync::Arc::clone(&sources), ); - let sources = snapshot.source_store_for(root); let catalog = tsconfig_catalog(root, config, tsconfig_path, &visible_paths, &sources)?; - check_with_facts_and_catalog(root, config, &facts, &catalog, None, &sources) + check_with_facts_and_catalog(root, config, &facts, &catalog, None, &sources, &session) } fn check_with_facts_and_catalog( @@ -87,9 +95,8 @@ fn check_with_facts_and_catalog( catalog: &crate::codebase::ts_resolver::TsConfigCatalog, inferred_roots: Option<&crate::codebase::config::InferredRoots>, sources: &crate::codebase::ts_source::SourceStore, + session: &crate::codebase::analysis_session::AnalysisSession, ) -> Result> { - let session = - crate::codebase::analysis_session::AnalysisSession::new(crate::diagnostics::current()); let visible_files = shared .files() .iter() @@ -98,7 +105,7 @@ fn check_with_facts_and_catalog( let resolver = crate::codebase::ts_resolver::ScopedImportResolver::new_in_session( catalog, &visible_files, - &session, + session, ); runner::check_with_resolver( root, diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs index 9f54fb6d4..b5f34ae10 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs @@ -8,6 +8,7 @@ use super::{ use crate::codebase::check_facts::CheckFactMap; use crate::codebase::rules::{path_filter::RulePathFilter, sort_findings}; use crate::codebase::ts_resolver::{normalize_path, ImportResolution}; +use crate::codebase::ts_source::matching_disable_directive; use crate::config::v2::schema::{NoMistakesConfig, RuleDef}; use anyhow::{bail, Result}; use std::collections::HashSet; @@ -103,6 +104,11 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { .filter(|component| rule_filter.is_match(&component.file)) .collect::>(); let component_keys: HashSet = components.iter().map(|c| c.key.clone()).collect(); + let suppression_filtered_component_keys: HashSet = components + .iter() + .filter(|component| !component_is_suppressed(root, shared, component)) + .map(|component| component.key.clone()) + .collect(); let all_component_keys = all_react_component_keys(project_root, shared); let story_files = reachable_story_files( project_root, @@ -135,7 +141,7 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { root, project_root, &opts, - &component_keys, + &suppression_filtered_component_keys, &allow_files, shared, )); @@ -174,3 +180,23 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { findings.retain(|finding| rule_filter.is_match(&root.join(&finding.file))); Ok(findings) } + +fn component_is_suppressed( + root: &Path, + shared: &CheckFactMap, + component: &super::types::Component, +) -> bool { + let component_path = normalize_path(&component.file); + let rooted_component_path = normalize_path(&root.join(&component.file)); + shared + .ts + .iter() + .find(|(path, _)| { + let path = normalize_path(path); + path == component_path || path == rooted_component_path + }) + .and_then(|(_, facts)| facts.source.as_deref()) + .is_some_and(|source| { + matching_disable_directive(source, Some(component.line as u32), RULE_ID).is_some() + }) +} diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs index 658360607..c5296a354 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs @@ -32,7 +32,6 @@ pub(crate) fn check_with_files_sources_and_deferred_suppression( root: &Path, config: &NoMistakesConfig, all_files: &[PathBuf], - _exclusive_files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, defer_suppression: bool, ) -> Result> { diff --git a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs index 4baab1c65..909e582e3 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs @@ -83,7 +83,7 @@ fn combined_scan_applies_line_suppression_before_releasing_source() { } #[test] -fn exclusive_sources_are_not_retained_and_overlapping_sources_are_memoized() { +fn combined_sources_are_memoized_for_all_rust_rules() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../test-cases/rules/filesystem-dispatch/rust-combined/fixture"); let root = crate::codebase::ts_resolver::normalize_path(&root); @@ -97,7 +97,6 @@ fn exclusive_sources_are_not_retained_and_overlapping_sources_are_memoized() { &root, &config, &files, - &files, &exclusive_sources, false, ) @@ -109,7 +108,6 @@ fn exclusive_sources_are_not_retained_and_overlapping_sources_are_memoized() { &root, &config, &files, - &[], &overlapping_sources, false, ) diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/discovery/visible.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/discovery/visible.rs index 77a16bc59..2bfd8d063 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/discovery/visible.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/discovery/visible.rs @@ -105,8 +105,8 @@ fn expand_config_patterns_from_visible( } } } else { - let path = root.join(pattern); - if path.exists() { + let path = crate::codebase::ts_resolver::normalize_path(&root.join(pattern)); + if path.is_file() { configs.push(ConfigFile { path, runner }); } } diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index a911ef991..abc1d2f9e 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -1,13 +1,9 @@ -use super::checker::{check_dynamic_import, DynamicCheckContext}; -use super::{ - config, manual_mocks, matching_test_files_with_filter, reachable, resolve_mock_specifiers, -}; -use super::{RuleFinding, RULE_ID}; +use super::RuleFinding; +use super::{config, manual_mocks, matching_test_files_with_filter, reachable}; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::dependencies::graph::{DepGraph, GraphFiles}; use crate::codebase::rules::test_no_unmocked_dynamic_imports::runtime::runtime_deps; use crate::codebase::ts_resolver::{ScopedImportResolver, TsConfig}; -use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; use crate::config::v2::NoMistakesConfig; use anyhow::Result; use dashmap::DashMap; @@ -17,6 +13,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; mod graph; +mod per_test; mod setup_mocks; mod tsconfig_catalog; @@ -35,10 +32,20 @@ pub fn check_with_facts( tsconfig_path: Option<&Path>, shared: &CheckFactMap, ) -> Result> { - let (tsconfig, catalog) = tsconfig_catalog::for_request(root, tsconfig_path, shared)?; let session = crate::codebase::analysis_session::AnalysisSession::new(crate::diagnostics::current()); - check_with_prepared_facts_and_session(root, config, &tsconfig, &catalog, shared, &session) + session.insert_visible_paths( + root, + std::sync::Arc::new(crate::codebase::ts_source::VisiblePathSnapshot::from_paths( + root, + shared.files(), + )), + ); + let sources = session.visible_paths(root).source_store_for(root); + let (tsconfig, catalog) = tsconfig_catalog::for_request(root, tsconfig_path, shared, &sources)?; + check_with_prepared_facts_and_session( + root, config, &tsconfig, &catalog, shared, &session, &sources, + ) } #[doc(hidden)] @@ -51,7 +58,17 @@ pub fn check_with_prepared_facts( let catalog = tsconfig_catalog::forced(root, tsconfig); let session = crate::codebase::analysis_session::AnalysisSession::new(crate::diagnostics::current()); - check_with_prepared_facts_and_session(root, config, tsconfig, &catalog, shared, &session) + session.insert_visible_paths( + root, + std::sync::Arc::new(crate::codebase::ts_source::VisiblePathSnapshot::from_paths( + root, + shared.files(), + )), + ); + let sources = session.visible_paths(root).source_store_for(root); + check_with_prepared_facts_and_session( + root, config, tsconfig, &catalog, shared, &session, &sources, + ) } pub(crate) struct PreparedFactsGraphRequest<'a> { @@ -112,82 +129,22 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( test_files .into_par_iter() .map(|file| { - let Some(file_facts) = shared.ts.get(&file) else { - anyhow::bail!("missing shared facts for {}", file.display()); - }; - let Some(source) = file_facts.source.as_deref() else { - anyhow::bail!("missing source facts for {}", file.display()); - }; - // A file-disabled parse error cannot yield findings for - // audit mode, but must not abort unrelated test files. - if has_disable_file_comment(source, RULE_ID) - && (file_facts.parse_error.is_some() || !defer_suppression) - { - return Ok(PerTestResult::default()); - } - if let Some(error) = &file_facts.parse_error { - anyhow::bail!("failed to parse {}: {error}", file.display()); - } - let Some(facts) = file_facts.dynamic_imports.as_ref() else { - anyhow::bail!("missing dynamic import facts for {}", file.display()); - }; - let mut mocks = manual_mocks.clone(); - mocks.extend(setup_mocks::with_facts( - root, - setup_data, - &file, - &resolver, - &graph_files, - shared, - )?); - mocks.extend(resolve_mock_specifiers( - &facts.mock_specifiers, - &file, - &resolver, - Some(&graph_files), - )); - let mut local_findings = Vec::new(); - { - let mut check_context = DynamicCheckContext { - root, - file: &file, - resolver: &resolver, - graph, - graph_files: Some(&graph_files), - file_universe: Some(&visible_files), - mocks: &mocks, - dependency_cache: &dependency_cache, - findings: &mut local_findings, - }; - for import in &facts.dynamic_imports { - if defer_suppression - || !has_disable_comment(source, import.line as u32, RULE_ID) - { - check_dynamic_import(&mut check_context, import.clone()); - } - } - } - let reachable = reachable::collect_with_deferred_suppression( - reachable::ReachableContext { + per_test::analyze( + per_test::Request { root, config, resolver: &resolver, graph, - graph_files: Some(&graph_files), - file_universe: Some(&visible_files), - shared: Some(shared), - file_cache: None, + graph_files: &graph_files, + visible_files: &visible_files, + manual_mocks: &manual_mocks, + setup_data, + shared, + dependency_cache: &dependency_cache, + defer_suppression, }, - &file, - &mocks, - &dependency_cache, - defer_suppression, - )?; - Ok(PerTestResult { - direct_findings: local_findings, - reachable_findings: reachable.findings, - covered_reachable_imports: reachable.covered, - }) + file, + ) }) .collect::>>() })?; diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs index aaaaba5cb..f62dc9d04 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/graph.rs @@ -13,6 +13,7 @@ pub(crate) fn check_with_prepared_facts_and_session( tsconfig_catalog: &TsConfigCatalog, shared: &CheckFactMap, session: &std::sync::Arc, + sources: &crate::codebase::ts_source::SourceStore, ) -> Result> { let graph = crate::perf_trace::trace("test_no_unmocked_dynamic_imports.graph_build", || { DepGraph::build_with_complete_check_facts_and_session( @@ -28,7 +29,6 @@ pub(crate) fn check_with_prepared_facts_and_session( session.clone(), ) })?; - let sources = crate::codebase::rules::source_store_for_files(shared.files()); check_with_prepared_facts_graph_and_session(PreparedFactsGraphRequest { root, config, @@ -36,7 +36,7 @@ pub(crate) fn check_with_prepared_facts_and_session( shared, graph: &graph, session, - sources: &sources, + sources, defer_suppression: false, }) } diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs new file mode 100644 index 000000000..368649ccb --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs @@ -0,0 +1,121 @@ +use super::super::checker::{check_dynamic_import, DynamicCheckContext}; +use super::super::RULE_ID; +use super::super::{config, reachable, resolve_mock_specifiers}; +use super::PerTestResult; +use crate::codebase::check_facts::CheckFactMap; +use crate::codebase::dependencies::graph::{DepGraph, GraphFiles}; +use crate::codebase::ts_resolver::ScopedImportResolver; +use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; +use crate::config::v2::NoMistakesConfig; +use anyhow::Result; +use dashmap::DashMap; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(super) struct Request<'a> { + pub(super) root: &'a Path, + pub(super) config: &'a NoMistakesConfig, + pub(super) resolver: &'a ScopedImportResolver<'a>, + pub(super) graph: &'a DepGraph, + pub(super) graph_files: &'a GraphFiles, + pub(super) visible_files: &'a HashSet, + pub(super) manual_mocks: &'a HashSet, + pub(super) setup_data: &'a [config::ConfigSetupData], + pub(super) shared: &'a CheckFactMap, + pub(super) dependency_cache: &'a DashMap>>, + pub(super) defer_suppression: bool, +} + +pub(super) fn analyze(request: Request<'_>, file: PathBuf) -> Result { + let Request { + root, + config, + resolver, + graph, + graph_files, + visible_files, + manual_mocks, + setup_data, + shared, + dependency_cache, + defer_suppression, + } = request; + let Some(file_facts) = shared.ts.get(&file) else { + anyhow::bail!("missing shared facts for {}", file.display()); + }; + let Some(source) = file_facts.source.as_deref() else { + anyhow::bail!("missing source facts for {}", file.display()); + }; + let file_disabled = has_disable_file_comment(source, RULE_ID); + // A file-disabled parse error cannot yield findings for audit mode, but + // must not abort unrelated test files. + if file_disabled && (file_facts.parse_error.is_some() || !defer_suppression) { + return Ok(PerTestResult::default()); + } + if let Some(error) = &file_facts.parse_error { + anyhow::bail!("failed to parse {}: {error}", file.display()); + } + let Some(facts) = file_facts.dynamic_imports.as_ref() else { + anyhow::bail!("missing dynamic import facts for {}", file.display()); + }; + let mut mocks = manual_mocks.clone(); + mocks.extend(super::setup_mocks::with_facts( + root, + setup_data, + &file, + resolver, + graph_files, + shared, + )?); + mocks.extend(resolve_mock_specifiers( + &facts.mock_specifiers, + &file, + resolver, + Some(graph_files), + )); + let mut direct_findings = Vec::new(); + { + let mut check_context = DynamicCheckContext { + root, + file: &file, + resolver, + graph, + graph_files: Some(graph_files), + file_universe: Some(visible_files), + mocks: &mocks, + dependency_cache, + findings: &mut direct_findings, + }; + for import in &facts.dynamic_imports { + if defer_suppression || !has_disable_comment(source, import.line as u32, RULE_ID) { + check_dynamic_import(&mut check_context, import.clone()); + } + } + } + let reachable = reachable::collect_with_deferred_suppression( + reachable::ReachableContext { + root, + config, + resolver, + graph, + graph_files: Some(graph_files), + file_universe: Some(visible_files), + shared: Some(shared), + file_cache: None, + }, + &file, + &mocks, + dependency_cache, + defer_suppression, + )?; + Ok(PerTestResult { + direct_findings, + reachable_findings: reachable.findings, + covered_reachable_imports: if file_disabled { + HashSet::new() + } else { + reachable.covered + }, + }) +} diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tsconfig_catalog.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tsconfig_catalog.rs index c97c3819f..9e62f9c82 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tsconfig_catalog.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tsconfig_catalog.rs @@ -6,24 +6,24 @@ pub(super) fn for_request( root: &Path, tsconfig_path: Option<&Path>, shared: &crate::codebase::check_facts::CheckFactMap, + sources: &crate::codebase::ts_source::SourceStore, ) -> Result<(TsConfig, TsConfigCatalog)> { // Prepared facts define the complete request universe. Do not rebuild a // live snapshot here: it can discover ignored/generated configs that the // caller intentionally excluded from its graph facts. let visible = shared.files(); - let sources = crate::codebase::rules::source_store_for_files(visible); let tsconfig = crate::codebase::ts_resolver::resolve_tsconfig_from_visible_and_sources( tsconfig_path, root, visible, - &sources, + sources, )?; let catalog = crate::codebase::rules::run::prepared_tsconfig_catalog( root, tsconfig_path, &tsconfig, visible, - &sources, + sources, None, ); Ok((tsconfig, catalog)) diff --git a/crates/no-mistakes/src/codebase/unique_exports/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index 7452e6a8c..c274a29c8 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/collector.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/collector.rs @@ -62,9 +62,13 @@ pub(super) fn collect_file_exports( occurrence.file = file.rel.clone(); occurrence.line = export.line; occurrence.kind = export_kind_str(&export.kind).to_string(); - occurrence.suppressed = file.disabled + let current_suppressed = file.disabled || has_disable_comment(&file.source, export.line, RULE_ID) || has_disable_line_comment(&file.source, export.line, RULE_ID); + if current_suppressed { + occurrence.suppression_location = Some((file.rel.clone(), export.line)); + } + occurrence.suppressed |= current_suppressed; if !super::nextjs::is_framework_export( &occurrence.file, &occurrence.name, @@ -92,6 +96,21 @@ pub(super) fn collect_file_exports( .map(|origin| origin.bucket) .unwrap_or_else(|| ExportBucket::from_export(export)) }; + let origin_suppressed = resolved_origin + .as_ref() + .is_some_and(|origin| origin.suppressed); + let current_suppressed = file.disabled + || has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID); + let suppression_location = if current_suppressed { + Some((file.rel.clone(), export.line)) + } else if origin_suppressed { + resolved_origin + .as_ref() + .and_then(|origin| origin.suppression_location.clone()) + } else { + None + }; let origin = resolved_origin .map(|origin| { if export.is_type_only { @@ -111,9 +130,8 @@ pub(super) fn collect_file_exports( line: export.line, kind: export_kind_str(&export.kind).to_string(), origin, - suppressed: file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID) - || has_disable_line_comment(&file.source, export.line, RULE_ID), + suppressed: current_suppressed || origin_suppressed, + suppression_location, }); } _ => { @@ -128,6 +146,10 @@ pub(super) fn collect_file_exports( suppressed: file.disabled || has_disable_comment(&file.source, export.line, RULE_ID) || has_disable_line_comment(&file.source, export.line, RULE_ID), + suppression_location: (file.disabled + || has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID)) + .then(|| (file.rel.clone(), export.line)), }); } } @@ -140,6 +162,8 @@ pub(super) fn collect_file_exports( pub(super) fn should_skip_export(file: &SourceFile, export: &Export) -> bool { export.name == "default" - || (!file.defer_suppression && has_disable_comment(&file.source, export.line, RULE_ID)) + || (!file.defer_suppression + && (has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID))) || super::nextjs::is_framework_export(&file.rel, &export.name, file.is_nextjs_project) } diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 83b1deb59..d2bb335f4 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -45,8 +45,16 @@ pub(super) fn unique_export_findings( { findings.push(UniqueExportFinding { rule: RULE_ID.to_string(), - file: duplicate.file.clone(), - line: duplicate.line, + file: duplicate + .suppression_location + .as_ref() + .map(|(file, _)| file.clone()) + .unwrap_or_else(|| duplicate.file.clone()), + line: duplicate + .suppression_location + .as_ref() + .map(|(_, line)| *line) + .unwrap_or(duplicate.line), export_name: name.clone(), export_kind: bucket.as_str().to_string(), message: format!( diff --git a/crates/no-mistakes/src/codebase/unique_exports/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/origin.rs index 7045d3885..599f90c84 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/origin.rs @@ -1,5 +1,6 @@ use super::{ExportBucket, ExportOrigin, SourceFile}; use crate::codebase::ts_resolver::ImportResolverFacade; +use crate::codebase::ts_source::{has_disable_comment, has_disable_line_comment}; use crate::codebase::ts_symbols::{Export, ExportKind}; use crate::codebase::workspaces::WorkspaceMap; use std::collections::{HashMap, HashSet}; @@ -85,7 +86,8 @@ impl OriginSearch<'_, R> { self.workspace, self.remapper, ) - .and_then(|resolved| self.find(&resolved, imported)), + .and_then(|resolved| self.find(&resolved, imported)) + .map(|origin| self.with_current_suppression(file, export, origin)), _ if export.name == imported => Some(origin_for_export( file, export, @@ -114,21 +116,41 @@ impl OriginSearch<'_, R> { }; if export.is_type_only { if let Some(origin) = resolved_origin { - Some(ExportOrigin { - bucket: ExportBucket::Type, - ..origin - }) + Some(self.with_current_suppression( + file, + export, + ExportOrigin { + bucket: ExportBucket::Type, + ..origin + }, + )) } else { Some(origin_for_export(file, export, ExportBucket::Type)) } } else { - if resolved_origin.is_some() { - resolved_origin + if let Some(origin) = resolved_origin { + Some(self.with_current_suppression(file, export, origin)) } else { Some(origin_for_export(file, export, ExportBucket::Value)) } } } + + fn with_current_suppression( + &self, + file: &SourceFile, + export: &Export, + mut origin: ExportOrigin, + ) -> ExportOrigin { + if file.disabled + || has_disable_comment(&file.source, export.line, super::RULE_ID) + || has_disable_line_comment(&file.source, export.line, super::RULE_ID) + { + origin.suppressed = true; + origin.suppression_location = Some((file.rel.clone(), export.line)); + } + origin + } } pub(super) fn origin_for_export( @@ -141,6 +163,13 @@ pub(super) fn origin_for_export( line: export.line, name: export.name.clone(), bucket, + suppressed: file.disabled + || has_disable_comment(&file.source, export.line, super::RULE_ID) + || has_disable_line_comment(&file.source, export.line, super::RULE_ID), + suppression_location: (file.disabled + || has_disable_comment(&file.source, export.line, super::RULE_ID) + || has_disable_line_comment(&file.source, export.line, super::RULE_ID)) + .then(|| (file.rel.clone(), export.line)), } } diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests.rs b/crates/no-mistakes/src/codebase/unique_exports/tests.rs index f77966a67..691096c32 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests.rs @@ -29,6 +29,15 @@ fn finding_names(findings: &[UniqueExportFinding]) -> Vec<(String, String)> { .collect() } +#[test] +fn standalone_unique_exports_honors_same_line_suppression_in_static_fixture() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-unique-canonical"); + let findings = analyze_project(&root, None, None).unwrap(); + assert!(findings.iter().any(|finding| finding.file == "src/c.ts")); + assert!(!findings.iter().any(|finding| finding.file == "src/a.ts")); +} + #[test] fn pass4b_unique_origin_skips_ignored_local_and_workspace_candidates() { let fixture = crate::test_support::materialize_gitignore_fixture("pass4b-shadow"); diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index a4b3493da..f60e64d12 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -82,6 +82,9 @@ pub(super) struct ExportOccurrence { /// Deferred aggregate analysis needs this to keep a suppressed occurrence /// from becoming the canonical export for a visible duplicate. pub(super) suppressed: bool, + /// The source location whose directive suppressed this occurrence. Origin + /// directives must remain auditable even when a re-export is the duplicate. + pub(super) suppression_location: Option<(String, u32)>, } #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] @@ -90,4 +93,6 @@ pub(super) struct ExportOrigin { pub(super) line: u32, pub(super) name: String, pub(super) bucket: ExportBucket, + pub(super) suppressed: bool, + pub(super) suppression_location: Option<(String, u32)>, } diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs index 1ecc1f1dd..dd9f44d32 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs @@ -65,7 +65,9 @@ impl SharedCheckContext { && !self.playwright_rules_enabled && !self.graph_rules_enabled { - return Ok(crate::check_runner::empty_results([None])); + let mut results = crate::check_runner::empty_results([None]); + results.include_suppressed = include_suppressed; + return Ok(results); } let scoped_facts = self .graph_plan diff --git a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs index 2316a72a8..95123e5f6 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs @@ -112,6 +112,21 @@ fn analyze_project_check_applies_shared_suppression_accounting() { })); } +#[test] +fn analyze_project_empty_check_includes_empty_suppression_array_in_audit_mode() { + let root = check_runner_fixture("empty"); + let output = analyze_project_json_impl( + json!({ + "root": root, + "reports": [{ "type": "check", "includeSuppressed": true }] + }) + .to_string(), + ) + .unwrap(); + let result: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(result["reports"][0]["result"]["suppressed"], json!([])); +} + #[test] fn analyze_project_reachability_check_uses_full_graph_with_standalone_parity() { let root = check_runner_fixture("required-reachability-ignores-filesystem-skip"); diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index e382bb142..6252b21ce 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -1,6 +1,9 @@ use super::*; use serde_json::json; +#[path = "check_suppression.rs"] +mod check_suppression; + fn static_check_fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/check") @@ -184,20 +187,6 @@ fn check_json_preserves_direct_and_reachable_dynamic_import_reports_when_auditin ); } -#[test] -fn check_json_skips_file_disabled_parse_errors_without_losing_other_dynamic_imports() { - let (baseline, audit) = - baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error"); - assert!(baseline["warnings"].as_array().is_some_and(Vec::is_empty)); - assert!(baseline["rules"].as_array().is_some_and(|findings| { - findings.iter().any(|finding| { - finding["rule"] == "test-no-unmocked-dynamic-imports" - && finding["file"] == "tests/direct.test.mts" - }) - })); - assert_eq!(audit["suppressed"], json!([])); -} - #[test] fn check_json_preserves_server_boundary_report_when_auditing_suppression() { let (_, audit) = baseline_and_audit("aggregate-server-route-client-boundary"); @@ -217,60 +206,14 @@ fn check_json_preserves_server_boundary_report_when_auditing_suppression() { #[test] fn check_json_preserves_agents_size_report_when_auditing_suppression() { - let (_, audit) = baseline_and_audit("aggregate-agents-md-max-size"); - assert_suppression( - &audit, - &json!({ - "domain": "filesystem", - "rule": "agents-md-max-size", - "file": "AGENTS.md", - "line": 1, - "directiveKind": "file", - "directiveLine": 1, - "reason": "3 lines (max 2) - trim to keep agent context lean", - }), - ); -} - -#[test] -fn check_json_preserves_storybook_file_and_component_reports_when_auditing_suppression() { - let (_, audit) = baseline_and_audit("aggregate-require-storybook-stories"); - assert_suppression( - &audit, - &json!({ - "domain": "rules", - "rule": "require-storybook-stories", - "file": "web/components/ComponentSuppressed.tsx", - "line": 2, - "directiveKind": "nextLine", - "directiveLine": 1, - "reason": "React component `ComponentSuppressed` is selected for Storybook coverage but no reachable story imports it or a parent component that renders it. Add a Storybook story, add an accepted colocated test when `allow_colocated_tests` is enabled, render it through a covered parent component, exclude it from `require-storybook-stories`, or add a documented no-mistakes disable comment.", - }), - ); - assert_suppression( - &audit, - &json!({ - "domain": "rules", - "rule": "require-storybook-stories", - "file": "web/components/FileSuppressed.tsx", - "line": 2, - "directiveKind": "file", - "directiveLine": 1, - "reason": "React component `FileSuppressed` is selected for Storybook coverage but no reachable story imports it or a parent component that renders it. Add a Storybook story, add an accepted colocated test when `allow_colocated_tests` is enabled, render it through a covered parent component, exclude it from `require-storybook-stories`, or add a documented no-mistakes disable comment.", - }), - ); -} - -#[test] -fn check_json_audit_mode_includes_an_empty_suppression_array() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check-runner/empty"); - let baseline = check_json_impl(json!({ "root": root }).to_string()).unwrap(); - let baseline: serde_json::Value = serde_json::from_str(&baseline).unwrap(); - assert!(baseline.get("suppressed").is_none()); - - let audit = - check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); - let audit: serde_json::Value = serde_json::from_str(&audit).unwrap(); + let (baseline, audit) = baseline_and_audit("aggregate-agents-md-max-size"); + assert!(baseline["rules"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["rule"] == "agents-md-max-size" + && item["file"] == "AGENTS.md" + && item["message"] == "3 lines (max 2) - trim to keep agent context lean" + }) + })); assert_eq!(audit["suppressed"], json!([])); } @@ -349,30 +292,6 @@ fn check_json_uses_filter_precedence_for_overlapping_directives() { assert_eq!(finding["directive"]["line"], 3); } -#[test] -fn check_json_keeps_unsuppressed_duplicate_when_suppressed_export_sorts_first() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/check/suppression-unique-canonical"); - let baseline: serde_json::Value = - serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) - .unwrap(); - assert!(baseline["codebase"].as_array().is_some_and(Vec::is_empty)); - - let audit: serde_json::Value = serde_json::from_str( - &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), - ) - .unwrap(); - assert!(audit["codebase"].as_array().is_some_and(Vec::is_empty)); - assert!(audit["suppressed"] - .as_array() - .is_some_and(|items| items.iter().any(|item| { - item["rule"] == "unique-exports" - && item["file"] == "src/a.ts" - && item["directive"]["kind"] == "line" - && item["directive"]["line"] == 1 - }))); -} - #[test] fn check_json_does_not_hide_later_react_fetch_after_first_is_suppressed() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -383,7 +302,10 @@ fn check_json_does_not_hide_later_react_fetch_after_first_is_suppressed() { assert!(!value["react"].as_array().unwrap().is_empty(), "{value}"); assert!(value["suppressed"].as_array().is_some_and(|items| items .iter() - .any(|item| { item["domain"] == "react" && item["line"] == 3 }))); + .any(|item| { item["domain"] == "react" && item["line"] == 5 }))); + assert!(value["react"] + .as_array() + .is_some_and(|items| { items.iter().any(|item| item["file"] == "app/Fetcher.tsx") })); } #[test] diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs new file mode 100644 index 000000000..8c96416df --- /dev/null +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -0,0 +1,157 @@ +use super::{assert_suppression, baseline_and_audit, check_json_impl, static_check_fixture}; +use serde_json::json; +use std::path::PathBuf; + +#[test] +fn check_json_skips_file_disabled_parse_errors_without_losing_other_dynamic_imports() { + let (baseline, audit) = + baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error"); + assert!(baseline["warnings"].as_array().is_some_and(Vec::is_empty)); + assert!(baseline["rules"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["rule"] == "test-no-unmocked-dynamic-imports" + && finding["file"] == "tests/direct.test.mts" + }) + })); + assert!(audit["suppressed"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["rule"] == "test-no-unmocked-dynamic-imports" + && finding["file"] == "tests/disabled-mock.test.mts" + }) + })); +} + +#[test] +fn check_json_reads_explicit_gitignored_test_configs_through_request_sources() { + let root = static_check_fixture("aggregate-dynamic-import-gitignored-config"); + let output = check_json_impl(json!({ "root": root }).to_string()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(value["rules"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["rule"] == "test-no-unmocked-dynamic-imports" + && finding["file"] == "tests/visible.test.mts" + }) + })); +} + +#[test] +fn check_json_preserves_storybook_file_and_component_reports_when_auditing_suppression() { + let (baseline, audit) = baseline_and_audit("aggregate-require-storybook-stories"); + assert!(baseline["rules"].as_array().is_some_and(Vec::is_empty)); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "require-storybook-stories", + "file": "web/components/ComponentSuppressed.tsx", + "line": 2, + "directiveKind": "nextLine", + "directiveLine": 1, + "reason": "React component `ComponentSuppressed` is selected for Storybook coverage but no reachable story imports it or a parent component that renders it. Add a Storybook story, add an accepted colocated test when `allow_colocated_tests` is enabled, render it through a covered parent component, exclude it from `require-storybook-stories`, or add a documented no-mistakes disable comment.", + }), + ); + assert_suppression( + &audit, + &json!({ + "domain": "rules", + "rule": "require-storybook-stories", + "file": "web/components/FileSuppressed.tsx", + "line": 1, + "directiveKind": "file", + "directiveLine": 1, + "reason": "Storybook component opt-out `components/FileSuppressed.tsx#FileSuppressed` does not match a selected component.", + }), + ); +} + +#[test] +fn check_json_audit_mode_includes_an_empty_suppression_array() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check-runner/empty"); + let baseline = check_json_impl(json!({ "root": root }).to_string()).unwrap(); + let baseline: serde_json::Value = serde_json::from_str(&baseline).unwrap(); + assert!(baseline.get("suppressed").is_none()); + let audit = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); + let audit: serde_json::Value = serde_json::from_str(&audit).unwrap(); + assert_eq!(audit["suppressed"], json!([])); +} + +#[test] +fn check_json_keeps_unsuppressed_duplicate_when_suppressed_export_sorts_first() { + let root = static_check_fixture("suppression-unique-canonical"); + let baseline: serde_json::Value = + serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) + .unwrap(); + assert!(baseline["codebase"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["rule"] == "unique-exports" && item["file"] == "src/c.ts") + })); + let audit: serde_json::Value = serde_json::from_str( + &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), + ) + .unwrap(); + assert!(audit["codebase"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["rule"] == "unique-exports" && item["file"] == "src/c.ts") + })); + assert!(audit["suppressed"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "src/a.ts" + && item["directive"]["kind"] == "line" + && item["directive"]["line"] == 1 + }) + })); +} + +#[test] +fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports() { + let root = static_check_fixture("suppression-unique-canonical"); + let baseline: serde_json::Value = + serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) + .unwrap(); + assert!(baseline["codebase"].as_array().is_some_and(|items| { + !items.iter().any(|item| { + matches!( + item["exportName"].as_str(), + Some("chained" | "wildOnly" | "TypeThing") + ) + }) + })); + let audit: serde_json::Value = serde_json::from_str( + &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), + ) + .unwrap(); + assert!(audit["suppressed"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "src/named-barrel.ts" + && item["line"] == 2 + && item["directive"]["kind"] == "nextLine" + && item["directive"]["line"] == 1 + && item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("chained")) + }) && items.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "shared/suppressed-origin.ts" + && item["line"] == 5 + && item["directive"]["kind"] == "file" + && item["directive"]["line"] == 3 + && item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("wildOnly")) + }) && items.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "src/type-barrel.ts" + && item["line"] == 2 + && item["directive"]["kind"] == "nextLine" + && item["directive"]["line"] == 1 + && item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("TypeThing")) + }) + })); +} diff --git a/crates/no-mistakes/src/react_traits/mod.rs b/crates/no-mistakes/src/react_traits/mod.rs index d619e3488..2db8a77c5 100644 --- a/crates/no-mistakes/src/react_traits/mod.rs +++ b/crates/no-mistakes/src/react_traits/mod.rs @@ -16,4 +16,6 @@ pub use report::text::{ print_results, print_results_md, print_usages, print_usages_md, print_violations, print_violations_md, }; -pub use report::types::{AggregatedFacts, Callsite, ComponentFacts, UsagesReport, Violation}; +pub use report::types::{ + AggregatedFacts, Callsite, ComponentFacts, ReactSuppressionTarget, UsagesReport, Violation, +}; diff --git a/crates/no-mistakes/src/react_traits/pipeline/check.rs b/crates/no-mistakes/src/react_traits/pipeline/check.rs index 1e528290c..80b970d6c 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check.rs @@ -1,4 +1,6 @@ -use crate::react_traits::report::types::{FileConfig, RootConfig, Violation}; +use crate::react_traits::report::types::{ + FileConfig, ReactSuppressionTarget, RootConfig, Violation, +}; use anyhow::Result; use std::path::Path; @@ -113,13 +115,44 @@ fn assert_no_fetch_violations( .as_ref() .is_some_and(|agg| agg.has_fetch); if has_fetch { + let inherited_lines = facts + .inherited_from_children + .as_ref() + .map(|agg| agg.fetch_lines.clone()) + .unwrap_or_default(); + let mut suppression_lines: Vec = + facts.fetches.iter().map(|fetch| fetch.line).collect(); + suppression_lines.extend(inherited_lines.iter().copied()); + let inherited_locations = facts + .inherited_from_children + .as_ref() + .map(|agg| agg.fetch_locations.clone()) + .unwrap_or_default(); + let mut suppression_targets = facts + .fetches + .iter() + .map(|fetch| ReactSuppressionTarget { + file: fetch.file.clone(), + line: fetch.line, + }) + .collect::>(); + suppression_targets.extend( + inherited_locations + .into_iter() + .map(|(file, line)| ReactSuppressionTarget { file, line }), + ); violations.push(Violation { component: facts.name.clone(), file: facts.file.clone(), rule: "assert-no-fetch".to_string(), detail: facts.fetches.first().and_then(|f| f.shape.clone()), - line: facts.fetches.first().map(|f| f.line), - suppression_lines: facts.fetches.iter().map(|fetch| fetch.line).collect(), + line: facts + .fetches + .first() + .map(|fetch| fetch.line) + .or_else(|| inherited_lines.first().copied()), + suppression_lines, + suppression_targets, }); } } diff --git a/crates/no-mistakes/src/react_traits/pipeline/run.rs b/crates/no-mistakes/src/react_traits/pipeline/run.rs index 954093f4c..3368b28f7 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run.rs @@ -170,6 +170,14 @@ fn aggregate_children_inner( agg.uses_context_provider |= child_facts.uses_context_provider; agg.uses_suspense |= child_facts.uses_suspense; agg.has_fetch |= !child_facts.fetches.is_empty(); + agg.fetch_lines + .extend(child_facts.fetches.iter().map(|fetch| fetch.line)); + agg.fetch_locations.extend( + child_facts + .fetches + .iter() + .map(|fetch| (fetch.file.clone(), fetch.line)), + ); let child_agg = aggregate_children_inner(&child_facts, file_cache, root, visible_files, visited); agg.has_state |= child_agg.has_state; @@ -179,6 +187,8 @@ fn aggregate_children_inner( agg.uses_memo |= child_agg.uses_memo; agg.has_props |= child_agg.has_props; agg.passes_props |= child_agg.passes_props; + agg.fetch_lines.extend(child_agg.fetch_lines); + agg.fetch_locations.extend(child_agg.fetch_locations); } } agg diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs index 37d96593b..7eeb161d1 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs @@ -139,6 +139,14 @@ fn merge_component(agg: &mut AggregatedFacts, facts: &ComponentFacts) { agg.uses_context_provider |= facts.uses_context_provider; agg.uses_suspense |= facts.uses_suspense; agg.has_fetch |= !facts.fetches.is_empty(); + agg.fetch_lines + .extend(facts.fetches.iter().map(|fetch| fetch.line)); + agg.fetch_locations.extend( + facts + .fetches + .iter() + .map(|fetch| (fetch.file.clone(), fetch.line)), + ); } fn merge_aggregate(agg: &mut AggregatedFacts, child: &AggregatedFacts) { @@ -149,6 +157,9 @@ fn merge_aggregate(agg: &mut AggregatedFacts, child: &AggregatedFacts) { agg.uses_memo |= child.uses_memo; agg.has_props |= child.has_props; agg.passes_props |= child.passes_props; + agg.fetch_lines.extend(child.fetch_lines.iter().copied()); + agg.fetch_locations + .extend(child.fetch_locations.iter().cloned()); } #[cfg(test)] diff --git a/crates/no-mistakes/src/react_traits/report/text/tests.rs b/crates/no-mistakes/src/react_traits/report/text/tests.rs index 997de2624..a76198073 100644 --- a/crates/no-mistakes/src/react_traits/report/text/tests.rs +++ b/crates/no-mistakes/src/react_traits/report/text/tests.rs @@ -80,6 +80,7 @@ fn print_violations_outputs_violations() { detail: Some("GET /api/users".to_string()), line: Some(1), suppression_lines: vec![1], + suppression_targets: Vec::new(), }]; print_violations(&violations); } @@ -93,6 +94,7 @@ fn print_violations_no_detail() { detail: None, line: None, suppression_lines: Vec::new(), + suppression_targets: Vec::new(), }]; print_violations(&violations); } diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index 2f24450e4..d2963bf41 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -29,6 +29,11 @@ pub struct AggregatedFacts { pub uses_context_provider: bool, pub uses_suspense: bool, pub has_fetch: bool, + /// Internal fetch locations inherited through rendered child components. + #[serde(skip)] + pub fetch_lines: Vec, + #[serde(skip)] + pub fetch_locations: Vec<(String, usize)>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -125,6 +130,14 @@ pub struct Violation { /// stable direct React report schema. #[serde(skip)] pub suppression_lines: Vec, + #[serde(skip)] + pub suppression_targets: Vec, +} + +#[derive(Debug, Clone)] +pub struct ReactSuppressionTarget { + pub file: String, + pub line: usize, } #[derive(Default, Deserialize)] diff --git a/fixtures/check/aggregate-agents-md-max-size/AGENTS.md b/fixtures/check/aggregate-agents-md-max-size/AGENTS.md index 2189c5830..fc4520cd4 100644 --- a/fixtures/check/aggregate-agents-md-max-size/AGENTS.md +++ b/fixtures/check/aggregate-agents-md-max-size/AGENTS.md @@ -1,3 +1,3 @@ -// no-mistakes-disable-file agents-md-max-size: this policy file is intentionally long +This policy file is intentionally long. line one line two diff --git a/fixtures/check/aggregate-dynamic-import-gitignored-config/.gitignore b/fixtures/check/aggregate-dynamic-import-gitignored-config/.gitignore new file mode 100644 index 000000000..0db696558 --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-gitignored-config/.gitignore @@ -0,0 +1 @@ +vitest.config.mts diff --git a/fixtures/check/aggregate-dynamic-import-gitignored-config/.no-mistakes.yml b/fixtures/check/aggregate-dynamic-import-gitignored-config/.no-mistakes.yml new file mode 100644 index 000000000..d4abed644 --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-gitignored-config/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository diff --git a/fixtures/check/aggregate-dynamic-import-gitignored-config/src/leaf.mts b/fixtures/check/aggregate-dynamic-import-gitignored-config/src/leaf.mts new file mode 100644 index 000000000..1e9853c5f --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-gitignored-config/src/leaf.mts @@ -0,0 +1 @@ +export const leaf = true diff --git a/fixtures/check/aggregate-dynamic-import-gitignored-config/tests/visible.test.mts b/fixtures/check/aggregate-dynamic-import-gitignored-config/tests/visible.test.mts new file mode 100644 index 000000000..595a5d71f --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-gitignored-config/tests/visible.test.mts @@ -0,0 +1,5 @@ +import { test } from 'vitest' + +test('visible test remains in the bounded request inventory', async () => { + await import('../src/leaf.mts') +}) diff --git a/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts b/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts index 4d8a097d0..e2336e7db 100644 --- a/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts +++ b/fixtures/check/aggregate-nextjs-no-caching/web/app/page.ts @@ -1,4 +1,4 @@ export async function loadUser() { - // no-mistakes-disable-next-line nextjs-no-caching: request data must stay uncached here - return fetch('/api/user', { cache: 'force-cache' }) + // no-mistakes-disable-next-line nextjs-no-caching: force-cache is intentional for this fixture + return fetch('/data/user', { cache: 'force-cache' }) } diff --git a/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml index 096f097fe..26e1d88b7 100644 --- a/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml +++ b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml @@ -13,3 +13,5 @@ rules: stories: - stories/**/*.stories.tsx includeAllReactNamedExports: true + allow_components: + "components/FileSuppressed.tsx#FileSuppressed": "covered by the parent application shell" diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/other.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/other.mts new file mode 100644 index 000000000..8fc166ded --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/src/other.mts @@ -0,0 +1 @@ +export const other = true diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled-mock.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled-mock.test.mts new file mode 100644 index 000000000..de09a9e81 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-disabled-parse-error/tests/disabled-mock.test.mts @@ -0,0 +1,9 @@ +// no-mistakes-disable-file test-no-unmocked-dynamic-imports: mock is intentionally local to this legacy test +import { test, vi } from 'vitest' + +vi.mock('../src/leaf.mts') + +test('legacy mock does not hide another test finding', async () => { + await import('../src/leaf.mts') + await import('../src/other.mts') +}) diff --git a/fixtures/check/suppression-react-multiple/app/Child.tsx b/fixtures/check/suppression-react-multiple/app/Child.tsx new file mode 100644 index 000000000..a5331e9e0 --- /dev/null +++ b/fixtures/check/suppression-react-multiple/app/Child.tsx @@ -0,0 +1,4 @@ +export default async function Child() { + await fetch('/data/child'); + return ; +} diff --git a/fixtures/check/suppression-react-multiple/app/Fetcher.tsx b/fixtures/check/suppression-react-multiple/app/Fetcher.tsx index 6efd1bc8a..acca708bb 100644 --- a/fixtures/check/suppression-react-multiple/app/Fetcher.tsx +++ b/fixtures/check/suppression-react-multiple/app/Fetcher.tsx @@ -1,6 +1,7 @@ +import Child from './Child'; + export default async function Fetcher() { // no-mistakes-disable-next-line assert-no-fetch: first call is intentional await fetch('/api/first'); - await fetch('/api/second'); - return
; + return ; } diff --git a/fixtures/check/suppression-unique-canonical/.no-mistakes.yml b/fixtures/check/suppression-unique-canonical/.no-mistakes.yml index 177df01d8..47aafcfbc 100644 --- a/fixtures/check/suppression-unique-canonical/.no-mistakes.yml +++ b/fixtures/check/suppression-unique-canonical/.no-mistakes.yml @@ -1,3 +1,8 @@ +projects: + source: + type: library + root: src + rules: - rule: unique-exports - scope: repository + projects: [source] diff --git a/fixtures/check/suppression-unique-canonical/shared/suppressed-origin.ts b/fixtures/check/suppression-unique-canonical/shared/suppressed-origin.ts new file mode 100644 index 000000000..5d23619bd --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/shared/suppressed-origin.ts @@ -0,0 +1,5 @@ +// This origin is resolver-visible but outside the configured analysis project; +// the audit therefore proves suppression survives both re-export paths. +// no-mistakes-disable-file unique-exports: compatibility export is intentionally re-exported +export const chained = 1; +export const wildOnly = 1; diff --git a/fixtures/check/suppression-unique-canonical/shared/type-origin.ts b/fixtures/check/suppression-unique-canonical/shared/type-origin.ts new file mode 100644 index 000000000..56a218006 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/shared/type-origin.ts @@ -0,0 +1 @@ +export type TypeThing = { origin: true }; diff --git a/fixtures/check/suppression-unique-canonical/src/c.ts b/fixtures/check/suppression-unique-canonical/src/c.ts new file mode 100644 index 000000000..14de10e3f --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/c.ts @@ -0,0 +1 @@ +export const shared = 3; diff --git a/fixtures/check/suppression-unique-canonical/src/chained-visible.ts b/fixtures/check/suppression-unique-canonical/src/chained-visible.ts new file mode 100644 index 000000000..480636919 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/chained-visible.ts @@ -0,0 +1,2 @@ +export const chained = 2; +export const wildOnly = 2; diff --git a/fixtures/check/suppression-unique-canonical/src/named-barrel.ts b/fixtures/check/suppression-unique-canonical/src/named-barrel.ts new file mode 100644 index 000000000..a504f4bbe --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/named-barrel.ts @@ -0,0 +1,2 @@ +// no-mistakes-disable-next-line unique-exports: compatibility barrel is intentional +export { chained } from '../shared/suppressed-origin'; diff --git a/fixtures/check/suppression-unique-canonical/src/type-barrel.ts b/fixtures/check/suppression-unique-canonical/src/type-barrel.ts new file mode 100644 index 000000000..b271e168f --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/type-barrel.ts @@ -0,0 +1,2 @@ +// no-mistakes-disable-next-line unique-exports: type compatibility barrel is intentional +export type { TypeThing } from '../shared/type-origin'; diff --git a/fixtures/check/suppression-unique-canonical/src/type-visible.ts b/fixtures/check/suppression-unique-canonical/src/type-visible.ts new file mode 100644 index 000000000..922dfad39 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/type-visible.ts @@ -0,0 +1 @@ +export type TypeThing = { origin: false }; diff --git a/fixtures/check/suppression-unique-canonical/src/wild-barrel.ts b/fixtures/check/suppression-unique-canonical/src/wild-barrel.ts new file mode 100644 index 000000000..2a9551070 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/wild-barrel.ts @@ -0,0 +1 @@ +export * from '../shared/suppressed-origin'; From 94355e4e298d3e2ddeb7a7fc1aa601b61d8f62d6 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 14:19:50 -0700 Subject: [PATCH 17/62] refactor: split deferred reachable analysis --- .../reachable.rs | 108 +---------------- .../reachable/deferred.rs | 112 ++++++++++++++++++ 2 files changed, 118 insertions(+), 102 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs index 584b94382..0176063de 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs @@ -1,10 +1,9 @@ -use super::checker::{evaluate_dynamic_import, DynamicCheckContext, DynamicImportKey}; -use super::{ast, runtime_deps, RULE_ID}; +use super::ast; +use super::checker::DynamicImportKey; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::dependencies::graph::{DepGraph, GraphFiles}; use crate::codebase::rules::RuleFinding; use crate::codebase::ts_resolver::ImportResolution; -use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; use crate::config::v2::NoMistakesConfig; use anyhow::{Context, Result}; use dashmap::DashMap; @@ -12,6 +11,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; +mod deferred; + pub(super) struct CachedFileFacts { pub(super) source: String, pub(super) dynamic_imports: Vec, @@ -33,7 +34,7 @@ pub(super) fn collect( mocks: &HashSet, dependency_cache: &DashMap>>, ) -> Result { - collect_with_deferred_suppression(ctx, test_file, mocks, dependency_cache, false) + deferred::collect(ctx, test_file, mocks, dependency_cache, false) } pub(super) fn collect_with_deferred_suppression( @@ -43,104 +44,7 @@ pub(super) fn collect_with_deferred_suppression( dependency_cache: &DashMap>>, defer_suppression: bool, ) -> Result { - let test_reachable = dependency_cache - .entry(test_file.to_path_buf()) - .or_insert_with(|| { - Arc::new(runtime_deps( - ctx.graph, - test_file.to_path_buf(), - ctx.file_universe, - )) - }) - .clone(); - let mut result = ReachableResult { - findings: Vec::new(), - covered: HashSet::new(), - }; - for file in test_reachable.iter() { - if !crate::codebase::dependencies::extract::is_indexable(file) - || is_under_skipped_dir(ctx.root, ctx.config, file) - { - continue; - } - // A reachable file that is itself a mocked target never executes its own body - // (the mock factory fully replaces it), so its internal imports must not be - // scanned. Without this, a typed mock specifier's `import(...)` carrier (or any - // other coincidental dynamic-import edge to a mocked module) makes the mocked - // module's own dependencies look "reachable" and produces false positives. See #506. - if mocks.contains(file) { - continue; - } - if let Some(shared) = ctx.shared { - // Canonical graphs may contain supplemental roots requested by another report. - // A missing entry is outside this check's prepared scope and must not widen it. - let Some(file_facts) = shared.ts.get(file) else { - continue; - }; - if file_facts.parse_error.is_some() { - continue; - } - if let (Some(source), Some(facts)) = ( - file_facts.source.as_deref(), - file_facts.dynamic_imports.as_ref(), - ) { - if !defer_suppression && has_disable_file_comment(source, RULE_ID) { - continue; - } - let mut local_findings = Vec::new(); - let check_context = DynamicCheckContext { - root: ctx.root, - file, - resolver: ctx.resolver, - graph: ctx.graph, - graph_files: ctx.graph_files, - file_universe: ctx.file_universe, - mocks, - dependency_cache, - findings: &mut local_findings, - }; - for import in &facts.dynamic_imports { - if defer_suppression - || !has_disable_comment(source, import.line as u32, RULE_ID) - { - collect_outcome( - &mut result, - evaluate_dynamic_import(&check_context, import.clone()), - ); - } - } - continue; - } - } - let cached = get_or_cache_file(file, ctx.file_cache)?; - if !defer_suppression && has_disable_file_comment(&cached.source, RULE_ID) { - continue; - } - let mut local_findings = Vec::new(); - let check_context = DynamicCheckContext { - root: ctx.root, - file, - resolver: ctx.resolver, - graph: ctx.graph, - graph_files: ctx.graph_files, - file_universe: ctx.file_universe, - mocks, - dependency_cache, - findings: &mut local_findings, - }; - for import in &cached.dynamic_imports { - if !defer_suppression - && has_disable_comment(&cached.source, import.line as u32, RULE_ID) - { - continue; - } - collect_outcome( - &mut result, - evaluate_dynamic_import(&check_context, import.clone()), - ); - } - } - Ok(result) + deferred::collect(ctx, test_file, mocks, dependency_cache, defer_suppression) } fn collect_outcome(result: &mut ReachableResult, outcome: super::checker::DynamicImportOutcome) { diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs new file mode 100644 index 000000000..951cbec00 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs @@ -0,0 +1,112 @@ +use super::super::checker::{evaluate_dynamic_import, DynamicCheckContext}; +use super::super::{runtime_deps, RULE_ID}; +use super::{collect_outcome, get_or_cache_file, is_under_skipped_dir}; +use super::{ReachableContext, ReachableResult}; +use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; +use anyhow::Result; +use dashmap::DashMap; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(super) fn collect( + ctx: ReachableContext<'_>, + test_file: &Path, + mocks: &HashSet, + dependency_cache: &DashMap>>, + defer_suppression: bool, +) -> Result { + let test_reachable = dependency_cache + .entry(test_file.to_path_buf()) + .or_insert_with(|| { + Arc::new(runtime_deps( + ctx.graph, + test_file.to_path_buf(), + ctx.file_universe, + )) + }) + .clone(); + let mut result = ReachableResult { + findings: Vec::new(), + covered: HashSet::new(), + }; + for file in test_reachable.iter() { + if !crate::codebase::dependencies::extract::is_indexable(file) + || is_under_skipped_dir(ctx.root, ctx.config, file) + { + continue; + } + // Mock factories replace the target body, so its own imports are not + // reachable. This also prevents typed mock carriers from leaking. + if mocks.contains(file) { + continue; + } + if let Some(shared) = ctx.shared { + let Some(file_facts) = shared.ts.get(file) else { + continue; + }; + if file_facts.parse_error.is_some() { + continue; + } + if let (Some(source), Some(facts)) = ( + file_facts.source.as_deref(), + file_facts.dynamic_imports.as_ref(), + ) { + if !defer_suppression && has_disable_file_comment(source, RULE_ID) { + continue; + } + let mut local_findings = Vec::new(); + let check_context = DynamicCheckContext { + root: ctx.root, + file, + resolver: ctx.resolver, + graph: ctx.graph, + graph_files: ctx.graph_files, + file_universe: ctx.file_universe, + mocks, + dependency_cache, + findings: &mut local_findings, + }; + for import in &facts.dynamic_imports { + if defer_suppression + || !has_disable_comment(source, import.line as u32, RULE_ID) + { + collect_outcome( + &mut result, + evaluate_dynamic_import(&check_context, import.clone()), + ); + } + } + continue; + } + } + let cached = get_or_cache_file(file, ctx.file_cache)?; + if !defer_suppression && has_disable_file_comment(&cached.source, RULE_ID) { + continue; + } + let mut local_findings = Vec::new(); + let check_context = DynamicCheckContext { + root: ctx.root, + file, + resolver: ctx.resolver, + graph: ctx.graph, + graph_files: ctx.graph_files, + file_universe: ctx.file_universe, + mocks, + dependency_cache, + findings: &mut local_findings, + }; + for import in &cached.dynamic_imports { + if !defer_suppression + && has_disable_comment(&cached.source, import.line as u32, RULE_ID) + { + continue; + } + collect_outcome( + &mut result, + evaluate_dynamic_import(&check_context, import.clone()), + ); + } + } + Ok(result) +} From 24f80c61b7b30f85e444930029ac61d0fc17b26c Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 14:30:23 -0700 Subject: [PATCH 18/62] fix: account for advisory suppressions --- .../no-mistakes/src/check_runner/results.rs | 21 +++++++------- .../src/check_runner/results/suppression.rs | 3 ++ .../src/codebase/rules/agents_md_max_size.rs | 29 +++++++++++++++---- .../agents_md_max_size_budget.rs | 22 ++++++++++++-- .../src/napi_api/tests/check_suppression.rs | 19 ++++++++++++ .../.no-mistakes.yml | 7 +++++ .../GUIDANCE.md | 2 ++ .../no-mistakes/analyze-project-types.d.ts | 5 +++- packages/no-mistakes/report-types.d.ts | 9 +++++- packages/no-mistakes/scripts/api.test.js | 13 ++++++++- 10 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 fixtures/check/aggregate-agents-md-advisory-suppression/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-agents-md-advisory-suppression/GUIDANCE.md diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index 943a29519..bdc961cd0 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -78,6 +78,16 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result { pub(super) filesystem: &'a mut Vec, pub(super) integration: &'a mut Vec, pub(super) codebase: &'a mut Vec, + pub(super) advisories: &'a mut Vec, } pub(super) fn apply(input: Inputs<'_>) -> Vec { @@ -29,6 +30,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { filesystem, integration, codebase, + advisories, } = input; let mut suppressed = Vec::new(); suppress_react(root, sources, react, &mut suppressed); @@ -46,6 +48,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { )); suppress_rules(root, sources, rules, "rules", &mut suppressed); suppress_rules(root, sources, filesystem, "filesystem", &mut suppressed); + suppress_rules(root, sources, advisories, "advisories", &mut suppressed); suppressed.extend(suppress_domain_findings_with_sources( root, integration, diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs index 42354255d..dcd356409 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs @@ -8,7 +8,9 @@ use std::path::{Path, PathBuf}; pub const RULE_ID: &str = "agents-md-max-size"; mod agents_md_max_size_budget; -use agents_md_max_size_budget::{scan, scan_advisories_with_sources, scan_with_sources}; +use agents_md_max_size_budget::{ + scan, scan_advisories_with_sources, scan_advisories_with_sources_deferred, scan_with_sources, +}; const DEFAULT_MAX_LINES: usize = 200; const DEFAULT_MAX_CHARS: usize = 12_000; @@ -49,7 +51,7 @@ pub fn advisories_with_files( all_files: &[PathBuf], ) -> Result> { let sources = super::source_store_for_files(all_files); - advisories_with_files_inner(root, config, all_files, &sources) + advisories_with_files_inner(root, config, all_files, &sources, false) } pub fn advisories_with_files_and_sources( @@ -58,7 +60,17 @@ pub fn advisories_with_files_and_sources( all_files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, ) -> Result> { - advisories_with_files_inner(root, config, all_files, sources) + advisories_with_files_inner(root, config, all_files, sources, false) +} + +#[doc(hidden)] +pub fn advisories_with_files_sources_and_deferred_suppression( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, +) -> Result> { + advisories_with_files_inner(root, config, all_files, sources, true) } fn advisories_with_files_inner( @@ -66,6 +78,7 @@ fn advisories_with_files_inner( config: &NoMistakesConfig, all_files: &[PathBuf], sources: &crate::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Result> { let mut advisories = Vec::new(); for rule in config.rule_applications(RULE_ID) { @@ -88,10 +101,16 @@ fn advisories_with_files_inner( .cloned() .collect(); let files = super::path_filter::filter_rule_files(root, config, rule, &files)?; - advisories.extend(scan_advisories_with_sources(root, &opts, &files, sources)?); + advisories.extend(if defer_suppression { + scan_advisories_with_sources_deferred(root, &opts, &files, sources, true)? + } else { + scan_advisories_with_sources(root, &opts, &files, sources)? + }); } super::sort_findings(&mut advisories); - super::suppress_rule_findings_with_sources(root, &mut advisories, sources); + if !defer_suppression { + super::suppress_rule_findings_with_sources(root, &mut advisories, sources); + } Ok(advisories) } diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs index 1368d39a9..89a677a90 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs @@ -44,6 +44,16 @@ pub(super) fn scan_advisories_with_sources( opts: &Options, files: &[PathBuf], sources: &SourceStore, +) -> Result> { + scan_advisories_with_sources_deferred(root, opts, files, sources, false) +} + +pub(super) fn scan_advisories_with_sources_deferred( + root: &Path, + opts: &Options, + files: &[PathBuf], + sources: &SourceStore, + defer_suppression: bool, ) -> Result> { let max_chars = opts.max_chars.unwrap_or(DEFAULT_MAX_CHARS); let threshold = opts.advisory_chars_remaining.unwrap_or_default(); @@ -51,7 +61,14 @@ pub(super) fn scan_advisories_with_sources( .par_iter() .filter_map(|path| { let content = crate::codebase::rules::read_source(sources, path)?; - check_advisory_content(path, root, max_chars, threshold, &content) + check_advisory_content( + path, + root, + max_chars, + threshold, + &content, + defer_suppression, + ) }) .collect(); advisories.sort_by(|a, b| a.file.cmp(&b.file).then(a.message.cmp(&b.message))); @@ -107,8 +124,9 @@ fn check_advisory_content( max_chars: usize, threshold: usize, content: &str, + defer_suppression: bool, ) -> Option { - if has_disable_file_comment(content, RULE_ID) { + if !defer_suppression && has_disable_file_comment(content, RULE_ID) { return None; } let char_count = content.chars().count(); diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 8c96416df..793afb870 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -2,6 +2,25 @@ use super::{assert_suppression, baseline_and_audit, check_json_impl, static_chec use serde_json::json; use std::path::PathBuf; +#[test] +fn check_json_accounts_for_suppressed_near_limit_advisories() { + let (baseline, audit) = baseline_and_audit("aggregate-agents-md-advisory-suppression"); + assert!(baseline["advisories"].as_array().is_some_and(Vec::is_empty)); + assert!(audit["suppressed"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["domain"] == "advisories" + && finding["rule"] == "agents-md-max-size" + && finding["file"] == "GUIDANCE.md" + && finding["line"] == 1 + && finding["directive"]["kind"] == "file" + && finding["directive"]["line"] == 1 + && finding["reason"] + .as_str() + .is_some_and(|reason| reason.contains("remaining")) + }) + })); +} + #[test] fn check_json_skips_file_disabled_parse_errors_without_losing_other_dynamic_imports() { let (baseline, audit) = diff --git a/fixtures/check/aggregate-agents-md-advisory-suppression/.no-mistakes.yml b/fixtures/check/aggregate-agents-md-advisory-suppression/.no-mistakes.yml new file mode 100644 index 000000000..4875940ca --- /dev/null +++ b/fixtures/check/aggregate-agents-md-advisory-suppression/.no-mistakes.yml @@ -0,0 +1,7 @@ +rules: + - rule: agents-md-max-size + scope: repository + options: + filenames: [GUIDANCE.md] + maxChars: 220 + advisoryCharsRemaining: 220 diff --git a/fixtures/check/aggregate-agents-md-advisory-suppression/GUIDANCE.md b/fixtures/check/aggregate-agents-md-advisory-suppression/GUIDANCE.md new file mode 100644 index 000000000..33db3cb39 --- /dev/null +++ b/fixtures/check/aggregate-agents-md-advisory-suppression/GUIDANCE.md @@ -0,0 +1,2 @@ +// no-mistakes-disable-file agents-md-max-size: this near-limit guidance is intentionally retained +Keep this guidance close to the configured context budget for the advisory regression. diff --git a/packages/no-mistakes/analyze-project-types.d.ts b/packages/no-mistakes/analyze-project-types.d.ts index 2ea2ac904..2ea5accc5 100644 --- a/packages/no-mistakes/analyze-project-types.d.ts +++ b/packages/no-mistakes/analyze-project-types.d.ts @@ -25,7 +25,10 @@ type BatchedReactUsagesOptions = Pick< "root" | "tsconfig" | "config" | "targets" | "include" > & Required>; -type BatchedCheckOptions = Pick; +type BatchedCheckOptions = Pick< + ProjectOptions, + "root" | "tsconfig" | "config" | "includeSuppressed" +>; export type AnalyzeProjectReportRequest = | ({ type: "dependencies" | "dependents" | "related"; id?: string } & BatchedTraverseOptions) diff --git a/packages/no-mistakes/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index 4a7e4baf6..a129b0cae 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -35,7 +35,14 @@ export interface CheckReport { } export interface SuppressedFinding { - domain: "react" | "queues" | "rules" | "filesystem" | "integration" | "codebase"; + domain: + | "react" + | "queues" + | "rules" + | "filesystem" + | "integration" + | "codebase" + | "advisories"; rule: string; file: string; line?: number; diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 44acd8d4a..d75d0108e 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -204,6 +204,17 @@ test("programmatic API proxies object options through async native addon calls", }, }, ); + assert.deepEqual( + await api.analyzeProject({ + reports: [{ type: "check", includeSuppressed: true }], + }), + { + command: "analyzeProject", + options: { + reports: [{ type: "check", includeSuppressed: true }], + }, + }, + ); assert.equal( (await api.symbols({ files: ["d.mts"], include: "both" })).options.include, "both", @@ -474,7 +485,7 @@ test("analyzeProject declarations mirror report-specific runtime requirements", ); assert.match( analyzeProjectDeclarations, - /type BatchedCheckOptions = Pick/, + /type BatchedCheckOptions = Pick<[\s\S]*?"root" \| "tsconfig" \| "config" \| "includeSuppressed"[\s\S]*?>/, ); assert.match(analyzeProjectDeclarations, /type: "check"; id\?: string } & BatchedCheckOptions/); assert.match( From b7e02ea161b21289420f9537032177ff1766027a Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 15:05:23 -0700 Subject: [PATCH 19/62] fix: complete suppression review regressions --- .../src/check_runner/results/suppression.rs | 53 +++++++++++----- .../rules/require_storybook_stories.rs | 1 + .../rules/require_storybook_stories/runner.rs | 25 +------- .../require_storybook_stories/suppression.rs | 51 ++++++++++++++++ .../codebase/rules/suppression/accounting.rs | 8 ++- .../reachable/deferred.rs | 61 ++++++++++--------- .../test_no_unmocked_dynamic_imports/tests.rs | 13 ++-- .../with_facts.rs | 1 + .../with_facts/per_test.rs | 14 +++-- .../src/codebase/unique_exports/types.rs | 28 ++++++++- .../src/napi_api/tests/check_suppression.rs | 49 +++++++++++++++ .../.no-mistakes.yml | 3 + .../app/Child.tsx | 7 +++ .../app/ParentA.tsx | 5 ++ .../app/ParentB.tsx | 5 ++ .../shared/identity-origin.ts | 1 + .../src/identity-a.ts | 1 + .../src/identity-b.ts | 4 ++ packages/no-mistakes/scripts/api.test.js | 4 ++ 19 files changed, 254 insertions(+), 80 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs create mode 100644 fixtures/check/suppression-react-inherited-parents/.no-mistakes.yml create mode 100644 fixtures/check/suppression-react-inherited-parents/app/Child.tsx create mode 100644 fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx create mode 100644 fixtures/check/suppression-react-inherited-parents/app/ParentB.tsx create mode 100644 fixtures/check/suppression-unique-canonical/shared/identity-origin.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/identity-a.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/identity-b.ts diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index e75269d1b..fb1d059de 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -44,6 +44,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { file: &finding.file, line: Some(finding.line), reason: &finding.message, + identity: None, }, )); suppress_rules(root, sources, rules, "rules", &mut suppressed); @@ -59,6 +60,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { file: &finding.file, line: Some(finding.line as usize), reason: &finding.message, + identity: None, }, )); suppressed.extend(suppress_domain_findings_with_sources( @@ -71,6 +73,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { file: &finding.file, line: Some(finding.line as usize), reason: &finding.message, + identity: None, }, )); suppressed.sort(); @@ -80,6 +83,11 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { /// A component-level React diagnostic covers every local fetch. Preserve its /// single stable public finding unless all of those fetches are suppressed. +struct ReactSuppressionFinding { + finding: react_traits::Violation, + identity: String, +} + pub(super) fn suppress_react( root: &std::path::Path, sources: &SourceStore, @@ -87,34 +95,44 @@ pub(super) fn suppress_react( suppressed: &mut Vec, ) { findings.retain(|finding| { + let identity = format!("{}@{}", finding.component, finding.file); let mut locations = if !finding.suppression_targets.is_empty() { finding .suppression_targets .iter() - .map(|target| react_traits::Violation { - file: target.file.clone(), - line: Some(target.line), - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), - ..finding.clone() + .map(|target| ReactSuppressionFinding { + finding: react_traits::Violation { + file: target.file.clone(), + line: Some(target.line), + suppression_lines: Vec::new(), + suppression_targets: Vec::new(), + ..finding.clone() + }, + identity: identity.clone(), }) .collect() } else if !finding.suppression_lines.is_empty() { finding .suppression_lines .iter() - .map(|line| react_traits::Violation { - line: Some(*line), - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), - ..finding.clone() + .map(|line| ReactSuppressionFinding { + finding: react_traits::Violation { + line: Some(*line), + suppression_lines: Vec::new(), + suppression_targets: Vec::new(), + ..finding.clone() + }, + identity: identity.clone(), }) .collect() } else { - vec![react_traits::Violation { - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), - ..finding.clone() + vec![ReactSuppressionFinding { + finding: react_traits::Violation { + suppression_lines: Vec::new(), + suppression_targets: Vec::new(), + ..finding.clone() + }, + identity, }] }; suppressed.extend(suppress_domain_findings_with_sources( @@ -127,7 +145,8 @@ pub(super) fn suppress_react( }); } -fn react_target(finding: &react_traits::Violation) -> SuppressionTarget<'_> { +fn react_target(entry: &ReactSuppressionFinding) -> SuppressionTarget<'_> { + let finding = &entry.finding; SuppressionTarget { domain: "react", rule: &finding.rule, @@ -137,6 +156,7 @@ fn react_target(finding: &react_traits::Violation) -> SuppressionTarget<'_> { .detail .as_deref() .unwrap_or("component fetch assertion failed"), + identity: Some(&entry.identity), } } @@ -157,6 +177,7 @@ fn suppress_rules( file: &finding.file, line: Some(finding.line), reason: &finding.message, + identity: None, }, )); } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs index 5a7c42a34..608d173fa 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs @@ -16,6 +16,7 @@ mod findings; mod prepared; mod runner; mod selection; +mod suppression; mod types; use colocated_tests::covered_components as colocated_test_covered_components; diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs index b5f34ae10..9b01f0e3f 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs @@ -1,3 +1,4 @@ +use super::suppression::{component_is_suppressed, component_suppression_sources}; use super::{ all_react_component_keys, colocated_test_covered_components, component_disabled, directly_covered_components, dynamic_or_mock_boundary_files, effective_story_patterns, @@ -8,7 +9,6 @@ use super::{ use crate::codebase::check_facts::CheckFactMap; use crate::codebase::rules::{path_filter::RulePathFilter, sort_findings}; use crate::codebase::ts_resolver::{normalize_path, ImportResolution}; -use crate::codebase::ts_source::matching_disable_directive; use crate::config::v2::schema::{NoMistakesConfig, RuleDef}; use anyhow::{bail, Result}; use std::collections::HashSet; @@ -104,9 +104,10 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { .filter(|component| rule_filter.is_match(&component.file)) .collect::>(); let component_keys: HashSet = components.iter().map(|c| c.key.clone()).collect(); + let suppression_sources = component_suppression_sources(root, &components, sources); let suppression_filtered_component_keys: HashSet = components .iter() - .filter(|component| !component_is_suppressed(root, shared, component)) + .filter(|component| !component_is_suppressed(root, &suppression_sources, component)) .map(|component| component.key.clone()) .collect(); let all_component_keys = all_react_component_keys(project_root, shared); @@ -180,23 +181,3 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { findings.retain(|finding| rule_filter.is_match(&root.join(&finding.file))); Ok(findings) } - -fn component_is_suppressed( - root: &Path, - shared: &CheckFactMap, - component: &super::types::Component, -) -> bool { - let component_path = normalize_path(&component.file); - let rooted_component_path = normalize_path(&root.join(&component.file)); - shared - .ts - .iter() - .find(|(path, _)| { - let path = normalize_path(path); - path == component_path || path == rooted_component_path - }) - .and_then(|(_, facts)| facts.source.as_deref()) - .is_some_and(|source| { - matching_disable_directive(source, Some(component.line as u32), RULE_ID).is_some() - }) -} diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs new file mode 100644 index 000000000..ffe60d0c3 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs @@ -0,0 +1,51 @@ +use super::types::Component; +use crate::codebase::ts_resolver::normalize_path; +use crate::codebase::ts_source::matching_disable_directive; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +pub(super) fn component_is_suppressed( + root: &Path, + sources: &HashMap>, + component: &Component, +) -> bool { + let component_path = normalize_path(&component.file); + let rooted_component_path = normalize_path(&root.join(&component.file)); + sources + .get(&component_path) + .or_else(|| sources.get(&rooted_component_path)) + .map(Arc::as_ref) + .is_some_and(|source| { + matching_disable_directive(source, Some(component.line as u32), super::RULE_ID) + .is_some() + }) +} + +/// Index only selected components, so suppression checks reuse one request +/// SourceStore read per selected path rather than scanning every TS fact. +pub(super) fn component_suppression_sources( + root: &Path, + components: &[Component], + sources: &crate::codebase::ts_source::SourceStore, +) -> HashMap> { + components + .iter() + .map(|component| &component.file) + .filter_map(|path| { + let candidate = if path.is_absolute() { + path.clone() + } else { + root.join(path) + }; + let source = sources.read_path(&candidate).ok()?; + let normalized = normalize_path(path); + let rooted = normalize_path(&root.join(path)); + Some((normalized, source, rooted)) + }) + .fold(HashMap::new(), |mut by_path, (path, source, rooted)| { + by_path.insert(path, Arc::clone(&source)); + by_path.insert(rooted, source); + by_path + }) +} diff --git a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs index 3f98a0aa0..724686753 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -14,6 +14,9 @@ pub struct SuppressionTarget<'a> { pub file: &'a str, pub line: Option, pub reason: &'a str, + /// Internal identity used when multiple public findings share a source + /// location. It is intentionally omitted from the serialized contract. + pub identity: Option<&'a str>, } #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)] @@ -81,7 +84,10 @@ pub fn suppress_domain_findings_with_sources( rule: target.rule.to_string(), file: target.file.to_string(), line: target.line, - reason: target.reason.to_string(), + reason: target.identity.map_or_else( + || target.reason.to_string(), + |identity| format!("{} (component {identity})", target.reason), + ), directive, }); false diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs index 951cbec00..c2fb58a0c 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs @@ -48,37 +48,40 @@ pub(super) fn collect( if file_facts.parse_error.is_some() { continue; } - if let (Some(source), Some(facts)) = ( - file_facts.source.as_deref(), - file_facts.dynamic_imports.as_ref(), - ) { - if !defer_suppression && has_disable_file_comment(source, RULE_ID) { - continue; - } - let mut local_findings = Vec::new(); - let check_context = DynamicCheckContext { - root: ctx.root, - file, - resolver: ctx.resolver, - graph: ctx.graph, - graph_files: ctx.graph_files, - file_universe: ctx.file_universe, - mocks, - dependency_cache, - findings: &mut local_findings, - }; - for import in &facts.dynamic_imports { - if defer_suppression - || !has_disable_comment(source, import.line as u32, RULE_ID) - { - collect_outcome( - &mut result, - evaluate_dynamic_import(&check_context, import.clone()), - ); - } - } + // A prepared request is authoritative, including incomplete or + // failed entries. Falling back to disk would violate one-pass + // ownership and can produce findings from facts outside the + // request's declared inventory. + let Some(source) = file_facts.source.as_deref() else { + continue; + }; + let Some(facts) = file_facts.dynamic_imports.as_ref() else { + continue; + }; + if !defer_suppression && has_disable_file_comment(source, RULE_ID) { continue; } + let mut local_findings = Vec::new(); + let check_context = DynamicCheckContext { + root: ctx.root, + file, + resolver: ctx.resolver, + graph: ctx.graph, + graph_files: ctx.graph_files, + file_universe: ctx.file_universe, + mocks, + dependency_cache, + findings: &mut local_findings, + }; + for import in &facts.dynamic_imports { + if defer_suppression || !has_disable_comment(source, import.line as u32, RULE_ID) { + collect_outcome( + &mut result, + evaluate_dynamic_import(&check_context, import.clone()), + ); + } + } + continue; } let cached = get_or_cache_file(file, ctx.file_cache)?; if !defer_suppression && has_disable_file_comment(&cached.source, RULE_ID) { diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/tests.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/tests.rs index bc4cc4ebd..0fde77e7c 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/tests.rs @@ -445,9 +445,10 @@ fn reachable_check_uses_shared_facts_without_disk_read() { } #[test] -fn reachable_check_falls_back_to_disk_when_dep_facts_incomplete() { - // reachable.rs:54 — closing `}` of `if let (Some(source), Some(facts))`. - // When a dep is in shared.ts but source/dynamic_imports is None, fall through to disk. +fn reachable_check_does_not_fall_back_to_disk_when_shared_facts_incomplete() { + // A prepared entry is authoritative even when its source or extracted + // dynamic-import facts are incomplete; only shared=None uses legacy disk + // parsing. This protects one-pass request ownership. let root = fixture(); let tsconfig = TsConfig { dir: root.clone(), @@ -457,12 +458,14 @@ fn reachable_check_falls_back_to_disk_when_dep_facts_incomplete() { }; let resolver = ImportResolver::new(&tsconfig); let test_file = root.join("tests").join("good.test.mts"); - let dep = root.join("src").join("child.mts"); + let dep = root.join("src").join("unmocked-next-dynamic-component.mts"); let mut forward = HashMap::new(); forward.insert(test_file.clone(), vec![dep.clone()]); let graph = from_raw_maps(root.clone(), forward, Default::default()); let mut shared_ts = HashMap::new(); - // dep is in shared.ts but with source=None (incomplete facts) + // dep is in shared.ts but with source=None (incomplete facts). The file + // also exists in the fixture and has an unmocked import, so a disk + // fallback would produce a finding. shared_ts.insert( dep.clone(), crate::codebase::check_facts::CheckFileFacts { diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index abc1d2f9e..78c5e67f2 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -140,6 +140,7 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( manual_mocks: &manual_mocks, setup_data, shared, + sources, dependency_cache: &dependency_cache, defer_suppression, }, diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs index 368649ccb..ca61416d7 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs @@ -5,7 +5,7 @@ use super::PerTestResult; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::dependencies::graph::{DepGraph, GraphFiles}; use crate::codebase::ts_resolver::ScopedImportResolver; -use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; +use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment, SourceStore}; use crate::config::v2::NoMistakesConfig; use anyhow::Result; use dashmap::DashMap; @@ -23,6 +23,7 @@ pub(super) struct Request<'a> { pub(super) manual_mocks: &'a HashSet, pub(super) setup_data: &'a [config::ConfigSetupData], pub(super) shared: &'a CheckFactMap, + pub(super) sources: &'a SourceStore, pub(super) dependency_cache: &'a DashMap>>, pub(super) defer_suppression: bool, } @@ -38,16 +39,17 @@ pub(super) fn analyze(request: Request<'_>, file: PathBuf) -> Result, file: PathBuf) -> Result, } -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Debug, Clone)] pub(super) struct ExportOrigin { pub(super) file: String, pub(super) line: u32, @@ -96,3 +96,29 @@ pub(super) struct ExportOrigin { pub(super) suppressed: bool, pub(super) suppression_location: Option<(String, u32)>, } + +impl ExportOrigin { + fn identity(&self) -> (&str, u32, &str, ExportBucket) { + (&self.file, self.line, &self.name, self.bucket) + } +} + +impl PartialEq for ExportOrigin { + fn eq(&self, other: &Self) -> bool { + self.identity() == other.identity() + } +} + +impl Eq for ExportOrigin {} + +impl PartialOrd for ExportOrigin { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ExportOrigin { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.identity().cmp(&other.identity()) + } +} diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 793afb870..4ebc1a864 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -174,3 +174,52 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports }) })); } + +#[test] +fn check_json_deduplicates_same_origin_even_when_one_barrel_is_suppressed() { + let (baseline, audit) = baseline_and_audit("suppression-unique-canonical"); + for output in [&baseline, &audit] { + assert!(output["codebase"] + .as_array() + .is_some_and(|items| { !items.iter().any(|item| item["exportName"] == "identity") })); + } + assert!(audit["suppressed"].as_array().is_some_and(|items| { + !items + .iter() + .any(|item| item["rule"] == "unique-exports" && item["file"] == "src/identity-b.ts") + })); +} + +#[test] +fn check_json_keeps_inherited_react_suppressions_distinct_by_parent_component() { + let (baseline, audit) = baseline_and_audit("suppression-react-inherited-parents"); + assert!(baseline["react"].as_array().is_some_and(Vec::is_empty)); + let inherited = audit["suppressed"] + .as_array() + .unwrap() + .iter() + .filter(|item| item["domain"] == "react") + .collect::>(); + // The child also has a direct suppressed diagnostic; the two inherited + // records below are the invariant this fixture protects. + assert_eq!(inherited.len(), 3, "{audit}"); + let parents = inherited + .iter() + .filter(|item| { + item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("Parent")) + }) + .collect::>(); + assert_eq!(parents.len(), 2, "{audit}"); + assert!(parents.iter().any(|item| { + item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("ParentA")) + })); + assert!(parents.iter().any(|item| { + item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("ParentB")) + })); +} diff --git a/fixtures/check/suppression-react-inherited-parents/.no-mistakes.yml b/fixtures/check/suppression-react-inherited-parents/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/suppression-react-inherited-parents/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/suppression-react-inherited-parents/app/Child.tsx b/fixtures/check/suppression-react-inherited-parents/app/Child.tsx new file mode 100644 index 000000000..00d61670f --- /dev/null +++ b/fixtures/check/suppression-react-inherited-parents/app/Child.tsx @@ -0,0 +1,7 @@ +export default async function Child() { + // Both parents inherit this one suppressed fetch. The audit must retain + // one record per parent even though the source location is shared. + // no-mistakes-disable-next-line assert-no-fetch: shared child fetch is intentional + await fetch('/data/child'); + return ; +} diff --git a/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx b/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx new file mode 100644 index 000000000..5bee26b18 --- /dev/null +++ b/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx @@ -0,0 +1,5 @@ +import Child from './Child'; + +export default async function ParentA() { + return ; +} diff --git a/fixtures/check/suppression-react-inherited-parents/app/ParentB.tsx b/fixtures/check/suppression-react-inherited-parents/app/ParentB.tsx new file mode 100644 index 000000000..a482636b1 --- /dev/null +++ b/fixtures/check/suppression-react-inherited-parents/app/ParentB.tsx @@ -0,0 +1,5 @@ +import Child from './Child'; + +export default async function ParentB() { + return ; +} diff --git a/fixtures/check/suppression-unique-canonical/shared/identity-origin.ts b/fixtures/check/suppression-unique-canonical/shared/identity-origin.ts new file mode 100644 index 000000000..35187f4b1 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/shared/identity-origin.ts @@ -0,0 +1 @@ +export const identity = 1; diff --git a/fixtures/check/suppression-unique-canonical/src/identity-a.ts b/fixtures/check/suppression-unique-canonical/src/identity-a.ts new file mode 100644 index 000000000..a64fb045b --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/identity-a.ts @@ -0,0 +1 @@ +export { identity } from '../shared/identity-origin'; diff --git a/fixtures/check/suppression-unique-canonical/src/identity-b.ts b/fixtures/check/suppression-unique-canonical/src/identity-b.ts new file mode 100644 index 000000000..3b4ffd28e --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/identity-b.ts @@ -0,0 +1,4 @@ +// This duplicate has the same resolved origin as identity-a.ts. The directive +// must not turn suppression metadata into a distinct origin identity. +// no-mistakes-disable-next-line unique-exports: identity compatibility barrel +export { identity } from '../shared/identity-origin'; diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index d75d0108e..738da78c3 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -488,6 +488,10 @@ test("analyzeProject declarations mirror report-specific runtime requirements", /type BatchedCheckOptions = Pick<[\s\S]*?"root" \| "tsconfig" \| "config" \| "includeSuppressed"[\s\S]*?>/, ); assert.match(analyzeProjectDeclarations, /type: "check"; id\?: string } & BatchedCheckOptions/); + assert.match( + readFileSync(join(packageRoot, "report-types.d.ts"), "utf8"), + /domain:[\s\S]*\| "advisories";/, + ); assert.match( readFileSync(join(packageRoot, "types.d.ts"), "utf8"), /export \* from "\.\/analyze-project-types";/, From a330fa5e0a5b818337b44e01edcad990408c183a Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 15:17:36 -0700 Subject: [PATCH 20/62] fix: preserve duplicate suppression audit entries --- .../src/check_runner/results/suppression.rs | 1 - .../src/codebase/rules/suppression/accounting.rs | 1 - .../src/napi_api/tests/check_suppression.rs | 16 ++++++++++++++++ .../.no-mistakes.yml | 7 +++++++ .../src/leaf.mts | 1 + .../tests/same-line.test.mts | 7 +++++++ .../vitest.config.mts | 5 +++++ 7 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 fixtures/check/aggregate-dynamic-import-same-line/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-dynamic-import-same-line/src/leaf.mts create mode 100644 fixtures/check/aggregate-dynamic-import-same-line/tests/same-line.test.mts create mode 100644 fixtures/check/aggregate-dynamic-import-same-line/vitest.config.mts diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index fb1d059de..45636b775 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -77,7 +77,6 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { }, )); suppressed.sort(); - suppressed.dedup(); suppressed } diff --git a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs index 724686753..8210d43e9 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -93,6 +93,5 @@ pub fn suppress_domain_findings_with_sources( false }); suppressed.sort(); - suppressed.dedup(); suppressed } diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 4ebc1a864..80aa51ea5 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -40,6 +40,22 @@ fn check_json_skips_file_disabled_parse_errors_without_losing_other_dynamic_impo })); } +#[test] +fn check_json_accounts_for_each_same_line_dynamic_import_suppression() { + let (baseline, audit) = baseline_and_audit("aggregate-dynamic-import-same-line"); + assert!(baseline["rules"].as_array().is_some_and(Vec::is_empty)); + let suppressed_findings = audit["suppressed"] + .as_array() + .unwrap() + .iter() + .filter(|finding| { + finding["domain"] == "rules" && finding["rule"] == "test-no-unmocked-dynamic-imports" + }) + .collect::>(); + assert_eq!(suppressed_findings.len(), 2, "{audit}"); + assert_eq!(suppressed_findings[0], suppressed_findings[1]); +} + #[test] fn check_json_reads_explicit_gitignored_test_configs_through_request_sources() { let root = static_check_fixture("aggregate-dynamic-import-gitignored-config"); diff --git a/fixtures/check/aggregate-dynamic-import-same-line/.no-mistakes.yml b/fixtures/check/aggregate-dynamic-import-same-line/.no-mistakes.yml new file mode 100644 index 000000000..d4abed644 --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-same-line/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository diff --git a/fixtures/check/aggregate-dynamic-import-same-line/src/leaf.mts b/fixtures/check/aggregate-dynamic-import-same-line/src/leaf.mts new file mode 100644 index 000000000..bbf0ecdf0 --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-same-line/src/leaf.mts @@ -0,0 +1 @@ +export const leaf = true; diff --git a/fixtures/check/aggregate-dynamic-import-same-line/tests/same-line.test.mts b/fixtures/check/aggregate-dynamic-import-same-line/tests/same-line.test.mts new file mode 100644 index 000000000..c3276aea2 --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-same-line/tests/same-line.test.mts @@ -0,0 +1,7 @@ +import { test } from 'vitest'; + +test('same-line dynamic imports are independently suppressed', async () => { + // Each import is a distinct finding even though both share this line. + // no-mistakes-disable-next-line test-no-unmocked-dynamic-imports: duplicate imports are intentional + await Promise.all([import('../src/leaf.mts'), import('../src/./leaf.mts')]); +}); diff --git a/fixtures/check/aggregate-dynamic-import-same-line/vitest.config.mts b/fixtures/check/aggregate-dynamic-import-same-line/vitest.config.mts new file mode 100644 index 000000000..8a474844e --- /dev/null +++ b/fixtures/check/aggregate-dynamic-import-same-line/vitest.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { include: ['tests/**/*.test.mts'] }, +}); From 98417e4fe78e065a40fb121a0c7e42bbe29e5231 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 15:33:09 -0700 Subject: [PATCH 21/62] fix: preserve prepared suppression and export representatives --- .../src/check_runner/tests/architecture.rs | 13 +++++++- .../with_facts.rs | 1 - .../with_facts/per_test.rs | 14 ++++----- .../src/codebase/rules/tests/extended.rs | 31 +++++++++++++++++++ .../src/codebase/unique_exports/findings.rs | 27 ++++++++++------ .../src/napi_api/tests/check_suppression.rs | 20 ++++++++++++ .../shared/collision-origin.ts | 1 + .../src/collision-a.ts | 4 +++ .../src/collision-b.ts | 1 + .../src/collision-c.ts | 1 + 10 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 fixtures/check/suppression-unique-canonical/shared/collision-origin.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/collision-a.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/collision-b.ts create mode 100644 fixtures/check/suppression-unique-canonical/src/collision-c.ts diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index 5656e7248..d708224fd 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -255,7 +255,18 @@ fn aggregate_rule_coordinator_delegates_variant_dispatch() { // Keep per-rule variant selection out of the aggregate coordinator so its // complexity remains bounded as additional rules are introduced. assert!(execution.contains("mod helpers;")); - assert!(execution.contains("use helpers::{storybook_findings, suppress_findings};")); + // Keep this structural: the helper request type may grow with prepared + // inputs without invalidating the coordinator ownership assertion. + for symbol in [ + "storybook_findings", + "suppress_findings", + "StorybookFindingsRequest", + ] { + assert!( + execution.contains(symbol), + "expanded helper import must include {symbol}" + ); + } assert!(helpers.contains("pub(super) fn storybook_findings(")); assert!(helpers.contains("check_with_prepared_facts_for_aggregate")); assert_eq!(storybook_block.matches("storybook_findings(").count(), 1); diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index 78c5e67f2..abc1d2f9e 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -140,7 +140,6 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( manual_mocks: &manual_mocks, setup_data, shared, - sources, dependency_cache: &dependency_cache, defer_suppression, }, diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs index ca61416d7..368649ccb 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs @@ -5,7 +5,7 @@ use super::PerTestResult; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::dependencies::graph::{DepGraph, GraphFiles}; use crate::codebase::ts_resolver::ScopedImportResolver; -use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment, SourceStore}; +use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; use crate::config::v2::NoMistakesConfig; use anyhow::Result; use dashmap::DashMap; @@ -23,7 +23,6 @@ pub(super) struct Request<'a> { pub(super) manual_mocks: &'a HashSet, pub(super) setup_data: &'a [config::ConfigSetupData], pub(super) shared: &'a CheckFactMap, - pub(super) sources: &'a SourceStore, pub(super) dependency_cache: &'a DashMap>>, pub(super) defer_suppression: bool, } @@ -39,17 +38,16 @@ pub(super) fn analyze(request: Request<'_>, file: PathBuf) -> Result, file: PathBuf) -> Result, @@ -23,18 +25,25 @@ pub(super) fn unique_export_findings( let mut findings = Vec::new(); for ((name, bucket), mut occurrences) in buckets { occurrences.sort_by(|a, b| (&a.file, a.line, &a.kind).cmp(&(&b.file, b.line, &b.kind))); - let mut origins = BTreeSet::new(); - let unique_occurrences = occurrences - .into_iter() - .filter(|occurrence| origins.insert(occurrence.origin.clone())) - .collect::>(); + let mut unique_occurrences: Vec = Vec::new(); + let mut origin_indices: BTreeMap = BTreeMap::new(); + for occurrence in occurrences { + let origin = occurrence.origin.clone(); + if let Some(index) = origin_indices.get(&origin).copied() { + if unique_occurrences[index].suppressed && !occurrence.suppressed { + unique_occurrences[index] = occurrence; + } + } else { + let index = unique_occurrences.len(); + origin_indices.insert(origin, index); + unique_occurrences.push(occurrence); + } + } if unique_occurrences.len() < 2 { continue; } // In aggregate mode preserve standalone semantics: a suppressed // occurrence must not turn an unsuppressed export into a duplicate. - // Still emit the suppressed occurrence so finalization can account for - // its directive. let first = unique_occurrences .iter() .find(|occurrence| !occurrence.suppressed) diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 80aa51ea5..3e8464b68 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -206,6 +206,26 @@ fn check_json_deduplicates_same_origin_even_when_one_barrel_is_suppressed() { })); } +#[test] +fn check_json_prefers_unsuppressed_same_origin_representative() { + let (baseline, audit) = baseline_and_audit("suppression-unique-canonical"); + assert!(baseline["codebase"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["exportName"] == "collision" && item["file"] == "src/collision-c.ts") + })); + assert!(audit["codebase"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["exportName"] == "collision" && item["file"] == "src/collision-c.ts") + })); + assert!(audit["suppressed"].as_array().is_some_and(|items| { + !items + .iter() + .any(|item| item["rule"] == "unique-exports" && item["file"] == "src/collision-a.ts") + })); +} + #[test] fn check_json_keeps_inherited_react_suppressions_distinct_by_parent_component() { let (baseline, audit) = baseline_and_audit("suppression-react-inherited-parents"); diff --git a/fixtures/check/suppression-unique-canonical/shared/collision-origin.ts b/fixtures/check/suppression-unique-canonical/shared/collision-origin.ts new file mode 100644 index 000000000..ba2678618 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/shared/collision-origin.ts @@ -0,0 +1 @@ +export const collision = 1; diff --git a/fixtures/check/suppression-unique-canonical/src/collision-a.ts b/fixtures/check/suppression-unique-canonical/src/collision-a.ts new file mode 100644 index 000000000..3a0eaedcf --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/collision-a.ts @@ -0,0 +1,4 @@ +// Lexically first barrel is suppressed, but must not hide the unsuppressed +// same-origin barrel or the distinct collision below. +// no-mistakes-disable-next-line unique-exports: compatibility barrel +export { collision } from '../shared/collision-origin'; diff --git a/fixtures/check/suppression-unique-canonical/src/collision-b.ts b/fixtures/check/suppression-unique-canonical/src/collision-b.ts new file mode 100644 index 000000000..0cfb4ccc6 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/collision-b.ts @@ -0,0 +1 @@ +export { collision } from '../shared/collision-origin'; diff --git a/fixtures/check/suppression-unique-canonical/src/collision-c.ts b/fixtures/check/suppression-unique-canonical/src/collision-c.ts new file mode 100644 index 000000000..6dc0a59d3 --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/collision-c.ts @@ -0,0 +1 @@ +export const collision = 2; From c6da8d1e99a0ecfb5c6a9ab4b05e170551de300d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 15:42:33 -0700 Subject: [PATCH 22/62] fix: authorize explicit storybook config sources --- .../no-mistakes/src/check_runner/prepared.rs | 5 +++++ .../rules/require_storybook_stories.rs | 15 +++++++++++++ .../rules/require_storybook_stories/config.rs | 22 +++++++++++++++++++ .../src/napi_api/tests/check_suppression.rs | 7 ++++++ .../.gitignore | 1 + .../.no-mistakes.yml | 16 ++++++++++++++ .../.storybook/main.ts | 3 +++ .../custom/Widget.examples.tsx | 7 ++++++ .../src/Widget.tsx | 3 +++ 9 files changed, 79 insertions(+) create mode 100644 fixtures/check/aggregate-require-storybook-explicit-config/.gitignore create mode 100644 fixtures/check/aggregate-require-storybook-explicit-config/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-require-storybook-explicit-config/.storybook/main.ts create mode 100644 fixtures/check/aggregate-require-storybook-explicit-config/custom/Widget.examples.tsx create mode 100644 fixtures/check/aggregate-require-storybook-explicit-config/src/Widget.tsx diff --git a/crates/no-mistakes/src/check_runner/prepared.rs b/crates/no-mistakes/src/check_runner/prepared.rs index 99fd945a7..d3a7c6b1d 100644 --- a/crates/no-mistakes/src/check_runner/prepared.rs +++ b/crates/no-mistakes/src/check_runner/prepared.rs @@ -64,6 +64,11 @@ pub(crate) fn prepare_from_shared( let codebase_config = no_mistakes::codebase::config::config_from_loaded_v2(root, config_path, &config); let sources = visible_paths.source_store_for(root); + if config.rule_configured(no_mistakes::codebase::rules::REQUIRE_STORYBOOK_STORIES) { + no_mistakes::codebase::rules::require_storybook_stories::authorize_configured_sources( + root, &config, &sources, + ); + } let tsconfig_catalog = Arc::new(if let Some(path) = tsconfig_path { let path = if path.is_absolute() { path.to_path_buf() diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs index 608d173fa..263b3f84f 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories.rs @@ -55,6 +55,20 @@ pub fn configured_project_roots(root: &Path, config: &NoMistakesConfig) -> Vec , +}; diff --git a/fixtures/check/aggregate-require-storybook-explicit-config/src/Widget.tsx b/fixtures/check/aggregate-require-storybook-explicit-config/src/Widget.tsx new file mode 100644 index 000000000..e96b79573 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-explicit-config/src/Widget.tsx @@ -0,0 +1,3 @@ +export function Widget() { + return ; +} From 9fc7c7b98848990f1818741f1a51b2e8535d32f8 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 15:57:03 -0700 Subject: [PATCH 23/62] fix: preserve batched check options and source reuse --- .../src/codebase/rules/filesystem_dispatch/tests.rs | 9 +++++++-- crates/no-mistakes/src/codebase/rules/tests/extended.rs | 7 ++++++- packages/no-mistakes/analyze-project-types.d.ts | 2 +- packages/no-mistakes/report-types.d.ts | 9 +-------- packages/no-mistakes/scripts/api.test.js | 2 +- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index f31b3ca40..4ce93c639 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -380,7 +380,7 @@ fn combined_rust_rules_emit_all_configured_findings() { } #[test] -fn aggregate_drops_exclusive_rust_sources_without_global_suppression_rereads() { +fn aggregate_reads_rust_sources_once_without_global_suppression_rereads() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../test-cases/rules/filesystem-dispatch/rust-combined/fixture"); let root = crate::codebase::ts_resolver::normalize_path(&root); @@ -389,6 +389,11 @@ fn aggregate_drops_exclusive_rust_sources_without_global_suppression_rereads() { let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(&root); let files = snapshot.paths_for(&root); let sources = snapshot.source_store_for(&root); + // Aggregate fact collection warms this request-owned store before the + // filesystem dispatcher. The Rust rules must reuse that source and avoid + // a second read for final suppression accounting. + sources.read_path(&root.join("src/lib.rs")).unwrap(); + assert_eq!(sources.physical_read_count(), 1); let findings = run_filesystem_rules_with_config_snapshot_catalog_and_sources( &root, @@ -407,7 +412,7 @@ fn aggregate_drops_exclusive_rust_sources_without_global_suppression_rereads() { .unwrap(); assert_eq!(findings.len(), 3, "{findings:#?}"); - assert_eq!(sources.physical_read_count(), 0); + assert_eq!(sources.physical_read_count(), 1); } #[test] diff --git a/crates/no-mistakes/src/codebase/rules/tests/extended.rs b/crates/no-mistakes/src/codebase/rules/tests/extended.rs index bc4e13276..2ffe5016b 100644 --- a/crates/no-mistakes/src/codebase/rules/tests/extended.rs +++ b/crates/no-mistakes/src/codebase/rules/tests/extended.rs @@ -122,7 +122,12 @@ fn dynamic_import_check_uses_authoritative_source_fact_for_suppression() { ..Default::default() }, ); - let physical_source = std::fs::read_to_string(&test).unwrap(); + let physical_source = facts + .ts + .get(&test) + .and_then(|file_facts| file_facts.source.as_deref()) + .expect("collected source fact for the physical fixture") + .to_owned(); // The prepared source is the request snapshot. Its directive differs from // disk so a lower-level reread would incorrectly restore these findings. let snapshot_source = diff --git a/packages/no-mistakes/analyze-project-types.d.ts b/packages/no-mistakes/analyze-project-types.d.ts index 2ea5accc5..cc1fa90e7 100644 --- a/packages/no-mistakes/analyze-project-types.d.ts +++ b/packages/no-mistakes/analyze-project-types.d.ts @@ -27,7 +27,7 @@ type BatchedReactUsagesOptions = Pick< Required>; type BatchedCheckOptions = Pick< ProjectOptions, - "root" | "tsconfig" | "config" | "includeSuppressed" + "root" | "tsconfig" | "config" | "include" | "includeSuppressed" >; export type AnalyzeProjectReportRequest = diff --git a/packages/no-mistakes/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index a129b0cae..b11099304 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -35,14 +35,7 @@ export interface CheckReport { } export interface SuppressedFinding { - domain: - | "react" - | "queues" - | "rules" - | "filesystem" - | "integration" - | "codebase" - | "advisories"; + domain: "react" | "queues" | "rules" | "filesystem" | "integration" | "codebase" | "advisories"; rule: string; file: string; line?: number; diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 738da78c3..6dbae56f8 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -485,7 +485,7 @@ test("analyzeProject declarations mirror report-specific runtime requirements", ); assert.match( analyzeProjectDeclarations, - /type BatchedCheckOptions = Pick<[\s\S]*?"root" \| "tsconfig" \| "config" \| "includeSuppressed"[\s\S]*?>/, + /type BatchedCheckOptions = Pick<[\s\S]*?"root" \| "tsconfig" \| "config" \| "include" \| "includeSuppressed"[\s\S]*?>/, ); assert.match(analyzeProjectDeclarations, /type: "check"; id\?: string } & BatchedCheckOptions/); assert.match( From 6a4cd5e7443c0d2386d511ad4c638f189b632ea5 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 16:35:33 -0700 Subject: [PATCH 24/62] fix: preserve deferred dynamic suppression provenance --- .../no-mistakes/src/check_runner/results.rs | 2 + .../src/check_runner/results/suppression.rs | 14 ++++- .../results/suppression/provenance.rs | 42 ++++++++++++++ crates/no-mistakes/src/check_runner/tests.rs | 1 + crates/no-mistakes/src/check_tasks.rs | 32 +++++++---- .../no-mistakes/src/check_tasks/filesystem.rs | 1 + crates/no-mistakes/src/check_tasks/tests.rs | 1 + crates/no-mistakes/src/codebase/rules/mod.rs | 5 +- crates/no-mistakes/src/codebase/rules/run.rs | 10 +++- .../src/codebase/rules/run/prepared.rs | 16 ++++-- .../codebase/rules/run/prepared/execution.rs | 57 +++++++++++++------ .../src/codebase/rules/suppression.rs | 4 +- .../codebase/rules/suppression/accounting.rs | 18 +++++- .../rules/test_no_unmocked_dynamic_imports.rs | 2 +- .../with_facts.rs | 54 +++++++++++++----- .../with_facts/per_test.rs | 2 + .../src/napi_api/tests/check_suppression.rs | 40 +++++++++++++ .../.no-mistakes.yml | 7 +++ .../src/helper.mts | 3 + .../src/leaf.mts | 1 + .../tests/disabled.test.mts | 7 +++ .../vitest.config.mts | 5 ++ .../.no-mistakes.yml | 7 +++ .../src/helper.mts | 3 + .../src/leaf.mts | 1 + .../tests/disabled.test.mts | 7 +++ .../tests/visible.test.mts | 6 ++ .../vitest.config.mts | 5 ++ 28 files changed, 300 insertions(+), 53 deletions(-) create mode 100644 crates/no-mistakes/src/check_runner/results/suppression/provenance.rs create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/helper.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/leaf.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/tests/disabled.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/vitest.config.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/.no-mistakes.yml create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/helper.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/leaf.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/disabled.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/visible.test.mts create mode 100644 fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/vitest.config.mts diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index bdc961cd0..eb1c1a211 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -75,6 +75,7 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result { pub(super) root: &'a std::path::Path, pub(super) sources: &'a SourceStore, pub(super) react: &'a mut Vec, pub(super) queues: &'a mut Vec, pub(super) rules: &'a mut Vec, + pub(super) rule_suppression_sources: &'a [Option], pub(super) filesystem: &'a mut Vec, pub(super) integration: &'a mut Vec, pub(super) codebase: &'a mut Vec, @@ -27,6 +31,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { react, queues, rules, + rule_suppression_sources, filesystem, integration, codebase, @@ -47,7 +52,14 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { identity: None, }, )); - suppress_rules(root, sources, rules, "rules", &mut suppressed); + suppress_rules_with_sources( + root, + sources, + rules, + rule_suppression_sources, + "rules", + &mut suppressed, + ); suppress_rules(root, sources, filesystem, "filesystem", &mut suppressed); suppress_rules(root, sources, advisories, "advisories", &mut suppressed); suppressed.extend(suppress_domain_findings_with_sources( diff --git a/crates/no-mistakes/src/check_runner/results/suppression/provenance.rs b/crates/no-mistakes/src/check_runner/results/suppression/provenance.rs new file mode 100644 index 000000000..a9b6d1199 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/suppression/provenance.rs @@ -0,0 +1,42 @@ +use no_mistakes::codebase::rules::{ + suppress_domain_findings_with_source_files, RuleFinding, SuppressedFinding, SuppressionTarget, +}; +use no_mistakes::codebase::ts_source::SourceStore; + +struct RuleSuppressionEntry { + finding: RuleFinding, + source_file: Option, +} + +pub(super) fn suppress_rules_with_sources( + root: &std::path::Path, + sources: &SourceStore, + findings: &mut Vec, + source_files: &[Option], + domain: &'static str, + suppressed: &mut Vec, +) { + let mut entries = findings + .drain(..) + .enumerate() + .map(|(index, finding)| RuleSuppressionEntry { + finding, + source_file: source_files.get(index).cloned().flatten(), + }) + .collect::>(); + suppressed.extend(suppress_domain_findings_with_source_files( + root, + &mut entries, + sources, + |entry| SuppressionTarget { + domain, + rule: &entry.finding.rule, + file: &entry.finding.file, + line: Some(entry.finding.line), + reason: &entry.finding.message, + identity: None, + }, + |entry| entry.source_file.as_deref(), + )); + findings.extend(entries.into_iter().map(|entry| entry.finding)); +} diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 0f1f2f734..e2f458e66 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -447,6 +447,7 @@ fn assert_domain_error(results: DomainResults, expected: &str) { fn empty_task(findings: T) -> CheckTask { CheckTask { findings, + suppression_sources: Vec::new(), warning: None, duration: Duration::ZERO, } diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index b1f02245c..4aa8cdc56 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -16,6 +16,7 @@ pub(crate) use filesystem::{filesystem_rules_configured, run_filesystem_rules_ch pub(crate) struct CheckTask { pub(crate) findings: T, + pub(crate) suppression_sources: Vec>, pub(crate) warning: Option, pub(crate) duration: Duration, } @@ -45,6 +46,7 @@ pub(crate) fn run_react_check( ); Ok(CheckTask { findings, + suppression_sources: Vec::new(), warning, duration, }) @@ -78,6 +80,7 @@ pub(crate) fn run_queue_check( let findings = findings?; Ok(CheckTask { findings, + suppression_sources: Vec::new(), warning: None, duration, }) @@ -87,19 +90,24 @@ pub(crate) fn run_rules_check( inputs: rules::PreparedRulesCheck<'_>, dependency_graph: Option<&no_mistakes::codebase::dependencies::graph::DepGraph>, ) -> Result>> { - let ((findings, warning), duration) = no_mistakes::diagnostics::measure_if_enabled( - "analysis.rules", - no_mistakes::diagnostics::TimingKind::Parallel, - || match rules::run_check_with_config_facts_playwright_and_graph(inputs, dependency_graph) { - Ok(findings) => (findings, None), - Err(err) => ( - Vec::new(), - Some(format!("warning: rules check skipped: {err:#}")), - ), - }, - ); + let (((findings, suppression_sources), warning), duration) = + no_mistakes::diagnostics::measure_if_enabled( + "analysis.rules", + no_mistakes::diagnostics::TimingKind::Parallel, + || match rules::run_check_with_config_facts_playwright_and_graph_with_suppression( + inputs, + dependency_graph, + ) { + Ok(findings) => ((findings.findings, findings.suppression_sources), None), + Err(err) => ( + (Vec::new(), Vec::new()), + Some(format!("warning: rules check skipped: {err:#}")), + ), + }, + ); Ok(CheckTask { findings, + suppression_sources, warning, duration, }) @@ -135,6 +143,7 @@ pub(crate) fn run_integration_check( let findings = findings?; Ok(CheckTask { findings, + suppression_sources: Vec::new(), warning: None, duration, }) @@ -170,6 +179,7 @@ pub(crate) fn run_codebase_check_with_catalog( let findings = findings?; Ok(CheckTask { findings, + suppression_sources: Vec::new(), warning: None, duration, }) diff --git a/crates/no-mistakes/src/check_tasks/filesystem.rs b/crates/no-mistakes/src/check_tasks/filesystem.rs index 52b5b000a..1cd05b312 100644 --- a/crates/no-mistakes/src/check_tasks/filesystem.rs +++ b/crates/no-mistakes/src/check_tasks/filesystem.rs @@ -69,6 +69,7 @@ pub(crate) fn run_filesystem_rules_check_with_facts( let findings = findings?; Ok(CheckTask { findings, + suppression_sources: Vec::new(), warning: None, duration, }) diff --git a/crates/no-mistakes/src/check_tasks/tests.rs b/crates/no-mistakes/src/check_tasks/tests.rs index 1ba490ccd..7c5778118 100644 --- a/crates/no-mistakes/src/check_tasks/tests.rs +++ b/crates/no-mistakes/src/check_tasks/tests.rs @@ -33,6 +33,7 @@ pub(crate) fn run_codebase_check( ); Ok(CheckTask { findings: findings?, + suppression_sources: Vec::new(), warning: None, duration, }) diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 5d1108931..9f4780331 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -69,6 +69,8 @@ pub use filesystem_dispatch::{ }; pub use ids::*; #[doc(hidden)] +pub use run::run_check_with_config_facts_playwright_and_graph_with_suppression; +#[doc(hidden)] pub use run::{ canonical_graph_plan, canonical_graph_requires_full_file_universe, run_check_with_config_facts_playwright_and_graph, @@ -84,7 +86,8 @@ pub(crate) use file_matching::matching_files; pub(crate) use source_access::{read_source, source_store_for_files}; #[doc(hidden)] pub use suppression::{ - suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, + suppress_domain_findings_with_source_files, suppress_domain_findings_with_sources, + SuppressedFinding, SuppressionTarget, }; pub(crate) use suppression::{ suppress_rule_findings_with_source, suppress_rule_findings_with_sources, diff --git a/crates/no-mistakes/src/codebase/rules/run.rs b/crates/no-mistakes/src/codebase/rules/run.rs index 4e3f562bc..49f370fdf 100644 --- a/crates/no-mistakes/src/codebase/rules/run.rs +++ b/crates/no-mistakes/src/codebase/rules/run.rs @@ -1,6 +1,6 @@ use super::{ forbidden_dependencies, nextjs_no_api_routes, nextjs_no_caching, require_storybook_stories, - required_entrypoint_reachability, rule_enabled, server_route_client_boundary, sort_findings, + required_entrypoint_reachability, rule_enabled, server_route_client_boundary, suppress_rule_findings_with_sources, test_no_unmocked_dynamic_imports, RuleFinding, FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, REQUIRED_ENTRYPOINT_REACHABILITY, REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, @@ -12,8 +12,16 @@ use std::path::Path; mod prepared; mod standalone; +#[doc(hidden)] +pub struct PreparedRuleFindings { + pub findings: Vec, + pub suppression_sources: Vec>, +} + #[doc(hidden)] pub use prepared::run_check_with_config_facts_playwright_and_graph; +#[doc(hidden)] +pub use prepared::run_check_with_config_facts_playwright_and_graph_with_suppression; pub use prepared::{canonical_graph_plan, canonical_graph_requires_full_file_universe}; pub use prepared::{run_check_with_config_and_facts_and_playwright, PreparedRulesCheck}; diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared.rs b/crates/no-mistakes/src/codebase/rules/run/prepared.rs index a1d59242e..6137190f1 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -1,10 +1,10 @@ use super::{ any_codebase_rule_enabled, forbidden_dependencies, nextjs_no_api_routes, nextjs_no_caching, require_storybook_stories, required_entrypoint_reachability, rule_enabled, - server_route_client_boundary, sort_findings, suppress_rule_findings_with_sources, - test_no_unmocked_dynamic_imports, RuleFinding, FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, - NEXTJS_NO_CACHING, REQUIRED_ENTRYPOINT_REACHABILITY, REQUIRE_STORYBOOK_STORIES, - SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, + server_route_client_boundary, suppress_rule_findings_with_sources, + test_no_unmocked_dynamic_imports, PreparedRuleFindings, RuleFinding, FORBIDDEN_DEPENDENCIES, + NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, REQUIRED_ENTRYPOINT_REACHABILITY, + REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, }; use crate::codebase::dependencies::graph::{DepGraph, GraphBuildPlan}; use anyhow::Result; @@ -79,5 +79,13 @@ pub fn run_check_with_config_facts_playwright_and_graph( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, ) -> Result> { + Ok(execution::run(inputs, dependency_graph)?.findings) +} + +#[doc(hidden)] +pub fn run_check_with_config_facts_playwright_and_graph_with_suppression( + inputs: PreparedRulesCheck<'_>, + dependency_graph: Option<&DepGraph>, +) -> Result { execution::run(inputs, dependency_graph) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index 9bd56e912..657bcf103 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -8,7 +8,7 @@ use helpers::{storybook_findings, suppress_findings, StorybookFindingsRequest}; pub(super) fn run( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, -) -> Result> { +) -> Result { let PreparedRulesCheck { session, root, @@ -25,7 +25,10 @@ pub(super) fn run( defer_suppression, } = inputs; if !any_codebase_rule_enabled(config) { - return Ok(Vec::new()); + return Ok(PreparedRuleFindings { + findings: Vec::new(), + suppression_sources: Vec::new(), + }); } if let Some(graph_plan) = canonical_graph_plan(config) { let (required_facts, _) = match prepared_graph { @@ -80,13 +83,14 @@ pub(super) fn run( None }; let mut findings = Vec::new(); + let mut suppression_sources = Vec::new(); // Aggregate callers derive this once and pass Some(inferred_roots) through // every prepared rule adapter. if rule_enabled(config, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS) { - findings.extend(crate::perf_trace::trace( + let dynamic_findings = crate::perf_trace::trace( "rules.test_no_unmocked_dynamic_imports", || { - test_no_unmocked_dynamic_imports::check_with_prepared_facts_graph_and_session( + test_no_unmocked_dynamic_imports::check_with_prepared_facts_graph_and_session_with_suppression( test_no_unmocked_dynamic_imports::PreparedFactsGraphRequest { root, config, @@ -100,7 +104,9 @@ pub(super) fn run( }, ) }, - )?); + )?; + suppression_sources.extend(dynamic_findings.suppression_sources); + findings.extend(dynamic_findings.findings); } if rule_enabled(config, SERVER_ROUTE_CLIENT_BOUNDARY) { let boundary_findings = server_route_client_boundary::check_with_facts_for_aggregate( @@ -110,6 +116,7 @@ pub(super) fn run( inferred_roots, defer_suppression, )?; + suppression_sources.extend(std::iter::repeat_n(None, boundary_findings.len())); findings.extend(boundary_findings); } if rule_enabled(config, NEXTJS_NO_API_ROUTES) { @@ -120,19 +127,22 @@ pub(super) fn run( inferred_roots, defer_suppression, )?; + suppression_sources.extend(std::iter::repeat_n(None, api_route_findings.len())); findings.extend(api_route_findings); } if rule_enabled(config, NEXTJS_NO_CACHING) { - findings.extend(nextjs_no_caching::check_with_facts_for_aggregate( + let caching_findings = nextjs_no_caching::check_with_facts_for_aggregate( root, config, shared, inferred_roots, defer_suppression, - )?); + )?; + suppression_sources.extend(std::iter::repeat_n(None, caching_findings.len())); + findings.extend(caching_findings); } if rule_enabled(config, REQUIRE_STORYBOOK_STORIES) { - findings.extend(storybook_findings(StorybookFindingsRequest { + let storybook_findings = storybook_findings(StorybookFindingsRequest { root, config, prepared_tsconfig_catalog, @@ -141,12 +151,13 @@ pub(super) fn run( session: &session, defer_suppression, sources, - })?); + })?; + suppression_sources.extend(std::iter::repeat_n(None, storybook_findings.len())); + findings.extend(storybook_findings); } if crate::playwright::rules::configured(config) { - findings.extend(crate::perf_trace::trace( - "rules.playwright", - || match prepared_playwright { + let playwright_findings = + crate::perf_trace::trace("rules.playwright", || match prepared_playwright { Some(prepared) => crate::playwright::rules::check_with_prepared_facts( root, config_path, @@ -157,8 +168,9 @@ pub(super) fn run( None => { crate::playwright::rules::check_with_facts(root, config_path, config, shared) } - }, - )?); + })?; + suppression_sources.extend(std::iter::repeat_n(None, playwright_findings.len())); + findings.extend(playwright_findings); } let graph_findings = graph_rule_findings( root, @@ -169,10 +181,21 @@ pub(super) fn run( dependency_graph, inferred_roots, ); - findings.extend(graph_findings?); + let graph_findings = graph_findings?; + suppression_sources.extend(std::iter::repeat_n(None, graph_findings.len())); + findings.extend(graph_findings); if !defer_suppression { suppress_findings(root, &mut findings, sources); } - sort_findings(&mut findings); - Ok(findings) + let mut paired = findings + .into_iter() + .zip(suppression_sources) + .collect::>(); + paired.sort_by(|(a, _), (b, _)| a.cmp(b)); + paired.dedup_by(|(a, source_a), (b, source_b)| a == b && source_a == source_b); + let (findings, suppression_sources): (Vec<_>, Vec<_>) = paired.into_iter().unzip(); + Ok(PreparedRuleFindings { + findings, + suppression_sources, + }) } diff --git a/crates/no-mistakes/src/codebase/rules/suppression.rs b/crates/no-mistakes/src/codebase/rules/suppression.rs index 2a4cf2af4..4090b0661 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -4,8 +4,8 @@ use std::path::{Path, PathBuf}; mod accounting; pub use accounting::{ - suppress_domain_findings_with_sources, SuppressedFinding, SuppressionDirective, - SuppressionDirectiveKind, SuppressionTarget, + suppress_domain_findings_with_source_files, suppress_domain_findings_with_sources, + SuppressedFinding, SuppressionDirective, SuppressionDirectiveKind, SuppressionTarget, }; pub(crate) fn suppress_rule_findings_with_sources_except( diff --git a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs index 8210d43e9..de5efa63c 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -55,17 +55,31 @@ pub fn suppress_domain_findings_with_sources( findings: &mut Vec, sources: &crate::codebase::ts_source::SourceStore, describe: impl Fn(&T) -> SuppressionTarget<'_>, +) -> Vec { + suppress_domain_findings_with_source_files(root, findings, sources, describe, |_| None) +} + +/// Retain findings while reading suppression directives from an internal +/// provenance path. The public finding location remains the target location. +#[doc(hidden)] +pub fn suppress_domain_findings_with_source_files( + root: &Path, + findings: &mut Vec, + sources: &crate::codebase::ts_source::SourceStore, + describe: impl Fn(&T) -> SuppressionTarget<'_>, + source_file: impl Fn(&T) -> Option<&str>, ) -> Vec { let lexical_root = crate::codebase::ts_source::normalize_discovery_path(root); let mut cached_sources = HashMap::new(); let mut suppressed = Vec::new(); findings.retain(|finding| { let target = describe(finding); + let source_file = source_file(finding).unwrap_or(target.file); let source = cached_sources - .entry(target.file.to_string()) + .entry(source_file.to_string()) .or_insert_with(|| { let (candidate, is_absolute) = - finding_source_candidate(&lexical_root, target.file, true)?; + finding_source_candidate(&lexical_root, source_file, true)?; let path = if is_absolute { sources.trusted_regular_path(&candidate) } else { diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs index b5cc5fab8..b249a554c 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs @@ -19,7 +19,7 @@ pub(crate) use standalone::{ use std::path::Path; pub use with_facts::{check_with_facts, check_with_prepared_facts}; pub(crate) use with_facts::{ - check_with_prepared_facts_graph_and_session, PreparedFactsGraphRequest, + check_with_prepared_facts_graph_and_session_with_suppression, PreparedFactsGraphRequest, }; pub const RULE_ID: &str = "test-no-unmocked-dynamic-imports"; diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index abc1d2f9e..069ad418f 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -24,6 +24,12 @@ struct PerTestResult { direct_findings: Vec, reachable_findings: Vec, covered_reachable_imports: HashSet, + reachable_suppression_file: Option, +} + +pub(crate) struct PreparedDynamicFindings { + pub(crate) findings: Vec, + pub(crate) suppression_sources: Vec>, } pub fn check_with_facts( @@ -85,6 +91,12 @@ pub(crate) struct PreparedFactsGraphRequest<'a> { pub(crate) fn check_with_prepared_facts_graph_and_session( request: PreparedFactsGraphRequest<'_>, ) -> Result> { + Ok(check_with_prepared_facts_graph_and_session_with_suppression(request)?.findings) +} + +pub(crate) fn check_with_prepared_facts_graph_and_session_with_suppression( + request: PreparedFactsGraphRequest<'_>, +) -> Result { let PreparedFactsGraphRequest { root, config, @@ -153,18 +165,34 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( for result in &per_test { covered_reachable_imports.extend(result.covered_reachable_imports.iter().cloned()); } - let mut findings: Vec = per_test + let mut findings = Vec::new(); + let mut suppression_sources = Vec::new(); + for result in per_test { + let reachable_suppression_file = result.reachable_suppression_file; + for finding in result.direct_findings { + findings.push(finding); + suppression_sources.push(None); + } + for entry in result.reachable_findings { + if covered_reachable_imports.contains(&entry.key) { + continue; + } + findings.push(entry.finding); + suppression_sources.push(reachable_suppression_file.clone()); + } + } + // Keep the internal provenance aligned with findings after deterministic + // ordering. Public findings retain their helper location; only the + // suppression source lookup may use the originating disabled test. + let mut paired = findings .into_iter() - .flat_map(|result| { - result.direct_findings.into_iter().chain( - result - .reachable_findings - .into_iter() - .filter(|entry| !covered_reachable_imports.contains(&entry.key)) - .map(|entry| entry.finding), - ) - }) - .collect(); - findings.sort_by(|a, b| (&a.file, a.line, &a.target).cmp(&(&b.file, b.line, &b.target))); - Ok(findings) + .zip(suppression_sources) + .collect::>(); + paired + .sort_by(|(a, _), (b, _)| (&a.file, a.line, &a.target).cmp(&(&b.file, b.line, &b.target))); + let (findings, suppression_sources): (Vec<_>, Vec<_>) = paired.into_iter().unzip(); + Ok(PreparedDynamicFindings { + findings, + suppression_sources, + }) } diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs index 368649ccb..5943f70f3 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs @@ -112,6 +112,8 @@ pub(super) fn analyze(request: Request<'_>, file: PathBuf) -> Result>(); + assert_eq!(suppressed.len(), 1, "{audit}"); + assert_eq!(suppressed[0]["file"], "src/helper.mts"); + assert_eq!(suppressed[0]["directive"]["kind"], "file"); + assert_eq!(suppressed[0]["directive"]["line"], 1); +} + +#[test] +fn check_json_audits_reachable_findings_from_disabled_tests() { + let (baseline, audit) = + baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only"); + assert!(baseline["rules"].as_array().is_some_and(Vec::is_empty)); + assert!(audit["suppressed"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["domain"] == "rules" + && finding["rule"] == "test-no-unmocked-dynamic-imports" + && finding["file"] == "src/helper.mts" + && finding["directive"]["kind"] == "file" + && finding["directive"]["line"] == 1 + }) + })); +} + #[test] fn check_json_accounts_for_each_same_line_dynamic_import_suppression() { let (baseline, audit) = baseline_and_audit("aggregate-dynamic-import-same-line"); diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/.no-mistakes.yml b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/.no-mistakes.yml new file mode 100644 index 000000000..d4abed644 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/helper.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/helper.mts new file mode 100644 index 000000000..d8dccd1a2 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/helper.mts @@ -0,0 +1,3 @@ +export async function loadHelper() { + return import('./leaf.mts') +} diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/leaf.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/leaf.mts new file mode 100644 index 000000000..1e9853c5f --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/src/leaf.mts @@ -0,0 +1 @@ +export const leaf = true diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/tests/disabled.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/tests/disabled.test.mts new file mode 100644 index 000000000..543bf9b50 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/tests/disabled.test.mts @@ -0,0 +1,7 @@ +// no-mistakes-disable-file test-no-unmocked-dynamic-imports: legacy test is intentionally suppressed +import { test } from 'vitest' +import { loadHelper } from '../src/helper.mts' + +test('legacy helper reachability', async () => { + await loadHelper() +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/vitest.config.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/vitest.config.mts new file mode 100644 index 000000000..1f2d81198 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only/vitest.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { include: ['tests/**/*.test.mts'] }, +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/.no-mistakes.yml b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/.no-mistakes.yml new file mode 100644 index 000000000..d4abed644 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/helper.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/helper.mts new file mode 100644 index 000000000..d8dccd1a2 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/helper.mts @@ -0,0 +1,3 @@ +export async function loadHelper() { + return import('./leaf.mts') +} diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/leaf.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/leaf.mts new file mode 100644 index 000000000..1e9853c5f --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/src/leaf.mts @@ -0,0 +1 @@ +export const leaf = true diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/disabled.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/disabled.test.mts new file mode 100644 index 000000000..543bf9b50 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/disabled.test.mts @@ -0,0 +1,7 @@ +// no-mistakes-disable-file test-no-unmocked-dynamic-imports: legacy test is intentionally suppressed +import { test } from 'vitest' +import { loadHelper } from '../src/helper.mts' + +test('legacy helper reachability', async () => { + await loadHelper() +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/visible.test.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/visible.test.mts new file mode 100644 index 000000000..a96fdfcf1 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/tests/visible.test.mts @@ -0,0 +1,6 @@ +import { test } from 'vitest' +import { loadHelper } from '../src/helper.mts' + +test('visible helper reachability', async () => { + await loadHelper() +}) diff --git a/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/vitest.config.mts b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/vitest.config.mts new file mode 100644 index 000000000..1f2d81198 --- /dev/null +++ b/fixtures/check/aggregate-test-no-unmocked-dynamic-imports-reachable-provenance/vitest.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { include: ['tests/**/*.test.mts'] }, +}) From 13e7c439409d0b3129a87fdd7d5ee467b49c5a10 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 17:04:47 -0700 Subject: [PATCH 25/62] fix: keep React suppression metadata internal --- .../no-mistakes/src/check_runner/results.rs | 2 + .../src/check_runner/results/suppression.rs | 54 ++++++++--------- .../check_runner/results/suppression_tests.rs | 28 +++++++-- crates/no-mistakes/src/check_runner/tests.rs | 1 + crates/no-mistakes/src/check_tasks.rs | 40 +++---------- .../no-mistakes/src/check_tasks/filesystem.rs | 1 + crates/no-mistakes/src/check_tasks/react.rs | 42 +++++++++++++ crates/no-mistakes/src/check_tasks/tests.rs | 1 + .../src/napi_api/tests/check_suppression.rs | 35 ++++++----- crates/no-mistakes/src/react_traits/mod.rs | 3 +- .../src/react_traits/pipeline/check.rs | 60 +++++++++++++------ .../src/react_traits/pipeline/check/tests.rs | 31 ++++++++++ .../src/react_traits/report/text/tests.rs | 26 ++++++-- .../src/react_traits/report/types.rs | 11 ---- 14 files changed, 219 insertions(+), 116 deletions(-) create mode 100644 crates/no-mistakes/src/check_tasks/react.rs diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index eb1c1a211..deda4f080 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -73,6 +73,7 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result { pub(super) root: &'a std::path::Path, pub(super) sources: &'a SourceStore, pub(super) react: &'a mut Vec, + pub(super) react_suppression_targets: &'a [Vec], pub(super) queues: &'a mut Vec, pub(super) rules: &'a mut Vec, pub(super) rule_suppression_sources: &'a [Option], @@ -29,6 +30,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { root, sources, react, + react_suppression_targets, queues, rules, rule_suppression_sources, @@ -38,7 +40,13 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { advisories, } = input; let mut suppressed = Vec::new(); - suppress_react(root, sources, react, &mut suppressed); + suppress_react( + root, + sources, + react, + react_suppression_targets, + &mut suppressed, + ); suppressed.extend(suppress_domain_findings_with_sources( root, queues, @@ -96,6 +104,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { /// single stable public finding unless all of those fetches are suppressed. struct ReactSuppressionFinding { finding: react_traits::Violation, + line: Option, identity: String, } @@ -103,46 +112,29 @@ pub(super) fn suppress_react( root: &std::path::Path, sources: &SourceStore, findings: &mut Vec, + suppression_targets: &[Vec], suppressed: &mut Vec, ) { - findings.retain(|finding| { + let original_findings = findings.drain(..).enumerate().collect::>(); + for (index, finding) in original_findings { let identity = format!("{}@{}", finding.component, finding.file); - let mut locations = if !finding.suppression_targets.is_empty() { - finding - .suppression_targets + let targets = suppression_targets.get(index).cloned().unwrap_or_default(); + let mut locations = if !targets.is_empty() { + targets .iter() .map(|target| ReactSuppressionFinding { finding: react_traits::Violation { file: target.file.clone(), - line: Some(target.line), - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), - ..finding.clone() - }, - identity: identity.clone(), - }) - .collect() - } else if !finding.suppression_lines.is_empty() { - finding - .suppression_lines - .iter() - .map(|line| ReactSuppressionFinding { - finding: react_traits::Violation { - line: Some(*line), - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), ..finding.clone() }, + line: Some(target.line), identity: identity.clone(), }) .collect() } else { vec![ReactSuppressionFinding { - finding: react_traits::Violation { - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), - ..finding.clone() - }, + finding: finding.clone(), + line: None, identity, }] }; @@ -152,8 +144,10 @@ pub(super) fn suppress_react( sources, react_target, )); - !locations.is_empty() - }); + if !locations.is_empty() { + findings.push(finding); + } + } } fn react_target(entry: &ReactSuppressionFinding) -> SuppressionTarget<'_> { @@ -162,7 +156,7 @@ fn react_target(entry: &ReactSuppressionFinding) -> SuppressionTarget<'_> { domain: "react", rule: &finding.rule, file: &finding.file, - line: finding.line, + line: entry.line, reason: finding .detail .as_deref() diff --git a/crates/no-mistakes/src/check_runner/results/suppression_tests.rs b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs index 89afee3b9..54dc10401 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression_tests.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs @@ -14,12 +14,32 @@ fn line_less_react_findings_are_not_dropped_by_suppression_adapter() { file: "app/Fetcher.tsx".to_string(), rule: "assert-no-fetch".to_string(), detail: None, - line: None, - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), }]; let mut suppressed = Vec::new(); - suppress_react(&root, &sources, &mut findings, &mut suppressed); + suppress_react(&root, &sources, &mut findings, &[], &mut suppressed); assert_eq!(findings.len(), 1); assert!(suppressed.is_empty()); } + +#[test] +fn aggregate_react_suppression_uses_sidecar_locations_for_public_four_field_findings() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-react-multiple"); + let snapshot = VisiblePathSnapshot::new(&root); + let sources = snapshot.source_store_for(&root); + let mut findings = vec![Violation { + component: "Fetcher".to_string(), + file: "app/Fetcher.tsx".to_string(), + rule: "assert-no-fetch".to_string(), + detail: None, + }]; + let targets = vec![vec![no_mistakes::react_traits::ReactSuppressionTarget { + file: "app/Fetcher.tsx".to_string(), + line: 5, + }]]; + let mut suppressed = Vec::new(); + suppress_react(&root, &sources, &mut findings, &targets, &mut suppressed); + assert!(findings.is_empty()); + assert_eq!(suppressed.len(), 1); + assert_eq!(suppressed[0].directive.line, 4); +} diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index e2f458e66..68df5eeb1 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -447,6 +447,7 @@ fn assert_domain_error(results: DomainResults, expected: &str) { fn empty_task(findings: T) -> CheckTask { CheckTask { findings, + react_suppression_targets: Vec::new(), suppression_sources: Vec::new(), warning: None, duration: Duration::ZERO, diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index 4aa8cdc56..ced5b60af 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -5,53 +5,25 @@ use no_mistakes::codebase::unique_exports::{self, UniqueExportFinding}; use no_mistakes::config::v2::NoMistakesConfig; use no_mistakes::integration_tests::{self, IntegrationFinding}; use no_mistakes::queue::CheckFinding; -use no_mistakes::react_traits; use std::time::Duration; mod filesystem; +mod react; #[cfg(test)] mod tests; pub(crate) use filesystem::{filesystem_rules_configured, run_filesystem_rules_check_with_facts}; +pub(crate) use react::run_react_check; pub(crate) struct CheckTask { pub(crate) findings: T, + pub(crate) react_suppression_targets: + Vec>, pub(crate) suppression_sources: Vec>, pub(crate) warning: Option, pub(crate) duration: Duration, } -pub(crate) fn run_react_check( - root: &std::path::Path, - enabled: bool, - facts: &CheckFactMap, - prepared: &react_traits::PreparedReactCheck, -) -> Result>> { - let ((findings, warning), duration) = no_mistakes::diagnostics::measure_if_enabled( - "analysis.react", - no_mistakes::diagnostics::TimingKind::Parallel, - || { - if enabled { - match react_traits::run_check_with_prepared_facts(root, &[], facts, prepared) { - Ok(findings) => (findings, None), - Err(err) => ( - Vec::new(), - Some(format!("warning: react check skipped: {err:#}")), - ), - } - } else { - (Vec::new(), None) - } - }, - ); - Ok(CheckTask { - findings, - suppression_sources: Vec::new(), - warning, - duration, - }) -} - pub(crate) fn run_queue_check( root: &std::path::Path, prepared_tsconfig_catalog: &std::sync::Arc, @@ -80,6 +52,7 @@ pub(crate) fn run_queue_check( let findings = findings?; Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), suppression_sources: Vec::new(), warning: None, duration, @@ -107,6 +80,7 @@ pub(crate) fn run_rules_check( ); Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), suppression_sources, warning, duration, @@ -143,6 +117,7 @@ pub(crate) fn run_integration_check( let findings = findings?; Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), suppression_sources: Vec::new(), warning: None, duration, @@ -179,6 +154,7 @@ pub(crate) fn run_codebase_check_with_catalog( let findings = findings?; Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), suppression_sources: Vec::new(), warning: None, duration, diff --git a/crates/no-mistakes/src/check_tasks/filesystem.rs b/crates/no-mistakes/src/check_tasks/filesystem.rs index 1cd05b312..6b5fc506c 100644 --- a/crates/no-mistakes/src/check_tasks/filesystem.rs +++ b/crates/no-mistakes/src/check_tasks/filesystem.rs @@ -69,6 +69,7 @@ pub(crate) fn run_filesystem_rules_check_with_facts( let findings = findings?; Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), suppression_sources: Vec::new(), warning: None, duration, diff --git a/crates/no-mistakes/src/check_tasks/react.rs b/crates/no-mistakes/src/check_tasks/react.rs new file mode 100644 index 000000000..2fd2a77e6 --- /dev/null +++ b/crates/no-mistakes/src/check_tasks/react.rs @@ -0,0 +1,42 @@ +use super::CheckTask; +use anyhow::Result; +use no_mistakes::codebase::check_facts::CheckFactMap; +use no_mistakes::react_traits; + +pub(crate) fn run_react_check( + root: &std::path::Path, + enabled: bool, + facts: &CheckFactMap, + prepared: &react_traits::PreparedReactCheck, +) -> Result>> { + let (((findings, react_suppression_targets), warning), duration) = + no_mistakes::diagnostics::measure_if_enabled( + "analysis.react", + no_mistakes::diagnostics::TimingKind::Parallel, + || { + if enabled { + match react_traits::run_check_with_prepared_facts_for_aggregate( + root, + &[], + facts, + prepared, + ) { + Ok(findings) => ((findings.findings, findings.suppression_targets), None), + Err(err) => ( + (Vec::new(), Vec::new()), + Some(format!("warning: react check skipped: {err:#}")), + ), + } + } else { + ((Vec::new(), Vec::new()), None) + } + }, + ); + Ok(CheckTask { + findings, + react_suppression_targets, + suppression_sources: Vec::new(), + warning, + duration, + }) +} diff --git a/crates/no-mistakes/src/check_tasks/tests.rs b/crates/no-mistakes/src/check_tasks/tests.rs index 7c5778118..cd9e8e665 100644 --- a/crates/no-mistakes/src/check_tasks/tests.rs +++ b/crates/no-mistakes/src/check_tasks/tests.rs @@ -33,6 +33,7 @@ pub(crate) fn run_codebase_check( ); Ok(CheckTask { findings: findings?, + react_suppression_targets: Vec::new(), suppression_sources: Vec::new(), warning: None, duration, diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 80bfa1f06..068ba7043 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -44,12 +44,14 @@ fn check_json_skips_file_disabled_parse_errors_without_losing_other_dynamic_impo fn check_json_preserves_reachable_suppression_provenance_for_disabled_tests() { let (baseline, audit) = baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-reachable-provenance"); - assert!(baseline["rules"].as_array().is_some_and(|findings| { - findings.iter().any(|finding| { - finding["rule"] == "test-no-unmocked-dynamic-imports" - && finding["file"] == "src/helper.mts" - }) - })); + let baseline_rules = baseline["rules"].as_array().unwrap(); + assert_eq!(baseline_rules.len(), 1, "{baseline}"); + assert_eq!( + baseline_rules[0]["rule"], + "test-no-unmocked-dynamic-imports" + ); + assert_eq!(baseline_rules[0]["file"], "src/helper.mts"); + assert_eq!(audit["rules"], baseline["rules"]); let suppressed = audit["suppressed"] .as_array() .unwrap() @@ -69,15 +71,19 @@ fn check_json_audits_reachable_findings_from_disabled_tests() { let (baseline, audit) = baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-reachable-disabled-only"); assert!(baseline["rules"].as_array().is_some_and(Vec::is_empty)); - assert!(audit["suppressed"].as_array().is_some_and(|findings| { - findings.iter().any(|finding| { - finding["domain"] == "rules" - && finding["rule"] == "test-no-unmocked-dynamic-imports" - && finding["file"] == "src/helper.mts" - && finding["directive"]["kind"] == "file" - && finding["directive"]["line"] == 1 + assert!(audit["rules"].as_array().is_some_and(Vec::is_empty)); + let suppressed = audit["suppressed"] + .as_array() + .unwrap() + .iter() + .filter(|finding| { + finding["domain"] == "rules" && finding["rule"] == "test-no-unmocked-dynamic-imports" }) - })); + .collect::>(); + assert_eq!(suppressed.len(), 1, "{audit}"); + assert_eq!(suppressed[0]["file"], "src/helper.mts"); + assert_eq!(suppressed[0]["directive"]["kind"], "file"); + assert_eq!(suppressed[0]["directive"]["line"], 1); } #[test] @@ -144,6 +150,7 @@ fn check_json_honors_ignored_explicit_storybook_config_patterns() { let (baseline, audit) = baseline_and_audit("aggregate-require-storybook-explicit-config"); assert!(baseline["rules"].as_array().is_some_and(Vec::is_empty)); assert!(audit["rules"].as_array().is_some_and(Vec::is_empty)); + assert!(audit["suppressed"].as_array().is_some_and(Vec::is_empty)); } #[test] diff --git a/crates/no-mistakes/src/react_traits/mod.rs b/crates/no-mistakes/src/react_traits/mod.rs index 2db8a77c5..41ca0a04b 100644 --- a/crates/no-mistakes/src/react_traits/mod.rs +++ b/crates/no-mistakes/src/react_traits/mod.rs @@ -7,7 +7,8 @@ pub use pipeline::check::check_enabled; pub use pipeline::check::run_check_with_facts; #[doc(hidden)] pub use pipeline::check::{ - prepare_check_from_loaded_config, run_check_with_prepared_facts, PreparedReactCheck, + prepare_check_from_loaded_config, run_check_with_prepared_facts, + run_check_with_prepared_facts_for_aggregate, PreparedReactCheck, PreparedReactFindings, }; pub use pipeline::run_analyze; pub use pipeline::run_check; diff --git a/crates/no-mistakes/src/react_traits/pipeline/check.rs b/crates/no-mistakes/src/react_traits/pipeline/check.rs index 80b970d6c..92a7c685e 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check.rs @@ -11,6 +11,12 @@ pub struct PreparedReactCheck { effective_no_fetch: bool, } +#[doc(hidden)] +pub struct PreparedReactFindings { + pub findings: Vec, + pub suppression_targets: Vec>, +} + impl PreparedReactCheck { pub fn enabled(&self) -> bool { self.effective_no_fetch @@ -104,10 +110,39 @@ pub fn run_check_with_prepared_facts( Ok(assert_no_fetch_violations(&facts_list)) } +#[doc(hidden)] +pub fn run_check_with_prepared_facts_for_aggregate( + root: &Path, + targets: &[String], + shared: &crate::codebase::check_facts::CheckFactMap, + prepared: &PreparedReactCheck, +) -> Result { + if !prepared.enabled() { + return Ok(PreparedReactFindings { + findings: Vec::new(), + suppression_targets: Vec::new(), + }); + } + let facts_list = crate::react_traits::pipeline::run_with_facts::run_analyze_inner_with_facts( + root, + &prepared.file_config, + targets, + shared, + )?; + Ok(assert_no_fetch_violations_with_suppression(&facts_list)) +} + fn assert_no_fetch_violations( facts_list: &[crate::react_traits::ComponentFacts], ) -> Vec { + assert_no_fetch_violations_with_suppression(facts_list).findings +} + +fn assert_no_fetch_violations_with_suppression( + facts_list: &[crate::react_traits::ComponentFacts], +) -> PreparedReactFindings { let mut violations = Vec::new(); + let mut suppression_targets = Vec::new(); for facts in facts_list { let has_fetch = !facts.fetches.is_empty() || facts @@ -115,20 +150,12 @@ fn assert_no_fetch_violations( .as_ref() .is_some_and(|agg| agg.has_fetch); if has_fetch { - let inherited_lines = facts - .inherited_from_children - .as_ref() - .map(|agg| agg.fetch_lines.clone()) - .unwrap_or_default(); - let mut suppression_lines: Vec = - facts.fetches.iter().map(|fetch| fetch.line).collect(); - suppression_lines.extend(inherited_lines.iter().copied()); let inherited_locations = facts .inherited_from_children .as_ref() .map(|agg| agg.fetch_locations.clone()) .unwrap_or_default(); - let mut suppression_targets = facts + let mut finding_targets = facts .fetches .iter() .map(|fetch| ReactSuppressionTarget { @@ -136,7 +163,7 @@ fn assert_no_fetch_violations( line: fetch.line, }) .collect::>(); - suppression_targets.extend( + finding_targets.extend( inherited_locations .into_iter() .map(|(file, line)| ReactSuppressionTarget { file, line }), @@ -146,17 +173,14 @@ fn assert_no_fetch_violations( file: facts.file.clone(), rule: "assert-no-fetch".to_string(), detail: facts.fetches.first().and_then(|f| f.shape.clone()), - line: facts - .fetches - .first() - .map(|fetch| fetch.line) - .or_else(|| inherited_lines.first().copied()), - suppression_lines, - suppression_targets, }); + suppression_targets.push(finding_targets); } } - violations + PreparedReactFindings { + findings: violations, + suppression_targets, + } } pub fn check_enabled( diff --git a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs index 3ccb07c94..22d119760 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs @@ -125,6 +125,37 @@ fn run_check_with_facts_reports_violations_when_assert_no_fetch_is_enabled() { assert!(!violations.is_empty(), "expected fetch violations"); } +#[test] +fn aggregate_check_keeps_public_violations_and_private_suppression_locations_separate() { + use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; + + let root = assert_no_fetch_root(); + let files = crate::codebase::ts_source::discover_visible_paths(&root); + let facts = collect_check_facts( + &root, + files, + CheckFactPlan { + react: true, + ..CheckFactPlan::default() + }, + ); + let prepared = prepare_check_from_loaded_config( + &crate::config::v2::load_v2_config(&root, None).unwrap(), + false, + ); + let report = + run_check_with_prepared_facts_for_aggregate(&root, &[], &facts, &prepared).unwrap(); + assert_eq!(report.findings.len(), report.suppression_targets.len()); + assert!(report + .findings + .iter() + .any(|finding| finding.rule == "assert-no-fetch")); + assert!(report + .suppression_targets + .iter() + .any(|targets| !targets.is_empty())); +} + #[test] fn prepared_check_uses_frozen_visible_files_after_source_is_removed() { use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; diff --git a/crates/no-mistakes/src/react_traits/report/text/tests.rs b/crates/no-mistakes/src/react_traits/report/text/tests.rs index a76198073..39a89d6f0 100644 --- a/crates/no-mistakes/src/react_traits/report/text/tests.rs +++ b/crates/no-mistakes/src/react_traits/report/text/tests.rs @@ -78,9 +78,6 @@ fn print_violations_outputs_violations() { file: "app/components/Fetcher.tsx".to_string(), rule: "assert-no-fetch".to_string(), detail: Some("GET /api/users".to_string()), - line: Some(1), - suppression_lines: vec![1], - suppression_targets: Vec::new(), }]; print_violations(&violations); } @@ -92,9 +89,26 @@ fn print_violations_no_detail() { file: "app/components/Fetcher.tsx".to_string(), rule: "assert-no-fetch".to_string(), detail: None, - line: None, - suppression_lines: Vec::new(), - suppression_targets: Vec::new(), }]; print_violations(&violations); } + +#[test] +fn violation_json_shape_remains_public_four_fields() { + let violation = Violation { + component: "Fetcher".to_string(), + file: "app/components/Fetcher.tsx".to_string(), + rule: "assert-no-fetch".to_string(), + detail: None, + }; + + assert_eq!( + serde_json::to_value(violation).unwrap(), + serde_json::json!({ + "component": "Fetcher", + "file": "app/components/Fetcher.tsx", + "rule": "assert-no-fetch", + "detail": null, + }) + ); +} diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index d2963bf41..c4ae88984 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -121,17 +121,6 @@ pub struct Violation { pub file: String, pub rule: String, pub detail: Option, - /// Internal location for the aggregate check suppression adapter. Direct - /// React check output remains byte-for-byte compatible. - #[serde(skip)] - pub line: Option, - /// All local fetch locations represented by this component-level - /// diagnostic. Aggregate suppression uses these without changing the - /// stable direct React report schema. - #[serde(skip)] - pub suppression_lines: Vec, - #[serde(skip)] - pub suppression_targets: Vec, } #[derive(Debug, Clone)] From 28512776503c08b6cfba16347c357cc72fd79aef Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 17:11:59 -0700 Subject: [PATCH 26/62] test: track aggregate React task entrypoint --- crates/no-mistakes/src/check_runner/tests/architecture.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index d708224fd..c6e065ef0 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -26,7 +26,7 @@ fn aggregate_check_injects_prepared_config_into_every_domain() { } for shared_entrypoint in [ - "run_check_with_prepared_facts", + "run_check_with_prepared_facts_for_aggregate", "run_check_with_config_facts_playwright_and_graph", "queue::analyze_project_with_prepared_facts_and_catalog_and_session", "integration_tests::check_with_prepared_facts_catalog_and_session", @@ -162,6 +162,7 @@ fn check_task_sources() -> String { [ include_str!("../../check_tasks.rs"), include_str!("../../check_tasks/filesystem.rs"), + include_str!("../../check_tasks/react.rs"), ] .concat() } From bcdfe5a8e1a214abed98e27e4032ad7897573a5d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 17:23:51 -0700 Subject: [PATCH 27/62] fix: keep aggregate React metadata internal --- .../src/react_traits/pipeline/check.rs | 67 ++--------------- .../react_traits/pipeline/check/aggregate.rs | 65 +++++++++++++++++ .../src/react_traits/pipeline/run.rs | 47 ++++++------ .../react_traits/pipeline/run/test_support.rs | 2 +- .../react_traits/pipeline/run_with_facts.rs | 73 +++++++++++++------ .../src/react_traits/report/text/tests.rs | 13 ++++ .../src/react_traits/report/types.rs | 5 -- 7 files changed, 159 insertions(+), 113 deletions(-) create mode 100644 crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs diff --git a/crates/no-mistakes/src/react_traits/pipeline/check.rs b/crates/no-mistakes/src/react_traits/pipeline/check.rs index 92a7c685e..c0dd7f3b1 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check.rs @@ -1,9 +1,11 @@ -use crate::react_traits::report::types::{ - FileConfig, ReactSuppressionTarget, RootConfig, Violation, -}; +use crate::react_traits::report::types::{FileConfig, RootConfig, Violation}; use anyhow::Result; use std::path::Path; +mod aggregate; +pub use aggregate::PreparedReactFindings; +use aggregate::{assert_no_fetch_violations, assert_no_fetch_violations_with_suppression}; + /// Parsed React check settings that can be shared across one request. #[doc(hidden)] pub struct PreparedReactCheck { @@ -11,12 +13,6 @@ pub struct PreparedReactCheck { effective_no_fetch: bool, } -#[doc(hidden)] -pub struct PreparedReactFindings { - pub findings: Vec, - pub suppression_targets: Vec>, -} - impl PreparedReactCheck { pub fn enabled(&self) -> bool { self.effective_no_fetch @@ -123,7 +119,7 @@ pub fn run_check_with_prepared_facts_for_aggregate( suppression_targets: Vec::new(), }); } - let facts_list = crate::react_traits::pipeline::run_with_facts::run_analyze_inner_with_facts( + let facts_list = crate::react_traits::pipeline::run_with_facts::run_analyze_inner_with_facts_and_suppression( root, &prepared.file_config, targets, @@ -132,57 +128,6 @@ pub fn run_check_with_prepared_facts_for_aggregate( Ok(assert_no_fetch_violations_with_suppression(&facts_list)) } -fn assert_no_fetch_violations( - facts_list: &[crate::react_traits::ComponentFacts], -) -> Vec { - assert_no_fetch_violations_with_suppression(facts_list).findings -} - -fn assert_no_fetch_violations_with_suppression( - facts_list: &[crate::react_traits::ComponentFacts], -) -> PreparedReactFindings { - let mut violations = Vec::new(); - let mut suppression_targets = Vec::new(); - for facts in facts_list { - let has_fetch = !facts.fetches.is_empty() - || facts - .inherited_from_children - .as_ref() - .is_some_and(|agg| agg.has_fetch); - if has_fetch { - let inherited_locations = facts - .inherited_from_children - .as_ref() - .map(|agg| agg.fetch_locations.clone()) - .unwrap_or_default(); - let mut finding_targets = facts - .fetches - .iter() - .map(|fetch| ReactSuppressionTarget { - file: fetch.file.clone(), - line: fetch.line, - }) - .collect::>(); - finding_targets.extend( - inherited_locations - .into_iter() - .map(|(file, line)| ReactSuppressionTarget { file, line }), - ); - violations.push(Violation { - component: facts.name.clone(), - file: facts.file.clone(), - rule: "assert-no-fetch".to_string(), - detail: facts.fetches.first().and_then(|f| f.shape.clone()), - }); - suppression_targets.push(finding_targets); - } - } - PreparedReactFindings { - findings: violations, - suppression_targets, - } -} - pub fn check_enabled( root: &Path, config_path: Option<&Path>, diff --git a/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs b/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs new file mode 100644 index 000000000..966a164a8 --- /dev/null +++ b/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs @@ -0,0 +1,65 @@ +use crate::react_traits::pipeline::run_with_facts::PreparedComponentFacts; +use crate::react_traits::report::types::{ReactSuppressionTarget, Violation}; + +#[doc(hidden)] +pub struct PreparedReactFindings { + pub findings: Vec, + pub suppression_targets: Vec>, +} + +pub(super) fn assert_no_fetch_violations( + facts_list: &[crate::react_traits::ComponentFacts], +) -> Vec { + let prepared = facts_list + .iter() + .cloned() + .map(|facts| PreparedComponentFacts { + facts, + inherited_fetch_locations: Vec::new(), + }) + .collect::>(); + assert_no_fetch_violations_with_suppression(&prepared).findings +} + +pub(super) fn assert_no_fetch_violations_with_suppression( + facts_list: &[PreparedComponentFacts], +) -> PreparedReactFindings { + let mut violations = Vec::new(); + let mut suppression_targets = Vec::new(); + for prepared_facts in facts_list { + let facts = &prepared_facts.facts; + let has_fetch = !facts.fetches.is_empty() + || facts + .inherited_from_children + .as_ref() + .is_some_and(|agg| agg.has_fetch); + if has_fetch { + let mut finding_targets = facts + .fetches + .iter() + .map(|fetch| ReactSuppressionTarget { + file: fetch.file.clone(), + line: fetch.line, + }) + .collect::>(); + finding_targets.extend( + prepared_facts + .inherited_fetch_locations + .iter() + .cloned() + .map(|(file, line)| ReactSuppressionTarget { file, line }), + ); + violations.push(Violation { + component: facts.name.clone(), + file: facts.file.clone(), + rule: "assert-no-fetch".to_string(), + detail: facts.fetches.first().and_then(|f| f.shape.clone()), + }); + suppression_targets.push(finding_targets); + } + } + PreparedReactFindings { + findings: violations, + suppression_targets, + } +} diff --git a/crates/no-mistakes/src/react_traits/pipeline/run.rs b/crates/no-mistakes/src/react_traits/pipeline/run.rs index 3368b28f7..49d398182 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run.rs @@ -101,8 +101,8 @@ pub(crate) fn run_analyze_inner_from_visible( &visible_files, &mut HashSet::new(), ); - if agg != AggregatedFacts::default() { - facts.inherited_from_children = Some(agg); + if agg.facts != AggregatedFacts::default() { + facts.inherited_from_children = Some(agg.facts); } all_results.push(facts); } @@ -121,7 +121,7 @@ fn aggregate_children_from_visible( root: &Path, visible_files: &HashSet, visited: &mut HashSet, -) -> AggregatedFacts { +) -> AggregateResult { aggregate_children_inner(facts, file_cache, root, Some(visible_files), visited) } @@ -131,8 +131,8 @@ fn aggregate_children_inner( root: &Path, visible_files: Option<&HashSet>, visited: &mut HashSet, -) -> AggregatedFacts { - let mut agg = AggregatedFacts::default(); +) -> AggregateResult { + let mut agg = AggregateResult::default(); for child_ref in &facts.children { let key = format!("{}#{}", child_ref.file, child_ref.name); if visited.contains(&key) { @@ -163,15 +163,13 @@ fn aggregate_children_inner( .and_then(|comps| comps.iter().find(|c| c.name == child_ref.name)) .cloned(); if let Some(child_facts) = child_facts_opt { - agg.has_state |= child_facts.has_state; - agg.has_props |= child_facts.has_props; - agg.passes_props |= child_facts.passes_props; - agg.uses_memo |= child_facts.uses_memo; - agg.uses_context_provider |= child_facts.uses_context_provider; - agg.uses_suspense |= child_facts.uses_suspense; - agg.has_fetch |= !child_facts.fetches.is_empty(); - agg.fetch_lines - .extend(child_facts.fetches.iter().map(|fetch| fetch.line)); + agg.facts.has_state |= child_facts.has_state; + agg.facts.has_props |= child_facts.has_props; + agg.facts.passes_props |= child_facts.passes_props; + agg.facts.uses_memo |= child_facts.uses_memo; + agg.facts.uses_context_provider |= child_facts.uses_context_provider; + agg.facts.uses_suspense |= child_facts.uses_suspense; + agg.facts.has_fetch |= !child_facts.fetches.is_empty(); agg.fetch_locations.extend( child_facts .fetches @@ -180,16 +178,21 @@ fn aggregate_children_inner( ); let child_agg = aggregate_children_inner(&child_facts, file_cache, root, visible_files, visited); - agg.has_state |= child_agg.has_state; - agg.has_fetch |= child_agg.has_fetch; - agg.uses_suspense |= child_agg.uses_suspense; - agg.uses_context_provider |= child_agg.uses_context_provider; - agg.uses_memo |= child_agg.uses_memo; - agg.has_props |= child_agg.has_props; - agg.passes_props |= child_agg.passes_props; - agg.fetch_lines.extend(child_agg.fetch_lines); + agg.facts.has_state |= child_agg.facts.has_state; + agg.facts.has_fetch |= child_agg.facts.has_fetch; + agg.facts.uses_suspense |= child_agg.facts.uses_suspense; + agg.facts.uses_context_provider |= child_agg.facts.uses_context_provider; + agg.facts.uses_memo |= child_agg.facts.uses_memo; + agg.facts.has_props |= child_agg.facts.has_props; + agg.facts.passes_props |= child_agg.facts.passes_props; agg.fetch_locations.extend(child_agg.fetch_locations); } } agg } + +#[derive(Default)] +struct AggregateResult { + facts: AggregatedFacts, + fetch_locations: Vec<(String, usize)>, +} diff --git a/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs b/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs index 1beee26b4..df998f909 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs @@ -27,5 +27,5 @@ pub(super) fn aggregate_children( components, ) })); - aggregate_children_inner(facts, file_cache, &root, None, visited) + aggregate_children_inner(facts, file_cache, &root, None, visited).facts } diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs index 7eeb161d1..825a97366 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs @@ -20,6 +20,25 @@ pub(crate) fn run_analyze_inner_with_facts( targets: &[String], shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result> { + Ok( + run_analyze_inner_with_facts_and_suppression(root, file_config, targets, shared)? + .into_iter() + .map(|entry| entry.facts) + .collect(), + ) +} + +pub(crate) struct PreparedComponentFacts { + pub(crate) facts: ComponentFacts, + pub(crate) inherited_fetch_locations: Vec<(String, usize)>, +} + +pub(crate) fn run_analyze_inner_with_facts_and_suppression( + root: &Path, + file_config: &FileConfig, + targets: &[String], + shared: &crate::codebase::check_facts::CheckFactMap, +) -> Result> { let root = crate::codebase::ts_source::normalize_discovery_path(root); let files = target_files(&root, file_config, targets, shared.files())?; let mut file_cache = HashMap::new(); @@ -49,10 +68,13 @@ pub(crate) fn run_analyze_inner_with_facts( &child_path_index, &mut HashSet::new(), ); - if agg != AggregatedFacts::default() { - facts.inherited_from_children = Some(agg); + if agg.facts != AggregatedFacts::default() { + facts.inherited_from_children = Some(agg.facts); } - all_results.push(facts); + all_results.push(PreparedComponentFacts { + facts, + inherited_fetch_locations: agg.fetch_locations, + }); } } Ok(all_results) @@ -92,8 +114,8 @@ fn aggregate_children_cached( file_cache: &HashMap>>, child_path_index: &HashMap, visited: &mut HashSet, -) -> AggregatedFacts { - let mut agg = AggregatedFacts::default(); +) -> AggregateResult { + let mut agg = AggregateResult::default(); for child_ref in &facts.children { let key = format!("{}#{}", child_ref.file, child_ref.name); if !visited.insert(key) { @@ -116,6 +138,12 @@ fn aggregate_children_cached( agg } +#[derive(Default)] +struct AggregateResult { + facts: AggregatedFacts, + fetch_locations: Vec<(String, usize)>, +} + fn child_path_index( root: &Path, file_cache: &HashMap>>, @@ -131,16 +159,14 @@ fn child_path_index( index } -fn merge_component(agg: &mut AggregatedFacts, facts: &ComponentFacts) { - agg.has_state |= facts.has_state; - agg.has_props |= facts.has_props; - agg.passes_props |= facts.passes_props; - agg.uses_memo |= facts.uses_memo; - agg.uses_context_provider |= facts.uses_context_provider; - agg.uses_suspense |= facts.uses_suspense; - agg.has_fetch |= !facts.fetches.is_empty(); - agg.fetch_lines - .extend(facts.fetches.iter().map(|fetch| fetch.line)); +fn merge_component(agg: &mut AggregateResult, facts: &ComponentFacts) { + agg.facts.has_state |= facts.has_state; + agg.facts.has_props |= facts.has_props; + agg.facts.passes_props |= facts.passes_props; + agg.facts.uses_memo |= facts.uses_memo; + agg.facts.uses_context_provider |= facts.uses_context_provider; + agg.facts.uses_suspense |= facts.uses_suspense; + agg.facts.has_fetch |= !facts.fetches.is_empty(); agg.fetch_locations.extend( facts .fetches @@ -149,15 +175,14 @@ fn merge_component(agg: &mut AggregatedFacts, facts: &ComponentFacts) { ); } -fn merge_aggregate(agg: &mut AggregatedFacts, child: &AggregatedFacts) { - agg.has_state |= child.has_state; - agg.has_fetch |= child.has_fetch; - agg.uses_suspense |= child.uses_suspense; - agg.uses_context_provider |= child.uses_context_provider; - agg.uses_memo |= child.uses_memo; - agg.has_props |= child.has_props; - agg.passes_props |= child.passes_props; - agg.fetch_lines.extend(child.fetch_lines.iter().copied()); +fn merge_aggregate(agg: &mut AggregateResult, child: &AggregateResult) { + agg.facts.has_state |= child.facts.has_state; + agg.facts.has_fetch |= child.facts.has_fetch; + agg.facts.uses_suspense |= child.facts.uses_suspense; + agg.facts.uses_context_provider |= child.facts.uses_context_provider; + agg.facts.uses_memo |= child.facts.uses_memo; + agg.facts.has_props |= child.facts.has_props; + agg.facts.passes_props |= child.facts.passes_props; agg.fetch_locations .extend(child.fetch_locations.iter().cloned()); } diff --git a/crates/no-mistakes/src/react_traits/report/text/tests.rs b/crates/no-mistakes/src/react_traits/report/text/tests.rs index 39a89d6f0..bf4a795bd 100644 --- a/crates/no-mistakes/src/react_traits/report/text/tests.rs +++ b/crates/no-mistakes/src/react_traits/report/text/tests.rs @@ -112,3 +112,16 @@ fn violation_json_shape_remains_public_four_fields() { }) ); } + +#[test] +fn aggregated_facts_keeps_the_public_seven_field_literal_shape() { + let _facts = AggregatedFacts { + has_state: false, + has_props: false, + passes_props: false, + uses_memo: false, + uses_context_provider: false, + uses_suspense: false, + has_fetch: false, + }; +} diff --git a/crates/no-mistakes/src/react_traits/report/types.rs b/crates/no-mistakes/src/react_traits/report/types.rs index c4ae88984..dca69b582 100644 --- a/crates/no-mistakes/src/react_traits/report/types.rs +++ b/crates/no-mistakes/src/react_traits/report/types.rs @@ -29,11 +29,6 @@ pub struct AggregatedFacts { pub uses_context_provider: bool, pub uses_suspense: bool, pub has_fetch: bool, - /// Internal fetch locations inherited through rendered child components. - #[serde(skip)] - pub fetch_lines: Vec, - #[serde(skip)] - pub fetch_locations: Vec<(String, usize)>, } #[derive(Debug, Clone, Serialize, Deserialize)] From 86be3834ed0a9f21fdb27ba110c3c62080cdebd9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 17:40:20 -0700 Subject: [PATCH 28/62] test: cover React aggregate compatibility branches --- .../src/react_traits/pipeline/check/tests.rs | 26 +++++++++++++++++++ .../src/react_traits/pipeline/run/tests.rs | 22 +++++++++++++++- .../pipeline/run_with_facts/tests.rs | 3 ++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs index 22d119760..39efb4550 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs @@ -125,6 +125,32 @@ fn run_check_with_facts_reports_violations_when_assert_no_fetch_is_enabled() { assert!(!violations.is_empty(), "expected fetch violations"); } +#[test] +fn prepared_check_and_aggregate_sidecar_cover_enabled_and_disabled_paths() { + use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; + + let root = assert_no_fetch_root(); + let fetcher = root.join("app/components/Fetcher.tsx"); + let facts = collect_check_facts( + &root, + vec![fetcher], + CheckFactPlan { + react: true, + ..CheckFactPlan::default() + }, + ); + let config = crate::config::v2::load_v2_config(&root, None).unwrap(); + let enabled = prepare_check_from_loaded_config(&config, true); + let violations = run_check_with_prepared_facts(&root, &[], &facts, &enabled).unwrap(); + assert!(!violations.is_empty()); + + let disabled = prepare_file_config(FileConfig::default(), false); + let aggregate = + run_check_with_prepared_facts_for_aggregate(&root, &[], &facts, &disabled).unwrap(); + assert!(aggregate.findings.is_empty()); + assert!(aggregate.suppression_targets.is_empty()); +} + #[test] fn aggregate_check_keeps_public_violations_and_private_suppression_locations_separate() { use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; diff --git a/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs index 284ac9119..280e0ea9e 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs @@ -1,6 +1,6 @@ use super::test_support::*; use super::*; -use crate::react_traits::report::types::{ComponentRef, Environment, FetchCall}; +use crate::react_traits::report::types::{AggregatedFacts, ComponentRef, Environment, FetchCall}; use std::collections::HashMap; fn fixture(name: &str) -> PathBuf { @@ -156,3 +156,23 @@ fn aggregate_children_skips_repeated_refs_and_unreadable_children() { assert!(agg.has_fetch); } + +#[test] +fn aggregate_children_skips_children_outside_the_visible_snapshot() { + let root = fixture("nested"); + let mut parent = component("Parent", "app/components/Parent.tsx"); + parent.children = vec![ComponentRef { + file: "app/components/Child.tsx".to_string(), + name: "Child".to_string(), + }]; + let child = component("Child", "app/components/Child.tsx"); + let child_path = root.join("app/components/Child.tsx"); + let mut cache = HashMap::from([(child_path, vec![child])]); + let visible = HashSet::from([root.join("app/components/Parent.tsx")]); + + let agg = + aggregate_children_from_visible(&parent, &mut cache, &root, &visible, &mut HashSet::new()); + + assert_eq!(agg.facts, AggregatedFacts::default()); + assert!(agg.fetch_locations.is_empty()); +} diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs index 319061ae7..4385c100a 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs @@ -138,7 +138,8 @@ fn run_analyze_inner_with_facts_covers_fallback_missing_cache_and_errors() { frontend_root: Some("app".to_string()), assert_no_fetch: None, }; - let shared = facts(Vec::new()); + let mut shared = facts(Vec::new()); + shared.files.push(root.join("app/components/Child.tsx")); let missing_cache = run_analyze_inner_with_facts( &root, From ff3d7cdaee4f7c97c6da911b2a24bd727a22d5a3 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 17:47:20 -0700 Subject: [PATCH 29/62] fix: honor parent React suppression directives --- .../src/check_runner/results/suppression.rs | 19 +++++++ .../src/napi_api/tests/check_suppression.rs | 8 +++ .../src/react_traits/pipeline/run.rs | 51 +++++++------------ .../react_traits/pipeline/run/test_support.rs | 2 +- .../src/react_traits/pipeline/run/tests.rs | 3 +- .../app/ParentA.tsx | 1 + 6 files changed, 49 insertions(+), 35 deletions(-) diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index 59a08d099..6629ea942 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -119,6 +119,25 @@ pub(super) fn suppress_react( for (index, finding) in original_findings { let identity = format!("{}@{}", finding.component, finding.file); let targets = suppression_targets.get(index).cloned().unwrap_or_default(); + if !targets.is_empty() { + // An inherited fetch still belongs to the parent component. Honor a + // parent file directive before evaluating each child fetch location. + let mut parent_location = vec![ReactSuppressionFinding { + finding: finding.clone(), + line: None, + identity: identity.clone(), + }]; + let parent_suppressed = suppress_domain_findings_with_sources( + root, + &mut parent_location, + sources, + react_target, + ); + if parent_location.is_empty() { + suppressed.extend(parent_suppressed); + continue; + } + } let mut locations = if !targets.is_empty() { targets .iter() diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 068ba7043..040d14048 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -312,4 +312,12 @@ fn check_json_keeps_inherited_react_suppressions_distinct_by_parent_component() .as_str() .is_some_and(|reason| reason.contains("ParentB")) })); + assert!(parents.iter().any(|item| { + item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("ParentA")) + && item["file"] == "app/ParentA.tsx" + && item["line"].is_null() + && item["directive"]["kind"] == "file" + })); } diff --git a/crates/no-mistakes/src/react_traits/pipeline/run.rs b/crates/no-mistakes/src/react_traits/pipeline/run.rs index 49d398182..954093f4c 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run.rs @@ -101,8 +101,8 @@ pub(crate) fn run_analyze_inner_from_visible( &visible_files, &mut HashSet::new(), ); - if agg.facts != AggregatedFacts::default() { - facts.inherited_from_children = Some(agg.facts); + if agg != AggregatedFacts::default() { + facts.inherited_from_children = Some(agg); } all_results.push(facts); } @@ -121,7 +121,7 @@ fn aggregate_children_from_visible( root: &Path, visible_files: &HashSet, visited: &mut HashSet, -) -> AggregateResult { +) -> AggregatedFacts { aggregate_children_inner(facts, file_cache, root, Some(visible_files), visited) } @@ -131,8 +131,8 @@ fn aggregate_children_inner( root: &Path, visible_files: Option<&HashSet>, visited: &mut HashSet, -) -> AggregateResult { - let mut agg = AggregateResult::default(); +) -> AggregatedFacts { + let mut agg = AggregatedFacts::default(); for child_ref in &facts.children { let key = format!("{}#{}", child_ref.file, child_ref.name); if visited.contains(&key) { @@ -163,36 +163,23 @@ fn aggregate_children_inner( .and_then(|comps| comps.iter().find(|c| c.name == child_ref.name)) .cloned(); if let Some(child_facts) = child_facts_opt { - agg.facts.has_state |= child_facts.has_state; - agg.facts.has_props |= child_facts.has_props; - agg.facts.passes_props |= child_facts.passes_props; - agg.facts.uses_memo |= child_facts.uses_memo; - agg.facts.uses_context_provider |= child_facts.uses_context_provider; - agg.facts.uses_suspense |= child_facts.uses_suspense; - agg.facts.has_fetch |= !child_facts.fetches.is_empty(); - agg.fetch_locations.extend( - child_facts - .fetches - .iter() - .map(|fetch| (fetch.file.clone(), fetch.line)), - ); + agg.has_state |= child_facts.has_state; + agg.has_props |= child_facts.has_props; + agg.passes_props |= child_facts.passes_props; + agg.uses_memo |= child_facts.uses_memo; + agg.uses_context_provider |= child_facts.uses_context_provider; + agg.uses_suspense |= child_facts.uses_suspense; + agg.has_fetch |= !child_facts.fetches.is_empty(); let child_agg = aggregate_children_inner(&child_facts, file_cache, root, visible_files, visited); - agg.facts.has_state |= child_agg.facts.has_state; - agg.facts.has_fetch |= child_agg.facts.has_fetch; - agg.facts.uses_suspense |= child_agg.facts.uses_suspense; - agg.facts.uses_context_provider |= child_agg.facts.uses_context_provider; - agg.facts.uses_memo |= child_agg.facts.uses_memo; - agg.facts.has_props |= child_agg.facts.has_props; - agg.facts.passes_props |= child_agg.facts.passes_props; - agg.fetch_locations.extend(child_agg.fetch_locations); + agg.has_state |= child_agg.has_state; + agg.has_fetch |= child_agg.has_fetch; + agg.uses_suspense |= child_agg.uses_suspense; + agg.uses_context_provider |= child_agg.uses_context_provider; + agg.uses_memo |= child_agg.uses_memo; + agg.has_props |= child_agg.has_props; + agg.passes_props |= child_agg.passes_props; } } agg } - -#[derive(Default)] -struct AggregateResult { - facts: AggregatedFacts, - fetch_locations: Vec<(String, usize)>, -} diff --git a/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs b/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs index df998f909..1beee26b4 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run/test_support.rs @@ -27,5 +27,5 @@ pub(super) fn aggregate_children( components, ) })); - aggregate_children_inner(facts, file_cache, &root, None, visited).facts + aggregate_children_inner(facts, file_cache, &root, None, visited) } diff --git a/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs index 280e0ea9e..bc1217816 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs @@ -173,6 +173,5 @@ fn aggregate_children_skips_children_outside_the_visible_snapshot() { let agg = aggregate_children_from_visible(&parent, &mut cache, &root, &visible, &mut HashSet::new()); - assert_eq!(agg.facts, AggregatedFacts::default()); - assert!(agg.fetch_locations.is_empty()); + assert_eq!(agg, AggregatedFacts::default()); } diff --git a/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx b/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx index 5bee26b18..80eaa0388 100644 --- a/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx +++ b/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx @@ -1,3 +1,4 @@ +// no-mistakes-disable-file assert-no-fetch: this parent intentionally inherits a suppressed child fetch import Child from './Child'; export default async function ParentA() { From c6aafb71c5ea09cc9de83f879952df92224ea650 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 18:03:57 -0700 Subject: [PATCH 30/62] test: cover React check analysis errors --- .../src/react_traits/pipeline/check/tests.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs index 39efb4550..8f60d2780 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs @@ -61,6 +61,13 @@ fn run_check_reports_violations_when_assert_no_fetch_is_enabled() { assert!(!violations.is_empty(), "expected fetch violations"); } +#[test] +fn run_check_returns_analysis_error_for_missing_frontend_root() { + let root = assert_no_fetch_root().join("missing"); + let error = run_check(&root, None, &[], true).unwrap_err(); + assert!(error.to_string().contains("frontend root not found")); +} + #[test] fn check_enabled_returns_true_when_assert_no_fetch_is_enabled() { let root = assert_no_fetch_root(); @@ -151,6 +158,22 @@ fn prepared_check_and_aggregate_sidecar_cover_enabled_and_disabled_paths() { assert!(aggregate.suppression_targets.is_empty()); } +#[test] +fn prepared_check_returns_analysis_error_for_missing_frontend_root() { + let root = assert_no_fetch_root().join("missing"); + let facts = crate::codebase::check_facts::CheckFactMap::default(); + let prepared = prepare_file_config( + FileConfig { + frontend_root: Some("app".to_string()), + assert_no_fetch: Some(true), + }, + false, + ); + + let error = run_check_with_prepared_facts(&root, &[], &facts, &prepared).unwrap_err(); + assert!(error.to_string().contains("frontend root not found")); +} + #[test] fn aggregate_check_keeps_public_violations_and_private_suppression_locations_separate() { use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; From 09e06473becf25aafa3ac46ab7a8bb211c19debf Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 18:16:01 -0700 Subject: [PATCH 31/62] test: cover prepared React usages branches --- .../src/react_traits/pipeline/usages/tests.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/no-mistakes/src/react_traits/pipeline/usages/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/usages/tests.rs index 1a52f6c2f..da13aa3ab 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/usages/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/usages/tests.rs @@ -148,6 +148,72 @@ fn target_file_not_found_is_an_error() { assert!(err.to_string().contains("target file not found")); } +#[test] +fn loaded_usages_rejects_an_absolute_missing_target() { + let root = fixture(); + let config = crate::config::v2::load_v2_config(&root, None).unwrap(); + let missing = root.join("app/components/missing.tsx"); + let error = run_usages_with_loaded_config_and_facts( + &root, + &config, + &missing.to_string_lossy(), + &[], + &UsagesInclude::all(), + &crate::codebase::check_facts::CheckFactMap::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("target file not found")); +} + +#[test] +fn loaded_usages_skips_files_without_prepared_usage_facts() { + let root = fixture(); + let target = root.join("app/components/button.tsx"); + let config = crate::config::v2::load_v2_config(&root, None).unwrap(); + let facts = crate::codebase::check_facts::CheckFactMap { + files: vec![target.clone()], + ..Default::default() + }; + let report = run_usages_with_loaded_config_and_facts( + &root, + &config, + "app/components/button.tsx#Button", + &[], + &UsagesInclude::all(), + &facts, + ) + .unwrap(); + assert!(report.callsites.is_empty()); + assert!(report.stories.unwrap().is_empty()); + assert!(report.tests.unwrap().is_empty()); +} + +#[test] +fn run_usages_returns_error_for_nonexistent_config_path() { + let root = fixture(); + let error = run_usages( + &root, + Some(Path::new("missing-no-mistakes.yaml")), + "app/components/button.tsx#Button", + &[], + &UsagesInclude::all(), + ) + .unwrap_err(); + assert!(error.to_string().contains("missing-no-mistakes.yaml")); +} + +#[test] +fn collect_usage_file_facts_supports_unscoped_import_resolution() { + let file = fixture().join("app/pages/home.tsx"); + let source = std::fs::read_to_string(&file).unwrap(); + let facts = crate::ast::with_program(&file, &source, |program, _| { + collect_usage_file_facts(&file, &source, program, None) + }) + .unwrap(); + assert!(!facts.imports.is_empty()); + assert!(!facts.callsites.is_empty()); +} + #[test] fn unparseable_target_file_yields_empty_prop_types() { let root = fixture(); From 4c31eef8b8a6ebd8fe5260f10bd716ca582c60b2 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 18:44:40 -0700 Subject: [PATCH 32/62] test: cover Storybook suppression adapters --- .../rules/require_storybook_stories/tests.rs | 22 ++++++++++ .../tests/coverage_rule_cases.rs | 41 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs index 1bbce6b69..2621c93d8 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs @@ -98,6 +98,28 @@ fn react_component(name: &str, file: &str, children: Vec) -> Compo } } +#[test] +fn deferred_suppression_sources_read_relative_component_paths() { + let root = fixture("comments"); + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(&root); + let sources = snapshot.source_store_for(&root); + let component = types::Component { + key: "components/DisabledFile.tsx#DisabledFile".to_string(), + file: PathBuf::from("components/DisabledFile.tsx"), + repo_file: "components/DisabledFile.tsx".to_string(), + project_file: "components/DisabledFile.tsx".to_string(), + export_name: "DisabledFile".to_string(), + line: 2, + explicit: true, + }; + + let indexed = suppression::component_suppression_sources(&root, &[component.clone()], &sources); + + assert!(suppression::component_is_suppressed( + &root, &indexed, &component, + )); +} + fn react_facts( components: Vec, ) -> std::sync::Arc { diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs index 8d02049c8..7828f1509 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_rule_cases.rs @@ -96,6 +96,47 @@ include_all_react_named_exports: true assert!(findings.is_empty(), "{findings:#?}"); } +#[test] +fn standalone_check_accepts_explicit_relative_and_absolute_tsconfig_paths() { + let root = fixture("covered"); + let config = config( + r#" +include_all_react_named_exports: true +"#, + ); + let relative_findings = + check(&root, &config, Some(std::path::Path::new("tsconfig.json"))).unwrap(); + assert!(relative_findings.is_empty(), "{relative_findings:#?}"); + + // Keep both forms covered: integrations commonly resolve --tsconfig before + // passing it to this standalone API, while the CLI passes relative paths. + let absolute_tsconfig = root.join("tsconfig.json"); + let absolute_findings = check(&root, &config, Some(&absolute_tsconfig)).unwrap(); + assert!(absolute_findings.is_empty(), "{absolute_findings:#?}"); +} + +#[test] +fn standalone_check_reports_an_explicit_missing_tsconfig() { + let root = fixture("covered"); + let config = config( + r#" +include_all_react_named_exports: true +"#, + ); + + let error = check( + &root, + &config, + Some(std::path::Path::new("missing-tsconfig.json")), + ) + .unwrap_err(); + + assert!( + error.to_string().contains("missing-tsconfig.json"), + "{error:#}" + ); +} + #[test] fn standalone_check_uses_package_local_aliases_for_stories_and_reexports() { let fixture = crate::test_support::materialize_gitignore_fixture("storybook-workspace-alias"); From 3d3fead02b77223945f78d78d45df4844d816f5d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 18:49:28 -0700 Subject: [PATCH 33/62] test: satisfy Storybook coverage lint --- .../src/codebase/rules/require_storybook_stories/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs index 2621c93d8..6f59d1067 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs @@ -113,7 +113,11 @@ fn deferred_suppression_sources_read_relative_component_paths() { explicit: true, }; - let indexed = suppression::component_suppression_sources(&root, &[component.clone()], &sources); + let indexed = suppression::component_suppression_sources( + &root, + std::slice::from_ref(&component), + &sources, + ); assert!(suppression::component_is_suppressed( &root, &indexed, &component, From 1409c4de02b5835107edd5cc42b9883738b94139 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 19:27:03 -0700 Subject: [PATCH 34/62] test: cover prepared dynamic import facts --- .../config/prepared_tests.rs | 23 +++ .../with_facts.rs | 3 + .../with_facts/tests.rs | 187 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tests.rs diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs index f3fa11fd6..a2153181b 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config/prepared_tests.rs @@ -39,6 +39,29 @@ fn aggregate_rule_uses_prepared_config_without_standalone_discovery() { assert!(!source.contains("discover_files(")); } +#[test] +fn prepared_config_reports_missing_visible_config_source() { + let root = fixture(); + let missing = root.join("missing-visible-vitest.config.ts"); + assert!( + !missing.exists(), + "the fixture intentionally has no config file" + ); + let mut config = NoMistakesConfig::default(); + config.tests.vitest.configs = Some(StringOrList::One( + "missing-visible-vitest.config.*".to_string(), + )); + + // The request inventory is authoritative: a config listed there without a + // readable source must report an error instead of falling back to disk. + let sources = crate::codebase::rules::source_store_for_files(std::slice::from_ref(&missing)); + let error = prepare_from_visible(&root, &config, &[missing], &sources) + .err() + .expect("missing prepared source must be reported"); + + assert!(error.to_string().contains("failed to read")); +} + #[test] fn pass4b_prepared_setup_files_drop_ignored_candidate_and_keep_visible_fallback() { let fixture = crate::test_support::materialize_gitignore_fixture("pass4b-shadow"); diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs index 069ad418f..2fd49f5b7 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts.rs @@ -196,3 +196,6 @@ pub(crate) fn check_with_prepared_facts_graph_and_session_with_suppression( suppression_sources, }) } + +#[cfg(test)] +mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tests.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tests.rs new file mode 100644 index 000000000..e5ce5bb02 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/tests.rs @@ -0,0 +1,187 @@ +use super::*; +use crate::codebase::check_facts::CheckFileFacts; +use crate::codebase::dependencies::graph::test_support::from_raw_maps; +use crate::codebase::ts_resolver::{ScopedImportResolver, TsConfigCatalog}; +use std::collections::HashMap; + +fn fixture() -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/codebase-analysis/test-no-unmocked-dynamic-imports/fixture"), + ) +} + +fn tsconfig(root: &Path) -> TsConfig { + TsConfig { + dir: root.to_path_buf(), + paths: Vec::new(), + paths_dir: root.to_path_buf(), + base_url: None, + } +} + +fn dynamic_facts(path: &Path, source: &str) -> std::sync::Arc { + CheckFileFacts { + source: Some(source.into()), + dynamic_imports: Some(super::super::ast::extract(path, source).unwrap()), + ..Default::default() + } + .into() +} + +#[test] +fn per_test_requires_a_prepared_test_fact_entry() { + let root = fixture(); + let test = root.join("tests/bad.test.mts"); + let visible = HashSet::from([test.clone()]); + let config = NoMistakesConfig::default(); + let tsconfig = tsconfig(&root); + let catalog = TsConfigCatalog::forced(&root, tsconfig, None); + let resolver = ScopedImportResolver::new(&catalog, &visible); + let graph = from_raw_maps(root.clone(), Default::default(), Default::default()); + let graph_files = GraphFiles::from_files(vec![test.clone()]); + let shared = CheckFactMap { + files: vec![test.clone()], + ..Default::default() + }; + let dependency_cache = DashMap::new(); + + let error = per_test::analyze( + per_test::Request { + root: &root, + config: &config, + resolver: &resolver, + graph: &graph, + graph_files: &graph_files, + visible_files: &visible, + manual_mocks: &HashSet::new(), + setup_data: &[], + shared: &shared, + dependency_cache: &dependency_cache, + defer_suppression: false, + }, + test, + ) + .err() + .expect("missing test facts must be reported"); + + assert!(error.to_string().contains("missing shared facts")); +} + +#[test] +fn per_test_analyzes_empty_prepared_dynamic_facts() { + let root = fixture(); + let test = root.join("tests/bad.test.mts"); + let visible = HashSet::from([test.clone()]); + let config = NoMistakesConfig::default(); + let tsconfig = tsconfig(&root); + let catalog = TsConfigCatalog::forced(&root, tsconfig, None); + let resolver = ScopedImportResolver::new(&catalog, &visible); + let graph = from_raw_maps(root.clone(), Default::default(), Default::default()); + let graph_files = GraphFiles::from_files(vec![test.clone()]); + let mut shared = CheckFactMap { + files: vec![test.clone()], + ..Default::default() + }; + shared.ts.insert( + test.clone(), + dynamic_facts(&test, "test('prepared facts', () => {});"), + ); + let dependency_cache = DashMap::new(); + + let result = per_test::analyze( + per_test::Request { + root: &root, + config: &config, + resolver: &resolver, + graph: &graph, + graph_files: &graph_files, + visible_files: &visible, + manual_mocks: &HashSet::new(), + setup_data: &[], + shared: &shared, + dependency_cache: &dependency_cache, + defer_suppression: false, + }, + test, + ) + .unwrap(); + + assert!(result.direct_findings.is_empty()); + assert!(result.reachable_findings.is_empty()); + assert!(result.reachable_suppression_file.is_none()); +} + +#[test] +fn prepared_reachability_skips_unavailable_facts_and_keeps_disabled_origin() { + let root = fixture(); + let test = root.join("tests/disabled.test.mts"); + let dependency = root.join("src/unmocked-next-dynamic-component.mts"); + let visible = HashSet::from([test.clone(), dependency.clone()]); + let config = NoMistakesConfig::default(); + let tsconfig = tsconfig(&root); + let catalog = TsConfigCatalog::forced(&root, tsconfig, None); + let resolver = ScopedImportResolver::new(&catalog, &visible); + let mut forward = HashMap::new(); + forward.insert(test.clone(), vec![dependency.clone()]); + let graph = from_raw_maps(root.clone(), forward, Default::default()); + let graph_files = GraphFiles::from_files(vec![test.clone(), dependency.clone()]); + let dependency_cache = DashMap::new(); + + for facts in [ + None, + Some( + CheckFileFacts { + parse_error: Some("fixture parse error".to_string()), + ..Default::default() + } + .into(), + ), + Some( + CheckFileFacts { + source: Some("export const helper = true;".into()), + ..Default::default() + } + .into(), + ), + ] { + let mut shared = CheckFactMap { + files: vec![test.clone(), dependency.clone()], + ..Default::default() + }; + shared.ts.insert( + test.clone(), + dynamic_facts( + &test, + "// no-mistakes-disable-file test-no-unmocked-dynamic-imports\ntest('disabled', () => {});", + ), + ); + if let Some(facts) = facts { + shared.ts.insert(dependency.clone(), facts); + } + + let result = per_test::analyze( + per_test::Request { + root: &root, + config: &config, + resolver: &resolver, + graph: &graph, + graph_files: &graph_files, + visible_files: &visible, + manual_mocks: &HashSet::new(), + setup_data: &[], + shared: &shared, + dependency_cache: &dependency_cache, + defer_suppression: true, + }, + test.clone(), + ) + .unwrap(); + + assert!(result.reachable_findings.is_empty()); + assert_eq!( + result.reachable_suppression_file.as_deref(), + Some("tests/disabled.test.mts") + ); + } +} From d4017eeb7e2a4f600966ad4f26a2ae3bb59ae7e3 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 19:32:32 -0700 Subject: [PATCH 35/62] test: cover general suppression paths --- crates/no-mistakes/src/check.rs | 79 ++++++++++++------- .../rules/agents_md_max_size/tests.rs | 19 +++++ .../rules/filesystem_dispatch/tests.rs | 44 +++++++++++ .../rules/nextjs_no_api_routes/tests.rs | 16 ++++ .../codebase/rules/nextjs_no_caching/tests.rs | 16 ++++ .../rules/require_storybook_stories/tests.rs | 22 ++++++ .../server_route_client_boundary/tests.rs | 15 ++++ 7 files changed, 183 insertions(+), 28 deletions(-) diff --git a/crates/no-mistakes/src/check.rs b/crates/no-mistakes/src/check.rs index a951c16c4..e25280b82 100644 --- a/crates/no-mistakes/src/check.rs +++ b/crates/no-mistakes/src/check.rs @@ -86,34 +86,8 @@ fn record_missing_check_timings(results: &check_runner::CheckResults) { .map(|entry| entry.label) .collect::>(); for (label, duration) in &results.timings { - let (label, kind) = match *label { - "discover" => ("discovery", no_mistakes::diagnostics::TimingKind::Serial), - "parse_extract" => ("parse", no_mistakes::diagnostics::TimingKind::Serial), - "react" => ( - "analysis.react", - no_mistakes::diagnostics::TimingKind::Parallel, - ), - "queues" => ( - "analysis.queues", - no_mistakes::diagnostics::TimingKind::Parallel, - ), - "rules" => ( - "analysis.rules", - no_mistakes::diagnostics::TimingKind::Parallel, - ), - "integration" => ( - "analysis.integration", - no_mistakes::diagnostics::TimingKind::Parallel, - ), - "codebase" => ( - "analysis.codebase", - no_mistakes::diagnostics::TimingKind::Parallel, - ), - "filesystem_rules" => ( - "analysis.filesystem_rules", - no_mistakes::diagnostics::TimingKind::Parallel, - ), - _ => continue, + let Some((label, kind)) = timing_metadata(label) else { + continue; }; if !existing.contains(label) { observer.record_duration(label, *duration, kind); @@ -121,6 +95,38 @@ fn record_missing_check_timings(results: &check_runner::CheckResults) { } } +fn timing_metadata(label: &str) -> Option<(&'static str, no_mistakes::diagnostics::TimingKind)> { + Some(match label { + "discover" => ("discovery", no_mistakes::diagnostics::TimingKind::Serial), + "parse_extract" => ("parse", no_mistakes::diagnostics::TimingKind::Serial), + "react" => ( + "analysis.react", + no_mistakes::diagnostics::TimingKind::Parallel, + ), + "queues" => ( + "analysis.queues", + no_mistakes::diagnostics::TimingKind::Parallel, + ), + "rules" => ( + "analysis.rules", + no_mistakes::diagnostics::TimingKind::Parallel, + ), + "integration" => ( + "analysis.integration", + no_mistakes::diagnostics::TimingKind::Parallel, + ), + "codebase" => ( + "analysis.codebase", + no_mistakes::diagnostics::TimingKind::Parallel, + ), + "filesystem_rules" => ( + "analysis.filesystem_rules", + no_mistakes::diagnostics::TimingKind::Parallel, + ), + _ => return None, + }) +} + fn has_failures(results: &check_runner::CheckResults) -> bool { !results.react.is_empty() || !results.queues.is_empty() @@ -129,3 +135,20 @@ fn has_failures(results: &check_runner::CheckResults) -> bool { || !results.codebase.is_empty() || !results.warnings.is_empty() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timing_metadata_omits_unknown_check_runner_labels() { + assert_eq!( + timing_metadata("queues"), + Some(( + "analysis.queues", + no_mistakes::diagnostics::TimingKind::Parallel + )) + ); + assert_eq!(timing_metadata("not-a-check-timing"), None); + } +} diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs index 9d4ea1523..97adf9ddd 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs @@ -149,6 +149,25 @@ fn checks_advisories_and_suppressions_share_one_source_read() { assert_eq!(sources.physical_read_count(), 1); } +#[test] +fn prepared_scan_ignores_missing_source_files() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/agents-md-max-size/pass"); + let missing = root.join("missing/AGENTS.md"); + let files = vec![missing]; + let sources = super::super::source_store_for_files(&files); + let findings = check_with_files_sources_and_deferred_suppression( + &root, + &config_with_rule("{maxChars: 20}"), + &files, + &sources, + false, + ) + .unwrap(); + + assert!(findings.is_empty()); +} + #[test] fn advisories_skip_over_limit_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index 4ce93c639..4ca8f8962 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -54,6 +54,50 @@ fn dispatch_with_files_returns_configuration_errors() { assert!(error.to_string().contains("parse"), "{error:#}"); } +#[test] +fn standalone_entrypoint_returns_configuration_errors() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/filesystem-dispatch/invalid-config"); + let error = run_filesystem_rules(&root, Some(&root.join(".no-mistakes.yml"))).unwrap_err(); + assert!(error.to_string().contains("parse"), "{error:#}"); +} + +#[test] +fn dispatch_uses_fallback_for_an_unknown_rule() { + fn fallback( + _root: &std::path::Path, + _config: &crate::config::v2::NoMistakesConfig, + _files: &[std::path::PathBuf], + ) -> anyhow::Result> { + Ok(vec![RuleFinding { + rule: "fallback".to_string(), + file: "fixture.txt".to_string(), + line: 1, + message: "fallback rule ran".to_string(), + import: None, + target: None, + }]) + } + + let root = std::path::Path::new("/fixture"); + let config = crate::config::v2::NoMistakesConfig::default(); + let files = Vec::new(); + let sources = crate::codebase::rules::source_store_for_files(&files); + let findings = super::run_rule::run_rule_with_sources(super::run_rule::RunRuleRequest { + rule_id: "future-filesystem-rule", + fallback, + root, + config: &config, + files: &files, + sources: &sources, + facts: None, + defer_suppression: false, + }) + .unwrap(); + + assert_eq!(findings[0].rule, "fallback"); +} + /// Cover all dispatch branches via `run_filesystem_rules`. /// Each rule's own `check()` fn is called; with an empty/non-git directory /// discover_files returns nothing, so no findings are emitted. diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs index 6dea2bd12..b6434919c 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs @@ -97,6 +97,22 @@ fn fact_runner_checks_nextjs_api_routes() { assert_eq!(findings.len(), 4); } +#[test] +fn fact_runner_reports_invalid_rule_include_globs() { + let root = fixture(); + let mut config = config(); + config.rules[0].include = vec!["[".to_string()]; + + let error = check_with_facts(&root, &config, &CheckFactMap::default()).unwrap_err(); + + assert!( + error + .to_string() + .contains("rule `nextjs-no-api-routes` include contains invalid glob"), + "{error:#}" + ); +} + #[test] fn fact_runner_ignores_missing_source_outside_target_roots() { let root = crate::codebase::ts_resolver::normalize_path(&fixture()); diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs index fd21e233a..0bba1d570 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs @@ -131,6 +131,22 @@ fn fact_runner_ignores_missing_facts_outside_target_roots() { assert!(findings.is_empty()); } +#[test] +fn fact_runner_reports_invalid_rule_include_globs() { + let root = fixture(); + let mut config = config(); + config.rules[0].include = vec!["[".to_string()]; + + let error = check_with_facts(&root, &config, &CheckFactMap::default()).unwrap_err(); + + assert!( + error + .to_string() + .contains("rule `nextjs-no-caching` include contains invalid glob"), + "{error:#}" + ); +} + #[test] fn fact_runner_requires_source_and_cache_facts_for_target_files() { let root = crate::codebase::ts_resolver::normalize_path(&fixture()); diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs index 6f59d1067..10076e7ea 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs @@ -124,6 +124,28 @@ fn deferred_suppression_sources_read_relative_component_paths() { )); } +#[test] +fn missing_project_target_is_ignored() { + let root = fixture("comments"); + let mut config = config(""); + config.projects.remove("web"); + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(&root); + let sources = snapshot.source_store_for(&root); + + let findings = super::runner::check_with_resolver( + &root, + &config, + &CheckFactMap::default(), + &empty_resolver(&root), + None, + false, + &sources, + ) + .unwrap(); + + assert!(findings.is_empty()); +} + fn react_facts( components: Vec, ) -> std::sync::Arc { diff --git a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs index eb61b0189..13480a4a6 100644 --- a/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs @@ -215,6 +215,21 @@ fn non_matching_rules_return_no_findings() { assert!(findings.is_empty()); } +#[test] +fn reports_invalid_rule_include_globs() { + let mut config = config(); + config.rules[0].include = vec!["[".to_string()]; + + let error = check(&fixture("fail"), &config).unwrap_err(); + + assert!( + error + .to_string() + .contains("rule `server-route-client-boundary` include contains invalid glob"), + "{error:#}" + ); +} + #[test] fn fact_path_returns_empty_when_route_globs_are_unconfigured() { let root = fixture("no-route"); From 1b5318bc41f55b7a6faae26a06ebdecd989d294f Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 19:33:40 -0700 Subject: [PATCH 36/62] test: cover unique export suppression origins --- .../unique_exports/tests/helper_edges.rs | 137 ++++++++++++++++++ .../src/source.ts | 1 + .../src/suppressed-barrel.ts | 3 + .../src/wild-barrel.ts | 4 + 4 files changed, 145 insertions(+) create mode 100644 fixtures/codebase/unique-exports-suppressed-origin/src/suppressed-barrel.ts create mode 100644 fixtures/codebase/unique-exports-suppressed-origin/src/wild-barrel.ts diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs b/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs index 79276f766..dbb62a056 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs @@ -99,3 +99,140 @@ fn defensive_helpers_ignore_missing_targets_and_non_matching_default_exports() { None ); } + +#[test] +fn deferred_reexports_preserve_suppression_provenance_and_origin_ordering() { + let root = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/codebase/unique-exports-suppressed-origin"), + ); + let all_files = discover_files(&root, &[]); + let source_files = scan::test_support::collect_source_files(&root, &all_files).unwrap(); + let files: HashMap = source_files + .into_iter() + .map(|mut file| { + // Aggregate checking defers directive filtering until after origin + // canonicalization, so every fixture file must follow that path. + file.defer_suppression = true; + if file.disabled { + // The standalone fixture helper intentionally omits symbols + // for disabled files; deferred checking still needs those + // exports to carry their suppression provenance. + file.symbols = crate::codebase::ts_symbols::extract_symbols_at_path( + &file.path, + &file.source, + false, + ) + .unwrap() + .into(); + } + (file.path.clone(), file) + }) + .collect(); + let tsconfig = crate::codebase::ts_resolver::TsConfig { + dir: root.clone(), + paths: Vec::new(), + paths_dir: root.clone(), + base_url: None, + }; + let resolver = ImportResolver::new(&tsconfig); + let workspace = WorkspaceMap::default(); + let remapper = + crate::codebase::ts_source::FrozenPathRemapper::from_paths(files.keys().cloned()); + assert_eq!( + super::super::origin::resolve_export_source( + "./source", + &root.join("src/barrel.ts"), + &resolver, + &workspace, + &remapper, + ), + Some(root.join("src/source.ts")) + ); + let collect = |relative: &str| { + let mut visiting = HashSet::new(); + let mut memo = HashMap::new(); + collector::collect_file_exports( + &root.join(relative), + &files, + &resolver, + &workspace, + &remapper, + &mut visiting, + &mut memo, + ) + }; + + let explicit = collect("src/barrel.ts"); + assert_eq!(explicit.len(), 1); + let explicit_origin = &explicit[0].origin; + assert!(explicit[0].suppressed, "{explicit:#?}"); + assert_eq!( + explicit[0].suppression_location.as_ref(), + Some(&("src/source.ts".to_string(), 2)) + ); + assert_eq!(explicit_origin.file, "src/source.ts"); + + let wildcard = collect("src/wild-barrel.ts"); + assert_eq!(wildcard.len(), 2); + assert!(wildcard.iter().all(|occurrence| occurrence.suppressed)); + assert!(wildcard.iter().all(|occurrence| { + occurrence.suppression_location.as_ref() == Some(&("src/wild-barrel.ts".to_string(), 4)) + })); + + let suppressed_barrel = collect("src/suppressed-barrel.ts"); + assert_eq!(suppressed_barrel.len(), 1); + assert!(suppressed_barrel[0].suppressed); + assert_eq!( + suppressed_barrel[0].suppression_location.as_ref(), + Some(&("src/suppressed-barrel.ts".to_string(), 3)) + ); + let mut suppressed_origin_visiting = HashSet::new(); + let suppressed_origin = collector::find_target_export_origin( + &root.join("src/suppressed-barrel.ts"), + "Shared", + &files, + &resolver, + &workspace, + &remapper, + &mut suppressed_origin_visiting, + ) + .unwrap(); + assert_eq!( + suppressed_origin.suppression_location.as_ref(), + Some(&("src/suppressed-barrel.ts".to_string(), 3)) + ); + + let missing = root.join("src/missing.ts"); + let missing_remapper = + crate::codebase::ts_source::FrozenPathRemapper::from_paths([missing.clone()]); + let mut visiting = HashSet::new(); + assert_eq!( + collector::find_target_export_origin( + &missing, + "Missing", + &files, + &resolver, + &workspace, + &missing_remapper, + &mut visiting, + ), + None + ); + + let mut equivalent = explicit_origin.clone(); + equivalent.suppressed = !equivalent.suppressed; + equivalent.suppression_location = None; + assert_eq!(*explicit_origin, equivalent); + assert_eq!( + explicit_origin.partial_cmp(&equivalent), + Some(std::cmp::Ordering::Equal) + ); + + assert!(!scan::package_json_has_next_dependency( + &root.join("package.json") + )); + assert!(!scan::package_json_has_next_dependency( + &root.join("missing-package.json") + )); +} diff --git a/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts b/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts index ae55bd626..cd251ced5 100644 --- a/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts @@ -1,2 +1,3 @@ // no-mistakes-disable-file unique-exports: this source is intentionally suppressed export const Shared = 1; +export const WildOnly = 2; diff --git a/fixtures/codebase/unique-exports-suppressed-origin/src/suppressed-barrel.ts b/fixtures/codebase/unique-exports-suppressed-origin/src/suppressed-barrel.ts new file mode 100644 index 000000000..a80c8401e --- /dev/null +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/suppressed-barrel.ts @@ -0,0 +1,3 @@ +// A barrel-local directive must be recorded on the resolved origin. +// no-mistakes-disable-next-line unique-exports: compatibility barrel +export { Shared } from './source'; diff --git a/fixtures/codebase/unique-exports-suppressed-origin/src/wild-barrel.ts b/fixtures/codebase/unique-exports-suppressed-origin/src/wild-barrel.ts new file mode 100644 index 000000000..0f28702eb --- /dev/null +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/wild-barrel.ts @@ -0,0 +1,4 @@ +// The deferred aggregate pass must retain suppression provenance through a +// wildcard compatibility barrel before the request-wide suppression pass. +// no-mistakes-disable-next-line unique-exports: compatibility wildcard +export * from './source'; From b58a2b91936455cb9543584ae2051503a1bf9f3d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 19:46:19 -0700 Subject: [PATCH 37/62] test: split suppression coverage modules --- crates/no-mistakes/src/check.rs | 16 +------ crates/no-mistakes/src/check/tests.rs | 13 ++++++ .../rules/filesystem_dispatch/tests.rs | 46 +------------------ .../filesystem_dispatch/tests/coverage.rs | 45 ++++++++++++++++++ .../codebase/rules/nextjs_no_caching/tests.rs | 17 +------ .../rules/nextjs_no_caching/tests/coverage.rs | 17 +++++++ 6 files changed, 79 insertions(+), 75 deletions(-) create mode 100644 crates/no-mistakes/src/check/tests.rs create mode 100644 crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests/coverage.rs create mode 100644 crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests/coverage.rs diff --git a/crates/no-mistakes/src/check.rs b/crates/no-mistakes/src/check.rs index e25280b82..9bb288686 100644 --- a/crates/no-mistakes/src/check.rs +++ b/crates/no-mistakes/src/check.rs @@ -137,18 +137,4 @@ fn has_failures(results: &check_runner::CheckResults) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn timing_metadata_omits_unknown_check_runner_labels() { - assert_eq!( - timing_metadata("queues"), - Some(( - "analysis.queues", - no_mistakes::diagnostics::TimingKind::Parallel - )) - ); - assert_eq!(timing_metadata("not-a-check-timing"), None); - } -} +mod tests; diff --git a/crates/no-mistakes/src/check/tests.rs b/crates/no-mistakes/src/check/tests.rs new file mode 100644 index 000000000..a8216deac --- /dev/null +++ b/crates/no-mistakes/src/check/tests.rs @@ -0,0 +1,13 @@ +use super::*; + +#[test] +fn timing_metadata_omits_unknown_check_runner_labels() { + assert_eq!( + timing_metadata("queues"), + Some(( + "analysis.queues", + no_mistakes::diagnostics::TimingKind::Parallel, + )) + ); + assert_eq!(timing_metadata("not-a-check-timing"), None); +} diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index 4ca8f8962..8142cf5be 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -54,50 +54,6 @@ fn dispatch_with_files_returns_configuration_errors() { assert!(error.to_string().contains("parse"), "{error:#}"); } -#[test] -fn standalone_entrypoint_returns_configuration_errors() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/rules/filesystem-dispatch/invalid-config"); - let error = run_filesystem_rules(&root, Some(&root.join(".no-mistakes.yml"))).unwrap_err(); - assert!(error.to_string().contains("parse"), "{error:#}"); -} - -#[test] -fn dispatch_uses_fallback_for_an_unknown_rule() { - fn fallback( - _root: &std::path::Path, - _config: &crate::config::v2::NoMistakesConfig, - _files: &[std::path::PathBuf], - ) -> anyhow::Result> { - Ok(vec![RuleFinding { - rule: "fallback".to_string(), - file: "fixture.txt".to_string(), - line: 1, - message: "fallback rule ran".to_string(), - import: None, - target: None, - }]) - } - - let root = std::path::Path::new("/fixture"); - let config = crate::config::v2::NoMistakesConfig::default(); - let files = Vec::new(); - let sources = crate::codebase::rules::source_store_for_files(&files); - let findings = super::run_rule::run_rule_with_sources(super::run_rule::RunRuleRequest { - rule_id: "future-filesystem-rule", - fallback, - root, - config: &config, - files: &files, - sources: &sources, - facts: None, - defer_suppression: false, - }) - .unwrap(); - - assert_eq!(findings[0].rule, "fallback"); -} - /// Cover all dispatch branches via `run_filesystem_rules`. /// Each rule's own `check()` fn is called; with an empty/non-git directory /// discover_files returns nothing, so no findings are emitted. @@ -583,3 +539,5 @@ fn aggregate_finding_and_suppression_share_one_physical_read() { assert_eq!(findings[0].file, "placeholder.ts"); assert_eq!(sources.physical_read_count(), 1); } + +mod coverage; diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests/coverage.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests/coverage.rs new file mode 100644 index 000000000..063d0ce9a --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests/coverage.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn standalone_entrypoint_returns_configuration_errors() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/filesystem-dispatch/invalid-config"); + let error = run_filesystem_rules(&root, Some(&root.join(".no-mistakes.yml"))).unwrap_err(); + assert!(error.to_string().contains("parse"), "{error:#}"); +} + +#[test] +fn dispatch_uses_fallback_for_an_unknown_rule() { + fn fallback( + _root: &std::path::Path, + _config: &crate::config::v2::NoMistakesConfig, + _files: &[std::path::PathBuf], + ) -> anyhow::Result> { + Ok(vec![RuleFinding { + rule: "fallback".to_string(), + file: "fixture.txt".to_string(), + line: 1, + message: "fallback rule ran".to_string(), + import: None, + target: None, + }]) + } + + let root = std::path::Path::new("/fixture"); + let config = crate::config::v2::NoMistakesConfig::default(); + let files = Vec::new(); + let sources = crate::codebase::rules::source_store_for_files(&files); + let findings = super::run_rule::run_rule_with_sources(super::run_rule::RunRuleRequest { + rule_id: "future-filesystem-rule", + fallback, + root, + config: &config, + files: &files, + sources: &sources, + facts: None, + defer_suppression: false, + }) + .unwrap(); + + assert_eq!(findings[0].rule, "fallback"); +} diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs index 0bba1d570..1fa51bd42 100644 --- a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs @@ -131,22 +131,6 @@ fn fact_runner_ignores_missing_facts_outside_target_roots() { assert!(findings.is_empty()); } -#[test] -fn fact_runner_reports_invalid_rule_include_globs() { - let root = fixture(); - let mut config = config(); - config.rules[0].include = vec!["[".to_string()]; - - let error = check_with_facts(&root, &config, &CheckFactMap::default()).unwrap_err(); - - assert!( - error - .to_string() - .contains("rule `nextjs-no-caching` include contains invalid glob"), - "{error:#}" - ); -} - #[test] fn fact_runner_requires_source_and_cache_facts_for_target_files() { let root = crate::codebase::ts_resolver::normalize_path(&fixture()); @@ -590,4 +574,5 @@ fn extract_ignores_commonjs_config_object_outside_next_config_files() { assert!(findings.is_empty()); } +mod coverage; mod nested; diff --git a/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests/coverage.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests/coverage.rs new file mode 100644 index 000000000..ffb8ca2a9 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests/coverage.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn fact_runner_reports_invalid_rule_include_globs() { + let root = fixture(); + let mut config = config(); + config.rules[0].include = vec!["[".to_string()]; + + let error = check_with_facts(&root, &config, &CheckFactMap::default()).unwrap_err(); + + assert!( + error + .to_string() + .contains("rule `nextjs-no-caching` include contains invalid glob"), + "{error:#}" + ); +} From 4768f2dd3f633a1fe0018c2c8b8894374d57c66c Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 20:01:52 -0700 Subject: [PATCH 38/62] fix: account only hidden React findings --- .../src/check_runner/results/suppression.rs | 15 ++++---- .../no-mistakes/src/napi_api/tests/check.rs | 34 +++++++++++++++++-- .../.no-mistakes.yml | 3 ++ .../app/Child.tsx | 5 +++ .../app/Fetcher.tsx | 7 ++++ 5 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 fixtures/check/suppression-react-all-multiple/.no-mistakes.yml create mode 100644 fixtures/check/suppression-react-all-multiple/app/Child.tsx create mode 100644 fixtures/check/suppression-react-all-multiple/app/Fetcher.tsx diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index 6629ea942..7b2d98c27 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -157,13 +157,14 @@ pub(super) fn suppress_react( identity, }] }; - suppressed.extend(suppress_domain_findings_with_sources( - root, - &mut locations, - sources, - react_target, - )); - if !locations.is_empty() { + let target_suppressions = + suppress_domain_findings_with_sources(root, &mut locations, sources, react_target); + if locations.is_empty() { + // A React violation is one component-level diagnostic even when + // several fetch locations contribute to it. Keep one deterministic + // directive record only after every contributing location is hidden. + suppressed.extend(target_suppressions.into_iter().next()); + } else { findings.push(finding); } } diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 6252b21ce..667b5e4a5 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -300,14 +300,42 @@ fn check_json_does_not_hide_later_react_fetch_after_first_is_suppressed() { check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); let value: serde_json::Value = serde_json::from_str(&output).unwrap(); assert!(!value["react"].as_array().unwrap().is_empty(), "{value}"); - assert!(value["suppressed"].as_array().is_some_and(|items| items - .iter() - .any(|item| { item["domain"] == "react" && item["line"] == 5 }))); + assert!(value["suppressed"] + .as_array() + .is_some_and(|items| items.iter().all(|item| item["domain"] != "react"))); assert!(value["react"] .as_array() .is_some_and(|items| { items.iter().any(|item| item["file"] == "app/Fetcher.tsx") })); } +#[test] +fn check_json_records_one_react_suppression_per_component_after_all_fetches_are_hidden() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-react-all-multiple"); + let output = + check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(value["react"].as_array().is_some_and(Vec::is_empty)); + let react_suppressions = value["suppressed"] + .as_array() + .unwrap() + .iter() + .filter(|item| item["domain"] == "react") + .collect::>(); + assert_eq!(react_suppressions.len(), 2, "{value}"); + assert_eq!( + react_suppressions + .iter() + .filter(|item| item["reason"] + .as_str() + .is_some_and(|reason| reason.contains("component default@app/Fetcher.tsx"))) + .count(), + 1, + "{value}" + ); + assert!(react_suppressions.iter().all(|item| item["line"] == 3)); +} + #[test] fn check_json_accounts_for_suppressed_combined_rust_rule() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/fixtures/check/suppression-react-all-multiple/.no-mistakes.yml b/fixtures/check/suppression-react-all-multiple/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/suppression-react-all-multiple/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/suppression-react-all-multiple/app/Child.tsx b/fixtures/check/suppression-react-all-multiple/app/Child.tsx new file mode 100644 index 000000000..022b84b10 --- /dev/null +++ b/fixtures/check/suppression-react-all-multiple/app/Child.tsx @@ -0,0 +1,5 @@ +export default async function Child() { + // no-mistakes-disable-next-line assert-no-fetch: this child call is intentional + await fetch('/data/child'); + return ; +} diff --git a/fixtures/check/suppression-react-all-multiple/app/Fetcher.tsx b/fixtures/check/suppression-react-all-multiple/app/Fetcher.tsx new file mode 100644 index 000000000..4f1dd6c56 --- /dev/null +++ b/fixtures/check/suppression-react-all-multiple/app/Fetcher.tsx @@ -0,0 +1,7 @@ +import Child from './Child'; + +export default async function Fetcher() { + // no-mistakes-disable-next-line assert-no-fetch: this parent call is intentional + await fetch('/api/first'); + return ; +} From 1fefd6fe4a3acf8977e94ae52b0e00b9d55080c9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 20:34:20 -0700 Subject: [PATCH 39/62] fix: preserve prepared check compatibility --- crates/no-mistakes/src/check_parallel.rs | 10 +- crates/no-mistakes/src/check_runner/tests.rs | 2 +- crates/no-mistakes/src/check_tasks.rs | 4 + .../no-mistakes/src/check_tasks/filesystem.rs | 13 +- .../src/codebase/rules/filesystem_dispatch.rs | 1 + .../candidate_index/tests.rs | 1 - .../rules/filesystem_dispatch/entrypoints.rs | 1 - .../rules/filesystem_dispatch/execute.rs | 112 +++++------------- .../filesystem_dispatch/execute/special.rs | 78 ++++++++++++ .../rules/filesystem_dispatch/tests.rs | 5 - crates/no-mistakes/src/codebase/rules/mod.rs | 1 + .../rules/require_storybook_stories/runner.rs | 2 +- .../require_storybook_stories/suppression.rs | 11 +- .../rules/require_storybook_stories/tests.rs | 41 ++++++- crates/no-mistakes/src/codebase/rules/run.rs | 3 +- .../src/codebase/rules/run/prepared.rs | 16 ++- .../codebase/rules/run/prepared/execution.rs | 27 ++--- .../rules/run/prepared/execution/helpers.rs | 17 +++ .../run/prepared/execution/source_store.rs | 18 +++ .../src/codebase/rules/run/prepared/tests.rs | 34 ++++++ .../src/codebase/rules/run/standalone.rs | 3 +- 21 files changed, 262 insertions(+), 138 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute/special.rs create mode 100644 crates/no-mistakes/src/codebase/rules/run/prepared/execution/source_store.rs create mode 100644 crates/no-mistakes/src/codebase/rules/run/prepared/tests.rs diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 01876c646..c9d719317 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -76,7 +76,6 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults let prepared_tsconfig_catalog = inputs.prepared_tsconfig_catalog; let visible_paths = inputs.visible_paths; let sources = inputs.sources; - let rule_sources = std::sync::Arc::clone(&sources); let inferred_roots = inputs.inferred_roots; let config = inputs.config; let codebase_config = inputs.codebase_config; @@ -123,10 +122,11 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults prepared_tsconfig, prepared_tsconfig_catalog, inferred_roots: Some(inferred_roots), - sources: rule_sources.as_ref(), - defer_suppression, + sources: Some(sources.as_ref()), }, dependency_graph.as_deref(), + sources.as_ref(), + defer_suppression, ) }) }, @@ -174,14 +174,14 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults discovered_files, no_mistakes::codebase::rules::filesystem_dispatch::PreparedFilesystemRuleInputs { snapshot: visible_paths, - sources, + sources: std::sync::Arc::clone(&sources), vitest_catalog: vitest_projects, workflow_documents, tsconfig_gate_project_inputs, config_path: config_path.as_deref(), - defer_suppression, }, Some(facts), + defer_suppression, ) }, ) diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 68df5eeb1..15df820ff 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -125,9 +125,9 @@ fn disabled_filesystem_check_returns_no_findings_without_dispatching_rules() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, - defer_suppression: false, }, None, + false, ) .unwrap(); diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index ced5b60af..04ef78372 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -62,6 +62,8 @@ pub(crate) fn run_queue_check( pub(crate) fn run_rules_check( inputs: rules::PreparedRulesCheck<'_>, dependency_graph: Option<&no_mistakes::codebase::dependencies::graph::DepGraph>, + sources: &no_mistakes::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Result>> { let (((findings, suppression_sources), warning), duration) = no_mistakes::diagnostics::measure_if_enabled( @@ -70,6 +72,8 @@ pub(crate) fn run_rules_check( || match rules::run_check_with_config_facts_playwright_and_graph_with_suppression( inputs, dependency_graph, + sources, + defer_suppression, ) { Ok(findings) => ((findings.findings, findings.suppression_sources), None), Err(err) => ( diff --git a/crates/no-mistakes/src/check_tasks/filesystem.rs b/crates/no-mistakes/src/check_tasks/filesystem.rs index 6b5fc506c..8e553e544 100644 --- a/crates/no-mistakes/src/check_tasks/filesystem.rs +++ b/crates/no-mistakes/src/check_tasks/filesystem.rs @@ -52,15 +52,22 @@ pub(crate) fn run_filesystem_rules_check_with_facts( files: &[PathBuf], prepared: rules::filesystem_dispatch::PreparedFilesystemRuleInputs<'_>, facts: Option<&no_mistakes::codebase::check_facts::CheckFactMap>, + defer_suppression: bool, ) -> Result>> { let (findings, duration) = no_mistakes::diagnostics::measure_if_enabled( "analysis.filesystem_rules", no_mistakes::diagnostics::TimingKind::Parallel, || -> Result<_> { Ok(if enabled { - rules::run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( - root, config, files, prepared, facts, - )? + if defer_suppression { + rules::run_filesystem_rules_with_config_snapshot_catalog_sources_facts_and_suppression( + root, config, files, prepared, facts, + )? + } else { + rules::run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( + root, config, files, prepared, facts, + )? + } } else { Vec::new() }) diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs index a17ecfb04..9c766890c 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs @@ -71,6 +71,7 @@ crate::filesystem_rules!(define_filesystem_rule_ids); pub use execute::{ run_filesystem_rules_with_config_snapshot_catalog_and_sources, run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts, + run_filesystem_rules_with_config_snapshot_catalog_sources_facts_and_suppression, PreparedFilesystemRuleInputs, }; diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs index 71c1263fd..7860e29f8 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs @@ -302,7 +302,6 @@ fn markdown_inventory_keeps_external_project_docs_but_skips_generated_directorie workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, - defer_suppression: false, }, ) .unwrap(); diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs index 5db5658d1..1c4caa12e 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs @@ -160,7 +160,6 @@ fn run_filesystem_rules_with_config_snapshot_path_and_catalog( workflow_documents: workflows.as_ref(), tsconfig_gate_project_inputs: project_inputs.as_ref(), config_path, - defer_suppression: false, }, facts.as_ref(), ) diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs index d2779f130..fe3eba68b 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs @@ -3,9 +3,11 @@ use anyhow::Result; use std::path::{Path, PathBuf}; use std::sync::Mutex; -type ResultAccumulator = Mutex>)>>; +mod special; -struct RuleRunInputs<'a> { +pub(super) type ResultAccumulator = Mutex>)>>; + +pub(super) struct RuleRunInputs<'a> { root: &'a Path, config: &'a crate::config::v2::NoMistakesConfig, snapshot: &'a crate::codebase::ts_source::VisiblePathSnapshot, @@ -30,9 +32,6 @@ pub struct PreparedFilesystemRuleInputs<'a> { pub workflow_documents: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, pub tsconfig_gate_project_inputs: Option<&'a tsconfig_gate_coverage::ProjectSourceInputs>, pub config_path: Option<&'a Path>, - /// Aggregate `check` applies SourceStore-backed suppression once after all - /// domains finish so it can report optional directive accounting. - pub defer_suppression: bool, } #[doc(hidden)] @@ -67,6 +66,29 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( files: &[PathBuf], prepared: PreparedFilesystemRuleInputs<'_>, facts: Option<&crate::codebase::check_facts::CheckFactMap>, +) -> Result> { + run_prepared_filesystem_rules(root, config, files, prepared, facts, false) +} + +/// Aggregate check adapter that defers suppression to the shared result pass. +#[doc(hidden)] +pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_facts_and_suppression( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + files: &[PathBuf], + prepared: PreparedFilesystemRuleInputs<'_>, + facts: Option<&crate::codebase::check_facts::CheckFactMap>, +) -> Result> { + run_prepared_filesystem_rules(root, config, files, prepared, facts, true) +} + +fn run_prepared_filesystem_rules( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + files: &[PathBuf], + prepared: PreparedFilesystemRuleInputs<'_>, + facts: Option<&crate::codebase::check_facts::CheckFactMap>, + defer_suppression: bool, ) -> Result> { let PreparedFilesystemRuleInputs { snapshot, @@ -75,7 +97,6 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( workflow_documents, tsconfig_gate_project_inputs, config_path, - defer_suppression, } = prepared; let acc = Mutex::new(Vec::new()); let metadata_files = metadata::metadata_files(root, config, files, snapshot); @@ -129,83 +150,6 @@ pub fn run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts( } fn run_enabled_rules(inputs: &RuleRunInputs<'_>) { - macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources(run_rule::RunRuleRequest { rule_id: $id, fallback: $call, root: inputs.root, config: inputs.config, files: inputs.candidates.candidates($id), sources: inputs.sources, facts: inputs.facts, defer_suppression: inputs.defer_suppression }); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; spawn_special_rules(scope, inputs); }); }; } + macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources(run_rule::RunRuleRequest { rule_id: $id, fallback: $call, root: inputs.root, config: inputs.config, files: inputs.candidates.candidates($id), sources: inputs.sources, facts: inputs.facts, defer_suppression: inputs.defer_suppression }); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; special::spawn(scope, inputs); }); }; } crate::filesystem_rules!(run_rules); } - -fn spawn_special_rules<'a>(scope: &rayon::Scope<'a>, inputs: &'a RuleRunInputs<'a>) { - let RuleRunInputs { - root, - config, - snapshot, - vitest_catalog, - sources, - facts: _, - defer_suppression, - workflow_documents, - tsconfig_gate_project_inputs, - config_path, - candidates, - markdown_facts, - acc, - } = *inputs; - markdown_dispatch::spawn(scope, root, config, candidates, markdown_facts, acc); - if registry::rust_rules_enabled(config) { - scope.spawn(move |_| { - let result = rust_rules_combined::check_with_files_sources_and_deferred_suppression( - root, - config, - candidates.rust_candidates(), - sources, - defer_suppression, - ); - acc.lock() - .expect("mutex poisoned") - .push(("rust-rules-combined", result)); - }); - } - if rule_enabled(config, VITEST_PROJECT_MAPPING) { - scope.spawn(move |_| { - let result = vitest_project_mapping::check_with_files_and_catalog( - root, - config, - candidates.candidates(VITEST_PROJECT_MAPPING), - vitest_catalog, - ); - acc.lock() - .expect("mutex poisoned") - .push((VITEST_PROJECT_MAPPING, result)); - }); - } - if rule_enabled(config, VITEST_CI_PATH_COVERAGE) { - scope.spawn(move |_| { let result = vitest_ci_path_coverage::check_with_files_from_snapshot_catalog_sources_and_workflows(root, config, candidates.candidates(VITEST_CI_PATH_COVERAGE), snapshot, vitest_catalog, sources, workflow_documents); acc.lock().expect("mutex poisoned").push((VITEST_CI_PATH_COVERAGE, result)); }); - } - if rule_enabled(config, TSCONFIG_GATE_COVERAGE) { - scope.spawn(move |_| { - let result = workflow_documents - .zip(tsconfig_gate_project_inputs) - .map_or_else( - || { - Err(anyhow::anyhow!( - "prepared workflow documents and project inputs are required for {TSCONFIG_GATE_COVERAGE}" - )) - }, - |(workflows, project_source_inputs)| { - tsconfig_gate_coverage::check_with_prepared( - root, - config, - tsconfig_gate_coverage::PreparedInputs { - tracked_paths: snapshot.tracked_paths_for(root).as_ref(), - workflows, - project_source_inputs, - sources, - config_path, - }, - ) - }); - acc.lock() - .expect("mutex poisoned") - .push((TSCONFIG_GATE_COVERAGE, result)); - }); - } -} diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute/special.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute/special.rs new file mode 100644 index 000000000..0f9b98252 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute/special.rs @@ -0,0 +1,78 @@ +use super::*; + +pub(super) fn spawn<'a>(scope: &rayon::Scope<'a>, inputs: &'a RuleRunInputs<'a>) { + let RuleRunInputs { + root, + config, + snapshot, + vitest_catalog, + sources, + facts: _, + defer_suppression, + workflow_documents, + tsconfig_gate_project_inputs, + config_path, + candidates, + markdown_facts, + acc, + } = *inputs; + markdown_dispatch::spawn(scope, root, config, candidates, markdown_facts, acc); + if registry::rust_rules_enabled(config) { + scope.spawn(move |_| { + let result = rust_rules_combined::check_with_files_sources_and_deferred_suppression( + root, + config, + candidates.rust_candidates(), + sources, + defer_suppression, + ); + acc.lock() + .expect("mutex poisoned") + .push(("rust-rules-combined", result)); + }); + } + if rule_enabled(config, VITEST_PROJECT_MAPPING) { + scope.spawn(move |_| { + let result = vitest_project_mapping::check_with_files_and_catalog( + root, + config, + candidates.candidates(VITEST_PROJECT_MAPPING), + vitest_catalog, + ); + acc.lock() + .expect("mutex poisoned") + .push((VITEST_PROJECT_MAPPING, result)); + }); + } + if rule_enabled(config, VITEST_CI_PATH_COVERAGE) { + scope.spawn(move |_| { let result = vitest_ci_path_coverage::check_with_files_from_snapshot_catalog_sources_and_workflows(root, config, candidates.candidates(VITEST_CI_PATH_COVERAGE), snapshot, vitest_catalog, sources, workflow_documents); acc.lock().expect("mutex poisoned").push((VITEST_CI_PATH_COVERAGE, result)); }); + } + if rule_enabled(config, TSCONFIG_GATE_COVERAGE) { + scope.spawn(move |_| { + let result = workflow_documents + .zip(tsconfig_gate_project_inputs) + .map_or_else( + || { + Err(anyhow::anyhow!( + "prepared workflow documents and project inputs are required for {TSCONFIG_GATE_COVERAGE}" + )) + }, + |(workflows, project_source_inputs)| { + tsconfig_gate_coverage::check_with_prepared( + root, + config, + tsconfig_gate_coverage::PreparedInputs { + tracked_paths: snapshot.tracked_paths_for(root).as_ref(), + workflows, + project_source_inputs, + sources, + config_path, + }, + ) + }); + acc.lock() + .expect("mutex poisoned") + .push((TSCONFIG_GATE_COVERAGE, result)); + }); + } +} diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index 8142cf5be..a3e18edc6 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -106,7 +106,6 @@ fn prepared_dispatch_rejects_tsconfig_gate_without_workflow_documents() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: Some(&config_path), - defer_suppression: false, }, ) .unwrap_err(); @@ -214,7 +213,6 @@ fn enabling_mermaid_validation_preserves_existing_markdown_findings() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: Some(&config_path), - defer_suppression: false, }, ) .unwrap() @@ -406,7 +404,6 @@ fn aggregate_reads_rust_sources_once_without_global_suppression_rereads() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, - defer_suppression: false, }, ) .unwrap(); @@ -461,7 +458,6 @@ comparisons: workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, - defer_suppression: false, }, ) .unwrap(); @@ -530,7 +526,6 @@ fn aggregate_finding_and_suppression_share_one_physical_read() { workflow_documents: None, tsconfig_gate_project_inputs: None, config_path: None, - defer_suppression: false, }, ) .unwrap(); diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 9f4780331..53da6588f 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -65,6 +65,7 @@ pub use filesystem_dispatch::{ run_filesystem_rules_with_config_snapshot_and_vitest_catalog, run_filesystem_rules_with_config_snapshot_catalog_and_sources, run_filesystem_rules_with_config_snapshot_catalog_sources_and_facts, + run_filesystem_rules_with_config_snapshot_catalog_sources_facts_and_suppression, run_filesystem_rules_with_files, run_filesystem_rules_with_visible_and_snapshot, }; pub use ids::*; diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs index 9b01f0e3f..66c262139 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/runner.rs @@ -104,7 +104,7 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { .filter(|component| rule_filter.is_match(&component.file)) .collect::>(); let component_keys: HashSet = components.iter().map(|c| c.key.clone()).collect(); - let suppression_sources = component_suppression_sources(root, &components, sources); + let suppression_sources = component_suppression_sources(root, &components, shared); let suppression_filtered_component_keys: HashSet = components .iter() .filter(|component| !component_is_suppressed(root, &suppression_sources, component)) diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs index ffe60d0c3..8cafd787e 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs @@ -22,23 +22,22 @@ pub(super) fn component_is_suppressed( }) } -/// Index only selected components, so suppression checks reuse one request -/// SourceStore read per selected path rather than scanning every TS fact. +/// Index only selected components from the caller's authoritative fact map. pub(super) fn component_suppression_sources( root: &Path, components: &[Component], - sources: &crate::codebase::ts_source::SourceStore, + shared: &crate::codebase::check_facts::CheckFactMap, ) -> HashMap> { components .iter() .map(|component| &component.file) .filter_map(|path| { - let candidate = if path.is_absolute() { + let candidate = normalize_path(&if path.is_absolute() { path.clone() } else { root.join(path) - }; - let source = sources.read_path(&candidate).ok()?; + }); + let source = shared.ts.get(&candidate)?.source.as_ref().map(Arc::clone)?; let normalized = normalize_path(path); let rooted = normalize_path(&root.join(path)); Some((normalized, source, rooted)) diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs index 10076e7ea..efd198fa8 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs @@ -99,10 +99,8 @@ fn react_component(name: &str, file: &str, children: Vec) -> Compo } #[test] -fn deferred_suppression_sources_read_relative_component_paths() { +fn deferred_suppression_sources_use_prepared_component_text() { let root = fixture("comments"); - let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(&root); - let sources = snapshot.source_store_for(&root); let component = types::Component { key: "components/DisabledFile.tsx#DisabledFile".to_string(), file: PathBuf::from("components/DisabledFile.tsx"), @@ -112,11 +110,46 @@ fn deferred_suppression_sources_read_relative_component_paths() { line: 2, explicit: true, }; + let path = normalize_path(&root.join(&component.file)); + let prepared_without_directive = CheckFactMap { + ts: HashMap::from([( + path.clone(), + std::sync::Arc::new(CheckFileFacts { + source: Some("export function DisabledFile() { return
; }".into()), + ..Default::default() + }), + )]), + ..Default::default() + }; let indexed = suppression::component_suppression_sources( &root, std::slice::from_ref(&component), - &sources, + &prepared_without_directive, + ); + // The fixture on disk is disabled, but the prepared source is authoritative. + assert!(!suppression::component_is_suppressed( + &root, &indexed, &component, + )); + + let prepared_with_directive = CheckFactMap { + ts: HashMap::from([( + path, + std::sync::Arc::new(CheckFileFacts { + source: Some( + "// no-mistakes-disable-file require-storybook-stories: prepared exemption\n\ + export function DisabledFile() { return
; }" + .into(), + ), + ..Default::default() + }), + )]), + ..Default::default() + }; + let indexed = suppression::component_suppression_sources( + &root, + std::slice::from_ref(&component), + &prepared_with_directive, ); assert!(suppression::component_is_suppressed( diff --git a/crates/no-mistakes/src/codebase/rules/run.rs b/crates/no-mistakes/src/codebase/rules/run.rs index 49f370fdf..a60e18ddf 100644 --- a/crates/no-mistakes/src/codebase/rules/run.rs +++ b/crates/no-mistakes/src/codebase/rules/run.rs @@ -84,8 +84,7 @@ pub fn run_check_with_facts_and_playwright( prepared_tsconfig: &prepared_tsconfig, prepared_tsconfig_catalog: &prepared_tsconfig_catalog, inferred_roots: None, - sources: &sources, - defer_suppression: false, + sources: Some(&sources), }) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared.rs b/crates/no-mistakes/src/codebase/rules/run/prepared.rs index 6137190f1..3fcd01024 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -11,6 +11,8 @@ use anyhow::Result; use std::path::Path; mod execution; +#[cfg(test)] +mod tests; /// Preloaded inputs for the aggregate rules check. /// @@ -29,13 +31,7 @@ pub struct PreparedRulesCheck<'a> { pub prepared_tsconfig: &'a crate::codebase::ts_resolver::TsConfig, pub prepared_tsconfig_catalog: &'a crate::codebase::ts_resolver::TsConfigCatalog, pub inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, - /// The request-owned source store. Aggregate callers must pass the same - /// store used for discovery and fact collection; standalone callers build - /// one store for their own request before entering this prepared path. - pub sources: &'a crate::codebase::ts_source::SourceStore, - /// Aggregate `check` defers suppression until every domain can share one - /// SourceStore-aware adapter and produce optional accounting. - pub defer_suppression: bool, + pub sources: Option<&'a crate::codebase::ts_source::SourceStore>, } /// Shared-config entry point used by the aggregate `check` command. @@ -79,13 +75,15 @@ pub fn run_check_with_config_facts_playwright_and_graph( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, ) -> Result> { - Ok(execution::run(inputs, dependency_graph)?.findings) + Ok(execution::run(inputs, dependency_graph, None, false)?.findings) } #[doc(hidden)] pub fn run_check_with_config_facts_playwright_and_graph_with_suppression( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, + sources: &crate::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Result { - execution::run(inputs, dependency_graph) + execution::run(inputs, dependency_graph, Some(sources), defer_suppression) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs index 657bcf103..71581bdfa 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -2,13 +2,23 @@ use super::*; mod graph_rules; mod helpers; +mod source_store; use graph_rules::graph_rule_findings; -use helpers::{storybook_findings, suppress_findings, StorybookFindingsRequest}; +use helpers::{finalize_findings, storybook_findings, suppress_findings, StorybookFindingsRequest}; pub(super) fn run( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, + aggregate_sources: Option<&crate::codebase::ts_source::SourceStore>, + defer_suppression: bool, ) -> Result { + let provided_sources = aggregate_sources.or(inputs.sources); + let fallback_sources = provided_sources + .is_none() + .then(|| source_store::for_request(&inputs)); + let sources = provided_sources + .or(fallback_sources.as_deref()) + .expect("prepared rules source fallback is initialized"); let PreparedRulesCheck { session, root, @@ -21,8 +31,7 @@ pub(super) fn run( prepared_tsconfig, prepared_tsconfig_catalog, inferred_roots, - sources, - defer_suppression, + sources: _, } = inputs; if !any_codebase_rule_enabled(config) { return Ok(PreparedRuleFindings { @@ -187,15 +196,5 @@ pub(super) fn run( if !defer_suppression { suppress_findings(root, &mut findings, sources); } - let mut paired = findings - .into_iter() - .zip(suppression_sources) - .collect::>(); - paired.sort_by(|(a, _), (b, _)| a.cmp(b)); - paired.dedup_by(|(a, source_a), (b, source_b)| a == b && source_a == source_b); - let (findings, suppression_sources): (Vec<_>, Vec<_>) = paired.into_iter().unzip(); - Ok(PreparedRuleFindings { - findings, - suppression_sources, - }) + Ok(finalize_findings(findings, suppression_sources)) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs index 1d8cdbf34..e3d94a867 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/helpers.rs @@ -43,3 +43,20 @@ pub(super) fn suppress_findings( ) { suppress_rule_findings_with_sources(root, findings, sources); } + +pub(super) fn finalize_findings( + findings: Vec, + suppression_sources: Vec>, +) -> PreparedRuleFindings { + let mut paired = findings + .into_iter() + .zip(suppression_sources) + .collect::>(); + paired.sort_by(|(a, _), (b, _)| a.cmp(b)); + paired.dedup_by(|(a, source_a), (b, source_b)| a == b && source_a == source_b); + let (findings, suppression_sources): (Vec<_>, Vec<_>) = paired.into_iter().unzip(); + PreparedRuleFindings { + findings, + suppression_sources, + } +} diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/source_store.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/source_store.rs new file mode 100644 index 000000000..df8080e1e --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/source_store.rs @@ -0,0 +1,18 @@ +use super::super::PreparedRulesCheck; + +/// Seed the caller's request session when a legacy prepared request omitted +/// sources, preserving the session as the one source-store owner. +pub(super) fn for_request( + inputs: &PreparedRulesCheck<'_>, +) -> std::sync::Arc { + let snapshot = + std::sync::Arc::new(crate::codebase::ts_source::VisiblePathSnapshot::from_paths( + inputs.root, + inputs.shared.files(), + )); + inputs.session.insert_visible_paths(inputs.root, snapshot); + inputs + .session + .visible_paths(inputs.root) + .source_store_for(inputs.root) +} diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/tests.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/tests.rs new file mode 100644 index 000000000..009aca941 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/tests.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn legacy_prepared_request_without_sources_uses_the_request_session() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check-runner/empty"); + let shared = crate::codebase::check_facts::CheckFactMap::default(); + let config = crate::config::v2::NoMistakesConfig::default(); + let tsconfig = crate::codebase::ts_resolver::TsConfig { + dir: root.clone(), + paths_dir: root.clone(), + ..Default::default() + }; + let catalog = + crate::codebase::ts_resolver::TsConfigCatalog::forced(&root, tsconfig.clone(), None); + + let findings = run_check_with_config_and_facts_and_playwright(PreparedRulesCheck { + session: crate::codebase::analysis_session::AnalysisSession::disabled(), + root: &root, + config_path: None, + tsconfig_path: None, + shared: &shared, + prepared_playwright: None, + config: &config, + prepared_graph: None, + prepared_tsconfig: &tsconfig, + prepared_tsconfig_catalog: &catalog, + inferred_roots: None, + sources: None, + }) + .unwrap(); + + assert!(findings.is_empty()); +} diff --git a/crates/no-mistakes/src/codebase/rules/run/standalone.rs b/crates/no-mistakes/src/codebase/rules/run/standalone.rs index 6e9157741..067cbace1 100644 --- a/crates/no-mistakes/src/codebase/rules/run/standalone.rs +++ b/crates/no-mistakes/src/codebase/rules/run/standalone.rs @@ -100,8 +100,7 @@ pub(super) fn run_check( prepared_tsconfig: &prepared_tsconfig, prepared_tsconfig_catalog: &prepared_tsconfig_catalog, inferred_roots: Some(&inferred_roots), - sources: &sources, - defer_suppression: false, + sources: Some(&sources), }) } From c035c5ee1b34cddd1fecba79ee0d566731eda884 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 20:47:48 -0700 Subject: [PATCH 40/62] fix: honor aggregate suppression parse errors --- .../src/check_runner/results/suppression.rs | 87 +-------------- .../check_runner/results/suppression/react.rs | 105 ++++++++++++++++++ .../check_runner/results/suppression_tests.rs | 31 ++++++ .../src/integration_tests/checks.rs | 8 ++ .../src/integration_tests/tests_errors.rs | 20 ++++ .../react_traits/pipeline/run_with_facts.rs | 7 +- .../pipeline/run_with_facts/tests.rs | 2 + .../run_with_facts/tests/suppression.rs | 31 ++++++ 8 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 crates/no-mistakes/src/check_runner/results/suppression/react.rs create mode 100644 crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index 7b2d98c27..8e0468c5a 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -10,6 +10,8 @@ use no_mistakes::react_traits; mod provenance; use provenance::suppress_rules_with_sources; +mod react; +pub(super) use react::suppress_react; pub(super) struct Inputs<'a> { pub(super) root: &'a std::path::Path, @@ -100,91 +102,6 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { suppressed } -/// A component-level React diagnostic covers every local fetch. Preserve its -/// single stable public finding unless all of those fetches are suppressed. -struct ReactSuppressionFinding { - finding: react_traits::Violation, - line: Option, - identity: String, -} - -pub(super) fn suppress_react( - root: &std::path::Path, - sources: &SourceStore, - findings: &mut Vec, - suppression_targets: &[Vec], - suppressed: &mut Vec, -) { - let original_findings = findings.drain(..).enumerate().collect::>(); - for (index, finding) in original_findings { - let identity = format!("{}@{}", finding.component, finding.file); - let targets = suppression_targets.get(index).cloned().unwrap_or_default(); - if !targets.is_empty() { - // An inherited fetch still belongs to the parent component. Honor a - // parent file directive before evaluating each child fetch location. - let mut parent_location = vec![ReactSuppressionFinding { - finding: finding.clone(), - line: None, - identity: identity.clone(), - }]; - let parent_suppressed = suppress_domain_findings_with_sources( - root, - &mut parent_location, - sources, - react_target, - ); - if parent_location.is_empty() { - suppressed.extend(parent_suppressed); - continue; - } - } - let mut locations = if !targets.is_empty() { - targets - .iter() - .map(|target| ReactSuppressionFinding { - finding: react_traits::Violation { - file: target.file.clone(), - ..finding.clone() - }, - line: Some(target.line), - identity: identity.clone(), - }) - .collect() - } else { - vec![ReactSuppressionFinding { - finding: finding.clone(), - line: None, - identity, - }] - }; - let target_suppressions = - suppress_domain_findings_with_sources(root, &mut locations, sources, react_target); - if locations.is_empty() { - // A React violation is one component-level diagnostic even when - // several fetch locations contribute to it. Keep one deterministic - // directive record only after every contributing location is hidden. - suppressed.extend(target_suppressions.into_iter().next()); - } else { - findings.push(finding); - } - } -} - -fn react_target(entry: &ReactSuppressionFinding) -> SuppressionTarget<'_> { - let finding = &entry.finding; - SuppressionTarget { - domain: "react", - rule: &finding.rule, - file: &finding.file, - line: entry.line, - reason: finding - .detail - .as_deref() - .unwrap_or("component fetch assertion failed"), - identity: Some(&entry.identity), - } -} - fn suppress_rules( root: &std::path::Path, sources: &SourceStore, diff --git a/crates/no-mistakes/src/check_runner/results/suppression/react.rs b/crates/no-mistakes/src/check_runner/results/suppression/react.rs new file mode 100644 index 000000000..89e1ac335 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/suppression/react.rs @@ -0,0 +1,105 @@ +use super::*; + +/// A component-level React diagnostic covers every local fetch. Preserve its +/// single stable public finding unless all of those fetches are suppressed. +struct ReactSuppressionFinding { + finding: react_traits::Violation, + line: Option, + identity: String, +} + +pub(in super::super) fn suppress_react( + root: &std::path::Path, + sources: &SourceStore, + findings: &mut Vec, + suppression_targets: &[Vec], + suppressed: &mut Vec, +) { + let original_findings = findings.drain(..).enumerate().collect::>(); + for (index, mut finding) in original_findings { + let identity = format!("{}@{}", finding.component, finding.file); + let targets = suppression_targets.get(index).cloned().unwrap_or_default(); + if !targets.is_empty() { + // An inherited fetch still belongs to the parent component. Honor a + // parent file directive before evaluating each child fetch location. + let mut parent_location = vec![ReactSuppressionFinding { + finding: finding.clone(), + line: None, + identity: identity.clone(), + }]; + let parent_suppressed = suppress_domain_findings_with_sources( + root, + &mut parent_location, + sources, + react_target, + ); + if parent_location.is_empty() { + suppressed.extend(parent_suppressed); + continue; + } + } + let mut locations = if !targets.is_empty() { + targets + .iter() + .map(|target| ReactSuppressionFinding { + finding: react_traits::Violation { + file: target.file.clone(), + ..finding.clone() + }, + line: Some(target.line), + identity: identity.clone(), + }) + .collect() + } else { + vec![ReactSuppressionFinding { + finding: finding.clone(), + line: None, + identity, + }] + }; + let target_suppressions = + suppress_domain_findings_with_sources(root, &mut locations, sources, react_target); + if locations.is_empty() { + // A React violation is one component-level diagnostic even when + // several fetch locations contribute to it. Keep one deterministic + // directive record only after every contributing location is hidden. + suppressed.extend(target_suppressions.into_iter().next()); + } else { + clear_suppressed_first_fetch_detail(&mut finding, &targets, &locations); + findings.push(finding); + } + } +} + +fn clear_suppressed_first_fetch_detail( + finding: &mut react_traits::Violation, + targets: &[react_traits::ReactSuppressionTarget], + locations: &[ReactSuppressionFinding], +) { + let Some(first_target) = targets.first() else { + return; + }; + let first_target_retained = locations.iter().any(|location| { + location.finding.file == first_target.file && location.line == Some(first_target.line) + }); + if !first_target_retained { + // The public detail belongs to the first local fetch. Once that target + // is hidden, do not report it for another fetch. + finding.detail = None; + } +} + +fn react_target(entry: &ReactSuppressionFinding) -> SuppressionTarget<'_> { + let finding = &entry.finding; + SuppressionTarget { + domain: "react", + rule: &finding.rule, + file: &finding.file, + line: entry.line, + reason: finding + .detail + .as_deref() + .unwrap_or("component fetch assertion failed"), + identity: Some(&entry.identity), + } +} diff --git a/crates/no-mistakes/src/check_runner/results/suppression_tests.rs b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs index 54dc10401..be16eca10 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression_tests.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs @@ -43,3 +43,34 @@ fn aggregate_react_suppression_uses_sidecar_locations_for_public_four_field_find assert_eq!(suppressed.len(), 1); assert_eq!(suppressed[0].directive.line, 4); } + +#[test] +fn retained_react_finding_does_not_describe_a_suppressed_first_fetch() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-react-multiple"); + let snapshot = VisiblePathSnapshot::new(&root); + let sources = snapshot.source_store_for(&root); + let mut findings = vec![Violation { + component: "Fetcher".to_string(), + file: "app/Fetcher.tsx".to_string(), + rule: "assert-no-fetch".to_string(), + detail: Some("GET /api/first".to_string()), + }]; + let targets = vec![vec![ + no_mistakes::react_traits::ReactSuppressionTarget { + file: "app/Fetcher.tsx".to_string(), + line: 5, + }, + no_mistakes::react_traits::ReactSuppressionTarget { + file: "app/Child.tsx".to_string(), + line: 2, + }, + ]]; + let mut suppressed = Vec::new(); + + suppress_react(&root, &sources, &mut findings, &targets, &mut suppressed); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].detail, None); + assert!(suppressed.is_empty()); +} diff --git a/crates/no-mistakes/src/integration_tests/checks.rs b/crates/no-mistakes/src/integration_tests/checks.rs index 2ecec1558..90645121a 100644 --- a/crates/no-mistakes/src/integration_tests/checks.rs +++ b/crates/no-mistakes/src/integration_tests/checks.rs @@ -7,6 +7,14 @@ pub(super) fn fail_on_dropped_files( ) -> Result<()> { for (file, facts) in &shared.ts { if let Some(error) = &facts.parse_error { + if facts.source.as_deref().is_some_and(|source| { + crate::codebase::ts_source::has_disable_file_comment( + source, + "integration-test-no-mocks", + ) + }) { + continue; + } anyhow::bail!( "failed to parse integration file {}: {error}", file.display() diff --git a/crates/no-mistakes/src/integration_tests/tests_errors.rs b/crates/no-mistakes/src/integration_tests/tests_errors.rs index db00b7ffb..7dc632a1d 100644 --- a/crates/no-mistakes/src/integration_tests/tests_errors.rs +++ b/crates/no-mistakes/src/integration_tests/tests_errors.rs @@ -84,3 +84,23 @@ fn check_with_facts_reports_dropped_helper_parse_errors() { assert!(error.to_string().contains("synthetic helper parse error")); } + +#[test] +fn file_disabled_parse_errors_do_not_abort_integration_checks() { + let root = fixture("basic"); + let file = root.join("helpers/openai.mts"); + let mut shared = crate::codebase::check_facts::CheckFactMap::default(); + shared.ts.insert( + file, + crate::codebase::check_facts::CheckFileFacts { + parse_error: Some("synthetic disabled parse error".to_string()), + source: Some( + "// no-mistakes-disable-file integration-test-no-mocks: generated file\n<".into(), + ), + ..Default::default() + } + .into(), + ); + + checks::fail_on_dropped_files(&shared).unwrap(); +} diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs index 825a97366..9098efa62 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs @@ -49,7 +49,12 @@ pub(crate) fn run_analyze_inner_with_facts_and_suppression( file_cache.insert(path.clone(), analysis.components.clone()); } if let Some(error) = &facts.parse_error { - parse_errors.insert(path, error); + let file_disabled = facts.source.as_deref().is_some_and(|source| { + crate::codebase::ts_source::has_disable_file_comment(source, "assert-no-fetch") + }); + if !file_disabled { + parse_errors.insert(path, error); + } } } let child_path_index = child_path_index(&root, &file_cache); diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs index 4385c100a..1fc34d770 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs @@ -4,6 +4,8 @@ use crate::react_traits::analyze::file::FileAnalysis; use crate::react_traits::report::types::{ComponentRef, Environment, FetchCall}; use std::collections::HashMap; +mod suppression; + fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../test-cases/react-traits-components") diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs new file mode 100644 index 000000000..b03a6fd5a --- /dev/null +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn file_disabled_parse_errors_are_skipped_before_react_aggregation() { + let root = fixture("nested"); + let file = root.join("app/components/Child.tsx"); + let mut shared = facts(Vec::new()); + shared.files.push(file.clone()); + shared.ts.insert( + file, + CheckFileFacts { + parse_error: Some("synthetic disabled parse error".to_string()), + source: Some("// no-mistakes-disable-file assert-no-fetch: generated source\n<".into()), + ..Default::default() + } + .into(), + ); + + let findings = run_analyze_inner_with_facts( + &root, + &FileConfig { + frontend_root: Some("app".to_string()), + assert_no_fetch: Some(true), + }, + &["app/components/Child.tsx".to_string()], + &shared, + ) + .unwrap(); + + assert!(findings.is_empty()); +} From f773acba4dbdaa70843024cb09ee3973dfd99c85 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 20:51:31 -0700 Subject: [PATCH 41/62] fix: retain suppression directive provenance --- .../no-mistakes/src/codebase/rules/suppression/accounting.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs index de5efa63c..e8832ee48 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -25,6 +25,8 @@ pub struct SuppressedFinding { pub domain: String, pub rule: String, pub file: String, + /// File containing the directive when it differs from the diagnostic target. + pub source_file: String, #[serde(skip_serializing_if = "Option::is_none")] pub line: Option, /// The deterministic diagnostic that would have been emitted. @@ -97,6 +99,7 @@ pub fn suppress_domain_findings_with_source_files( domain: target.domain.to_string(), rule: target.rule.to_string(), file: target.file.to_string(), + source_file: source_file.to_string(), line: target.line, reason: target.identity.map_or_else( || target.reason.to_string(), From ad553d67f9516c479ca7ffeae0d55756ed4104d5 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 21:00:23 -0700 Subject: [PATCH 42/62] test: follow split filesystem dispatch modules --- crates/no-mistakes/src/check_runner/tests/architecture.rs | 1 + .../codebase/rules/filesystem_dispatch/candidate_index/tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index c6e065ef0..9ad6ab387 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -111,6 +111,7 @@ fn aggregate_vitest_ci_coverage_reuses_the_request_snapshot() { let dispatcher = concat!( include_str!("../../codebase/rules/filesystem_dispatch.rs"), include_str!("../../codebase/rules/filesystem_dispatch/execute.rs"), + include_str!("../../codebase/rules/filesystem_dispatch/execute/special.rs"), ); let catalog = include_str!("../../codebase/rules/vitest_project_catalog.rs"); let mapping = include_str!("../../codebase/rules/vitest_project_mapping/project_sources.rs"); diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs index 7860e29f8..5da0cdfda 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs @@ -107,6 +107,7 @@ fn dispatch_prepares_one_index_and_only_reads_preclassified_views() { let dispatch = concat!( include_str!("../../filesystem_dispatch.rs"), include_str!("../execute.rs"), + include_str!("../execute/special.rs"), ); assert_eq!(dispatch.matches("RuleCandidateIndex::prepare").count(), 1); From dd76170169e5285ec0a9be00c3b36eb332de1a15 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 21:18:29 -0700 Subject: [PATCH 43/62] fix: defer suppression only for audit reports --- crates/no-mistakes/src/check_parallel.rs | 1 + .../no-mistakes/src/check_runner/results.rs | 51 ++++++++++++------- .../no-mistakes/src/check_runner/run_all.rs | 4 +- crates/no-mistakes/src/check_tasks.rs | 2 + .../src/codebase/unique_exports/findings.rs | 11 ++-- .../with_facts/prepared/aggregate.rs | 3 +- .../analyze_project/context/check_run.rs | 4 +- packages/no-mistakes/report-types.d.ts | 2 + .../no-mistakes/scripts/type-docs.test.js | 5 ++ 9 files changed, 55 insertions(+), 28 deletions(-) diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index c9d719317..2b67c5d8d 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -159,6 +159,7 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults unique_exports_enabled, facts, inferred_roots, + defer_suppression, ) }, ) diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index deda4f080..f2168fda1 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -81,12 +81,21 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result Result>> { let (findings, duration) = no_mistakes::diagnostics::measure_if_enabled( "analysis.codebase", @@ -149,6 +150,7 @@ pub(crate) fn run_codebase_check_with_catalog( facts, inferred_roots, session, + defer_suppression, )? } else { Vec::new() diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 4773a62ab..57ac89487 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -42,12 +42,11 @@ pub(super) fn unique_export_findings( if unique_occurrences.len() < 2 { continue; } - // In aggregate mode preserve standalone semantics: a suppressed - // occurrence must not turn an unsuppressed export into a duplicate. - let first = unique_occurrences - .iter() - .find(|occurrence| !occurrence.suppressed) - .unwrap_or(&unique_occurrences[0]); + // Standalone collection removes suppressed occurrences before this + // point. Deferred audit keeps their lexical canonical provenance so + // accounting can identify the directive source without changing the + // ordinary visible report. + let first = &unique_occurrences[0]; for duplicate in unique_occurrences .iter() .filter(|item| !std::ptr::eq(*item, first)) diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs index 4c832e597..02eecbb5a 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs @@ -16,6 +16,7 @@ pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_ shared: &CheckFactMap, inferred_roots: &crate::codebase::config::InferredRoots, session: &AnalysisSession, + defer_suppression: bool, ) -> Result> { analyze_project_with_optional_prepared_facts( root, @@ -27,6 +28,6 @@ pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_ shared, Some(inferred_roots), session, - true, + defer_suppression, ) } diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs index dd9f44d32..d94e23d98 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_run.rs @@ -105,7 +105,9 @@ impl SharedCheckContext { .prepared .tsconfig_gate_project_inputs .as_ref(), - defer_suppression: true, + // Preserve ordinary check behavior; defer only when this + // additive report requests suppression accounting. + defer_suppression: include_suppressed, }); let completed = crate::check_runner::complete_domain_checks(( react, diff --git a/packages/no-mistakes/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index b11099304..32dcb2edd 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -38,6 +38,8 @@ export interface SuppressedFinding { domain: "react" | "queues" | "rules" | "filesystem" | "integration" | "codebase" | "advisories"; rule: string; file: string; + /** File containing the suppression directive. */ + sourceFile: string; line?: number; reason: string; directive: { diff --git a/packages/no-mistakes/scripts/type-docs.test.js b/packages/no-mistakes/scripts/type-docs.test.js index c2b20f31d..a9da2ba85 100644 --- a/packages/no-mistakes/scripts/type-docs.test.js +++ b/packages/no-mistakes/scripts/type-docs.test.js @@ -57,3 +57,8 @@ test("every root/tsconfig/config option field carries its canonical JSDoc", () = // vacuous without failing the test. assert.ok(checked > 0, "expected to find at least one root/tsconfig/config option field"); }); + +test("suppression accounting declares the directive source file", () => { + const declarations = readFileSync(join(packageRoot, "report-types.d.ts"), "utf8"); + assert.match(declarations, /interface SuppressedFinding[\s\S]*sourceFile: string;/); +}); From fcea1da123f5f9fdd1b80f69d3c23db1dbffb1e2 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 21:27:31 -0700 Subject: [PATCH 44/62] refactor: group aggregate codebase inputs --- crates/no-mistakes/src/check_parallel.rs | 15 +++-- .../no-mistakes/src/check_runner/results.rs | 61 ++++++++----------- .../src/check_runner/results/advisories.rs | 21 +++++++ .../src/check_runner/results/suppression.rs | 11 ++++ crates/no-mistakes/src/check_tasks.rs | 31 +++++++--- 5 files changed, 87 insertions(+), 52 deletions(-) create mode 100644 crates/no-mistakes/src/check_runner/results/advisories.rs diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 2b67c5d8d..97836cf55 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -1,6 +1,6 @@ use crate::check_tasks::{ run_codebase_check_with_catalog, run_filesystem_rules_check_with_facts, run_integration_check, - run_queue_check, run_react_check, run_rules_check, CheckTask, + run_queue_check, run_react_check, run_rules_check, CheckTask, CodebaseCheckInputs, }; use no_mistakes::codebase::check_facts::CheckFactMap; use no_mistakes::codebase::rules::RuleFinding; @@ -78,8 +78,7 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults let sources = inputs.sources; let inferred_roots = inputs.inferred_roots; let config = inputs.config; - let codebase_config = inputs.codebase_config; - let vitest_projects = inputs.vitest_projects; + let (codebase_config, vitest_projects) = (inputs.codebase_config, inputs.vitest_projects); let workflow_documents = inputs.workflow_documents; let tsconfig_gate_project_inputs = inputs.tsconfig_gate_project_inputs; let defer_suppression = inputs.defer_suppression; @@ -151,16 +150,16 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults no_mistakes::diagnostics::with_observer( observer.clone(), || { - run_codebase_check_with_catalog( - &session, + run_codebase_check_with_catalog(CodebaseCheckInputs { + session: &session, root, - codebase_config, + config: codebase_config, prepared_tsconfig_catalog, - unique_exports_enabled, + enabled: unique_exports_enabled, facts, inferred_roots, defer_suppression, - ) + }) }, ) }, diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index f2168fda1..49c152232 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -8,6 +8,7 @@ use no_mistakes::queue::CheckFinding; use no_mistakes::react_traits; use std::time::Duration; +mod advisories; mod suppression; #[cfg(test)] mod suppression_tests; @@ -80,25 +81,14 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result Result> { + if !enabled { + return Ok(Vec::new()); + } + if include_suppressed { + no_mistakes::codebase::rules::agents_md_max_size::advisories_with_files_sources_and_deferred_suppression(root, config, files, sources) + } else { + no_mistakes::codebase::rules::agents_md_max_size::advisories_with_files_and_sources( + root, config, files, sources, + ) + } +} diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index 8e0468c5a..23e751ffb 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -27,6 +27,17 @@ pub(super) struct Inputs<'a> { pub(super) advisories: &'a mut Vec, } +pub(super) fn apply_if_requested( + include_suppressed: bool, + input: Inputs<'_>, +) -> Vec { + if include_suppressed { + apply(input) + } else { + Vec::new() + } +} + pub(super) fn apply(input: Inputs<'_>) -> Vec { let Inputs { root, diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index 71fac6a15..1942bd454 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -128,16 +128,31 @@ pub(crate) fn run_integration_check( }) } +pub(crate) struct CodebaseCheckInputs<'a> { + pub(crate) session: &'a no_mistakes::codebase::analysis_session::AnalysisSession, + pub(crate) root: &'a std::path::Path, + pub(crate) config: &'a no_mistakes::codebase::config::Config, + pub(crate) prepared_tsconfig_catalog: + &'a std::sync::Arc, + pub(crate) enabled: bool, + pub(crate) facts: &'a CheckFactMap, + pub(crate) inferred_roots: &'a no_mistakes::codebase::config::InferredRoots, + pub(crate) defer_suppression: bool, +} + pub(crate) fn run_codebase_check_with_catalog( - session: &no_mistakes::codebase::analysis_session::AnalysisSession, - root: &std::path::Path, - config: &no_mistakes::codebase::config::Config, - prepared_tsconfig_catalog: &std::sync::Arc, - enabled: bool, - facts: &CheckFactMap, - inferred_roots: &no_mistakes::codebase::config::InferredRoots, - defer_suppression: bool, + inputs: CodebaseCheckInputs<'_>, ) -> Result>> { + let CodebaseCheckInputs { + session, + root, + config, + prepared_tsconfig_catalog, + enabled, + facts, + inferred_roots, + defer_suppression, + } = inputs; let (findings, duration) = no_mistakes::diagnostics::measure_if_enabled( "analysis.codebase", no_mistakes::diagnostics::TimingKind::Parallel, From 1b1e420028f51cdb22f0410217e8f3de3e6b317c Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 21:51:15 -0700 Subject: [PATCH 45/62] fix: preserve early React suppression --- crates/no-mistakes/src/check_parallel.rs | 13 ++++++++-- .../src/check_parallel/react_dispatch.rs | 24 +++++++++++++++++++ .../no-mistakes/src/check_runner/results.rs | 2 +- .../src/check_runner/results/suppression.rs | 2 +- .../check_runner/results/suppression/react.rs | 2 +- crates/no-mistakes/src/check_tasks/react.rs | 17 ++++++++++++- .../src/codebase/unique_exports/findings.rs | 12 ++++++---- 7 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 crates/no-mistakes/src/check_parallel/react_dispatch.rs diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 97836cf55..6114f5292 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -1,6 +1,6 @@ use crate::check_tasks::{ run_codebase_check_with_catalog, run_filesystem_rules_check_with_facts, run_integration_check, - run_queue_check, run_react_check, run_rules_check, CheckTask, CodebaseCheckInputs, + run_queue_check, run_rules_check, CheckTask, CodebaseCheckInputs, }; use no_mistakes::codebase::check_facts::CheckFactMap; use no_mistakes::codebase::rules::RuleFinding; @@ -10,6 +10,8 @@ use no_mistakes::queue::CheckFinding; use no_mistakes::react_traits; use std::path::{Path, PathBuf}; +mod react_dispatch; + pub(crate) type DomainResults = ( anyhow::Result>>, anyhow::Result>>, @@ -88,7 +90,14 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults rayon::join( || { no_mistakes::diagnostics::with_observer(observer.clone(), || { - run_react_check(root, react_enabled, facts, prepared_react) + react_dispatch::run(react_dispatch::Inputs { + root, + enabled: react_enabled, + facts, + prepared: prepared_react, + sources: sources.as_ref(), + defer_suppression, + }) }) }, || { diff --git a/crates/no-mistakes/src/check_parallel/react_dispatch.rs b/crates/no-mistakes/src/check_parallel/react_dispatch.rs new file mode 100644 index 000000000..092df7638 --- /dev/null +++ b/crates/no-mistakes/src/check_parallel/react_dispatch.rs @@ -0,0 +1,24 @@ +use crate::check_tasks::{run_react_check, CheckTask}; +use no_mistakes::codebase::check_facts::CheckFactMap; +use no_mistakes::react_traits; + +pub(super) struct Inputs<'a> { + pub(super) root: &'a std::path::Path, + pub(super) enabled: bool, + pub(super) facts: &'a CheckFactMap, + pub(super) prepared: &'a react_traits::PreparedReactCheck, + pub(super) sources: &'a no_mistakes::codebase::ts_source::SourceStore, + pub(super) defer_suppression: bool, +} + +pub(super) fn run(inputs: Inputs<'_>) -> anyhow::Result>> { + let Inputs { + root, + enabled, + facts, + prepared, + sources, + defer_suppression, + } = inputs; + run_react_check(root, enabled, facts, prepared, sources, defer_suppression) +} diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index 49c152232..eb7b2e921 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -9,7 +9,7 @@ use no_mistakes::react_traits; use std::time::Duration; mod advisories; -mod suppression; +pub(crate) mod suppression; #[cfg(test)] mod suppression_tests; diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index 23e751ffb..ae442829f 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -11,7 +11,7 @@ use no_mistakes::react_traits; mod provenance; use provenance::suppress_rules_with_sources; mod react; -pub(super) use react::suppress_react; +pub(crate) use react::suppress_react; pub(super) struct Inputs<'a> { pub(super) root: &'a std::path::Path, diff --git a/crates/no-mistakes/src/check_runner/results/suppression/react.rs b/crates/no-mistakes/src/check_runner/results/suppression/react.rs index 89e1ac335..9e3e3fb7f 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression/react.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression/react.rs @@ -8,7 +8,7 @@ struct ReactSuppressionFinding { identity: String, } -pub(in super::super) fn suppress_react( +pub(crate) fn suppress_react( root: &std::path::Path, sources: &SourceStore, findings: &mut Vec, diff --git a/crates/no-mistakes/src/check_tasks/react.rs b/crates/no-mistakes/src/check_tasks/react.rs index 2fd2a77e6..8b7191bfb 100644 --- a/crates/no-mistakes/src/check_tasks/react.rs +++ b/crates/no-mistakes/src/check_tasks/react.rs @@ -8,6 +8,8 @@ pub(crate) fn run_react_check( enabled: bool, facts: &CheckFactMap, prepared: &react_traits::PreparedReactCheck, + sources: &no_mistakes::codebase::ts_source::SourceStore, + defer_suppression: bool, ) -> Result>> { let (((findings, react_suppression_targets), warning), duration) = no_mistakes::diagnostics::measure_if_enabled( @@ -21,7 +23,20 @@ pub(crate) fn run_react_check( facts, prepared, ) { - Ok(findings) => ((findings.findings, findings.suppression_targets), None), + Ok(mut findings) => { + if !defer_suppression { + // Ordinary checks suppress each component using + // the full local/inherited target sidecar. + crate::check_runner::results::suppression::suppress_react( + root, + sources, + &mut findings.findings, + &findings.suppression_targets, + &mut Vec::new(), + ); + } + ((findings.findings, findings.suppression_targets), None) + } Err(err) => ( (Vec::new(), Vec::new()), Some(format!("warning: react check skipped: {err:#}")), diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 57ac89487..35d25bb5a 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -42,11 +42,13 @@ pub(super) fn unique_export_findings( if unique_occurrences.len() < 2 { continue; } - // Standalone collection removes suppressed occurrences before this - // point. Deferred audit keeps their lexical canonical provenance so - // accounting can identify the directive source without changing the - // ordinary visible report. - let first = &unique_occurrences[0]; + // Preserve the visible duplicate representative in both ordinary and + // audit reports. Suppressed canonical provenance is retained by the + // occurrence metadata for accounting, not by changing this selection. + let first = unique_occurrences + .iter() + .find(|occurrence| !occurrence.suppressed) + .unwrap_or(&unique_occurrences[0]); for duplicate in unique_occurrences .iter() .filter(|item| !std::ptr::eq(*item, first)) From 93a9ea5ea5144527a6b915efc186ecee877cd7ba Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 22:15:47 -0700 Subject: [PATCH 46/62] refactor: split domain check inputs --- crates/no-mistakes/src/check_parallel.rs | 57 +------------------ .../no-mistakes/src/check_parallel/inputs.rs | 53 +++++++++++++++++ 2 files changed, 56 insertions(+), 54 deletions(-) create mode 100644 crates/no-mistakes/src/check_parallel/inputs.rs diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 6114f5292..c25fdd976 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -1,61 +1,10 @@ use crate::check_tasks::{ run_codebase_check_with_catalog, run_filesystem_rules_check_with_facts, run_integration_check, - run_queue_check, run_rules_check, CheckTask, CodebaseCheckInputs, + run_queue_check, run_rules_check, CodebaseCheckInputs, }; -use no_mistakes::codebase::check_facts::CheckFactMap; -use no_mistakes::codebase::rules::RuleFinding; -use no_mistakes::codebase::unique_exports::UniqueExportFinding; -use no_mistakes::integration_tests::IntegrationFinding; -use no_mistakes::queue::CheckFinding; -use no_mistakes::react_traits; -use std::path::{Path, PathBuf}; - +mod inputs; mod react_dispatch; - -pub(crate) type DomainResults = ( - anyhow::Result>>, - anyhow::Result>>, - anyhow::Result>>, - anyhow::Result>>, - anyhow::Result>>, - anyhow::Result>>, -); - -pub(crate) struct DomainCheckInputs<'a> { - pub(crate) session: std::sync::Arc, - pub(crate) root: &'a Path, - pub(crate) config_path: &'a Option, - pub(crate) tsconfig_path: &'a Option, - pub(crate) react_enabled: bool, - pub(crate) queues_enabled: bool, - pub(crate) integration_enabled: bool, - pub(crate) unique_exports_enabled: bool, - pub(crate) filesystem_rules_enabled: bool, - pub(crate) discovered_files: &'a [PathBuf], - pub(crate) facts: &'a CheckFactMap, - pub(crate) prepared_playwright: - Option<&'a no_mistakes::playwright::rules::PreparedPlaywrightRules>, - pub(crate) prepared_react: &'a no_mistakes::react_traits::PreparedReactCheck, - pub(crate) prepared_graph: - Option<&'a no_mistakes::codebase::dependencies::graph::PreparedGraphConfig>, - pub(crate) dependency_graph: - Option>, - pub(crate) prepared_tsconfig: &'a no_mistakes::codebase::ts_resolver::TsConfig, - pub(crate) prepared_tsconfig_catalog: - &'a std::sync::Arc, - pub(crate) visible_paths: &'a no_mistakes::codebase::ts_source::VisiblePathSnapshot, - pub(crate) sources: std::sync::Arc, - pub(crate) inferred_roots: &'a no_mistakes::codebase::config::InferredRoots, - pub(crate) config: &'a no_mistakes::config::v2::NoMistakesConfig, - pub(crate) codebase_config: &'a no_mistakes::codebase::config::Config, - pub(crate) vitest_projects: - Option<&'a no_mistakes::codebase::rules::PreparedVitestProjectCatalog>, - pub(crate) workflow_documents: - Option<&'a no_mistakes::codebase::ci_workflows::ParsedWorkflowSet>, - pub(crate) tsconfig_gate_project_inputs: - Option<&'a no_mistakes::codebase::rules::tsconfig_gate_coverage::ProjectSourceInputs>, - pub(crate) defer_suppression: bool, -} +pub(crate) use inputs::{DomainCheckInputs, DomainResults}; pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults { let observer = no_mistakes::diagnostics::current(); diff --git a/crates/no-mistakes/src/check_parallel/inputs.rs b/crates/no-mistakes/src/check_parallel/inputs.rs new file mode 100644 index 000000000..1d56bb8a2 --- /dev/null +++ b/crates/no-mistakes/src/check_parallel/inputs.rs @@ -0,0 +1,53 @@ +use crate::check_tasks::CheckTask; +use no_mistakes::codebase::check_facts::CheckFactMap; +use no_mistakes::codebase::rules::RuleFinding; +use no_mistakes::codebase::unique_exports::UniqueExportFinding; +use no_mistakes::integration_tests::IntegrationFinding; +use no_mistakes::queue::CheckFinding; +use no_mistakes::react_traits; +use std::path::{Path, PathBuf}; + +pub(crate) type DomainResults = ( + anyhow::Result>>, + anyhow::Result>>, + anyhow::Result>>, + anyhow::Result>>, + anyhow::Result>>, + anyhow::Result>>, +); + +pub(crate) struct DomainCheckInputs<'a> { + pub(crate) session: std::sync::Arc, + pub(crate) root: &'a Path, + pub(crate) config_path: &'a Option, + pub(crate) tsconfig_path: &'a Option, + pub(crate) react_enabled: bool, + pub(crate) queues_enabled: bool, + pub(crate) integration_enabled: bool, + pub(crate) unique_exports_enabled: bool, + pub(crate) filesystem_rules_enabled: bool, + pub(crate) discovered_files: &'a [PathBuf], + pub(crate) facts: &'a CheckFactMap, + pub(crate) prepared_playwright: + Option<&'a no_mistakes::playwright::rules::PreparedPlaywrightRules>, + pub(crate) prepared_react: &'a no_mistakes::react_traits::PreparedReactCheck, + pub(crate) prepared_graph: + Option<&'a no_mistakes::codebase::dependencies::graph::PreparedGraphConfig>, + pub(crate) dependency_graph: + Option>, + pub(crate) prepared_tsconfig: &'a no_mistakes::codebase::ts_resolver::TsConfig, + pub(crate) prepared_tsconfig_catalog: + &'a std::sync::Arc, + pub(crate) visible_paths: &'a no_mistakes::codebase::ts_source::VisiblePathSnapshot, + pub(crate) sources: std::sync::Arc, + pub(crate) inferred_roots: &'a no_mistakes::codebase::config::InferredRoots, + pub(crate) config: &'a no_mistakes::config::v2::NoMistakesConfig, + pub(crate) codebase_config: &'a no_mistakes::codebase::config::Config, + pub(crate) vitest_projects: + Option<&'a no_mistakes::codebase::rules::PreparedVitestProjectCatalog>, + pub(crate) workflow_documents: + Option<&'a no_mistakes::codebase::ci_workflows::ParsedWorkflowSet>, + pub(crate) tsconfig_gate_project_inputs: + Option<&'a no_mistakes::codebase::rules::tsconfig_gate_coverage::ProjectSourceInputs>, + pub(crate) defer_suppression: bool, +} From e69aba00f52c5376e650e605be059668decf7e55 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 22:36:47 -0700 Subject: [PATCH 47/62] fix: preserve ordinary suppression contracts --- .../src/check_runner/results/suppression.rs | 6 +- .../require_storybook_stories/suppression.rs | 9 +- .../src/napi_api/analyze_project/tests.rs | 16 ++++ .../no-mistakes/src/napi_api/tests/check.rs | 21 ++-- .../react_traits/pipeline/run_with_facts.rs | 11 +-- .../pipeline/run_with_facts/facts_only.rs | 96 +++++++++++++++++++ .../run_with_facts/tests/suppression.rs | 18 +++- .../.no-mistakes.yml | 1 + .../web/components/SameLine.tsx | 1 + .../.no-mistakes.yml | 3 + .../app/Broken.tsx | 2 + 11 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 crates/no-mistakes/src/react_traits/pipeline/run_with_facts/facts_only.rs create mode 100644 fixtures/check/aggregate-require-storybook-stories/web/components/SameLine.tsx create mode 100644 fixtures/check/react-analyze-suppressed-parse-error/.no-mistakes.yml create mode 100644 fixtures/check/react-analyze-suppressed-parse-error/app/Broken.tsx diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index ae442829f..fcc02b16b 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -31,8 +31,12 @@ pub(super) fn apply_if_requested( include_suppressed: bool, input: Inputs<'_>, ) -> Vec { + // Directive filtering is part of the ordinary check contract. The flag + // controls whether we retain the accounting records, not whether a + // directive hides its matching finding. + let suppressed = apply(input); if include_suppressed { - apply(input) + suppressed } else { Vec::new() } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs index 8cafd787e..e1b2913df 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs @@ -1,6 +1,6 @@ use super::types::Component; use crate::codebase::ts_resolver::normalize_path; -use crate::codebase::ts_source::matching_disable_directive; +use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -17,8 +17,11 @@ pub(super) fn component_is_suppressed( .or_else(|| sources.get(&rooted_component_path)) .map(Arc::as_ref) .is_some_and(|source| { - matching_disable_directive(source, Some(component.line as u32), super::RULE_ID) - .is_some() + // Stale allow-components auditing must use exactly the directives + // that ordinary component selection honors. In particular, + // same-line directives do not remove a component from selection. + has_disable_file_comment(source, super::RULE_ID) + || has_disable_comment(source, component.line as u32, super::RULE_ID) }) } diff --git a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs index 95123e5f6..3dcb01304 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs @@ -112,6 +112,22 @@ fn analyze_project_check_applies_shared_suppression_accounting() { })); } +#[test] +fn analyze_project_react_analysis_reports_a_parse_error_despite_check_only_directive() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/react-analyze-suppressed-parse-error"); + let error = analyze_project_json_impl( + json!({ + "root": root, + "reports": [{ "type": "reactAnalyze" }] + }) + .to_string(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to parse"), "{error:#}"); +} + #[test] fn analyze_project_empty_check_includes_empty_suppression_array_in_audit_mode() { let root = check_runner_fixture("empty"); diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 667b5e4a5..92754754f 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -236,22 +236,25 @@ fn check_json_accounts_for_react_queue_and_integration_adapters() { ), ]; for (fixture, domain, rule, directive_kind) in fixtures { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/check") - .join(fixture); - let output = - check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()) - .unwrap(); - let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + let (baseline, audit) = baseline_and_audit(fixture); + let result_field = if domain == "filesystem" { + "rules" + } else { + domain + }; assert!( - value["suppressed"] + baseline[result_field].as_array().is_some_and(Vec::is_empty), + "default check must filter {domain} directives: {baseline}" + ); + assert!( + audit["suppressed"] .as_array() .is_some_and(|findings| findings.iter().any(|finding| { finding["domain"] == domain && finding["rule"] == rule && finding["directive"]["kind"] == directive_kind })), - "{fixture}: {value}" + "{fixture}: {audit}" ); } } diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs index 9098efa62..b6ffd4a74 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts.rs @@ -4,6 +4,8 @@ use anyhow::Result; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +mod facts_only; + pub(crate) fn run_analyze_with_loaded_config_and_facts( root: &Path, config: &crate::config::v2::NoMistakesConfig, @@ -20,12 +22,7 @@ pub(crate) fn run_analyze_inner_with_facts( targets: &[String], shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result> { - Ok( - run_analyze_inner_with_facts_and_suppression(root, file_config, targets, shared)? - .into_iter() - .map(|entry| entry.facts) - .collect(), - ) + facts_only::run(root, file_config, targets, shared) } pub(crate) struct PreparedComponentFacts { @@ -149,7 +146,7 @@ struct AggregateResult { fetch_locations: Vec<(String, usize)>, } -fn child_path_index( +pub(super) fn child_path_index( root: &Path, file_cache: &HashMap>>, ) -> HashMap { diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/facts_only.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/facts_only.rs new file mode 100644 index 000000000..a1a788314 --- /dev/null +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/facts_only.rs @@ -0,0 +1,96 @@ +use crate::codebase::check_facts::CheckFactMap; +use crate::react_traits::report::types::{AggregatedFacts, ComponentFacts, FileConfig}; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// The normal prepared-facts analysis path intentionally has no suppression +/// locations. Those private sidecars are constructed only for aggregate check +/// accounting in the sibling pipeline. +pub(super) fn run( + root: &Path, + file_config: &FileConfig, + targets: &[String], + shared: &CheckFactMap, +) -> Result> { + let root = crate::codebase::ts_source::normalize_discovery_path(root); + let files = super::target_files(&root, file_config, targets, shared.files())?; + let mut file_cache = HashMap::new(); + let mut parse_errors = HashMap::new(); + for (path, facts) in &shared.ts { + let path = crate::codebase::ts_source::normalize_discovery_path(path); + if let Some(analysis) = &facts.react { + file_cache.insert(path.clone(), analysis.components.clone()); + } + if let Some(error) = &facts.parse_error { + parse_errors.insert(path, error); + } + } + let child_path_index = super::child_path_index(&root, &file_cache); + let mut all_results = Vec::new(); + for file in files { + if let Some(error) = parse_errors.get(&file) { + anyhow::bail!("failed to parse {}: {error}", file.display()); + } + let Some(components) = file_cache.get(&file) else { + continue; + }; + for mut facts in components.iter().cloned() { + let agg = + aggregate_children(&facts, &file_cache, &child_path_index, &mut HashSet::new()); + if agg != AggregatedFacts::default() { + facts.inherited_from_children = Some(agg); + } + all_results.push(facts); + } + } + Ok(all_results) +} + +fn aggregate_children( + facts: &ComponentFacts, + file_cache: &HashMap>>, + child_path_index: &HashMap, + visited: &mut HashSet, +) -> AggregatedFacts { + let mut agg = AggregatedFacts::default(); + for child_ref in &facts.children { + let key = format!("{}#{}", child_ref.file, child_ref.name); + if !visited.insert(key) { + continue; + } + let normalized_child_file = + crate::codebase::ts_source::normalize_discovery_path(Path::new(&child_ref.file)); + let child_facts = child_path_index + .get(&child_ref.file) + .or_else(|| child_path_index.get(normalized_child_file.to_string_lossy().as_ref())) + .and_then(|path| file_cache.get(path)) + .and_then(|components| components.iter().find(|item| item.name == child_ref.name)); + if let Some(child_facts) = child_facts { + merge_component(&mut agg, child_facts); + let child_agg = aggregate_children(child_facts, file_cache, child_path_index, visited); + merge_aggregate(&mut agg, &child_agg); + } + } + agg +} + +fn merge_component(agg: &mut AggregatedFacts, facts: &ComponentFacts) { + agg.has_state |= facts.has_state; + agg.has_props |= facts.has_props; + agg.passes_props |= facts.passes_props; + agg.uses_memo |= facts.uses_memo; + agg.uses_context_provider |= facts.uses_context_provider; + agg.uses_suspense |= facts.uses_suspense; + agg.has_fetch |= !facts.fetches.is_empty(); +} + +fn merge_aggregate(agg: &mut AggregatedFacts, child: &AggregatedFacts) { + agg.has_state |= child.has_state; + agg.has_fetch |= child.has_fetch; + agg.uses_suspense |= child.uses_suspense; + agg.uses_context_provider |= child.uses_context_provider; + agg.uses_memo |= child.uses_memo; + agg.has_props |= child.has_props; + agg.passes_props |= child.passes_props; +} diff --git a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs index b03a6fd5a..7fdcb39a3 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs @@ -1,7 +1,7 @@ use super::*; #[test] -fn file_disabled_parse_errors_are_skipped_before_react_aggregation() { +fn file_disabled_parse_errors_are_skipped_only_for_aggregate_react_checks() { let root = fixture("nested"); let file = root.join("app/components/Child.tsx"); let mut shared = facts(Vec::new()); @@ -16,7 +16,21 @@ fn file_disabled_parse_errors_are_skipped_before_react_aggregation() { .into(), ); - let findings = run_analyze_inner_with_facts( + let analysis_error = run_analyze_inner_with_facts( + &root, + &FileConfig { + frontend_root: Some("app".to_string()), + assert_no_fetch: Some(true), + }, + &["app/components/Child.tsx".to_string()], + &shared, + ) + .unwrap_err(); + assert!(analysis_error + .to_string() + .contains("synthetic disabled parse error")); + + let findings = run_analyze_inner_with_facts_and_suppression( &root, &FileConfig { frontend_root: Some("app".to_string()), diff --git a/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml index 26e1d88b7..3811a3d6c 100644 --- a/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml +++ b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml @@ -15,3 +15,4 @@ rules: includeAllReactNamedExports: true allow_components: "components/FileSuppressed.tsx#FileSuppressed": "covered by the parent application shell" + "components/SameLine.tsx#SameLine": "covered by the parent application shell" diff --git a/fixtures/check/aggregate-require-storybook-stories/web/components/SameLine.tsx b/fixtures/check/aggregate-require-storybook-stories/web/components/SameLine.tsx new file mode 100644 index 000000000..f6d62b8a4 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-stories/web/components/SameLine.tsx @@ -0,0 +1 @@ +export function SameLine() { return
same-line directive remains selected
} // no-mistakes-disable-line require-storybook-stories: this rule honors next-line and file directives during selection diff --git a/fixtures/check/react-analyze-suppressed-parse-error/.no-mistakes.yml b/fixtures/check/react-analyze-suppressed-parse-error/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/react-analyze-suppressed-parse-error/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/react-analyze-suppressed-parse-error/app/Broken.tsx b/fixtures/check/react-analyze-suppressed-parse-error/app/Broken.tsx new file mode 100644 index 000000000..dfa836fdd --- /dev/null +++ b/fixtures/check/react-analyze-suppressed-parse-error/app/Broken.tsx @@ -0,0 +1,2 @@ +// no-mistakes-disable-file assert-no-fetch: checks may suppress this assertion +export function Broken( { From 833f798301e6c8f25a688d9b8c14a8f088cdb019 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 23:22:16 -0700 Subject: [PATCH 48/62] perf: account for Rust suppression source reads --- crates/no-mistakes/benches/core_analysis/fixtures.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/no-mistakes/benches/core_analysis/fixtures.rs b/crates/no-mistakes/benches/core_analysis/fixtures.rs index ced2698b5..9eb0bd408 100644 --- a/crates/no-mistakes/benches/core_analysis/fixtures.rs +++ b/crates/no-mistakes/benches/core_analysis/fixtures.rs @@ -8,8 +8,8 @@ pub(super) const EXPECTED_SOURCE_FILES: usize = 14; pub(super) const EXPECTED_IMPACTED_CHECKS: usize = 1; // Nine graph-scope keys plus two session-scoped legacy-symbol keys. pub(super) const EXPECTED_MULTI_REPORT_RESOLVER_KEYS: u64 = 11; -// Fourteen source files plus four configuration and manifest reads. -pub(super) const EXPECTED_CHECK_SOURCE_READS: u64 = 18; +// Fourteen TS sources, one Rust rule source, and four config/manifest reads. +pub(super) const EXPECTED_CHECK_SOURCE_READS: u64 = 19; pub(super) const EXPECTED_CHECK_MANIFEST_PARSES: u64 = 4; pub(super) const EXPECTED_CHECK_RESOLVER_KEYS: u64 = 14; From 35c9dab32971b685fc13493f5e131da8120dba0c Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 09:24:33 -0700 Subject: [PATCH 49/62] test: cover prepared suppression contracts --- .../agents_md_max_size_budget.rs | 4 +- .../require_storybook_stories/suppression.rs | 2 +- .../config.rs | 7 ++- .../src/codebase/unique_exports/with_facts.rs | 54 +++++++++---------- .../unique_exports/with_facts/helpers.rs | 7 +++ crates/no-mistakes/src/napi_api/cli_parity.rs | 9 ++-- crates/no-mistakes/src/napi_api/tests.rs | 1 + .../tests/suppression_contract_tests.rs | 41 ++++++++++++++ .../react_traits/pipeline/run_with_facts.rs | 2 +- 9 files changed, 91 insertions(+), 36 deletions(-) create mode 100644 crates/no-mistakes/src/napi_api/tests/suppression_contract_tests.rs diff --git a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs index 89a677a90..0e7c183fa 100644 --- a/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs +++ b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/agents_md_max_size_budget.rs @@ -35,7 +35,7 @@ pub(super) fn scan_with_sources( ) }) .collect(); - findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.message.cmp(&b.message))); + findings.sort(); Ok(findings) } @@ -71,7 +71,7 @@ pub(super) fn scan_advisories_with_sources_deferred( ) }) .collect(); - advisories.sort_by(|a, b| a.file.cmp(&b.file).then(a.message.cmp(&b.message))); + advisories.sort(); Ok(advisories) } diff --git a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs index e1b2913df..e7f48cb90 100644 --- a/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs @@ -14,7 +14,7 @@ pub(super) fn component_is_suppressed( let rooted_component_path = normalize_path(&root.join(&component.file)); sources .get(&component_path) - .or_else(|| sources.get(&rooted_component_path)) + .or(sources.get(&rooted_component_path)) .map(Arc::as_ref) .is_some_and(|source| { // Stale allow-components auditing must use exactly the directives diff --git a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs index 45e00923d..125f8e0ba 100644 --- a/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs @@ -66,7 +66,10 @@ fn precompute_setup_data_from_config_files_inner( for config_file in config_files { let source = match sources { Some(sources) => crate::codebase::rules::read_source(sources, &config_file.path) - .ok_or_else(|| anyhow::anyhow!("failed to read {}", config_file.path.display()))? + .ok_or(anyhow::anyhow!( + "failed to read {}", + config_file.path.display() + ))? .to_string(), None => std::fs::read_to_string(&config_file.path)?, }; @@ -141,7 +144,7 @@ fn setup_files_from_configs_inner( for config_file in config_files { let source = match sources { Some(sources) => crate::codebase::rules::read_source(sources, &config_file) - .ok_or_else(|| anyhow::anyhow!("failed to read {}", config_file.display()))? + .ok_or(anyhow::anyhow!("failed to read {}", config_file.display()))? .to_string(), None => std::fs::read_to_string(&config_file)?, }; diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs index 6df13a753..a58b8df3d 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs @@ -9,7 +9,7 @@ use std::path::Path; mod helpers; mod prepared; -use helpers::{relative, shared_symbol_files}; +use helpers::{relative, shared_symbol_files, ApplicationProjectFilter}; pub use prepared::{ analyze_project_with_config_and_facts, analyze_project_with_prepared_facts, analyze_project_with_prepared_facts_and_inferred, @@ -146,26 +146,32 @@ fn filter_application_files( let include = GlobMatcher::new(&application.include, "unique-exports rule include")?; let exclude = GlobMatcher::new(&application.exclude, "unique-exports rule exclude")?; let mut inferred_roots = inferred_roots.cloned().unwrap_or_default(); - let projects = application - .projects - .iter() - .filter_map(|project_name| { - let project = config.projects.get(project_name)?; - let project_root = project - .effective_root_with_cache(root, &mut inferred_roots) - .unwrap_or_else(|| root.to_path_buf()); - let project_root = normalize_path(&project_root); - let project_include = - GlobMatcher::new(&project.include, "unique-exports project include").ok()?; - let project_exclude = - GlobMatcher::new(&project.exclude, "unique-exports project exclude").ok()?; - Some(ApplicationProjectFilter { - root: project_root, - include: project_include, - exclude: project_exclude, - }) - }) - .collect::>(); + let mut projects = Vec::new(); + for project_name in &application.projects { + let Some(project) = config.projects.get(project_name) else { + continue; + }; + let project_root = match project.effective_root_with_cache(root, &mut inferred_roots) { + Some(project_root) => project_root, + None => root.to_path_buf(), + }; + let project_root = normalize_path(&project_root); + let Ok(project_include) = + GlobMatcher::new(&project.include, "unique-exports project include") + else { + continue; + }; + let Ok(project_exclude) = + GlobMatcher::new(&project.exclude, "unique-exports project exclude") + else { + continue; + }; + projects.push(ApplicationProjectFilter { + root: project_root, + include: project_include, + exclude: project_exclude, + }); + } Ok(files .into_iter() .filter(|path| { @@ -195,11 +201,5 @@ fn filter_application_files( .collect()) } -struct ApplicationProjectFilter { - root: std::path::PathBuf, - include: crate::codebase::rules::path_filter::GlobMatcher, - exclude: crate::codebase::rules::path_filter::GlobMatcher, -} - #[cfg(test)] mod tests; diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/helpers.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/helpers.rs index 806d084a8..705790f70 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts/helpers.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/helpers.rs @@ -1,6 +1,13 @@ use super::filter_source_files; +use crate::codebase::rules::path_filter::GlobMatcher; use std::path::{Path, PathBuf}; +pub(super) struct ApplicationProjectFilter { + pub(super) root: PathBuf, + pub(super) include: GlobMatcher, + pub(super) exclude: GlobMatcher, +} + pub(super) fn relative(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) diff --git a/crates/no-mistakes/src/napi_api/cli_parity.rs b/crates/no-mistakes/src/napi_api/cli_parity.rs index a471ba5e2..d254980ca 100644 --- a/crates/no-mistakes/src/napi_api/cli_parity.rs +++ b/crates/no-mistakes/src/napi_api/cli_parity.rs @@ -144,9 +144,12 @@ pub(crate) fn impacted_checks_json_impl(options_json: String) -> napi::Result Date: Tue, 11 Aug 2026 10:54:47 -0700 Subject: [PATCH 50/62] fix: preserve ordinary react suppression alignment --- .../no-mistakes/src/check_runner/results.rs | 2 +- .../src/check_runner/results/suppression.rs | 20 +++++++++++-------- .../no-mistakes/src/napi_api/tests/check.rs | 18 +++++++++++++++++ .../.no-mistakes.yml | 3 +++ .../app/First.tsx | 5 +++++ .../app/Later.tsx | 4 ++++ 6 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 fixtures/check/suppression-react-component-order/.no-mistakes.yml create mode 100644 fixtures/check/suppression-react-component-order/app/First.tsx create mode 100644 fixtures/check/suppression-react-component-order/app/Later.tsx diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index eb7b2e921..97927d08c 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -107,7 +107,7 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result { pub(super) root: &'a std::path::Path, pub(super) sources: &'a SourceStore, pub(super) react: &'a mut Vec, - pub(super) react_suppression_targets: &'a [Vec], + /// React suppression is already applied by ordinary checks. Audit runs + /// defer it so they can retain the corresponding accounting records. + pub(super) react_suppression_targets: Option<&'a [Vec]>, pub(super) queues: &'a mut Vec, pub(super) rules: &'a mut Vec, pub(super) rule_suppression_sources: &'a [Option], @@ -57,13 +59,15 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { advisories, } = input; let mut suppressed = Vec::new(); - suppress_react( - root, - sources, - react, - react_suppression_targets, - &mut suppressed, - ); + if let Some(react_suppression_targets) = react_suppression_targets { + suppress_react( + root, + sources, + react, + react_suppression_targets, + &mut suppressed, + ); + } suppressed.extend(suppress_domain_findings_with_sources( root, queues, diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 92754754f..2e5ed7d22 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -311,6 +311,24 @@ fn check_json_does_not_hide_later_react_fetch_after_first_is_suppressed() { .is_some_and(|items| { items.iter().any(|item| item["file"] == "app/Fetcher.tsx") })); } +#[test] +fn ordinary_check_keeps_later_react_component_after_earlier_component_is_suppressed() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check/suppression-react-component-order"); + let output = check_json_impl(json!({ "root": root }).to_string()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert!(value.get("suppressed").is_none()); + assert!( + value["react"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["file"] == "app/Later.tsx" && finding["rule"] == "assert-no-fetch" + }) + }), + "{value}" + ); +} + #[test] fn check_json_records_one_react_suppression_per_component_after_all_fetches_are_hidden() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/fixtures/check/suppression-react-component-order/.no-mistakes.yml b/fixtures/check/suppression-react-component-order/.no-mistakes.yml new file mode 100644 index 000000000..b98e8f73f --- /dev/null +++ b/fixtures/check/suppression-react-component-order/.no-mistakes.yml @@ -0,0 +1,3 @@ +reactTraits: + frontendRoot: app + assertNoFetch: true diff --git a/fixtures/check/suppression-react-component-order/app/First.tsx b/fixtures/check/suppression-react-component-order/app/First.tsx new file mode 100644 index 000000000..bc134d885 --- /dev/null +++ b/fixtures/check/suppression-react-component-order/app/First.tsx @@ -0,0 +1,5 @@ +export default async function First() { + // no-mistakes-disable-next-line assert-no-fetch: this component is intentionally suppressed + await fetch('/api/first'); + return ; +} diff --git a/fixtures/check/suppression-react-component-order/app/Later.tsx b/fixtures/check/suppression-react-component-order/app/Later.tsx new file mode 100644 index 000000000..c9a5e9104 --- /dev/null +++ b/fixtures/check/suppression-react-component-order/app/Later.tsx @@ -0,0 +1,4 @@ +export default async function Later() { + await fetch('/api/later'); + return ; +} From 822a4c35d30b0a8e181915291495474f2cf18a3a Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 11:14:03 -0700 Subject: [PATCH 51/62] fix: preserve suppression parity in audit mode --- .../ts_source/source_store/validation.rs | 2 +- .../src/codebase/unique_exports/origin.rs | 5 +++- .../codebase/unique_exports/tests/origin.rs | 4 ++-- .../src/napi_api/tests/check_suppression.rs | 24 ++++++++++++++----- .../src/chained-barrel.ts | 3 +++ 5 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 fixtures/check/suppression-unique-canonical/src/chained-barrel.ts diff --git a/crates/no-mistakes/src/codebase/ts_source/source_store/validation.rs b/crates/no-mistakes/src/codebase/ts_source/source_store/validation.rs index 0339efa73..67a235ed0 100644 --- a/crates/no-mistakes/src/codebase/ts_source/source_store/validation.rs +++ b/crates/no-mistakes/src/codebase/ts_source/source_store/validation.rs @@ -13,7 +13,7 @@ pub(super) fn validated_regular_path( ) -> Option { if inventory .classification_for_path(candidate) - .is_some_and(super::super::FileClassification::is_lexical_file) + .is_some_and(super::super::FileClassification::target_is_file) { return Some(candidate.to_path_buf()); } diff --git a/crates/no-mistakes/src/codebase/unique_exports/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/origin.rs index 599f90c84..363951012 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/origin.rs @@ -43,7 +43,10 @@ impl OriginSearch<'_, R> { self.visiting.remove(&target); return None; }; - if file.disabled && !file.defer_suppression { + // A disabled re-export target is absent from ordinary analysis. Keep + // that fallback identity when accounting is requested as well, so the + // additive audit flag cannot change active duplicate findings. + if file.disabled { self.visiting.remove(&target); return None; } diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs index c697dab44..33b7c9c3a 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs @@ -114,7 +114,7 @@ export type { MissingType } from './missing'\n", } #[test] -fn deferred_suppression_preserves_reexport_origin_identity() { +fn deferred_suppression_keeps_disabled_reexport_as_fallback_origin() { let root = suppression_origin_fixture(); let source = fixture_source_file(&root, "src/source.ts", true); let barrel = fixture_source_file(&root, "src/barrel.ts", true); @@ -136,5 +136,5 @@ fn deferred_suppression_preserves_reexport_origin_identity() { &WorkspaceMap::default(), ); - assert_eq!(origin.file, "src/source.ts"); + assert_eq!(origin.file, "src/barrel.ts"); } diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 040d14048..ea8dd1014 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -202,12 +202,9 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports serde_json::from_str(&check_json_impl(json!({ "root": root }).to_string()).unwrap()) .unwrap(); assert!(baseline["codebase"].as_array().is_some_and(|items| { - !items.iter().any(|item| { - matches!( - item["exportName"].as_str(), - Some("chained" | "wildOnly" | "TypeThing") - ) - }) + !items + .iter() + .any(|item| matches!(item["exportName"].as_str(), Some("wildOnly" | "TypeThing"))) })); let audit: serde_json::Value = serde_json::from_str( &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), @@ -245,6 +242,21 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports })); } +#[test] +fn check_json_keeps_named_reexport_duplicates_when_auditing_suppression() { + let (baseline, audit) = baseline_and_audit("suppression-unique-canonical"); + for report in [&baseline, &audit] { + assert!( + report["codebase"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["exportName"] == "chained" && item["file"] == "src/chained-visible.ts" + }) + }), + "{report}" + ); + } +} + #[test] fn check_json_deduplicates_same_origin_even_when_one_barrel_is_suppressed() { let (baseline, audit) = baseline_and_audit("suppression-unique-canonical"); diff --git a/fixtures/check/suppression-unique-canonical/src/chained-barrel.ts b/fixtures/check/suppression-unique-canonical/src/chained-barrel.ts new file mode 100644 index 000000000..23b42163c --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/chained-barrel.ts @@ -0,0 +1,3 @@ +// The source is file-disabled; this barrel must remain an active fallback +// duplicate in both ordinary and suppression-accounting modes. +export { chained } from '../shared/suppressed-origin'; From a2560d3cd895f551d619d753c4e8719790b7692b Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 11:24:16 -0700 Subject: [PATCH 52/62] test: cover lexical named re-export suppression --- .../unique_exports/tests/helper_edges.rs | 19 ++++++++++++------- .../src/barrel.ts | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs b/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs index dbb62a056..74d2f3137 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests/helper_edges.rs @@ -101,7 +101,7 @@ fn defensive_helpers_ignore_missing_targets_and_non_matching_default_exports() { } #[test] -fn deferred_reexports_preserve_suppression_provenance_and_origin_ordering() { +fn deferred_reexports_keep_named_reexports_lexically_visible() { let root = crate::codebase::ts_resolver::normalize_path( &PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/codebase/unique-exports-suppressed-origin"), @@ -166,12 +166,17 @@ fn deferred_reexports_preserve_suppression_provenance_and_origin_ordering() { let explicit = collect("src/barrel.ts"); assert_eq!(explicit.len(), 1); let explicit_origin = &explicit[0].origin; - assert!(explicit[0].suppressed, "{explicit:#?}"); - assert_eq!( - explicit[0].suppression_location.as_ref(), - Some(&("src/source.ts".to_string(), 2)) - ); - assert_eq!(explicit_origin.file, "src/source.ts"); + // A disabled target has no exported identity for an unsuppressed named + // re-export to inherit. The barrel occurrence must therefore remain + // visible and use its own lexical origin. + assert!(!explicit[0].suppressed, "{explicit:#?}"); + assert_eq!(explicit[0].suppression_location, None); + assert_eq!(explicit_origin.file, "src/barrel.ts"); + assert_eq!(explicit_origin.line, 2); + assert_eq!(explicit_origin.name, "Shared"); + assert_eq!(explicit_origin.bucket, ExportBucket::Value); + assert!(!explicit_origin.suppressed); + assert_eq!(explicit_origin.suppression_location, None); let wildcard = collect("src/wild-barrel.ts"); assert_eq!(wildcard.len(), 2); diff --git a/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts b/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts index 8c7487c1e..c419796bd 100644 --- a/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts @@ -1,2 +1,2 @@ -// This re-export must retain the suppressed source's identity during aggregate auditing. +// Counterintuitively, this named re-export remains visible: source suppressions are lexical. export { Shared } from './source'; From 0faeae0c2846a8be3b1daa83f48fb211dfa97e35 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 11:31:09 -0700 Subject: [PATCH 53/62] fix: avoid React check fact clones --- .../react_traits/pipeline/check/aggregate.rs | 47 +++++++++---------- .../src/react_traits/pipeline/check/tests.rs | 29 ++++++++++++ .../no-mistakes/analyze-project-types.d.ts | 2 +- packages/no-mistakes/scripts/api.test.js | 6 ++- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs b/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs index 966a164a8..6eade6fd7 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs @@ -1,5 +1,5 @@ use crate::react_traits::pipeline::run_with_facts::PreparedComponentFacts; -use crate::react_traits::report::types::{ReactSuppressionTarget, Violation}; +use crate::react_traits::report::types::{ComponentFacts, ReactSuppressionTarget, Violation}; #[doc(hidden)] pub struct PreparedReactFindings { @@ -7,18 +7,11 @@ pub struct PreparedReactFindings { pub suppression_targets: Vec>, } -pub(super) fn assert_no_fetch_violations( - facts_list: &[crate::react_traits::ComponentFacts], -) -> Vec { - let prepared = facts_list - .iter() - .cloned() - .map(|facts| PreparedComponentFacts { - facts, - inherited_fetch_locations: Vec::new(), - }) - .collect::>(); - assert_no_fetch_violations_with_suppression(&prepared).findings +/// Builds public React findings directly from the request-scoped facts. The +/// ordinary prepared-facts path does not need suppression locations, so it +/// must borrow these facts instead of cloning every component into sidecars. +pub(super) fn assert_no_fetch_violations(facts_list: &[ComponentFacts]) -> Vec { + facts_list.iter().filter_map(violation_for).collect() } pub(super) fn assert_no_fetch_violations_with_suppression( @@ -28,12 +21,7 @@ pub(super) fn assert_no_fetch_violations_with_suppression( let mut suppression_targets = Vec::new(); for prepared_facts in facts_list { let facts = &prepared_facts.facts; - let has_fetch = !facts.fetches.is_empty() - || facts - .inherited_from_children - .as_ref() - .is_some_and(|agg| agg.has_fetch); - if has_fetch { + if let Some(violation) = violation_for(facts) { let mut finding_targets = facts .fetches .iter() @@ -49,12 +37,7 @@ pub(super) fn assert_no_fetch_violations_with_suppression( .cloned() .map(|(file, line)| ReactSuppressionTarget { file, line }), ); - violations.push(Violation { - component: facts.name.clone(), - file: facts.file.clone(), - rule: "assert-no-fetch".to_string(), - detail: facts.fetches.first().and_then(|f| f.shape.clone()), - }); + violations.push(violation); suppression_targets.push(finding_targets); } } @@ -63,3 +46,17 @@ pub(super) fn assert_no_fetch_violations_with_suppression( suppression_targets, } } + +fn violation_for(facts: &ComponentFacts) -> Option { + let has_fetch = !facts.fetches.is_empty() + || facts + .inherited_from_children + .as_ref() + .is_some_and(|agg| agg.has_fetch); + has_fetch.then(|| Violation { + component: facts.name.clone(), + file: facts.file.clone(), + rule: "assert-no-fetch".to_string(), + detail: facts.fetches.first().and_then(|f| f.shape.clone()), + }) +} diff --git a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs index 8f60d2780..67787892f 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check/tests.rs @@ -205,6 +205,35 @@ fn aggregate_check_keeps_public_violations_and_private_suppression_locations_sep .any(|targets| !targets.is_empty())); } +#[test] +fn public_prepared_check_matches_aggregate_findings_for_fetch_fixture() { + use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; + + let root = assert_no_fetch_root(); + let facts = collect_check_facts( + &root, + crate::codebase::ts_source::discover_visible_paths(&root), + CheckFactPlan { + react: true, + ..CheckFactPlan::default() + }, + ); + let prepared = prepare_check_from_loaded_config( + &crate::config::v2::load_v2_config(&root, None).unwrap(), + false, + ); + + let public = run_check_with_prepared_facts(&root, &[], &facts, &prepared).unwrap(); + let aggregate = + run_check_with_prepared_facts_for_aggregate(&root, &[], &facts, &prepared).unwrap(); + + assert_eq!( + serde_json::to_value(public).unwrap(), + serde_json::to_value(aggregate.findings).unwrap(), + "the public prepared-facts check must not require suppression sidecars" + ); +} + #[test] fn prepared_check_uses_frozen_visible_files_after_source_is_removed() { use crate::codebase::check_facts::{collect_check_facts, CheckFactPlan}; diff --git a/packages/no-mistakes/analyze-project-types.d.ts b/packages/no-mistakes/analyze-project-types.d.ts index cc1fa90e7..2ea5accc5 100644 --- a/packages/no-mistakes/analyze-project-types.d.ts +++ b/packages/no-mistakes/analyze-project-types.d.ts @@ -27,7 +27,7 @@ type BatchedReactUsagesOptions = Pick< Required>; type BatchedCheckOptions = Pick< ProjectOptions, - "root" | "tsconfig" | "config" | "include" | "includeSuppressed" + "root" | "tsconfig" | "config" | "includeSuppressed" >; export type AnalyzeProjectReportRequest = diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 6dbae56f8..8349ee85c 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -485,7 +485,11 @@ test("analyzeProject declarations mirror report-specific runtime requirements", ); assert.match( analyzeProjectDeclarations, - /type BatchedCheckOptions = Pick<[\s\S]*?"root" \| "tsconfig" \| "config" \| "include" \| "includeSuppressed"[\s\S]*?>/, + /type BatchedCheckOptions = Pick<[\s\S]*?"root" \| "tsconfig" \| "config" \| "includeSuppressed"[\s\S]*?>/, + ); + assert.doesNotMatch( + analyzeProjectDeclarations, + /type BatchedCheckOptions = Pick<[\s\S]*?"include" \| "includeSuppressed"/, ); assert.match(analyzeProjectDeclarations, /type: "check"; id\?: string } & BatchedCheckOptions/); assert.match( From 78cb7813d618e273554e889641acb934bce21ac4 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 11:58:48 -0700 Subject: [PATCH 54/62] refactor: reduce unique export collector complexity --- .../src/codebase/unique_exports/collector.rs | 55 +++++++++---------- .../codebase/unique_exports/tests/origin.rs | 26 +++++++++ 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/crates/no-mistakes/src/codebase/unique_exports/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index c274a29c8..ebfb5ca31 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/collector.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/collector.rs @@ -62,13 +62,10 @@ pub(super) fn collect_file_exports( occurrence.file = file.rel.clone(); occurrence.line = export.line; occurrence.kind = export_kind_str(&export.kind).to_string(); - let current_suppressed = file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID) - || has_disable_line_comment(&file.source, export.line, RULE_ID); - if current_suppressed { - occurrence.suppression_location = Some((file.rel.clone(), export.line)); + if let Some(location) = current_suppression_location(file, export) { + occurrence.suppression_location = Some(location); + occurrence.suppressed = true; } - occurrence.suppressed |= current_suppressed; if !super::nextjs::is_framework_export( &occurrence.file, &occurrence.name, @@ -99,18 +96,10 @@ pub(super) fn collect_file_exports( let origin_suppressed = resolved_origin .as_ref() .is_some_and(|origin| origin.suppressed); - let current_suppressed = file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID) - || has_disable_line_comment(&file.source, export.line, RULE_ID); - let suppression_location = if current_suppressed { - Some((file.rel.clone(), export.line)) - } else if origin_suppressed { - resolved_origin - .as_ref() - .and_then(|origin| origin.suppression_location.clone()) - } else { - None - }; + let current_suppression = current_suppression_location(file, export); + let current_suppressed = current_suppression.is_some(); + let suppression_location = current_suppression + .or_else(|| suppressed_origin_location(resolved_origin.as_ref())); let origin = resolved_origin .map(|origin| { if export.is_type_only { @@ -136,6 +125,7 @@ pub(super) fn collect_file_exports( } _ => { let bucket = ExportBucket::from_export(export); + let suppression_location = current_suppression_location(file, export); out.push(ExportOccurrence { name: export.name.clone(), bucket, @@ -143,13 +133,8 @@ pub(super) fn collect_file_exports( line: export.line, kind: export_kind_str(&export.kind).to_string(), origin: origin_for_export(file, export, bucket), - suppressed: file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID) - || has_disable_line_comment(&file.source, export.line, RULE_ID), - suppression_location: (file.disabled - || has_disable_comment(&file.source, export.line, RULE_ID) - || has_disable_line_comment(&file.source, export.line, RULE_ID)) - .then(|| (file.rel.clone(), export.line)), + suppressed: suppression_location.is_some(), + suppression_location, }); } } @@ -162,8 +147,22 @@ pub(super) fn collect_file_exports( pub(super) fn should_skip_export(file: &SourceFile, export: &Export) -> bool { export.name == "default" - || (!file.defer_suppression - && (has_disable_comment(&file.source, export.line, RULE_ID) - || has_disable_line_comment(&file.source, export.line, RULE_ID))) + || (!file.defer_suppression && current_suppression_location(file, export).is_some()) || super::nextjs::is_framework_export(&file.rel, &export.name, file.is_nextjs_project) } + +pub(super) fn current_suppression_location( + file: &SourceFile, + export: &Export, +) -> Option<(String, u32)> { + (file.disabled + || has_disable_comment(&file.source, export.line, RULE_ID) + || has_disable_line_comment(&file.source, export.line, RULE_ID)) + .then(|| (file.rel.clone(), export.line)) +} + +fn suppressed_origin_location(origin: Option<&ExportOrigin>) -> Option<(String, u32)> { + origin + .filter(|origin| origin.suppressed) + .and_then(|origin| origin.suppression_location.clone()) +} diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs index 33b7c9c3a..a5159dce1 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs @@ -34,6 +34,32 @@ fn source_file(root: &Path, rel: &str, source: &str) -> SourceFile { } } +#[test] +fn current_suppression_location_reports_file_and_line_directives() { + let root = fixture("unique-exports-edge-cases"); + let visible = source_file(&root, "src/visible.ts", "export const Visible = 1;\n"); + let line_disabled = source_file( + &root, + "src/line-disabled.ts", + "// no-mistakes-disable-next-line unique-exports\nexport const Hidden = 1;\n", + ); + let mut file_disabled = visible.clone(); + file_disabled.disabled = true; + + assert_eq!( + collector::current_suppression_location(&visible, &visible.symbols.exports[0]), + None + ); + assert_eq!( + collector::current_suppression_location(&line_disabled, &line_disabled.symbols.exports[0]), + Some(("src/line-disabled.ts".to_string(), 2)) + ); + assert_eq!( + collector::current_suppression_location(&file_disabled, &file_disabled.symbols.exports[0]), + Some(("src/visible.ts".to_string(), 1)) + ); +} + fn suppression_origin_fixture() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/codebase/unique-exports-suppressed-origin") From a04f85ed315547aa7a3a82f9076451b34522cf06 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 12:05:52 -0700 Subject: [PATCH 55/62] fix: retain unique export suppression provenance --- .../src/check_runner/results/suppression.rs | 6 ++++-- .../src/codebase/unique_exports/findings.rs | 16 ++++++---------- .../src/codebase/unique_exports/types.rs | 4 ++++ .../src/napi_api/tests/check_suppression.rs | 5 +++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index 6d8022b99..ba31ac139 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -1,6 +1,7 @@ use no_mistakes::codebase::rules::RuleFinding; use no_mistakes::codebase::rules::{ - suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, + suppress_domain_findings_with_source_files, suppress_domain_findings_with_sources, + SuppressedFinding, SuppressionTarget, }; use no_mistakes::codebase::ts_source::SourceStore; use no_mistakes::codebase::unique_exports::UniqueExportFinding; @@ -104,7 +105,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { identity: None, }, )); - suppressed.extend(suppress_domain_findings_with_sources( + suppressed.extend(suppress_domain_findings_with_source_files( root, codebase, sources, @@ -116,6 +117,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { reason: &finding.message, identity: None, }, + |finding| finding.suppression_source_file.as_deref(), )); suppressed.sort(); suppressed diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 35d25bb5a..b96a90ad1 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -55,16 +55,8 @@ pub(super) fn unique_export_findings( { findings.push(UniqueExportFinding { rule: RULE_ID.to_string(), - file: duplicate - .suppression_location - .as_ref() - .map(|(file, _)| file.clone()) - .unwrap_or_else(|| duplicate.file.clone()), - line: duplicate - .suppression_location - .as_ref() - .map(|(_, line)| *line) - .unwrap_or(duplicate.line), + file: duplicate.file.clone(), + line: duplicate.line, export_name: name.clone(), export_kind: bucket.as_str().to_string(), message: format!( @@ -74,6 +66,10 @@ pub(super) fn unique_export_findings( first.file, first.line ), + suppression_source_file: duplicate + .suppression_location + .as_ref() + .map(|(file, _)| file.clone()) }); } } diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index 77ac24c2a..16c910e9c 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -17,6 +17,10 @@ pub struct UniqueExportFinding { pub export_name: String, pub export_kind: String, pub message: String, + /// Internal suppression provenance. The public diagnostic location remains + /// the re-export target; aggregate checking reads directives from here. + #[serde(skip)] + pub suppression_source_file: Option, } #[derive(Debug, Clone)] diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index ea8dd1014..126c40ada 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -222,8 +222,9 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports .is_some_and(|reason| reason.contains("chained")) }) && items.iter().any(|item| { item["rule"] == "unique-exports" - && item["file"] == "shared/suppressed-origin.ts" - && item["line"] == 5 + && item["file"] == "src/wild-barrel.ts" + && item["sourceFile"] == "shared/suppressed-origin.ts" + && item["line"] == 1 && item["directive"]["kind"] == "file" && item["directive"]["line"] == 3 && item["reason"] From cdbaebe8c50c11bc268aef7b32f56ab0280b8ed9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 12:07:47 -0700 Subject: [PATCH 56/62] fix: scope suppressed findings to check options --- packages/no-mistakes/analyze-project-types.d.ts | 3 ++- packages/no-mistakes/index.d.ts | 3 ++- packages/no-mistakes/scripts/api.test.js | 12 ++++++++++++ packages/no-mistakes/traversal-types.d.ts | 7 +++++-- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/no-mistakes/analyze-project-types.d.ts b/packages/no-mistakes/analyze-project-types.d.ts index 2ea5accc5..9bb093d94 100644 --- a/packages/no-mistakes/analyze-project-types.d.ts +++ b/packages/no-mistakes/analyze-project-types.d.ts @@ -9,6 +9,7 @@ import type { import type { PlaywrightOptions, PlaywrightRelatedOptions } from "./report-types"; import type { ProjectOptions, + CheckOptions, SymbolsListOptions, SymbolsSignatureImpactOptions, TraverseOptions, @@ -26,7 +27,7 @@ type BatchedReactUsagesOptions = Pick< > & Required>; type BatchedCheckOptions = Pick< - ProjectOptions, + CheckOptions, "root" | "tsconfig" | "config" | "includeSuppressed" >; diff --git a/packages/no-mistakes/index.d.ts b/packages/no-mistakes/index.d.ts index 992b7002c..f86044fc8 100644 --- a/packages/no-mistakes/index.d.ts +++ b/packages/no-mistakes/index.d.ts @@ -4,6 +4,7 @@ import type { AnalyzeProjectResult, CallSitesOptions, CallSitesResult, + CheckOptions, DeadExportsOptions, DeadExportsResult, DependencyResult, @@ -93,7 +94,7 @@ export function resolveCheck( ): Promise; export function fetches(options?: WithInvocationOptions): Promise; export function flow(options: WithInvocationOptions): Promise; -export function check(options?: WithInvocationOptions): Promise; +export function check(options?: WithInvocationOptions): Promise; export function validateMermaidMarkdown( options: WithInvocationOptions, ): Promise; diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 8349ee85c..5c0b68625 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -439,6 +439,18 @@ test("analyzeProject declarations mirror report-specific runtime requirements", /options: WithInvocationOptions,\n\): Promise;/, ); assert.match(traversalDeclarations, /mode: "signature-impact";\n symbol: string;/); + assert.match( + traversalDeclarations, + /export interface CheckOptions extends ProjectOptions \{[\s\S]*?includeSuppressed\?: boolean;/, + ); + assert.doesNotMatch( + traversalDeclarations, + /export interface ProjectOptions \{[^}]*includeSuppressed/, + ); + assert.match( + readFileSync(join(packageRoot, "index.d.ts"), "utf8"), + /check\(options\?: WithInvocationOptions\): Promise;/, + ); assert.match( analyzeProjectDeclarations, /type: "symbols"; id\?: string } & \(SymbolsListOptions \| SymbolsSignatureImpactOptions\)/, diff --git a/packages/no-mistakes/traversal-types.d.ts b/packages/no-mistakes/traversal-types.d.ts index f23a11a0d..2b72120e0 100644 --- a/packages/no-mistakes/traversal-types.d.ts +++ b/packages/no-mistakes/traversal-types.d.ts @@ -201,11 +201,14 @@ export interface ProjectOptions { roots?: string[]; depth?: number; assertNoFetch?: boolean; - /** Add deterministic accounting for findings hidden by no-mistakes directives. */ - includeSuppressed?: boolean; direction?: "deps" | "dependents" | "both"; /** `reactUsages` target component (`path` or `path#Symbol`). */ target?: string; /** `reactUsages` `--include` spec: comma-separated `stories,tests,props`. */ include?: string; } + +export interface CheckOptions extends ProjectOptions { + /** Add deterministic accounting for findings hidden by no-mistakes directives. */ + includeSuppressed?: boolean; +} From 41d793917357fd8f9b431bb36278dccedaeff8e3 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 12:11:34 -0700 Subject: [PATCH 57/62] style: format analyze project check options --- packages/no-mistakes/analyze-project-types.d.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/no-mistakes/analyze-project-types.d.ts b/packages/no-mistakes/analyze-project-types.d.ts index 9bb093d94..dee280e05 100644 --- a/packages/no-mistakes/analyze-project-types.d.ts +++ b/packages/no-mistakes/analyze-project-types.d.ts @@ -8,8 +8,8 @@ import type { } from "./named-query-types"; import type { PlaywrightOptions, PlaywrightRelatedOptions } from "./report-types"; import type { - ProjectOptions, CheckOptions, + ProjectOptions, SymbolsListOptions, SymbolsSignatureImpactOptions, TraverseOptions, @@ -26,10 +26,7 @@ type BatchedReactUsagesOptions = Pick< "root" | "tsconfig" | "config" | "targets" | "include" > & Required>; -type BatchedCheckOptions = Pick< - CheckOptions, - "root" | "tsconfig" | "config" | "includeSuppressed" ->; +type BatchedCheckOptions = Pick; export type AnalyzeProjectReportRequest = | ({ type: "dependencies" | "dependents" | "related"; id?: string } & BatchedTraverseOptions) From f10fdc28c1797e9c395e91a0668990ec7f34c885 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 12:32:21 -0700 Subject: [PATCH 58/62] fix: preserve unique export suppression lines --- .../src/check_runner/results/suppression.rs | 11 ++++-- crates/no-mistakes/src/codebase/rules/mod.rs | 4 +- .../src/codebase/rules/suppression.rs | 5 ++- .../codebase/rules/suppression/accounting.rs | 21 +++++++++- .../src/codebase/unique_exports/findings.rs | 38 ++++++++++++++++--- .../src/codebase/unique_exports/types.rs | 2 +- .../src/napi_api/tests/check_suppression.rs | 34 +++++++++++++++++ .../.no-mistakes.yml | 8 ++++ .../shared/line-origin.ts | 3 ++ .../shared/next-origin.ts | 3 ++ .../src/active-a.ts | 1 + .../src/active-b.ts | 1 + .../src/line-barrel.ts | 1 + .../src/line-visible.ts | 1 + .../src/next-barrel.ts | 1 + .../src/next-visible.ts | 1 + .../tsconfig.json | 1 + 17 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 fixtures/check/suppression-unique-origin-lines/.no-mistakes.yml create mode 100644 fixtures/check/suppression-unique-origin-lines/shared/line-origin.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/shared/next-origin.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/src/active-a.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/src/active-b.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/src/line-barrel.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/src/line-visible.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/src/next-barrel.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/src/next-visible.ts create mode 100644 fixtures/check/suppression-unique-origin-lines/tsconfig.json diff --git a/crates/no-mistakes/src/check_runner/results/suppression.rs b/crates/no-mistakes/src/check_runner/results/suppression.rs index ba31ac139..09c3d5277 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -1,6 +1,6 @@ use no_mistakes::codebase::rules::RuleFinding; use no_mistakes::codebase::rules::{ - suppress_domain_findings_with_source_files, suppress_domain_findings_with_sources, + suppress_domain_findings_with_source_locations, suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, }; use no_mistakes::codebase::ts_source::SourceStore; @@ -105,7 +105,7 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { identity: None, }, )); - suppressed.extend(suppress_domain_findings_with_source_files( + suppressed.extend(suppress_domain_findings_with_source_locations( root, codebase, sources, @@ -117,7 +117,12 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { reason: &finding.message, identity: None, }, - |finding| finding.suppression_source_file.as_deref(), + |finding| { + finding + .suppression_source_location + .as_ref() + .map(|(file, line)| (file.as_str(), usize::try_from(*line).ok())) + }, )); suppressed.sort(); suppressed diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 53da6588f..3cece9db2 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -87,8 +87,8 @@ pub(crate) use file_matching::matching_files; pub(crate) use source_access::{read_source, source_store_for_files}; #[doc(hidden)] pub use suppression::{ - suppress_domain_findings_with_source_files, suppress_domain_findings_with_sources, - SuppressedFinding, SuppressionTarget, + suppress_domain_findings_with_source_files, suppress_domain_findings_with_source_locations, + suppress_domain_findings_with_sources, SuppressedFinding, SuppressionTarget, }; pub(crate) use suppression::{ suppress_rule_findings_with_source, suppress_rule_findings_with_sources, diff --git a/crates/no-mistakes/src/codebase/rules/suppression.rs b/crates/no-mistakes/src/codebase/rules/suppression.rs index 4090b0661..969f72425 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -4,8 +4,9 @@ use std::path::{Path, PathBuf}; mod accounting; pub use accounting::{ - suppress_domain_findings_with_source_files, suppress_domain_findings_with_sources, - SuppressedFinding, SuppressionDirective, SuppressionDirectiveKind, SuppressionTarget, + suppress_domain_findings_with_source_files, suppress_domain_findings_with_source_locations, + suppress_domain_findings_with_sources, SuppressedFinding, SuppressionDirective, + SuppressionDirectiveKind, SuppressionTarget, }; pub(crate) fn suppress_rule_findings_with_sources_except( diff --git a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs index e8832ee48..a044d8f72 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -70,13 +70,30 @@ pub fn suppress_domain_findings_with_source_files( sources: &crate::codebase::ts_source::SourceStore, describe: impl Fn(&T) -> SuppressionTarget<'_>, source_file: impl Fn(&T) -> Option<&str>, +) -> Vec { + suppress_domain_findings_with_source_locations(root, findings, sources, describe, |finding| { + source_file(finding).map(|file| (file, None)) + }) +} + +/// Retain findings while reading directives from an internal provenance +/// location. The public finding location remains the target location, while +/// line-specific directives are matched against the provenance source line. +#[doc(hidden)] +pub fn suppress_domain_findings_with_source_locations( + root: &Path, + findings: &mut Vec, + sources: &crate::codebase::ts_source::SourceStore, + describe: impl Fn(&T) -> SuppressionTarget<'_>, + source_location: impl Fn(&T) -> Option<(&str, Option)>, ) -> Vec { let lexical_root = crate::codebase::ts_source::normalize_discovery_path(root); let mut cached_sources = HashMap::new(); let mut suppressed = Vec::new(); findings.retain(|finding| { let target = describe(finding); - let source_file = source_file(finding).unwrap_or(target.file); + let (source_file, source_line) = + source_location(finding).unwrap_or((target.file, target.line)); let source = cached_sources .entry(source_file.to_string()) .or_insert_with(|| { @@ -91,7 +108,7 @@ pub fn suppress_domain_findings_with_source_files( }); let Some(directive) = source .as_deref() - .and_then(|source| matching_directive(source, target.rule, target.line)) + .and_then(|source| matching_directive(source, target.rule, source_line)) else { return true; }; diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index b96a90ad1..ee1f15205 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -45,10 +45,41 @@ pub(super) fn unique_export_findings( // Preserve the visible duplicate representative in both ordinary and // audit reports. Suppressed canonical provenance is retained by the // occurrence metadata for accounting, not by changing this selection. - let first = unique_occurrences + let first_active = unique_occurrences .iter() .find(|occurrence| !occurrence.suppressed) .unwrap_or(&unique_occurrences[0]); + // An origin directive is only discoverable during deferred audit + // analysis. Keep its lexically canonical re-export as the active + // comparison point so audit mode preserves ordinary output, and add a + // sidecar finding solely for directive accounting below. + let suppressed_origin_canonical = unique_occurrences.first().filter(|occurrence| { + occurrence.suppressed + && occurrence + .suppression_location + .as_ref() + .is_some_and(|(file, _)| file != &occurrence.file) + }); + let first = suppressed_origin_canonical.unwrap_or(first_active); + if let Some(canonical) = suppressed_origin_canonical { + if !std::ptr::eq(canonical, first_active) { + findings.push(UniqueExportFinding { + rule: RULE_ID.to_string(), + file: canonical.file.clone(), + line: canonical.line, + export_name: name.clone(), + export_kind: bucket.as_str().to_string(), + message: format!( + "{} `{}` is already exported from {}:{}; rename or consolidate this exported API", + bucket.message_label(), + name, + first_active.file, + first_active.line + ), + suppression_source_location: canonical.suppression_location.clone(), + }); + } + } for duplicate in unique_occurrences .iter() .filter(|item| !std::ptr::eq(*item, first)) @@ -66,10 +97,7 @@ pub(super) fn unique_export_findings( first.file, first.line ), - suppression_source_file: duplicate - .suppression_location - .as_ref() - .map(|(file, _)| file.clone()) + suppression_source_location: duplicate.suppression_location.clone(), }); } } diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index 16c910e9c..38802581d 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -20,7 +20,7 @@ pub struct UniqueExportFinding { /// Internal suppression provenance. The public diagnostic location remains /// the re-export target; aggregate checking reads directives from here. #[serde(skip)] - pub suppression_source_file: Option, + pub suppression_source_location: Option<(String, u32)>, } #[derive(Debug, Clone)] diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 126c40ada..dde7adc35 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -243,6 +243,40 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports })); } +#[test] +fn check_json_matches_origin_line_directives_for_reexport_suppression_audits() { + let (baseline, audit) = baseline_and_audit("suppression-unique-origin-lines"); + for report in [&baseline, &audit] { + assert!(report["codebase"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["rule"] == "unique-exports" + && item["exportName"] == "active" + && item["file"] == "src/active-b.ts" + && item["line"] == 1 + }) + })); + } + + let suppressed = audit["suppressed"].as_array().unwrap(); + assert_eq!(suppressed.len(), 2, "{audit}"); + assert!(suppressed.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "src/line-barrel.ts" + && item["sourceFile"] == "shared/line-origin.ts" + && item["line"] == 1 + && item["directive"]["kind"] == "line" + && item["directive"]["line"] == 3 + })); + assert!(suppressed.iter().any(|item| { + item["rule"] == "unique-exports" + && item["file"] == "src/next-barrel.ts" + && item["sourceFile"] == "shared/next-origin.ts" + && item["line"] == 1 + && item["directive"]["kind"] == "nextLine" + && item["directive"]["line"] == 2 + })); +} + #[test] fn check_json_keeps_named_reexport_duplicates_when_auditing_suppression() { let (baseline, audit) = baseline_and_audit("suppression-unique-canonical"); diff --git a/fixtures/check/suppression-unique-origin-lines/.no-mistakes.yml b/fixtures/check/suppression-unique-origin-lines/.no-mistakes.yml new file mode 100644 index 000000000..47aafcfbc --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/.no-mistakes.yml @@ -0,0 +1,8 @@ +projects: + source: + type: library + root: src + +rules: + - rule: unique-exports + projects: [source] diff --git a/fixtures/check/suppression-unique-origin-lines/shared/line-origin.ts b/fixtures/check/suppression-unique-origin-lines/shared/line-origin.ts new file mode 100644 index 000000000..fded64d22 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/shared/line-origin.ts @@ -0,0 +1,3 @@ +// Keep the origin line distinct from its barrel's public finding line. +// This duplicate is a supported compatibility export. +export const lineOrigin = 1; // no-mistakes-disable-line unique-exports: origin compatibility export diff --git a/fixtures/check/suppression-unique-origin-lines/shared/next-origin.ts b/fixtures/check/suppression-unique-origin-lines/shared/next-origin.ts new file mode 100644 index 000000000..049cb4752 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/shared/next-origin.ts @@ -0,0 +1,3 @@ +// Keep this directive and its target distinct from the barrel location. +// no-mistakes-disable-next-line unique-exports: origin compatibility export +export const nextOrigin = 1; diff --git a/fixtures/check/suppression-unique-origin-lines/src/active-a.ts b/fixtures/check/suppression-unique-origin-lines/src/active-a.ts new file mode 100644 index 000000000..ecf5e36b0 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/src/active-a.ts @@ -0,0 +1 @@ +export const active = 1; diff --git a/fixtures/check/suppression-unique-origin-lines/src/active-b.ts b/fixtures/check/suppression-unique-origin-lines/src/active-b.ts new file mode 100644 index 000000000..7a93c20e1 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/src/active-b.ts @@ -0,0 +1 @@ +export const active = 2; diff --git a/fixtures/check/suppression-unique-origin-lines/src/line-barrel.ts b/fixtures/check/suppression-unique-origin-lines/src/line-barrel.ts new file mode 100644 index 000000000..4225a85d6 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/src/line-barrel.ts @@ -0,0 +1 @@ +export { lineOrigin } from '../shared/line-origin'; diff --git a/fixtures/check/suppression-unique-origin-lines/src/line-visible.ts b/fixtures/check/suppression-unique-origin-lines/src/line-visible.ts new file mode 100644 index 000000000..2e68e6e52 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/src/line-visible.ts @@ -0,0 +1 @@ +export const lineOrigin = 2; diff --git a/fixtures/check/suppression-unique-origin-lines/src/next-barrel.ts b/fixtures/check/suppression-unique-origin-lines/src/next-barrel.ts new file mode 100644 index 000000000..b23055962 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/src/next-barrel.ts @@ -0,0 +1 @@ +export { nextOrigin } from '../shared/next-origin'; diff --git a/fixtures/check/suppression-unique-origin-lines/src/next-visible.ts b/fixtures/check/suppression-unique-origin-lines/src/next-visible.ts new file mode 100644 index 000000000..19047235f --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/src/next-visible.ts @@ -0,0 +1 @@ +export const nextOrigin = 2; diff --git a/fixtures/check/suppression-unique-origin-lines/tsconfig.json b/fixtures/check/suppression-unique-origin-lines/tsconfig.json new file mode 100644 index 000000000..0bfe0a4c7 --- /dev/null +++ b/fixtures/check/suppression-unique-origin-lines/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"ESNext"}} From 1f751e82b60466f601d07cb888831cecec0d34e3 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 12:53:21 -0700 Subject: [PATCH 59/62] fix: preserve active unique export parity --- .../src/codebase/unique_exports/findings.rs | 108 +++++++++--------- .../src/napi_api/tests/check_suppression.rs | 14 +++ 2 files changed, 68 insertions(+), 54 deletions(-) diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index ee1f15205..855a8907f 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -42,66 +42,66 @@ pub(super) fn unique_export_findings( if unique_occurrences.len() < 2 { continue; } - // Preserve the visible duplicate representative in both ordinary and - // audit reports. Suppressed canonical provenance is retained by the - // occurrence metadata for accounting, not by changing this selection. - let first_active = unique_occurrences + let active_occurrences = unique_occurrences .iter() - .find(|occurrence| !occurrence.suppressed) - .unwrap_or(&unique_occurrences[0]); - // An origin directive is only discoverable during deferred audit - // analysis. Keep its lexically canonical re-export as the active - // comparison point so audit mode preserves ordinary output, and add a - // sidecar finding solely for directive accounting below. - let suppressed_origin_canonical = unique_occurrences.first().filter(|occurrence| { - occurrence.suppressed - && occurrence - .suppression_location - .as_ref() - .is_some_and(|(file, _)| file != &occurrence.file) - }); - let first = suppressed_origin_canonical.unwrap_or(first_active); - if let Some(canonical) = suppressed_origin_canonical { - if !std::ptr::eq(canonical, first_active) { - findings.push(UniqueExportFinding { - rule: RULE_ID.to_string(), - file: canonical.file.clone(), - line: canonical.line, - export_name: name.clone(), - export_kind: bucket.as_str().to_string(), - message: format!( - "{} `{}` is already exported from {}:{}; rename or consolidate this exported API", - bucket.message_label(), - name, - first_active.file, - first_active.line - ), - suppression_source_location: canonical.suppression_location.clone(), - }); + .filter(|occurrence| !occurrence.suppressed) + .collect::>(); + // Baseline and audit reports must select their public duplicate from + // the same active occurrences. Suppressed occurrences below are only + // sidecars for directive accounting. + let first_active = active_occurrences.first().copied(); + if let Some(first_active) = first_active { + for duplicate in active_occurrences.into_iter().skip(1) { + findings.push(finding(duplicate, first_active, &name, bucket, None)); } } - for duplicate in unique_occurrences - .iter() - .filter(|item| !std::ptr::eq(*item, first)) - { - findings.push(UniqueExportFinding { - rule: RULE_ID.to_string(), - file: duplicate.file.clone(), - line: duplicate.line, - export_name: name.clone(), - export_kind: bucket.as_str().to_string(), - message: format!( - "{} `{}` is already exported from {}:{}; rename or consolidate this exported API", - bucket.message_label(), - name, - first.file, - first.line - ), - suppression_source_location: duplicate.suppression_location.clone(), - }); + + // Retain every suppressed duplicate as an accounting sidecar without + // permitting it to become the public comparison anchor. When every + // occurrence is suppressed, preserve the normal n - 1 cardinality. + let sidecar_anchor = first_active.or_else(|| unique_occurrences.first()); + if let Some(sidecar_anchor) = sidecar_anchor { + for suppressed in unique_occurrences + .iter() + .filter(|occurrence| occurrence.suppressed) + { + if !std::ptr::eq(suppressed, sidecar_anchor) { + findings.push(finding( + suppressed, + sidecar_anchor, + &name, + bucket, + suppressed.suppression_location.clone(), + )); + } + } } } findings.sort(); findings.dedup(); Ok(findings) } + +fn finding( + duplicate: &ExportOccurrence, + first: &ExportOccurrence, + name: &str, + bucket: ExportBucket, + suppression_source_location: Option<(String, u32)>, +) -> UniqueExportFinding { + UniqueExportFinding { + rule: RULE_ID.to_string(), + file: duplicate.file.clone(), + line: duplicate.line, + export_name: name.to_string(), + export_kind: bucket.as_str().to_string(), + message: format!( + "{} `{}` is already exported from {}:{}; rename or consolidate this exported API", + bucket.message_label(), + name, + first.file, + first.line + ), + suppression_source_location, + } +} diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index dde7adc35..7dfecf3ad 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -210,6 +210,20 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports &check_json_impl(json!({ "root": root, "includeSuppressed": true }).to_string()).unwrap(), ) .unwrap(); + // `wildOnly` has one visible export and one wildcard re-export whose + // source directive is suppressed. Audit mode may account for that + // directive, but must not turn the suppressed wildcard into a public + // duplicate anchor. + let active_wild_only = |report: &serde_json::Value| { + report["codebase"] + .as_array() + .unwrap() + .iter() + .filter(|item| item["exportName"] == "wildOnly") + .cloned() + .collect::>() + }; + assert_eq!(active_wild_only(&audit), active_wild_only(&baseline)); assert!(audit["suppressed"].as_array().is_some_and(|items| { items.iter().any(|item| { item["rule"] == "unique-exports" From 729fff7679f9fc232f23541a6c8db1b25788b872 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 13:25:50 -0700 Subject: [PATCH 60/62] fix: preserve origin suppression parity --- .../src/codebase/unique_exports/findings.rs | 13 +++++++++--- .../src/codebase/unique_exports/origin.rs | 5 ++++- .../src/napi_api/tests/check_suppression.rs | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 855a8907f..d6e1a8047 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -30,7 +30,7 @@ pub(super) fn unique_export_findings( for occurrence in occurrences { let origin = occurrence.origin.clone(); if let Some(index) = origin_indices.get(&origin).copied() { - if unique_occurrences[index].suppressed && !occurrence.suppressed { + if suppressed(&unique_occurrences[index]) && !suppressed(&occurrence) { unique_occurrences[index] = occurrence; } } else { @@ -44,7 +44,7 @@ pub(super) fn unique_export_findings( } let active_occurrences = unique_occurrences .iter() - .filter(|occurrence| !occurrence.suppressed) + .filter(|occurrence| !suppressed(occurrence)) .collect::>(); // Baseline and audit reports must select their public duplicate from // the same active occurrences. Suppressed occurrences below are only @@ -63,7 +63,7 @@ pub(super) fn unique_export_findings( if let Some(sidecar_anchor) = sidecar_anchor { for suppressed in unique_occurrences .iter() - .filter(|occurrence| occurrence.suppressed) + .filter(|occurrence| suppressed(occurrence)) { if !std::ptr::eq(suppressed, sidecar_anchor) { findings.push(finding( @@ -105,3 +105,10 @@ fn finding( suppression_source_location, } } + +fn suppressed(occurrence: &ExportOccurrence) -> bool { + // Re-export provenance can be populated independently from the legacy + // boolean while deferred aggregate suppression is resolving a source + // directive. Either representation means this occurrence is a sidecar. + occurrence.suppressed || occurrence.suppression_location.is_some() +} diff --git a/crates/no-mistakes/src/codebase/unique_exports/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/origin.rs index 363951012..8f739d8f7 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/origin.rs @@ -55,7 +55,10 @@ impl OriginSearch<'_, R> { .symbols .exports .iter() - .filter(|export| !super::collector::should_skip_export(file, export)) + // Origin lookup carries directive provenance to a re-export even + // for ordinary checks. The caller suppresses that re-export + // consistently with audit mode; only file-disabled sources stay + // absent through the early return above. .find_map(|export| self.find_export(file, export, imported)); self.visiting.remove(&target); found diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 7dfecf3ad..6c74885f2 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -260,6 +260,26 @@ fn check_json_propagates_origin_suppression_through_named_and_wildcard_reexports #[test] fn check_json_matches_origin_line_directives_for_reexport_suppression_audits() { let (baseline, audit) = baseline_and_audit("suppression-unique-origin-lines"); + // Both source-origin directive forms are accounting-only sidecars: they + // must not make their visible re-export partner an active duplicate. + let active_origin_reexports = |report: &serde_json::Value| { + report["codebase"] + .as_array() + .unwrap() + .iter() + .filter(|item| { + matches!( + item["exportName"].as_str(), + Some("lineOrigin" | "nextOrigin") + ) + }) + .cloned() + .collect::>() + }; + assert_eq!( + active_origin_reexports(&audit), + active_origin_reexports(&baseline), + ); for report in [&baseline, &audit] { assert!(report["codebase"].as_array().is_some_and(|items| { items.iter().any(|item| { From 530b01f1add8fcb0ceb685e0ae0dcee9682bedc5 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 13:51:18 -0700 Subject: [PATCH 61/62] fix: preserve public unique export findings --- .../no-mistakes/src/check_parallel/inputs.rs | 4 +-- .../no-mistakes/src/check_runner/results.rs | 10 ++++-- .../src/check_runner/results/suppression.rs | 12 +++---- crates/no-mistakes/src/check_runner/tests.rs | 4 +-- crates/no-mistakes/src/check_tasks.rs | 4 +-- crates/no-mistakes/src/check_tasks/tests.rs | 10 ++++-- .../src/codebase/unique_exports.rs | 4 +-- .../src/codebase/unique_exports/findings.rs | 35 ++++++++++--------- .../src/codebase/unique_exports/tests.rs | 12 +++++++ .../src/codebase/unique_exports/types.rs | 10 ++++-- .../src/codebase/unique_exports/with_facts.rs | 6 ++-- .../unique_exports/with_facts/prepared.rs | 8 +++-- .../with_facts/prepared/aggregate.rs | 8 ++--- .../with_facts/prepared/public.rs | 30 ++++++++++++++++ 14 files changed, 110 insertions(+), 47 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/public.rs diff --git a/crates/no-mistakes/src/check_parallel/inputs.rs b/crates/no-mistakes/src/check_parallel/inputs.rs index 1d56bb8a2..d255e5cbd 100644 --- a/crates/no-mistakes/src/check_parallel/inputs.rs +++ b/crates/no-mistakes/src/check_parallel/inputs.rs @@ -1,7 +1,7 @@ use crate::check_tasks::CheckTask; use no_mistakes::codebase::check_facts::CheckFactMap; use no_mistakes::codebase::rules::RuleFinding; -use no_mistakes::codebase::unique_exports::UniqueExportFinding; +use no_mistakes::codebase::unique_exports::PreparedUniqueExportFinding; use no_mistakes::integration_tests::IntegrationFinding; use no_mistakes::queue::CheckFinding; use no_mistakes::react_traits; @@ -12,7 +12,7 @@ pub(crate) type DomainResults = ( anyhow::Result>>, anyhow::Result>>, anyhow::Result>>, - anyhow::Result>>, + anyhow::Result>>, anyhow::Result>>, ); diff --git a/crates/no-mistakes/src/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index 97927d08c..caa1fa35a 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -2,7 +2,7 @@ use crate::check_parallel::DomainResults; use crate::check_tasks::CheckTask; use anyhow::Result; use no_mistakes::codebase::rules::RuleFinding; -use no_mistakes::codebase::unique_exports::UniqueExportFinding; +use no_mistakes::codebase::unique_exports::{PreparedUniqueExportFinding, UniqueExportFinding}; use no_mistakes::integration_tests::IntegrationFinding; use no_mistakes::queue::CheckFinding; use no_mistakes::react_traits; @@ -44,7 +44,7 @@ pub(crate) struct CompletedDomainChecks { pub(crate) queues: CheckTask>, pub(crate) rules: CheckTask>, pub(crate) integration: CheckTask>, - pub(crate) codebase: CheckTask>, + pub(crate) codebase: CheckTask>, pub(crate) filesystem_rules: CheckTask>, } @@ -136,7 +136,11 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result { pub(super) rule_suppression_sources: &'a [Option], pub(super) filesystem: &'a mut Vec, pub(super) integration: &'a mut Vec, - pub(super) codebase: &'a mut Vec, + pub(super) codebase: &'a mut Vec, pub(super) advisories: &'a mut Vec, } @@ -111,10 +111,10 @@ pub(super) fn apply(input: Inputs<'_>) -> Vec { sources, |finding| SuppressionTarget { domain: "codebase", - rule: &finding.rule, - file: &finding.file, - line: Some(finding.line as usize), - reason: &finding.message, + rule: &finding.finding.rule, + file: &finding.finding.file, + line: Some(finding.finding.line as usize), + reason: &finding.finding.message, identity: None, }, |finding| { diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 15df820ff..ac1d36de0 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -4,7 +4,7 @@ use crate::check_parallel::DomainResults; use crate::check_tasks::CheckTask; use anyhow::anyhow; use no_mistakes::codebase::rules::{RuleFinding, RUST_MAX_LINES_PER_FILE, RUST_NO_INLINE_TESTS}; -use no_mistakes::codebase::unique_exports::UniqueExportFinding; +use no_mistakes::codebase::unique_exports::PreparedUniqueExportFinding; use no_mistakes::integration_tests::IntegrationFinding; use no_mistakes::queue::CheckFinding; use no_mistakes::react_traits; @@ -470,7 +470,7 @@ fn ok_integration() -> anyhow::Result>> { Ok(empty_task(Vec::new())) } -fn ok_codebase() -> anyhow::Result>> { +fn ok_codebase() -> anyhow::Result>> { Ok(empty_task(Vec::new())) } diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index 1942bd454..23380175e 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -1,7 +1,7 @@ use anyhow::Result; use no_mistakes::codebase::check_facts::CheckFactMap; use no_mistakes::codebase::rules::{self, RuleFinding}; -use no_mistakes::codebase::unique_exports::{self, UniqueExportFinding}; +use no_mistakes::codebase::unique_exports::{self, PreparedUniqueExportFinding}; use no_mistakes::config::v2::NoMistakesConfig; use no_mistakes::integration_tests::{self, IntegrationFinding}; use no_mistakes::queue::CheckFinding; @@ -142,7 +142,7 @@ pub(crate) struct CodebaseCheckInputs<'a> { pub(crate) fn run_codebase_check_with_catalog( inputs: CodebaseCheckInputs<'_>, -) -> Result>> { +) -> Result>> { let CodebaseCheckInputs { session, root, diff --git a/crates/no-mistakes/src/check_tasks/tests.rs b/crates/no-mistakes/src/check_tasks/tests.rs index cd9e8e665..03c6f5647 100644 --- a/crates/no-mistakes/src/check_tasks/tests.rs +++ b/crates/no-mistakes/src/check_tasks/tests.rs @@ -1,7 +1,7 @@ use super::CheckTask; use anyhow::Result; use no_mistakes::codebase::check_facts::CheckFactMap; -use no_mistakes::codebase::unique_exports::{self, UniqueExportFinding}; +use no_mistakes::codebase::unique_exports::{self, PreparedUniqueExportFinding}; use std::path::PathBuf; pub(crate) fn run_codebase_check( @@ -12,7 +12,7 @@ pub(crate) fn run_codebase_check( enabled: bool, facts: &CheckFactMap, inferred_roots: &no_mistakes::codebase::config::InferredRoots, -) -> Result>> { +) -> Result>> { let (findings, duration) = no_mistakes::diagnostics::measure_if_enabled( "analysis.codebase", no_mistakes::diagnostics::TimingKind::Parallel, @@ -26,6 +26,12 @@ pub(crate) fn run_codebase_check( inferred_roots, session, )? + .into_iter() + .map(|finding| PreparedUniqueExportFinding { + finding, + suppression_source_location: None, + }) + .collect() } else { Vec::new() }) diff --git a/crates/no-mistakes/src/codebase/unique_exports.rs b/crates/no-mistakes/src/codebase/unique_exports.rs index eb0abffa0..2ec544684 100644 --- a/crates/no-mistakes/src/codebase/unique_exports.rs +++ b/crates/no-mistakes/src/codebase/unique_exports.rs @@ -18,7 +18,7 @@ use collector::collect_file_exports; use findings::unique_export_findings; use scan::{filter_source_files, sorted_paths}; use types::{ExportBucket, ExportOccurrence, ExportOrigin, SourceFile}; -pub use types::{UniqueExportFinding, UniqueExportsOptions}; +pub use types::{PreparedUniqueExportFinding, UniqueExportFinding, UniqueExportsOptions}; pub use with_facts::{ analyze_project_with_config_and_facts, analyze_project_with_facts, analyze_project_with_prepared_facts, analyze_project_with_prepared_facts_and_inferred, @@ -67,7 +67,7 @@ fn analyze_unique_exports( options: UniqueExportsOptions, resolver: R, workspace: crate::codebase::workspaces::WorkspaceMap, -) -> Result> { +) -> Result> { let by_path: HashMap = source_files .into_iter() .map(|file| (file.path.clone(), file)) diff --git a/crates/no-mistakes/src/codebase/unique_exports/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index d6e1a8047..529d8cc71 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -1,5 +1,6 @@ use super::types::{ - ExportBucket, ExportOccurrence, ExportOrigin, UniqueExportFinding, UniqueExportsOptions, + ExportBucket, ExportOccurrence, ExportOrigin, PreparedUniqueExportFinding, UniqueExportFinding, + UniqueExportsOptions, }; use super::RULE_ID; use anyhow::Result; @@ -8,7 +9,7 @@ use std::collections::BTreeMap; pub(super) fn unique_export_findings( occurrences: Vec, options: UniqueExportsOptions, -) -> Result> { +) -> Result> { let mut buckets: BTreeMap<(String, ExportBucket), Vec> = BTreeMap::new(); for occurrence in occurrences { buckets @@ -88,20 +89,22 @@ fn finding( name: &str, bucket: ExportBucket, suppression_source_location: Option<(String, u32)>, -) -> UniqueExportFinding { - UniqueExportFinding { - rule: RULE_ID.to_string(), - file: duplicate.file.clone(), - line: duplicate.line, - export_name: name.to_string(), - export_kind: bucket.as_str().to_string(), - message: format!( - "{} `{}` is already exported from {}:{}; rename or consolidate this exported API", - bucket.message_label(), - name, - first.file, - first.line - ), +) -> PreparedUniqueExportFinding { + PreparedUniqueExportFinding { + finding: UniqueExportFinding { + rule: RULE_ID.to_string(), + file: duplicate.file.clone(), + line: duplicate.line, + export_name: name.to_string(), + export_kind: bucket.as_str().to_string(), + message: format!( + "{} `{}` is already exported from {}:{}; rename or consolidate this exported API", + bucket.message_label(), + name, + first.file, + first.line + ), + }, suppression_source_location, } } diff --git a/crates/no-mistakes/src/codebase/unique_exports/tests.rs b/crates/no-mistakes/src/codebase/unique_exports/tests.rs index 691096c32..e6f5e4916 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests.rs @@ -29,6 +29,18 @@ fn finding_names(findings: &[UniqueExportFinding]) -> Vec<(String, String)> { .collect() } +#[test] +fn public_finding_keeps_six_field_construction_compatibility() { + let _ = UniqueExportFinding { + rule: RULE_ID.to_string(), + file: "src/example.ts".to_string(), + line: 1, + export_name: "example".to_string(), + export_kind: "value".to_string(), + message: "example".to_string(), + }; +} + #[test] fn standalone_unique_exports_honors_same_line_suppression_in_static_fixture() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/no-mistakes/src/codebase/unique_exports/types.rs b/crates/no-mistakes/src/codebase/unique_exports/types.rs index 38802581d..849591ca1 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -17,9 +17,13 @@ pub struct UniqueExportFinding { pub export_name: String, pub export_kind: String, pub message: String, - /// Internal suppression provenance. The public diagnostic location remains - /// the re-export target; aggregate checking reads directives from here. - #[serde(skip)] +} + +/// Internal aggregate-check sidecar; the public finding remains six fields. +#[doc(hidden)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct PreparedUniqueExportFinding { + pub finding: UniqueExportFinding, pub suppression_source_location: Option<(String, u32)>, } diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs index a58b8df3d..8e4d3167f 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs @@ -1,6 +1,8 @@ use super::{analyze_unique_exports, filter_source_files, load_codebase_config_with_path}; use super::{normalize_path, workspaces}; -use super::{ImportResolver, UniqueExportFinding, UniqueExportsOptions}; +use super::{ + ImportResolver, PreparedUniqueExportFinding, UniqueExportFinding, UniqueExportsOptions, +}; use crate::codebase::analysis_session::AnalysisSession; use crate::codebase::check_facts::CheckFactMap; use anyhow::Result; @@ -49,7 +51,7 @@ struct ProjectRootsAnalysis<'a> { fn analyze_project_roots_with_facts( inputs: ProjectRootsAnalysis<'_>, -) -> Result> { +) -> Result> { let ProjectRootsAnalysis { session, root, diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs index 0c3a52d47..e1c1f23c2 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs @@ -3,12 +3,14 @@ use crate::codebase::analysis_session::AnalysisSession; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::config::Config; use crate::codebase::ts_resolver::normalize_path; -use crate::codebase::unique_exports::{UniqueExportFinding, RULE_ID}; +use crate::codebase::unique_exports::{PreparedUniqueExportFinding, UniqueExportFinding, RULE_ID}; use anyhow::Result; use std::path::Path; mod aggregate; +mod public; pub use aggregate::analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_check; +use public::analyze_project_with_optional_prepared_facts; #[derive(Clone, Copy, Default)] pub(super) struct PreparedResolution<'a> { @@ -127,7 +129,7 @@ pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session( ) } -fn analyze_project_with_optional_prepared_facts( +pub(super) fn analyze_project_with_optional_prepared_facts_prepared( root: &Path, config: &Config, resolution: PreparedResolution<'_>, @@ -135,7 +137,7 @@ fn analyze_project_with_optional_prepared_facts( inferred_roots: Option<&crate::codebase::config::InferredRoots>, session: &AnalysisSession, defer_suppression: bool, -) -> Result> { +) -> Result> { let normalized_root = normalize_path(root); let root = normalized_root.as_path(); let applications = config.rule_applications_for(RULE_ID); diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs index 02eecbb5a..c487ca0ce 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs @@ -1,8 +1,8 @@ -use super::{analyze_project_with_optional_prepared_facts, PreparedResolution}; +use super::{analyze_project_with_optional_prepared_facts_prepared, PreparedResolution}; use crate::codebase::analysis_session::AnalysisSession; use crate::codebase::check_facts::CheckFactMap; use crate::codebase::config::Config; -use crate::codebase::unique_exports::UniqueExportFinding; +use crate::codebase::unique_exports::PreparedUniqueExportFinding; use anyhow::Result; use std::path::Path; @@ -17,8 +17,8 @@ pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session_for_ inferred_roots: &crate::codebase::config::InferredRoots, session: &AnalysisSession, defer_suppression: bool, -) -> Result> { - analyze_project_with_optional_prepared_facts( +) -> Result> { + analyze_project_with_optional_prepared_facts_prepared( root, config, PreparedResolution { diff --git a/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/public.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/public.rs new file mode 100644 index 000000000..37d209c37 --- /dev/null +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/public.rs @@ -0,0 +1,30 @@ +use super::{analyze_project_with_optional_prepared_facts_prepared, PreparedResolution}; +use crate::codebase::analysis_session::AnalysisSession; +use crate::codebase::check_facts::CheckFactMap; +use crate::codebase::config::Config; +use crate::codebase::unique_exports::UniqueExportFinding; +use anyhow::Result; +use std::path::Path; + +pub(super) fn analyze_project_with_optional_prepared_facts( + root: &Path, + config: &Config, + resolution: PreparedResolution<'_>, + shared: &CheckFactMap, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, + session: &AnalysisSession, + defer_suppression: bool, +) -> Result> { + Ok(analyze_project_with_optional_prepared_facts_prepared( + root, + config, + resolution, + shared, + inferred_roots, + session, + defer_suppression, + )? + .into_iter() + .map(|prepared| prepared.finding) + .collect()) +} From 5d2329ebd024d0e97a2169b5110ced34dcb1d7e5 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Tue, 11 Aug 2026 14:15:08 -0700 Subject: [PATCH 62/62] fix: preserve suppression provenance --- .../no-mistakes/src/check_runner/enabled.rs | 6 +- .../check_runner/results/suppression/react.rs | 56 +++++++++++++++---- crates/no-mistakes/src/integration_tests.rs | 4 +- .../src/integration_tests/checks.rs | 19 ++++++- .../src/integration_tests/tests_errors.rs | 6 +- .../no-mistakes/src/napi_api/tests/check.rs | 13 ++++- .../src/napi_api/tests/check_suppression.rs | 20 +++++++ .../.no-mistakes.yml | 7 +++ .../helpers/malformed-helper.mts | 2 + .../tests/active.test.mts | 6 ++ .../tests/disabled-malformed.test.mts | 2 + .../vitest.config.mts | 7 +++ 12 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 fixtures/check/suppression-integration-malformed-helper/.no-mistakes.yml create mode 100644 fixtures/check/suppression-integration-malformed-helper/helpers/malformed-helper.mts create mode 100644 fixtures/check/suppression-integration-malformed-helper/tests/active.test.mts create mode 100644 fixtures/check/suppression-integration-malformed-helper/tests/disabled-malformed.test.mts create mode 100644 fixtures/check/suppression-integration-malformed-helper/vitest.config.mts diff --git a/crates/no-mistakes/src/check_runner/enabled.rs b/crates/no-mistakes/src/check_runner/enabled.rs index a1a062316..7f0bed560 100644 --- a/crates/no-mistakes/src/check_runner/enabled.rs +++ b/crates/no-mistakes/src/check_runner/enabled.rs @@ -66,7 +66,11 @@ pub(crate) fn fact_plan(enabled: EnabledChecks) -> CheckFactPlan { storybook: enabled.storybook_stories, server_route_client_boundary: enabled.boundary_rules, raw_source: enabled.nextjs_api_routes, - source: enabled.dynamic_import_rules + // Integration parse failures must retain their directive text so the + // prepared integration projection can distinguish disabled suite + // tests from malformed imported helpers without a second source read. + source: enabled.integration + || enabled.dynamic_import_rules || enabled.nextjs_caching || enabled.unique_exports || enabled.storybook_stories, diff --git a/crates/no-mistakes/src/check_runner/results/suppression/react.rs b/crates/no-mistakes/src/check_runner/results/suppression/react.rs index 9e3e3fb7f..f9be98ef6 100644 --- a/crates/no-mistakes/src/check_runner/results/suppression/react.rs +++ b/crates/no-mistakes/src/check_runner/results/suppression/react.rs @@ -5,6 +5,7 @@ use super::*; struct ReactSuppressionFinding { finding: react_traits::Violation, line: Option, + source_location: Option<(String, usize)>, identity: String, } @@ -25,6 +26,7 @@ pub(crate) fn suppress_react( let mut parent_location = vec![ReactSuppressionFinding { finding: finding.clone(), line: None, + source_location: None, identity: identity.clone(), }]; let parent_suppressed = suppress_domain_findings_with_sources( @@ -42,11 +44,13 @@ pub(crate) fn suppress_react( targets .iter() .map(|target| ReactSuppressionFinding { - finding: react_traits::Violation { - file: target.file.clone(), - ..finding.clone() - }, - line: Some(target.line), + // Same-file fetches are the public diagnostic location. + // For inherited fetches, keep the parent as the target + // and use the child fetch only as directive provenance. + finding: finding.clone(), + line: (target.file == finding.file).then_some(target.line), + source_location: (target.file != finding.file) + .then(|| (target.file.clone(), target.line)), identity: identity.clone(), }) .collect() @@ -54,16 +58,42 @@ pub(crate) fn suppress_react( vec![ReactSuppressionFinding { finding: finding.clone(), line: None, + source_location: None, identity, }] }; - let target_suppressions = - suppress_domain_findings_with_sources(root, &mut locations, sources, react_target); + let target_suppressions = suppress_domain_findings_with_source_locations( + root, + &mut locations, + sources, + react_target, + |location| { + location + .source_location + .as_ref() + .map(|(file, line)| (file.as_str(), Some(*line))) + }, + ); if locations.is_empty() { // A React violation is one component-level diagnostic even when // several fetch locations contribute to it. Keep one deterministic - // directive record only after every contributing location is hidden. - suppressed.extend(target_suppressions.into_iter().next()); + // directive record for the first contributing location only after + // every location is hidden. + let first_target = targets + .iter() + .find(|target| target.file == finding.file) + .or_else(|| targets.first()) + .expect("non-empty suppression targets have a first target"); + let first_suppression = target_suppressions.iter().find(|suppression| { + suppression.file == finding.file + && suppression.source_file == first_target.file + && if first_target.file == finding.file { + suppression.line == Some(first_target.line) + } else { + suppression.line.is_none() + } + }); + suppressed.extend(first_suppression.or(target_suppressions.first()).cloned()); } else { clear_suppressed_first_fetch_detail(&mut finding, &targets, &locations); findings.push(finding); @@ -80,7 +110,13 @@ fn clear_suppressed_first_fetch_detail( return; }; let first_target_retained = locations.iter().any(|location| { - location.finding.file == first_target.file && location.line == Some(first_target.line) + location.source_location.as_ref().map_or_else( + || { + location.finding.file == first_target.file + && location.line == Some(first_target.line) + }, + |(file, line)| file == &first_target.file && *line == first_target.line, + ) }); if !first_target_retained { // The public detail belongs to the first local fetch. Once that target diff --git a/crates/no-mistakes/src/integration_tests.rs b/crates/no-mistakes/src/integration_tests.rs index 744fb1b8c..7222b6015 100644 --- a/crates/no-mistakes/src/integration_tests.rs +++ b/crates/no-mistakes/src/integration_tests.rs @@ -123,7 +123,7 @@ pub fn check_with_prepared_facts_and_session( return Ok(Vec::new()); } - fail_on_dropped_files(shared)?; + fail_on_dropped_files(root, &suites, shared)?; let analyses: std::collections::BTreeMap = shared .ts .iter() @@ -175,7 +175,7 @@ pub fn check_with_prepared_facts_catalog_and_session( return Ok(Vec::new()); } - fail_on_dropped_files(shared)?; + fail_on_dropped_files(root, &suites, shared)?; let analyses: std::collections::BTreeMap = shared .ts .iter() diff --git a/crates/no-mistakes/src/integration_tests/checks.rs b/crates/no-mistakes/src/integration_tests/checks.rs index 90645121a..c39e24f0c 100644 --- a/crates/no-mistakes/src/integration_tests/checks.rs +++ b/crates/no-mistakes/src/integration_tests/checks.rs @@ -3,16 +3,31 @@ use anyhow::Result; use std::path::Path; pub(super) fn fail_on_dropped_files( + root: &Path, + suites: &[types::Suite], shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result<()> { + let suite_globs = suites + .iter() + .map(|suite| { + Ok(( + project_config::build_globset(&suite.include)?, + project_config::build_globset(&suite.exclude)?, + )) + }) + .collect::>>()?; for (file, facts) in &shared.ts { if let Some(error) = &facts.parse_error { - if facts.source.as_deref().is_some_and(|source| { + let disabled_suite_test = facts.source.as_deref().is_some_and(|source| { crate::codebase::ts_source::has_disable_file_comment( source, "integration-test-no-mocks", ) - }) { + }) && suite_globs.iter().any(|(include, exclude)| { + let relative = crate::codebase::ts_source::relative_slash_path(root, file); + include.is_match(&relative) && !exclude.is_match(&relative) + }); + if disabled_suite_test { continue; } anyhow::bail!( diff --git a/crates/no-mistakes/src/integration_tests/tests_errors.rs b/crates/no-mistakes/src/integration_tests/tests_errors.rs index 7dc632a1d..7546293d8 100644 --- a/crates/no-mistakes/src/integration_tests/tests_errors.rs +++ b/crates/no-mistakes/src/integration_tests/tests_errors.rs @@ -88,7 +88,9 @@ fn check_with_facts_reports_dropped_helper_parse_errors() { #[test] fn file_disabled_parse_errors_do_not_abort_integration_checks() { let root = fixture("basic"); - let file = root.join("helpers/openai.mts"); + let file = root.join("backend/unit.test.mts"); + let config = crate::config::v2::load_v2_config(&root, None).unwrap(); + let suites = test_support::configured_suites(&root, &config).unwrap(); let mut shared = crate::codebase::check_facts::CheckFactMap::default(); shared.ts.insert( file, @@ -102,5 +104,5 @@ fn file_disabled_parse_errors_do_not_abort_integration_checks() { .into(), ); - checks::fail_on_dropped_files(&shared).unwrap(); + checks::fail_on_dropped_files(&root, &suites, &shared).unwrap(); } diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index 2e5ed7d22..4b0221827 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -354,7 +354,18 @@ fn check_json_records_one_react_suppression_per_component_after_all_fetches_are_ 1, "{value}" ); - assert!(react_suppressions.iter().all(|item| item["line"] == 3)); + assert!(react_suppressions.iter().any(|item| { + item["file"] == "app/Child.tsx" + && item["sourceFile"] == "app/Child.tsx" + && item["line"] == 3 + })); + // Fetcher also inherits Child, but its own suppressed fetch remains the + // component diagnostic target instead of inheriting the child's line. + assert!(react_suppressions.iter().any(|item| { + item["file"] == "app/Fetcher.tsx" + && item["sourceFile"] == "app/Fetcher.tsx" + && item["line"] == 5 + })); } #[test] diff --git a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs index 6c74885f2..6f79c11d0 100644 --- a/crates/no-mistakes/src/napi_api/tests/check_suppression.rs +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -392,6 +392,11 @@ fn check_json_keeps_inherited_react_suppressions_distinct_by_parent_component() item["reason"] .as_str() .is_some_and(|reason| reason.contains("ParentB")) + && item["file"] == "app/ParentB.tsx" + && item["sourceFile"] == "app/Child.tsx" + && item["line"].is_null() + && item["directive"]["kind"] == "nextLine" + && item["directive"]["line"] == 4 })); assert!(parents.iter().any(|item| { item["reason"] @@ -402,3 +407,18 @@ fn check_json_keeps_inherited_react_suppressions_distinct_by_parent_component() && item["directive"]["kind"] == "file" })); } + +#[test] +fn check_json_rejects_disabled_malformed_helpers_reached_from_active_integration_tests() { + let root = static_check_fixture("suppression-integration-malformed-helper"); + let error = check_json_impl(json!({ "root": root }).to_string()).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("helpers/malformed-helper.mts"), + "{message}" + ); + assert!( + !message.contains("tests/disabled-malformed.test.mts"), + "{message}" + ); +} diff --git a/fixtures/check/suppression-integration-malformed-helper/.no-mistakes.yml b/fixtures/check/suppression-integration-malformed-helper/.no-mistakes.yml new file mode 100644 index 000000000..95ba65bb2 --- /dev/null +++ b/fixtures/check/suppression-integration-malformed-helper/.no-mistakes.yml @@ -0,0 +1,7 @@ +tests: + vitest: + configs: vitest.config.mts + projects: + unit: + integration_suites: + unit: [aws] diff --git a/fixtures/check/suppression-integration-malformed-helper/helpers/malformed-helper.mts b/fixtures/check/suppression-integration-malformed-helper/helpers/malformed-helper.mts new file mode 100644 index 000000000..62bd1cbaa --- /dev/null +++ b/fixtures/check/suppression-integration-malformed-helper/helpers/malformed-helper.mts @@ -0,0 +1,2 @@ +// no-mistakes-disable-file integration-test-no-mocks: intentionally malformed helper must not hide completeness errors +export const malformedHelper = diff --git a/fixtures/check/suppression-integration-malformed-helper/tests/active.test.mts b/fixtures/check/suppression-integration-malformed-helper/tests/active.test.mts new file mode 100644 index 000000000..d0d250b59 --- /dev/null +++ b/fixtures/check/suppression-integration-malformed-helper/tests/active.test.mts @@ -0,0 +1,6 @@ +import { test } from 'vitest' +import { malformedHelper } from '../helpers/malformed-helper.mts' + +test('imports the malformed helper', () => { + malformedHelper() +}) diff --git a/fixtures/check/suppression-integration-malformed-helper/tests/disabled-malformed.test.mts b/fixtures/check/suppression-integration-malformed-helper/tests/disabled-malformed.test.mts new file mode 100644 index 000000000..f47b25d4e --- /dev/null +++ b/fixtures/check/suppression-integration-malformed-helper/tests/disabled-malformed.test.mts @@ -0,0 +1,2 @@ +// no-mistakes-disable-file integration-test-no-mocks: intentionally malformed disabled suite test +export const = 'malformed' diff --git a/fixtures/check/suppression-integration-malformed-helper/vitest.config.mts b/fixtures/check/suppression-integration-malformed-helper/vitest.config.mts new file mode 100644 index 000000000..9decf082b --- /dev/null +++ b/fixtures/check/suppression-integration-malformed-helper/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [{ test: { name: 'unit', include: ['tests/**/*.test.mts'] } }], + }, +})