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; 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.rs b/crates/no-mistakes/src/check.rs index 4d2535c17..9bb288686 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 { @@ -77,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); @@ -112,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() @@ -120,3 +135,6 @@ fn has_failures(results: &check_runner::CheckResults) -> bool { || !results.codebase.is_empty() || !results.warnings.is_empty() } + +#[cfg(test)] +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/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 9e843557b..c25fdd976 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -1,58 +1,10 @@ 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_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}; - -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>, -} +mod inputs; +mod react_dispatch; +pub(crate) use inputs::{DomainCheckInputs, DomainResults}; pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults { let observer = no_mistakes::diagnostics::current(); @@ -75,20 +27,26 @@ 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; - 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; let ((react, queues), (rules, (integration, (codebase, filesystem_rules)))) = rayon::join( || { 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, + }) }) }, || { @@ -121,9 +79,11 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults prepared_tsconfig, prepared_tsconfig_catalog, inferred_roots: Some(inferred_roots), - sources: Some(&rule_sources), + sources: Some(sources.as_ref()), }, dependency_graph.as_deref(), + sources.as_ref(), + defer_suppression, ) }) }, @@ -148,15 +108,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, + }) }, ) }, @@ -171,13 +132,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(), }, Some(facts), + defer_suppression, ) }, ) 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..d255e5cbd --- /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::PreparedUniqueExportFinding; +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, +} 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.rs b/crates/no-mistakes/src/check_runner.rs index d528edb2d..a032d32d1 100644 --- a/crates/no-mistakes/src/check_runner.rs +++ b/crates/no-mistakes/src/check_runner.rs @@ -3,11 +3,11 @@ 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}; -pub(crate) use run_all::run_all; +pub(crate) use run_all::run_all_with_suppressed; #[cfg(test)] mod tests; 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/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/check_runner/results.rs b/crates/no-mistakes/src/check_runner/results.rs index e626d2f21..caa1fa35a 100644 --- a/crates/no-mistakes/src/check_runner/results.rs +++ b/crates/no-mistakes/src/check_runner/results.rs @@ -2,12 +2,17 @@ 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; use std::time::Duration; +mod advisories; +pub(crate) mod suppression; +#[cfg(test)] +mod suppression_tests; + pub(crate) struct FinalizeInput<'a> { pub(crate) root: &'a std::path::Path, pub(crate) config: &'a no_mistakes::config::v2::NoMistakesConfig, @@ -18,6 +23,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 +34,8 @@ pub(crate) struct CheckResults { pub(crate) codebase: Vec, pub(crate) warnings: Vec, pub(crate) advisories: Vec, + pub(crate) suppressed: Vec, + pub(crate) include_suppressed: bool, pub(crate) timings: Vec<(&'static str, Duration)>, } @@ -36,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>, } @@ -63,13 +71,24 @@ pub(crate) fn finalize_domain_checks(input: FinalizeInput<'_>) -> Result) -> Result) -> Result; 1]) -> CheckResults { codebase: Vec::new(), warnings, advisories: Vec::new(), + suppressed: Vec::new(), + include_suppressed: false, timings: vec![ ("discover", Duration::ZERO), ("parse_extract", Duration::ZERO), @@ -146,6 +186,8 @@ pub(crate) fn json_value(results: &CheckResults) -> serde_json::Value { codebase, warnings, advisories, + suppressed, + include_suppressed, timings, } = results; let _ = timings; @@ -158,6 +200,10 @@ pub(crate) fn json_value(results: &CheckResults) -> serde_json::Value { "warnings": warnings, "advisories": advisories, }); + if *include_suppressed { + 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/results/advisories.rs b/crates/no-mistakes/src/check_runner/results/advisories.rs new file mode 100644 index 000000000..62352596b --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/advisories.rs @@ -0,0 +1,21 @@ +use anyhow::Result; + +pub(super) fn collect( + enabled: bool, + include_suppressed: bool, + root: &std::path::Path, + config: &no_mistakes::config::v2::NoMistakesConfig, + files: &[std::path::PathBuf], + sources: &no_mistakes::codebase::ts_source::SourceStore, +) -> 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 new file mode 100644 index 000000000..12cb824a6 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/suppression.rs @@ -0,0 +1,151 @@ +use no_mistakes::codebase::rules::RuleFinding; +use no_mistakes::codebase::rules::{ + suppress_domain_findings_with_source_locations, suppress_domain_findings_with_sources, + SuppressedFinding, SuppressionTarget, +}; +use no_mistakes::codebase::ts_source::SourceStore; +use no_mistakes::codebase::unique_exports::PreparedUniqueExportFinding; +use no_mistakes::integration_tests::IntegrationFinding; +use no_mistakes::queue::CheckFinding; +use no_mistakes::react_traits; + +mod provenance; +use provenance::suppress_rules_with_sources; +mod react; +pub(crate) use react::suppress_react; + +pub(super) struct Inputs<'a> { + pub(super) root: &'a std::path::Path, + pub(super) sources: &'a SourceStore, + pub(super) react: &'a mut 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], + 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_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 { + suppressed + } else { + Vec::new() + } +} + +pub(super) fn apply(input: Inputs<'_>) -> Vec { + let Inputs { + root, + sources, + react, + react_suppression_targets, + queues, + rules, + rule_suppression_sources, + filesystem, + integration, + codebase, + advisories, + } = input; + let mut suppressed = Vec::new(); + 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, + sources, + |finding| SuppressionTarget { + domain: "queues", + rule: "queues-check", + file: &finding.file, + line: Some(finding.line), + reason: &finding.message, + identity: None, + }, + )); + 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( + root, + integration, + sources, + |finding| SuppressionTarget { + domain: "integration", + rule: "integration-test-no-mocks", + file: &finding.file, + line: Some(finding.line as usize), + reason: &finding.message, + identity: None, + }, + )); + suppressed.extend(suppress_domain_findings_with_source_locations( + root, + codebase, + sources, + |finding| SuppressionTarget { + domain: "codebase", + rule: &finding.finding.rule, + file: &finding.finding.file, + line: Some(finding.finding.line as usize), + reason: &finding.finding.message, + identity: None, + }, + |finding| { + finding + .suppression_source_location + .as_ref() + .map(|(file, line)| (file.as_str(), usize::try_from(*line).ok())) + }, + )); + suppressed.sort(); + 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, + identity: None, + }, + )); +} 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/results/suppression/react.rs b/crates/no-mistakes/src/check_runner/results/suppression/react.rs new file mode 100644 index 000000000..f9be98ef6 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/suppression/react.rs @@ -0,0 +1,141 @@ +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, + source_location: Option<(String, usize)>, + identity: String, +} + +pub(crate) 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, + source_location: 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 { + // 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() + } else { + vec![ReactSuppressionFinding { + finding: finding.clone(), + line: None, + source_location: None, + identity, + }] + }; + 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 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); + } + } +} + +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.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 + // 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 new file mode 100644 index 000000000..be16eca10 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/results/suppression_tests.rs @@ -0,0 +1,76 @@ +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, + }]; + let mut suppressed = Vec::new(); + 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); +} + +#[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/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs index f5ddf91df..c2d828dba 100644 --- a/crates/no-mistakes/src/check_runner/run_all.rs +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -8,10 +8,11 @@ use anyhow::{Context, Result}; use enabled::{fact_plan, integration_configured}; use std::path::PathBuf; -pub(crate) fn run_all( +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( @@ -101,7 +102,9 @@ pub(crate) fn run_all( 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", @@ -165,6 +168,9 @@ 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(), + // Ordinary checks preserve each domain's early suppression path; + // only audit requests need cross-domain suppression accounting. + defer_suppression: include_suppressed, }); no_mistakes::invocation::check_timeout()?; results::finalize_domain_checks(results::FinalizeInput { @@ -184,5 +190,6 @@ pub(crate) fn run_all( codebase, filesystem_rules, ))?, + include_suppressed, }) } diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 832824164..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; @@ -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") @@ -119,6 +127,7 @@ fn disabled_filesystem_check_returns_no_findings_without_dispatching_rules() { config_path: None, }, None, + false, ) .unwrap(); @@ -438,6 +447,8 @@ 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, } @@ -459,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_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index d016c7deb..9ad6ab387 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -26,11 +26,11 @@ 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", - "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!( @@ -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"); @@ -162,6 +163,7 @@ fn check_task_sources() -> String { [ include_str!("../../check_tasks.rs"), include_str!("../../check_tasks/filesystem.rs"), + include_str!("../../check_tasks/react.rs"), ] .concat() } @@ -255,9 +257,20 @@ 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_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/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/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index a9417949b..23380175e 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -1,55 +1,29 @@ 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; -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, - warning, - duration, - }) -} - pub(crate) fn run_queue_check( root: &std::path::Path, prepared_tsconfig_catalog: &std::sync::Arc, @@ -78,6 +52,8 @@ pub(crate) fn run_queue_check( let findings = findings?; Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), + suppression_sources: Vec::new(), warning: None, duration, }) @@ -86,20 +62,30 @@ 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, 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, + sources, + defer_suppression, + ) { + Ok(findings) => ((findings.findings, findings.suppression_sources), None), + Err(err) => ( + (Vec::new(), Vec::new()), + Some(format!("warning: rules check skipped: {err:#}")), + ), + }, + ); Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), + suppression_sources, warning, duration, }) @@ -135,32 +121,51 @@ pub(crate) fn run_integration_check( let findings = findings?; Ok(CheckTask { findings, + react_suppression_targets: Vec::new(), + suppression_sources: Vec::new(), warning: None, duration, }) } +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, -) -> Result>> { + 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, || -> 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, facts, inferred_roots, session, + defer_suppression, )? } else { Vec::new() @@ -170,6 +175,8 @@ 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 52b5b000a..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() }) @@ -69,6 +76,8 @@ 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..8b7191bfb --- /dev/null +++ b/crates/no-mistakes/src/check_tasks/react.rs @@ -0,0 +1,57 @@ +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, + sources: &no_mistakes::codebase::ts_source::SourceStore, + defer_suppression: bool, +) -> 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(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:#}")), + ), + } + } 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 1ba490ccd..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() }) @@ -33,6 +39,8 @@ 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/codebase/rules/agents_md_max_size.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size.rs index 03da6f985..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) } @@ -110,6 +129,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 +158,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..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 @@ -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,10 +25,17 @@ 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))); + findings.sort(); Ok(findings) } @@ -36,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(); @@ -43,21 +61,29 @@ 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))); + advisories.sort(); Ok(advisories) } -pub(super) fn check_content( +pub(super) fn check_content_with_deferred_suppression( path: &Path, root: &Path, max_lines: usize, max_chars: usize, content: &str, + defer_suppression: bool, ) -> Vec { - if has_disable_file_comment(content, RULE_ID) { + if !defer_suppression && has_disable_file_comment(content, RULE_ID) { return Vec::new(); } let file = relative_slash_path(root, path); @@ -98,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/codebase/rules/agents_md_max_size/tests.rs b/crates/no-mistakes/src/codebase/rules/agents_md_max_size/tests.rs index a1874fce9..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 @@ -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 { @@ -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/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/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_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 af2e42e2d..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 @@ -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] @@ -132,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); 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 98d461e81..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,15 +3,18 @@ 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, 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>, @@ -63,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, @@ -93,6 +119,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, @@ -106,97 +133,23 @@ 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) } 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(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: _, - 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(|_| { - let result = rust_rules_combined::check_with_files_and_sources( - root, - config, - candidates.rust_candidates(), - candidates.exclusive_rust_candidates(), - sources, - ); - 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/run_rule.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/run_rule.rs index fea38e021..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,21 +1,37 @@ 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>, -) -> 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_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/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index 0c8019446..a3e18edc6 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -378,7 +378,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); @@ -387,6 +387,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, @@ -404,7 +409,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] @@ -529,3 +534,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/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/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 1811dff15..3cece9db2 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; @@ -64,15 +65,17 @@ 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::*; #[doc(hidden)] -pub use run::canonical_graph_plan; +pub use run::run_check_with_config_facts_playwright_and_graph_with_suppression; #[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,26 +84,17 @@ 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_source_files, suppress_domain_findings_with_source_locations, + 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, }; -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/nextjs_no_api_routes.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes.rs index 34a3ae962..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,7 +8,7 @@ use anyhow::Result; use std::path::{Path, PathBuf}; mod aggregate; -pub(crate) use aggregate::{check_with_facts, check_with_facts_and_inferred}; +pub(crate) use aggregate::check_with_facts_for_aggregate; pub const RULE_ID: &str = "nextjs-no-api-routes"; @@ -36,8 +36,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..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,21 +4,14 @@ use anyhow::{bail, Context, Result}; use rayon::prelude::*; use std::path::{Path, PathBuf}; -pub(crate) fn check_with_facts( +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, None) -} - -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)) + check_with_optional_inferred(root, config, shared, inferred_roots, defer_suppression) } fn check_with_optional_inferred( @@ -26,6 +19,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 +49,7 @@ fn check_with_optional_inferred( |item| item.path, |item| item.source, inferred_roots, + defer_suppression, ) } @@ -98,6 +93,7 @@ pub(super) fn check_files( |item| item.path.as_path(), |item| item.source.as_ref(), None, + false, ) } @@ -108,6 +104,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 +132,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_api_routes/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_api_routes/tests.rs index c20adc336..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 @@ -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") @@ -89,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()); @@ -202,7 +226,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 +236,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)); } 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..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,21 +37,14 @@ pub fn check(root: &Path, config: &NoMistakesConfig) -> Result> check_files(&root, config, &files) } -pub(crate) fn check_with_facts( +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, None) -} - -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)) + check_with_optional_inferred(root, config, shared, inferred_roots, defer_suppression) } fn check_with_optional_inferred( @@ -59,6 +52,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 +90,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 +135,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 +149,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/nextjs_no_caching/tests.rs b/crates/no-mistakes/src/codebase/rules/nextjs_no_caching/tests.rs index 16629f22e..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 @@ -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; @@ -566,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:#}" + ); +} 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..263b3f84f 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; @@ -13,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; @@ -20,9 +24,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, -}; +pub(crate) use prepared::{check_with_prepared_facts_for_aggregate, PreparedStorybookCheck}; use selection::{component_disabled, file_disabled, selected_components}; use types::{GlobMatcher, Options}; @@ -53,21 +55,40 @@ pub fn configured_project_roots(root: &Path, config: &NoMistakesConfig) -> Vec, ) -> 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); + authorize_configured_sources(root, config, &sources); + let facts = collect_check_facts_with_graph_files_playwright_sources_and_session( + &session, root, - files, + (files, Vec::new()), CheckFactPlan { react: true, symbols: true, @@ -76,10 +97,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) + check_with_facts_and_catalog(root, config, &facts, &catalog, None, &sources, &session) } fn check_with_facts_and_catalog( @@ -88,9 +110,9 @@ 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, + session: &crate::codebase::analysis_session::AnalysisSession, ) -> Result> { - let session = - crate::codebase::analysis_session::AnalysisSession::new(crate::diagnostics::current()); let visible_files = shared .files() .iter() @@ -99,9 +121,17 @@ 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, config, shared, &resolver, inferred_roots) + 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..9968fd5b8 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 @@ -5,11 +5,34 @@ use std::path::{Path, PathBuf}; mod story_patterns; pub(super) use story_patterns::{extract_storybook_story_patterns, project_relative_pattern}; +pub(super) fn authorize_configured_sources( + root: &Path, + config: &NoMistakesConfig, + project_roots: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, +) { + let Some(configs) = config.tests.storybook.configs.as_ref() else { + return; + }; + for config_path in configs.values() { + for project_root in project_roots { + let path = resolve_storybook_config_path(root, project_root, &config_path); + if path.is_file() { + // Explicit config paths may be ignored or outside the visible + // inventory. SourceStore's bounded supplemental cache records + // this exact request-authorized path without a second store. + let _ = sources.read_path(&path); + } + } + } +} + pub(super) fn effective_story_patterns( root: &Path, 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 +41,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 d9a7a95f0..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,49 +5,34 @@ 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, - ) +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_and_inferred_and_session( - root: &Path, - config: &NoMistakesConfig, - prepared_tsconfig_catalog: &TsConfigCatalog, - shared: &CheckFactMap, - inferred_roots: &crate::codebase::config::InferredRoots, - session: &AnalysisSession, +pub(crate) fn check_with_prepared_facts_for_aggregate( + 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, shared, - Some(inferred_roots), + inferred_roots, session, - ) -} - -fn check_with_optional_inferred( - root: &Path, - config: &NoMistakesConfig, - prepared_tsconfig_catalog: &TsConfigCatalog, - shared: &CheckFactMap, - inferred_roots: Option<&crate::codebase::config::InferredRoots>, - session: &AnalysisSession, -) -> Result> { + defer_suppression, + sources, + } = input; let visible_files = shared .files() .iter() @@ -55,5 +40,13 @@ 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, + 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 82eadd105..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 @@ -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, @@ -21,6 +22,8 @@ struct RuleCheck<'a> { shared: &'a CheckFactMap, 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( @@ -29,6 +32,8 @@ pub(super) fn check_with_resolver( shared: &CheckFactMap, 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(); @@ -53,6 +58,8 @@ pub(super) fn check_with_resolver( shared, resolver, inferred_roots, + defer_suppression, + sources, })?); } sort_findings(&mut findings); @@ -68,13 +75,15 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { shared, 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); @@ -83,7 +92,10 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { root, project_root, shared, - &opts, + super::selection::SelectionOptions { + options: &opts, + defer_suppression, + }, &include, &exclude, &test_filter, @@ -92,6 +104,12 @@ 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, shared); + let suppression_filtered_component_keys: HashSet = components + .iter() + .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); let story_files = reachable_story_files( project_root, @@ -124,7 +142,7 @@ fn check_rule(inputs: RuleCheck<'_>) -> Result> { root, project_root, &opts, - &component_keys, + &suppression_filtered_component_keys, &allow_files, shared, )); @@ -141,8 +159,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..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,15 +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, ) -> 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 { @@ -48,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() { @@ -61,7 +68,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 { @@ -81,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/suppression.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs new file mode 100644 index 000000000..e7f48cb90 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/suppression.rs @@ -0,0 +1,53 @@ +use super::types::Component; +use crate::codebase::ts_resolver::normalize_path; +use crate::codebase::ts_source::{has_disable_comment, has_disable_file_comment}; +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(sources.get(&rooted_component_path)) + .map(Arc::as_ref) + .is_some_and(|source| { + // 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) + }) +} + +/// Index only selected components from the caller's authoritative fact map. +pub(super) fn component_suppression_sources( + root: &Path, + components: &[Component], + shared: &crate::codebase::check_facts::CheckFactMap, +) -> HashMap> { + components + .iter() + .map(|component| &component.file) + .filter_map(|path| { + let candidate = normalize_path(&if path.is_absolute() { + path.clone() + } else { + root.join(path) + }); + 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)) + }) + .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/require_storybook_stories/tests.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests.rs index 1bbce6b69..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 @@ -98,6 +98,87 @@ fn react_component(name: &str, file: &str, children: Vec) -> Compo } } +#[test] +fn deferred_suppression_sources_use_prepared_component_text() { + let root = fixture("comments"); + 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 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), + &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( + &root, &indexed, &component, + )); +} + +#[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/require_storybook_stories/tests/coverage_helpers.rs b/crates/no-mistakes/src/codebase/rules/require_storybook_stories/tests/coverage_helpers.rs index b03973e88..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,7 +380,10 @@ fn selection_and_transitive_helpers_cover_skip_paths() { &root, project_root, &shared, - &opts, + selection::SelectionOptions { + options: &opts, + defer_suppression: false, + }, &include, &exclude, &test_filter, 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..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"); @@ -153,12 +194,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 +216,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 +232,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 0fcd5b28a..a60e18ddf 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, + 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, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, }; @@ -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 f5f09c597..3fcd01024 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -1,17 +1,18 @@ 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, 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; use std::path::Path; mod execution; +#[cfg(test)] +mod tests; /// Preloaded inputs for the aggregate rules check. /// @@ -74,5 +75,15 @@ pub fn run_check_with_config_facts_playwright_and_graph( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, ) -> Result> { - execution::run(inputs, dependency_graph) + 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, 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 be66ce132..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}; +use helpers::{finalize_findings, storybook_findings, suppress_findings, StorybookFindingsRequest}; pub(super) fn run( inputs: PreparedRulesCheck<'_>, dependency_graph: Option<&DepGraph>, -) -> Result> { + 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,10 +31,13 @@ pub(super) fn run( prepared_tsconfig, prepared_tsconfig_catalog, inferred_roots, - sources, + sources: _, } = 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 { @@ -79,71 +92,81 @@ 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( - root, - config, - prepared_tsconfig, - prepared_tsconfig_catalog, - shared, - dependency_graph.expect("dynamic-import rule requires canonical graph"), - &session, + test_no_unmocked_dynamic_imports::check_with_prepared_facts_graph_and_session_with_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, + sources, + defer_suppression, + }, ) }, - )?); + )?; + suppression_sources.extend(dynamic_findings.suppression_sources); + findings.extend(dynamic_findings.findings); } 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, + )?; + suppression_sources.extend(std::iter::repeat_n(None, boundary_findings.len())); 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, + )?; + 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(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), - }?); + 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( + let storybook_findings = storybook_findings(StorybookFindingsRequest { root, config, prepared_tsconfig_catalog, shared, inferred_roots, - &session, - )?); + 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, @@ -154,8 +177,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, @@ -166,8 +190,11 @@ pub(super) fn run( dependency_graph, inferred_roots, ); - findings.extend(graph_findings?); - suppress_findings(root, &mut findings, sources); - sort_findings(&mut findings); - Ok(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); + } + 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 784871612..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 @@ -1,41 +1,62 @@ 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, -) -> 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( +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, + shared, + 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); +} + +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/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..c5296a354 100644 --- a/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs +++ b/crates/no-mistakes/src/codebase/rules/rust_rules_combined.rs @@ -28,12 +28,12 @@ pub(super) struct RustWork { pub(super) inline_allows: bool, } -pub(crate) fn check_with_files_and_sources( +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,13 +43,7 @@ pub(crate) fn check_with_files_and_sources( let mut findings: Vec = work .par_iter() .flat_map(|(path, work)| { - scan::scan_file( - root, - path, - work, - exclusive_files.binary_search(path).is_ok(), - sources, - ) + 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 30d0936ed..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 @@ -1,41 +1,42 @@ use super::*; -pub(super) fn scan_file( +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); - } 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( +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 +56,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/rules/rust_rules_combined/tests.rs b/crates/no-mistakes/src/codebase/rules/rust_rules_combined/tests.rs index 9c816e119..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 @@ -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, &sources, false) } fn config_with_rule(rule: &str) -> NoMistakesConfig { @@ -44,7 +46,9 @@ 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, &sources, false).is_empty() + ); } #[test] @@ -72,11 +76,14 @@ 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] -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); @@ -86,13 +93,25 @@ 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(); - assert_eq!(exclusive_sources.physical_read_count(), 0); + let exclusive = check_with_files_sources_and_deferred_suppression( + &root, + &config, + &files, + &exclusive_sources, + false, + ) + .unwrap(); + assert_eq!(exclusive_sources.physical_read_count(), 1); 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/server_route_client_boundary.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary.rs index 3dc77b78c..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 @@ -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), } } @@ -54,21 +52,14 @@ pub fn check(root: &Path, config: &NoMistakesConfig) -> Result> check_files(&root, config, &files) } -pub(crate) fn check_with_facts( +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, None) -} - -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)) + check_with_optional_inferred(root, config, shared, inferred_roots, defer_suppression) } fn check_with_optional_inferred( @@ -76,6 +67,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 +87,7 @@ fn check_with_optional_inferred( |item| item.path, |item| item.facts, inferred_roots, + defer_suppression, ) } @@ -105,6 +98,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 +147,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/server_route_client_boundary/tests.rs b/crates/no-mistakes/src/codebase/rules/server_route_client_boundary/tests.rs index 98090fd0c..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 @@ -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 { @@ -206,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"); 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 ccd3c9b53..969f72425 100644 --- a/crates/no-mistakes/src/codebase/rules/suppression.rs +++ b/crates/no-mistakes/src/codebase/rules/suppression.rs @@ -2,9 +2,12 @@ use super::RuleFinding; use std::collections::HashMap; use std::path::{Path, PathBuf}; -pub(crate) fn suppress_rule_findings(root: &Path, findings: &mut Vec) { - suppress_rule_findings_inner(root, findings, None, &[]); -} +mod accounting; +pub use accounting::{ + 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( root: &Path, @@ -27,7 +30,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>, @@ -110,3 +113,30 @@ 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 { + 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: line as usize, + }), + DisableDirective::Line { line } => Some(SuppressionDirective { + kind: SuppressionDirectiveKind::Line, + line: line as usize, + }), + DisableDirective::NextLine { line } => Some(SuppressionDirective { + kind: SuppressionDirectiveKind::NextLine, + line: line as usize, + }), + } +} 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..a044d8f72 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/suppression/accounting.rs @@ -0,0 +1,131 @@ +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, + /// 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)] +#[serde(rename_all = "camelCase")] +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. + 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 { + 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 { + 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_line) = + source_location(finding).unwrap_or((target.file, target.line)); + let source = cached_sources + .entry(source_file.to_string()) + .or_insert_with(|| { + let (candidate, is_absolute) = + finding_source_candidate(&lexical_root, source_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, source_line)) + else { + return true; + }; + suppressed.push(SuppressedFinding { + 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(), + |identity| format!("{} (component {identity})", target.reason), + ), + directive, + }); + false + }); + suppressed.sort(); + suppressed +} 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.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports.rs index 27d7dfc81..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 @@ -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_with_suppression, 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/config.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/config.rs index 867d7d940..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 @@ -39,25 +39,40 @@ 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(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 +85,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 +138,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(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/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/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..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 @@ -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,12 +33,35 @@ 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(")); } +#[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"); @@ -47,7 +72,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/reachable.rs b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable.rs index a26d891c7..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,100 +34,17 @@ pub(super) fn collect( mocks: &HashSet, dependency_cache: &DashMap>>, ) -> 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 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 !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 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 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, false) +} + +pub(super) fn collect_with_deferred_suppression( + ctx: ReachableContext<'_>, + test_file: &Path, + mocks: &HashSet, + dependency_cache: &DashMap>>, + defer_suppression: bool, +) -> 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..c2fb58a0c --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/reachable/deferred.rs @@ -0,0 +1,115 @@ +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; + } + // 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) { + 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) +} 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 3b3d7596c..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 @@ -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,15 +13,23 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; mod graph; +mod per_test; mod setup_mocks; mod tsconfig_catalog; pub(crate) use graph::check_with_prepared_facts_and_session; +#[derive(Default)] 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( @@ -34,10 +38,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)] @@ -50,18 +64,49 @@ 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> { + 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) sources: &'a crate::codebase::ts_source::SourceStore, + 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, + 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, + tsconfig_catalog, + shared, + graph, + session, + sources, + 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 @@ -74,7 +119,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(); @@ -96,79 +141,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()); - }; - if has_disable_file_comment(source, RULE_ID) { - return Ok(PerTestResult { - direct_findings: Vec::new(), - reachable_findings: Vec::new(), - covered_reachable_imports: HashSet::new(), - }); - } - 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 !has_disable_comment(source, import.line as u32, RULE_ID) { - check_dynamic_import(&mut check_context, import.clone()); - } - } - } - let reachable = reachable::collect( - 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, - )?; - Ok(PerTestResult { - direct_findings: local_findings, - reachable_findings: reachable.findings, - covered_reachable_imports: reachable.covered, - }) + file, + ) }) .collect::>>() })?; @@ -177,18 +165,37 @@ 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, + }) } + +#[cfg(test)] +mod tests; 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..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 @@ -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}; @@ -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,13 +29,14 @@ 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, - ) + 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..5943f70f3 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/test_no_unmocked_dynamic_imports/with_facts/per_test.rs @@ -0,0 +1,123 @@ +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, + reachable_suppression_file: file_disabled + .then(|| crate::codebase::ts_source::relative_slash_path(root, &file)), + 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/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") + ); + } +} 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/rules/tests/extended.rs b/crates/no-mistakes/src/codebase/rules/tests/extended.rs index 34036e6c6..2ffe5016b 100644 --- a/crates/no-mistakes/src/codebase/rules/tests/extended.rs +++ b/crates/no-mistakes/src/codebase/rules/tests/extended.rs @@ -108,6 +108,42 @@ fn run_check_with_facts_executes_valid_shared_facts() { assert_eq!(legacy, aggregate); } +#[test] +fn dynamic_import_check_uses_authoritative_source_fact_for_suppression() { + let root = dynamic_import_fixture(); + let test = root.join("tests/bad.test.mts"); + let mut facts = crate::codebase::check_facts::collect_check_facts( + &root, + crate::codebase::ts_source::discover_files(&root, &[]), + crate::codebase::check_facts::CheckFactPlan { + imports: true, + dynamic_imports: true, + source: true, + ..Default::default() + }, + ); + 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 = + format!("// no-mistakes-disable-file test-no-unmocked-dynamic-imports\n{physical_source}"); + facts.ts.insert( + test, + dynamic_import_test_facts(&root.join("tests/bad.test.mts"), &snapshot_source), + ); + let config = crate::config::v2::load_v2_config(&root, None).unwrap(); + let findings = + test_no_unmocked_dynamic_imports::check_with_facts(&root, &config, None, &facts).unwrap(); + assert!(!findings + .iter() + .any(|finding| finding.file == "tests/bad.test.mts")); +} + #[test] fn run_check_with_facts_resolves_setup_mocks() { let root = dynamic_import_fixture(); 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..308864133 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,14 @@ 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() +} + +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 { @@ -166,24 +170,22 @@ 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; - }; + let rest = leading_comment_text(rest)?; 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 { @@ -227,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..72d5ca8ed --- /dev/null +++ b/crates/no-mistakes/src/codebase/ts_source/disable_comments/directives.rs @@ -0,0 +1,28 @@ +#[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_comment(source, line, rule_id) { + return 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/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/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/codebase/unique_exports.rs b/crates/no-mistakes/src/codebase/unique_exports.rs index 9f75f6ccc..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, @@ -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"; @@ -66,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/collector.rs b/crates/no-mistakes/src/codebase/unique_exports/collector.rs index cc1ed669d..ebfb5ca31 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}; @@ -31,13 +31,12 @@ pub(super) fn collect_file_exports( memo.insert(path, out.clone()); return out; }; - if file.disabled { + 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) { @@ -63,6 +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(); + if let Some(location) = current_suppression_location(file, export) { + occurrence.suppression_location = Some(location); + occurrence.suppressed = true; + } if !super::nextjs::is_framework_export( &occurrence.file, &occurrence.name, @@ -90,6 +93,13 @@ 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_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 { @@ -109,10 +119,13 @@ pub(super) fn collect_file_exports( line: export.line, kind: export_kind_str(&export.kind).to_string(), origin, + suppressed: current_suppressed || origin_suppressed, + suppression_location, }); } _ => { let bucket = ExportBucket::from_export(export); + let suppression_location = current_suppression_location(file, export); out.push(ExportOccurrence { name: export.name.clone(), bucket, @@ -120,6 +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: suppression_location.is_some(), + suppression_location, }); } } @@ -132,6 +147,22 @@ 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) + || (!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/findings.rs b/crates/no-mistakes/src/codebase/unique_exports/findings.rs index 8855cd543..529d8cc71 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/findings.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/findings.rs @@ -1,12 +1,15 @@ -use super::types::{ExportBucket, ExportOccurrence, UniqueExportFinding, UniqueExportsOptions}; +use super::types::{ + ExportBucket, ExportOccurrence, ExportOrigin, PreparedUniqueExportFinding, UniqueExportFinding, + UniqueExportsOptions, +}; use super::RULE_ID; use anyhow::Result; -use std::collections::{BTreeMap, BTreeSet}; +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 @@ -23,33 +26,92 @@ 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 suppressed(&unique_occurrences[index]) && !suppressed(&occurrence) { + 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; } - let first = &unique_occurrences[0]; - for duplicate in unique_occurrences.iter().skip(1) { - 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 - ), - }); + let active_occurrences = unique_occurrences + .iter() + .filter(|occurrence| !suppressed(occurrence)) + .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)); + } + } + + // 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| suppressed(occurrence)) + { + 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)>, +) -> 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, + } +} + +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 032a44eb7..8f739d8f7 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}; @@ -42,6 +43,9 @@ impl OriginSearch<'_, R> { self.visiting.remove(&target); return None; }; + // 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; @@ -51,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 @@ -85,7 +92,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 +122,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 +169,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/scan.rs b/crates/no-mistakes/src/codebase/unique_exports/scan.rs index 95537c674..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(); @@ -35,12 +36,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 { @@ -51,9 +50,10 @@ 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), - 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..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,9 +28,10 @@ pub(crate) fn collect_source_files(root: &Path, files: &[PathBuf]) -> Result 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")) + .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"); @@ -306,7 +327,7 @@ fn collect_source_files_from_facts_reports_missing_fact_shapes() { let missing = crate::codebase::check_facts::CheckFactMap::default(); assert!( - scan::collect_source_files_from_facts(&root, &files, &missing) + scan::collect_source_files_from_facts(&root, &files, &missing, false) .unwrap_err() .to_string() .contains("missing shared facts") @@ -323,7 +344,7 @@ fn collect_source_files_from_facts_reports_missing_fact_shapes() { .into(), ); assert!( - scan::collect_source_files_from_facts(&root, &files, &parse_error) + scan::collect_source_files_from_facts(&root, &files, &parse_error, false) .unwrap_err() .to_string() .contains("bad syntax") @@ -332,7 +353,7 @@ fn collect_source_files_from_facts_reports_missing_fact_shapes() { let mut missing_source = crate::codebase::check_facts::CheckFactMap::default(); missing_source.ts.insert(file.clone(), Default::default()); assert!( - scan::collect_source_files_from_facts(&root, &files, &missing_source) + scan::collect_source_files_from_facts(&root, &files, &missing_source, false) .unwrap_err() .to_string() .contains("missing source facts") @@ -348,7 +369,7 @@ fn collect_source_files_from_facts_reports_missing_fact_shapes() { .into(), ); assert!( - scan::collect_source_files_from_facts(&root, &files, &missing_symbols) + scan::collect_source_files_from_facts(&root, &files, &missing_symbols, false) .unwrap_err() .to_string() .contains("missing symbol facts") 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..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 @@ -99,3 +99,145 @@ fn defensive_helpers_ignore_missing_targets_and_non_matching_default_exports() { None ); } + +#[test] +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"), + ); + 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; + // 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); + 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/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs b/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs index 5b14cb4e9..a5159dce1 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/tests/origin.rs @@ -29,10 +29,50 @@ fn source_file(root: &Path, rel: &str, source: &str) -> SourceFile { .unwrap() .into(), disabled: false, + defer_suppression: false, is_nextjs_project: false, } } +#[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") +} + +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, @@ -98,3 +138,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_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); + 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/barrel.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 ddf0a6a6d..849591ca1 100644 --- a/crates/no-mistakes/src/codebase/unique_exports/types.rs +++ b/crates/no-mistakes/src/codebase/unique_exports/types.rs @@ -19,6 +19,14 @@ pub struct UniqueExportFinding { pub message: String, } +/// 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)>, +} + #[derive(Debug, Clone)] pub(super) struct SourceFile { pub(super) path: PathBuf, @@ -26,6 +34,7 @@ pub(super) struct SourceFile { 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, } @@ -78,12 +87,46 @@ 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, + /// 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)] +#[derive(Debug, Clone)] pub(super) struct ExportOrigin { pub(super) file: String, pub(super) line: u32, pub(super) name: String, pub(super) bucket: ExportBucket, + 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/codebase/unique_exports/with_facts.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts.rs index 8ced830b4..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; @@ -9,7 +11,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, @@ -17,6 +19,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,12 +45,13 @@ struct ProjectRootsAnalysis<'a> { shared: &'a CheckFactMap, project_roots: Vec, options: UniqueExportsOptions, + defer_suppression: bool, inferred_roots: Option<&'a crate::codebase::config::InferredRoots>, } fn analyze_project_roots_with_facts( inputs: ProjectRootsAnalysis<'_>, -) -> Result> { +) -> Result> { let ProjectRootsAnalysis { session, root, @@ -56,6 +60,7 @@ fn analyze_project_roots_with_facts( shared, project_roots, options, + defer_suppression, inferred_roots, } = inputs; if project_roots.is_empty() { @@ -94,7 +99,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, @@ -138,26 +148,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| { @@ -187,11 +203,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/codebase/unique_exports/with_facts/prepared.rs b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared.rs index 517a2f711..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,10 +3,15 @@ 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> { pub(super) tsconfig_path: Option<&'a Path>, @@ -32,6 +37,7 @@ pub fn analyze_project_with_config_and_facts( shared, None, &session, + false, ) } @@ -53,6 +59,7 @@ pub fn analyze_project_with_prepared_facts( shared, None, &session, + false, ) } @@ -94,6 +101,7 @@ pub fn analyze_project_with_prepared_facts_and_inferred_and_session( shared, Some(inferred_roots), session, + false, ) } @@ -117,17 +125,19 @@ pub fn analyze_project_with_prepared_facts_catalog_and_inferred_and_session( shared, Some(inferred_roots), session, + false, ) } -fn analyze_project_with_optional_prepared_facts( +pub(super) fn analyze_project_with_optional_prepared_facts_prepared( root: &Path, config: &Config, resolution: PreparedResolution<'_>, shared: &CheckFactMap, inferred_roots: Option<&crate::codebase::config::InferredRoots>, session: &AnalysisSession, -) -> Result> { + defer_suppression: bool, +) -> Result> { let normalized_root = normalize_path(root); let root = normalized_root.as_path(); let applications = config.rule_applications_for(RULE_ID); @@ -154,6 +164,7 @@ fn analyze_project_with_optional_prepared_facts( shared, project_roots, options, + defer_suppression, inferred_roots, })?); } @@ -178,6 +189,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..c487ca0ce --- /dev/null +++ b/crates/no-mistakes/src/codebase/unique_exports/with_facts/prepared/aggregate.rs @@ -0,0 +1,33 @@ +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::PreparedUniqueExportFinding; +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, + defer_suppression: bool, +) -> Result> { + analyze_project_with_optional_prepared_facts_prepared( + root, + config, + PreparedResolution { + catalog: Some(tsconfig_catalog), + ..Default::default() + }, + shared, + Some(inferred_roots), + session, + defer_suppression, + ) +} 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()) +} 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/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 2ecec1558..c39e24f0c 100644 --- a/crates/no-mistakes/src/integration_tests/checks.rs +++ b/crates/no-mistakes/src/integration_tests/checks.rs @@ -3,10 +3,33 @@ 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 { + 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!( "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..7546293d8 100644 --- a/crates/no-mistakes/src/integration_tests/tests_errors.rs +++ b/crates/no-mistakes/src/integration_tests/tests_errors.rs @@ -84,3 +84,25 @@ 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("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, + 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(&root, &suites, &shared).unwrap(); +} 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..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 @@ -56,17 +56,18 @@ 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 && !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 @@ -104,6 +105,9 @@ impl SharedCheckContext { .prepared .tsconfig_gate_project_inputs .as_ref(), + // 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, @@ -113,42 +117,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, - }) + 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..3dcb01304 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,59 @@ 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_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"); + 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/cli_parity.rs b/crates/no-mistakes/src/napi_api/cli_parity.rs index 5a790c665..d254980ca 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)) @@ -143,9 +144,12 @@ pub(crate) fn impacted_checks_json_impl(options_json: String) -> napi::Result, 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.rs b/crates/no-mistakes/src/napi_api/tests.rs index 91fc19715..c087e6e03 100644 --- a/crates/no-mistakes/src/napi_api/tests.rs +++ b/crates/no-mistakes/src/napi_api/tests.rs @@ -382,11 +382,18 @@ 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"); + 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()); let root = crate::codebase::ts_resolver::normalize_path( &PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -455,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 [ @@ -548,5 +564,6 @@ mod async_task_tests; mod check; mod ci; mod react_usages; +mod suppression_contract_tests; mod tests_entrypoints; mod tests_sample_when_limited; diff --git a/crates/no-mistakes/src/napi_api/tests/check.rs b/crates/no-mistakes/src/napi_api/tests/check.rs index bafdba3c4..4b0221827 100644 --- a/crates/no-mistakes/src/napi_api/tests/check.rs +++ b/crates/no-mistakes/src/napi_api/tests/check.rs @@ -1,6 +1,54 @@ 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") + .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"); @@ -45,6 +93,302 @@ 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"].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] +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 (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!([])); +} + +#[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-filesystem", + "filesystem", + "no-empty-or-comments-only-files", + "file", + ), + ( + "suppression-integration", + "integration", + "integration-test-no-mocks", + "file", + ), + ]; + for (fixture, domain, rule, directive_kind) in fixtures { + let (baseline, audit) = baseline_and_audit(fixture); + let result_field = if domain == "filesystem" { + "rules" + } else { + domain + }; + assert!( + 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}: {audit}" + ); + } +} + +#[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_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_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().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 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")) + .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().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] +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/napi_api/tests/check_suppression.rs b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs new file mode 100644 index 000000000..6f79c11d0 --- /dev/null +++ b/crates/no-mistakes/src/napi_api/tests/check_suppression.rs @@ -0,0 +1,424 @@ +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_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) = + 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_preserves_reachable_suppression_provenance_for_disabled_tests() { + let (baseline, audit) = + baseline_and_audit("aggregate-test-no-unmocked-dynamic-imports-reachable-provenance"); + 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() + .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] +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["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] +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"); + 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_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] +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("wildOnly" | "TypeThing"))) + })); + let audit: serde_json::Value = serde_json::from_str( + &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" + && 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"] == "src/wild-barrel.ts" + && item["sourceFile"] == "shared/suppressed-origin.ts" + && item["line"] == 1 + && 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")) + }) + })); +} + +#[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| { + 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"); + 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"); + 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_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"); + 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")) + && 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"] + .as_str() + .is_some_and(|reason| reason.contains("ParentA")) + && item["file"] == "app/ParentA.tsx" + && item["line"].is_null() + && 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/crates/no-mistakes/src/napi_api/tests/suppression_contract_tests.rs b/crates/no-mistakes/src/napi_api/tests/suppression_contract_tests.rs new file mode 100644 index 000000000..85707c93d --- /dev/null +++ b/crates/no-mistakes/src/napi_api/tests/suppression_contract_tests.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use serde_json::json; + +use super::super::check_json_impl; + +#[test] +fn check_json_reports_suppressed_findings_from_prepared_root_fixtures() { + let fixtures = [ + ("suppression-react", "react", "assert-no-fetch"), + ("suppression-unique-canonical", "codebase", "unique-exports"), + ( + "aggregate-agents-md-advisory-suppression", + "advisories", + "agents-md-max-size", + ), + ( + "aggregate-test-no-unmocked-dynamic-imports", + "rules", + "test-no-unmocked-dynamic-imports", + ), + ]; + + for (fixture, domain, rule) 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 report: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!( + report["suppressed"].as_array().is_some_and(|findings| { + findings + .iter() + .any(|finding| finding["domain"] == domain && finding["rule"] == rule) + }), + "fixture {fixture}: {report}" + ); + } +} 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/mod.rs b/crates/no-mistakes/src/react_traits/mod.rs index d619e3488..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; @@ -16,4 +17,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 533c32ccf..c0dd7f3b1 100644 --- a/crates/no-mistakes/src/react_traits/pipeline/check.rs +++ b/crates/no-mistakes/src/react_traits/pipeline/check.rs @@ -2,6 +2,10 @@ 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 { @@ -102,26 +106,26 @@ pub fn run_check_with_prepared_facts( Ok(assert_no_fetch_violations(&facts_list)) } -fn assert_no_fetch_violations( - facts_list: &[crate::react_traits::ComponentFacts], -) -> Vec { - let mut violations = 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 { - 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()), - }); - } +#[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(), + }); } - violations + let facts_list = crate::react_traits::pipeline::run_with_facts::run_analyze_inner_with_facts_and_suppression( + root, + &prepared.file_config, + targets, + shared, + )?; + Ok(assert_no_fetch_violations_with_suppression(&facts_list)) } pub fn check_enabled( 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..6eade6fd7 --- /dev/null +++ b/crates/no-mistakes/src/react_traits/pipeline/check/aggregate.rs @@ -0,0 +1,62 @@ +use crate::react_traits::pipeline::run_with_facts::PreparedComponentFacts; +use crate::react_traits::report::types::{ComponentFacts, ReactSuppressionTarget, Violation}; + +#[doc(hidden)] +pub struct PreparedReactFindings { + pub findings: Vec, + pub suppression_targets: Vec>, +} + +/// 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( + 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; + if let Some(violation) = violation_for(facts) { + 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); + suppression_targets.push(finding_targets); + } + } + PreparedReactFindings { + findings: violations, + 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 3ccb07c94..67787892f 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(); @@ -125,6 +132,108 @@ 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 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}; + + 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 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/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs b/crates/no-mistakes/src/react_traits/pipeline/run/tests.rs index 2a3640e33..bc1217816 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 { @@ -149,9 +149,29 @@ 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()); 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, AggregatedFacts::default()); +} 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..bb2601965 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,6 +22,20 @@ pub(crate) fn run_analyze_inner_with_facts( targets: &[String], shared: &crate::codebase::check_facts::CheckFactMap, ) -> Result> { + facts_only::run(root, file_config, targets, shared) +} + +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(); @@ -30,7 +46,12 @@ pub(crate) fn run_analyze_inner_with_facts( 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); @@ -49,10 +70,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 +116,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) { @@ -103,7 +127,7 @@ fn aggregate_children_cached( crate::codebase::ts_source::normalize_discovery_path(Path::new(&child_ref.file)); let child_facts_opt = child_path_index .get(&child_ref.file) - .or_else(|| child_path_index.get(normalized_child_file.to_string_lossy().as_ref())) + .or(child_path_index.get(normalized_child_file.to_string_lossy().as_ref())) .and_then(|path| file_cache.get(path)) .and_then(|comps| comps.iter().find(|c| c.name == child_ref.name)); if let Some(child_facts) = child_facts_opt { @@ -116,7 +140,13 @@ fn aggregate_children_cached( agg } -fn child_path_index( +#[derive(Default)] +struct AggregateResult { + facts: AggregatedFacts, + fetch_locations: Vec<(String, usize)>, +} + +pub(super) fn child_path_index( root: &Path, file_cache: &HashMap>>, ) -> HashMap { @@ -131,24 +161,32 @@ 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(); +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 + .iter() + .map(|fetch| (fetch.file.clone(), fetch.line)), + ); } -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; +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()); } #[cfg(test)] 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.rs b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests.rs index 476c7fc00..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") @@ -75,6 +77,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 { @@ -137,7 +140,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, 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..7fdcb39a3 --- /dev/null +++ b/crates/no-mistakes/src/react_traits/pipeline/run_with_facts/tests/suppression.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +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()); + 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 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()), + assert_no_fetch: Some(true), + }, + &["app/components/Child.tsx".to_string()], + &shared, + ) + .unwrap(); + + assert!(findings.is_empty()); +} 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(); 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..bf4a795bd 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); } @@ -91,3 +92,36 @@ fn print_violations_no_detail() { }]; 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, + }) + ); +} + +#[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 23656cd85..dca69b582 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, + /// Internal location for aggregate-check suppression. Direct React reports + /// intentionally retain their established JSON schema. + #[serde(skip)] + pub line: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -114,6 +118,12 @@ pub struct Violation { pub detail: Option, } +#[derive(Debug, Clone)] +pub struct ReactSuppressionTarget { + pub file: String, + pub line: usize, +} + #[derive(Default, Deserialize)] #[serde(rename_all = "camelCase", default)] pub(crate) struct RootConfig { 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/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/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..fc4520cd4 --- /dev/null +++ b/fixtures/check/aggregate-agents-md-max-size/AGENTS.md @@ -0,0 +1,3 @@ +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-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'] }, +}); 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..e2336e7db --- /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: force-cache is intentional for this fixture + return fetch('/data/user', { cache: 'force-cache' }) +} diff --git a/fixtures/check/aggregate-require-storybook-explicit-config/.gitignore b/fixtures/check/aggregate-require-storybook-explicit-config/.gitignore new file mode 100644 index 000000000..9c23c96b9 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-explicit-config/.gitignore @@ -0,0 +1 @@ +.storybook/main.ts diff --git a/fixtures/check/aggregate-require-storybook-explicit-config/.no-mistakes.yml b/fixtures/check/aggregate-require-storybook-explicit-config/.no-mistakes.yml new file mode 100644 index 000000000..09147f0b0 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-explicit-config/.no-mistakes.yml @@ -0,0 +1,16 @@ +version: 2 + +tests: + storybook: + configs: .storybook/main.ts + +projects: + web: + type: nextjs + root: . + +rules: + - rule: require-storybook-stories + projects: [web] + options: + includeAllReactNamedExports: true diff --git a/fixtures/check/aggregate-require-storybook-explicit-config/.storybook/main.ts b/fixtures/check/aggregate-require-storybook-explicit-config/.storybook/main.ts new file mode 100644 index 000000000..40c824b6f --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-explicit-config/.storybook/main.ts @@ -0,0 +1,3 @@ +export default { + stories: ["../custom/**/*.examples.tsx"], +}; diff --git a/fixtures/check/aggregate-require-storybook-explicit-config/custom/Widget.examples.tsx b/fixtures/check/aggregate-require-storybook-explicit-config/custom/Widget.examples.tsx new file mode 100644 index 000000000..99c2d7419 --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-explicit-config/custom/Widget.examples.tsx @@ -0,0 +1,7 @@ +import { Widget } from "../src/Widget"; + +export default { component: Widget }; + +export const Basic = { + render: () => , +}; 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 ; +} 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..3811a3d6c --- /dev/null +++ b/fixtures/check/aggregate-require-storybook-stories/.no-mistakes.yml @@ -0,0 +1,18 @@ +version: 2 + +projects: + web: + type: nextjs + root: web + +rules: + - rule: require-storybook-stories + projects: + - web + options: + stories: + - stories/**/*.stories.tsx + 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/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/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/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-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/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/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-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/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'] }, +}) 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'] }, +}) 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'] }, +}) 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( { 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/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 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/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-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-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'] } }], + }, +}) 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-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 ; +} 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 ; +} 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..80eaa0388 --- /dev/null +++ b/fixtures/check/suppression-react-inherited-parents/app/ParentA.tsx @@ -0,0 +1,6 @@ +// no-mistakes-disable-file assert-no-fetch: this parent intentionally inherits a suppressed child fetch +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-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/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 new file mode 100644 index 000000000..acca708bb --- /dev/null +++ b/fixtures/check/suppression-react-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: first call is intentional + await fetch('/api/first'); + return ; +} 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
; +} 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..47aafcfbc --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/.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-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/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/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/a.ts b/fixtures/check/suppression-unique-canonical/src/a.ts new file mode 100644 index 000000000..541792bca --- /dev/null +++ b/fixtures/check/suppression-unique-canonical/src/a.ts @@ -0,0 +1 @@ +export const shared = 1; // no-mistakes-disable-line unique-exports: compatibility export 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/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-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'; 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/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; 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/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'; 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"}} 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"}} 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..c419796bd --- /dev/null +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/barrel.ts @@ -0,0 +1,2 @@ +// Counterintuitively, this named re-export remains visible: source suppressions are lexical. +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..cd251ced5 --- /dev/null +++ b/fixtures/codebase/unique-exports-suppressed-origin/src/source.ts @@ -0,0 +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'; diff --git a/packages/no-mistakes/analyze-project-types.d.ts b/packages/no-mistakes/analyze-project-types.d.ts index 2ea2ac904..dee280e05 100644 --- a/packages/no-mistakes/analyze-project-types.d.ts +++ b/packages/no-mistakes/analyze-project-types.d.ts @@ -8,6 +8,7 @@ import type { } from "./named-query-types"; import type { PlaywrightOptions, PlaywrightRelatedOptions } from "./report-types"; import type { + CheckOptions, ProjectOptions, SymbolsListOptions, SymbolsSignatureImpactOptions, @@ -25,7 +26,7 @@ type BatchedReactUsagesOptions = Pick< "root" | "tsconfig" | "config" | "targets" | "include" > & Required>; -type BatchedCheckOptions = Pick; +type BatchedCheckOptions = Pick; export type AnalyzeProjectReportRequest = | ({ type: "dependencies" | "dependents" | "related"; id?: string } & BatchedTraverseOptions) 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/report-types.d.ts b/packages/no-mistakes/report-types.d.ts index b3f2389cf..32dcb2edd 100644 --- a/packages/no-mistakes/report-types.d.ts +++ b/packages/no-mistakes/report-types.d.ts @@ -30,6 +30,22 @@ export interface CheckReport { codebase: unknown[]; warnings: string[]; advisories: unknown[]; + /** Present when `includeSuppressed` is requested; empty when no directives matched. */ + suppressed?: SuppressedFinding[]; +} + +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: { + kind: "file" | "line" | "nextLine"; + line: number; + }; } export interface QueueReport { diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 44acd8d4a..5c0b68625 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", @@ -428,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\)/, @@ -474,9 +497,17 @@ 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.doesNotMatch( + analyzeProjectDeclarations, + /type BatchedCheckOptions = Pick<[\s\S]*?"include" \| "includeSuppressed"/, ); 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";/, 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;/); +}); diff --git a/packages/no-mistakes/traversal-types.d.ts b/packages/no-mistakes/traversal-types.d.ts index 2b6005b94..2b72120e0 100644 --- a/packages/no-mistakes/traversal-types.d.ts +++ b/packages/no-mistakes/traversal-types.d.ts @@ -207,3 +207,8 @@ export interface ProjectOptions { /** `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; +}