From 8266c14f652ac019d95a04204b48f6154fb6e2d9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Fri, 7 Aug 2026 23:05:21 -0700 Subject: [PATCH 01/12] feat: add required entrypoint reachability rule --- crates/no-mistakes/src/check_runner.rs | 2 +- .../{forbidden_plan.rs => graph_plan.rs} | 0 .../no-mistakes/src/check_runner/run_all.rs | 22 +- .../src/check_runner/tests/architecture.rs | 6 +- crates/no-mistakes/src/check_tasks.rs | 4 - crates/no-mistakes/src/codebase/rules/ids.rs | 1 + crates/no-mistakes/src/codebase/rules/mod.rs | 4 +- .../rules/required_entrypoint_reachability.rs | 214 ++++++++++++++++++ .../required_entrypoint_reachability/tests.rs | 204 +++++++++++++++++ crates/no-mistakes/src/codebase/rules/run.rs | 12 +- .../src/codebase/rules/run/prepared.rs | 16 +- .../codebase/rules/run/prepared/execution.rs | 23 +- .../src/codebase/rules/run/standalone.rs | 8 +- .../analyze_project/context/check_prepare.rs | 20 +- .../analyze_project/context/check_run.rs | 2 +- crates/no-mistakes/tests/docs_coverage.rs | 1 + docs/rules/README.md | 1 + .../rules/required-entrypoint-reachability.md | 53 +++++ .../fixture/barrels/named.ts | 1 + .../fixture/barrels/star.ts | 1 + .../fixture/entrypoints/api.ts | 10 + .../fixture/entrypoints/worker.ts | 1 + .../fixture/sources/dynamic.ts | 1 + .../fixture/sources/named.ts | 1 + .../fixture/sources/required.ts | 1 + .../fixture/sources/star.ts | 1 + .../fixture/sources/static.ts | 1 + .../fixture/sources/suppressed.ts | 2 + .../fixture/sources/type-only.ts | 3 + .../fixture/sources/unreachable.ts | 1 + .../fixture/suppression.no-mistakes.yml | 6 + .../fixture/tsconfig.json | 7 + 32 files changed, 577 insertions(+), 53 deletions(-) rename crates/no-mistakes/src/check_runner/{forbidden_plan.rs => graph_plan.rs} (100%) create mode 100644 crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs create mode 100644 crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs create mode 100644 docs/rules/required-entrypoint-reachability.md create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/barrels/named.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/barrels/star.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/worker.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/dynamic.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/named.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/required.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/star.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/static.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/suppressed.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/type-only.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/unreachable.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/suppression.no-mistakes.yml create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/tsconfig.json diff --git a/crates/no-mistakes/src/check_runner.rs b/crates/no-mistakes/src/check_runner.rs index 0d72f3c3d..aa8bfbd38 100644 --- a/crates/no-mistakes/src/check_runner.rs +++ b/crates/no-mistakes/src/check_runner.rs @@ -1,5 +1,5 @@ pub(crate) mod enabled; -mod forbidden_plan; +mod graph_plan; pub(crate) mod prepared; mod results; mod run_all; diff --git a/crates/no-mistakes/src/check_runner/forbidden_plan.rs b/crates/no-mistakes/src/check_runner/graph_plan.rs similarity index 100% rename from crates/no-mistakes/src/check_runner/forbidden_plan.rs rename to crates/no-mistakes/src/check_runner/graph_plan.rs diff --git a/crates/no-mistakes/src/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs index bbf43372c..bdc3b457d 100644 --- a/crates/no-mistakes/src/check_runner/run_all.rs +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -1,5 +1,5 @@ use super::{ - complete_domain_checks, empty_results, enabled, forbidden_plan, prepared, results, CheckResults, + complete_domain_checks, empty_results, enabled, graph_plan, prepared, results, CheckResults, }; use crate::check_parallel::{run_domain_checks, DomainCheckInputs}; use crate::check_tasks; @@ -29,11 +29,8 @@ pub(crate) fn run_all( let unique_exports_enabled = check_tasks::unique_exports_configured(config); let enabled = enabled::ConfiguredChecks::from_config(config); let filesystem_rules_enabled = check_tasks::filesystem_rules_configured(config); - let forbidden_deps_enabled = check_tasks::forbidden_dependencies_configured(config); - let forbidden_graph_plan = forbidden_deps_enabled - .then(|| no_mistakes::codebase::rules::forbidden_dependencies::graph_plan(config)) - .flatten(); - let playwright_consumers = forbidden_graph_plan + let canonical_graph_plan = no_mistakes::codebase::rules::canonical_graph_plan(config); + let playwright_consumers = canonical_graph_plan .map( |plan| no_mistakes::playwright::rules::PlaywrightFactConsumers { graph_selectors: plan.playwright_selectors, @@ -76,21 +73,22 @@ pub(crate) fn run_all( ), )); } - let prepared_graph = forbidden_plan::prepare( + let prepared_graph = graph_plan::prepare( &root, config, - forbidden_plan::PreparedInputs { + graph_plan::PreparedInputs { codebase_config: &prepared.codebase_config, tsconfig: &prepared.tsconfig, visible_paths: prepared.visible_paths.as_ref(), workflow_documents: prepared.workflow_documents.as_ref(), }, - forbidden_graph_plan, + canonical_graph_plan, &mut playwright_fact_plan, &mut plan, )?; - let needs_shared_facts = - forbidden_deps_enabled || playwright_fact_plan.is_some() || plan_requests_facts(&plan); + let needs_shared_facts = canonical_graph_plan.is_some() + || playwright_fact_plan.is_some() + || plan_requests_facts(&plan); if !needs_shared_facts && !filesystem_rules_enabled && !no_mistakes::playwright::rules::configured(config) @@ -110,7 +108,7 @@ pub(crate) fn run_all( ) }, ); - let needs_full_graph_files = forbidden_graph_plan.is_some() || playwright_fact_plan.is_some(); + let needs_full_graph_files = canonical_graph_plan.is_some() || playwright_fact_plan.is_some(); let needs_graph_files = needs_shared_facts && (needs_full_graph_files || enabled.dynamic_import_rules); let (discovered, graph_files) = if needs_full_graph_files { diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index 04ddd2a44..2088b394f 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -5,7 +5,7 @@ fn aggregate_check_injects_prepared_config_into_every_domain() { include_str!("../run_all.rs"), ); let prepared = include_str!("../prepared.rs"); - let forbidden_plan = include_str!("../forbidden_plan.rs"); + let graph_plan = include_str!("../graph_plan.rs"); let parallel = include_str!("../../check_parallel.rs"); let tasks = check_task_sources(); @@ -55,8 +55,8 @@ fn aggregate_check_injects_prepared_config_into_every_domain() { 1 ); assert!(!prepared.contains("resolve_tsconfig_from_visible")); - assert!(forbidden_plan.contains("prepare_graph_config")); - assert!(forbidden_plan.contains("ts_fact_plan_and_context_for_plan_with_prepared")); + assert!(graph_plan.contains("prepare_graph_config")); + assert!(graph_plan.contains("ts_fact_plan_and_context_for_plan_with_prepared")); assert!(!runner.contains("react_traits::check_enabled")); assert!(prepared.contains("prepare_from_snapshot_with_catalog")); assert!(!tasks.contains("queue::analyze_project_with_prepared_facts(")); diff --git a/crates/no-mistakes/src/check_tasks.rs b/crates/no-mistakes/src/check_tasks.rs index 415b719f4..0e50c7286 100644 --- a/crates/no-mistakes/src/check_tasks.rs +++ b/crates/no-mistakes/src/check_tasks.rs @@ -182,10 +182,6 @@ pub(crate) fn queues_configured(config: &NoMistakesConfig) -> bool { .any(|project| !project.queues.enqueues.is_empty() || !project.queues.workers.is_empty()) } -pub(crate) fn forbidden_dependencies_configured(config: &NoMistakesConfig) -> bool { - rule_configured(config, rules::FORBIDDEN_DEPENDENCIES) -} - pub(crate) fn unique_exports_configured(config: &NoMistakesConfig) -> bool { rule_configured(config, unique_exports::RULE_ID) } diff --git a/crates/no-mistakes/src/codebase/rules/ids.rs b/crates/no-mistakes/src/codebase/rules/ids.rs index 7262bf254..1221ac388 100644 --- a/crates/no-mistakes/src/codebase/rules/ids.rs +++ b/crates/no-mistakes/src/codebase/rules/ids.rs @@ -24,6 +24,7 @@ pub use super::require_files_in_subdirs::RULE_ID as REQUIRE_FILES_IN_SUBDIRS; pub use super::require_storybook_stories::RULE_ID as REQUIRE_STORYBOOK_STORIES; pub use super::require_test_per_subdir::RULE_ID as REQUIRE_TEST_PER_SUBDIR; pub use super::required_companion_imports::RULE_ID as REQUIRED_COMPANION_IMPORTS; +pub use super::required_entrypoint_reachability::RULE_ID as REQUIRED_ENTRYPOINT_REACHABILITY; pub use super::required_local_docs::REQUIRED_DOC_SECTION_RULE_ID as REQUIRED_DOC_SECTION; pub use super::required_local_docs::RULE_ID as REQUIRED_LOCAL_DOCS; pub use super::rust_max_lines_per_file::RULE_ID as RUST_MAX_LINES_PER_FILE; diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index e1d691dbd..90296aa90 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -29,6 +29,7 @@ pub mod require_files_in_subdirs; pub mod require_storybook_stories; pub mod require_test_per_subdir; pub mod required_companion_imports; +pub mod required_entrypoint_reachability; pub mod required_local_docs; pub mod rust_max_lines_per_file; pub mod rust_no_inline_allows; @@ -65,7 +66,8 @@ pub use filesystem_dispatch::{ run_filesystem_rules_with_visible_and_snapshot, }; pub use ids::*; -pub(crate) use run::canonical_graph_plan; +#[doc(hidden)] +pub use run::canonical_graph_plan; #[doc(hidden)] pub use run::run_check_with_config_facts_playwright_and_graph; pub use run::{ diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs new file mode 100644 index 000000000..1be320734 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs @@ -0,0 +1,214 @@ +use super::RuleFinding; +use crate::codebase::dependencies::graph::{DepGraph, EdgeKind, GraphBuildPlan, NodeId}; +use crate::codebase::ts_source::relative_slash_path; +use crate::config::v2::NoMistakesConfig; +use anyhow::Result; +use serde::Deserialize; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +pub const RULE_ID: &str = "required-entrypoint-reachability"; + +#[derive(Debug, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub(crate) struct Options { + pub(crate) source_globs: Vec, + pub(crate) entrypoints: Vec, + pub(crate) max_depth: Option, +} + +pub(crate) fn graph_plan(config: &NoMistakesConfig) -> Option { + config + .rule_configured(RULE_ID) + .then(GraphBuildPlan::imports_and_workspace) +} + +#[cfg(test)] +pub(crate) fn check_with_graph( + root: &Path, + config: &NoMistakesConfig, + files: &[PathBuf], + graph: &DepGraph, +) -> Result> { + check_with_graph_and_inferred(root, config, files, graph, None) +} + +pub(crate) fn check_with_graph_and_inferred( + root: &Path, + config: &NoMistakesConfig, + files: &[PathBuf], + graph: &DepGraph, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, +) -> Result> { + let file_universe = files + .iter() + .map(|path| crate::codebase::ts_resolver::normalize_path(path)) + .collect::>(); + let mut findings = Vec::new(); + for rule in config.rule_applications(RULE_ID) { + let options: Options = rule.rule_options(); + let mut inferred_roots = inferred_roots.cloned().unwrap_or_default(); + let source_filter = super::path_filter::RulePathFilter::new_with_inferred( + root, + config, + rule, + &mut inferred_roots, + )?; + let scoped_files = files + .iter() + .filter(|path| source_filter.is_match(path)) + .cloned() + .collect::>(); + let target_roots = + super::target_roots_with_inferred(root, config, rule, &mut inferred_roots); + findings.extend(check_rule_application( + root, + &options, + &scoped_files, + &target_roots, + graph, + &file_universe, + )); + } + super::sort_findings(&mut findings); + Ok(findings) +} + +fn check_rule_application( + root: &Path, + options: &Options, + scoped_files: &[PathBuf], + target_roots: &[PathBuf], + graph: &DepGraph, + file_universe: &HashSet, +) -> Vec { + let mut findings = Vec::new(); + if options.source_globs.is_empty() { + findings.push(config_finding( + "each rule entry requires at least one sourceGlobs pattern", + None, + )); + } + if options.entrypoints.is_empty() { + findings.push(config_finding( + "each rule entry requires at least one entrypoint", + None, + )); + } + + let mut source_files = HashSet::new(); + for pattern in &options.source_globs { + match super::matching_files( + root, + std::slice::from_ref(pattern), + scoped_files, + target_roots, + ) { + Ok(matches) if matches.is_empty() => findings.push(config_finding( + &format!("sourceGlobs pattern `{pattern}` matched no files"), + Some(pattern.clone()), + )), + Ok(matches) => source_files.extend(matches), + Err(error) => findings.push(config_finding( + &format!("invalid sourceGlobs pattern `{pattern}`: {error}"), + Some(pattern.clone()), + )), + } + } + + let mut entrypoint_paths = Vec::new(); + let mut entrypoint_labels = Vec::new(); + for configured in &options.entrypoints { + let path = resolve_entrypoint(root, configured); + if path + .as_ref() + .is_none_or(|path| !file_universe.contains(path) || !graph.contains_file(path)) + { + findings.push(config_finding( + &format!("entrypoint `{configured}` does not exist"), + Some(configured.clone()), + )); + continue; + } + let path = path.expect("validated entrypoint path"); + entrypoint_labels.push(relative_slash_path(root, &path)); + entrypoint_paths.push(path); + } + entrypoint_labels.sort(); + entrypoint_labels.dedup(); + entrypoint_paths.sort(); + entrypoint_paths.dedup(); + + if !entrypoint_paths.is_empty() { + let allowed = runtime_edge_kinds(); + let roots = entrypoint_paths + .iter() + .cloned() + .map(NodeId::File) + .collect::>(); + let mut reachable = graph + .deps_of_in_file_universe(&roots, options.max_depth, Some(&allowed), file_universe) + .into_iter() + .filter_map(|entry| match entry.node { + NodeId::File(path) => Some(path), + _ => None, + }) + .collect::>(); + reachable.extend(entrypoint_paths); + let target = entrypoint_labels.join(","); + for source in source_files { + let source = crate::codebase::ts_resolver::normalize_path(&source); + if reachable.contains(&source) { + continue; + } + let file = relative_slash_path(root, &source); + findings.push(RuleFinding { + rule: RULE_ID.to_string(), + file: file.clone(), + line: 1, + message: format!( + "{file} is not runtime-reachable from configured entrypoints: {target}" + ), + import: None, + target: Some(target.clone()), + }); + } + } + findings +} + +fn resolve_entrypoint(root: &Path, configured: &str) -> Option { + let configured = Path::new(configured.trim_start_matches("./")); + let path = if configured.is_absolute() { + configured.to_path_buf() + } else { + root.join(configured) + }; + let path = crate::codebase::ts_resolver::normalize_path(&path); + path.starts_with(root).then_some(path) +} + +fn runtime_edge_kinds() -> HashSet { + [ + EdgeKind::Import, + EdgeKind::DynamicImport, + EdgeKind::Require, + EdgeKind::WorkspaceImport, + ] + .into_iter() + .collect() +} + +fn config_finding(message: &str, target: Option) -> RuleFinding { + RuleFinding { + rule: RULE_ID.to_string(), + file: ".no-mistakes.yml".to_string(), + line: 1, + message: format!("{RULE_ID}: {message}"), + import: None, + target, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs new file mode 100644 index 000000000..4c7a6bef1 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::config::v2::schema::{RuleDef, RuleScope}; +use std::path::{Path, PathBuf}; + +fn fixture() -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/rules/required-entrypoint-reachability/fixture"), + ) +} + +fn application(options: &str) -> RuleDef { + RuleDef { + rule: RULE_ID.to_string(), + scope: Some(RuleScope::Repository), + options: serde_yaml::from_str(options).unwrap(), + ..Default::default() + } +} + +fn run(mut rules: Vec) -> Vec { + let root = fixture(); + let files = crate::codebase::dependencies::graph::GraphFiles::discover(&root) + .all() + .to_vec(); + let tsconfig = crate::codebase::ts_resolver::resolve_tsconfig_from_visible( + Some(&root.join("tsconfig.json")), + &root, + &files, + ) + .unwrap(); + let graph_files = crate::codebase::dependencies::graph::GraphFiles::from_files(files.clone()); + let graph = DepGraph::build_with_plan_and_files( + &root, + &tsconfig, + GraphBuildPlan::imports_and_workspace(), + &graph_files, + ) + .unwrap(); + let mut config = NoMistakesConfig::default(); + config.rules.append(&mut rules); + check_with_graph(&root, &config, &files, &graph).unwrap() +} + +#[test] +fn accepts_static_dynamic_require_named_and_star_reexports() { + let findings = run(vec![application( + r#" +sourceGlobs: + - sources/static.ts + - sources/dynamic.ts + - sources/required.ts + - sources/named.ts + - sources/star.ts +entrypoints: [entrypoints/api.ts] +"#, + )]); + + assert!(findings.is_empty(), "unexpected findings: {findings:?}"); +} + +#[test] +fn rejects_type_only_and_unreachable_sources_in_deterministic_order() { + let findings = run(vec![application( + r#" +sourceGlobs: [sources/unreachable.ts, sources/type-only.ts] +entrypoints: [entrypoints/api.ts] +"#, + )]); + + assert_eq!( + findings + .iter() + .map(|finding| finding.file.as_str()) + .collect::>(), + ["sources/type-only.ts", "sources/unreachable.ts"] + ); + assert!(findings + .iter() + .all(|finding| finding.message.contains("not runtime-reachable"))); +} + +#[test] +fn max_depth_is_applied_per_rule_application() { + let pass = run(vec![application( + r#" +sourceGlobs: [sources/named.ts] +entrypoints: [entrypoints/api.ts] +maxDepth: 2 +"#, + )]); + let fail = run(vec![application( + r#" +sourceGlobs: [sources/named.ts] +entrypoints: [entrypoints/api.ts] +maxDepth: 1 +"#, + )]); + + assert!(pass.is_empty(), "unexpected findings: {pass:?}"); + assert_eq!(fail.len(), 1); + assert_eq!(fail[0].file, "sources/named.ts"); +} + +#[test] +fn rejects_missing_entrypoints_and_each_zero_match_source_pattern() { + let findings = run(vec![application( + r#" +sourceGlobs: + - sources/static.ts + - sources/no-match-b.ts + - sources/no-match-a.ts +entrypoints: [entrypoints/missing.ts] +"#, + )]); + + assert_eq!(findings.len(), 3); + assert_eq!( + findings.iter().map(|finding| finding.message.as_str()).collect::>(), + [ + "required-entrypoint-reachability: entrypoint `entrypoints/missing.ts` does not exist", + "required-entrypoint-reachability: sourceGlobs pattern `sources/no-match-a.ts` matched no files", + "required-entrypoint-reachability: sourceGlobs pattern `sources/no-match-b.ts` matched no files", + ] + ); +} + +#[test] +fn rejects_incomplete_options_and_invalid_source_globs() { + let findings = run(vec![ + application("{}"), + application( + r#" +sourceGlobs: ["["] +entrypoints: [entrypoints/api.ts] +"#, + ), + ]); + + assert_eq!(findings.len(), 3); + assert!(findings + .iter() + .any(|finding| finding.message.contains("requires at least one entrypoint"))); + assert!(findings.iter().any(|finding| finding + .message + .contains("requires at least one sourceGlobs pattern"))); + assert!(findings + .iter() + .any(|finding| finding.message.contains("invalid sourceGlobs pattern `[`"))); +} + +#[test] +fn common_exclude_removes_intentional_sources() { + let mut rule = application( + r#" +sourceGlobs: [sources/*.ts] +entrypoints: [entrypoints/api.ts] +"#, + ); + rule.exclude = vec![ + "sources/suppressed.ts".to_string(), + "sources/type-only.ts".to_string(), + "sources/unreachable.ts".to_string(), + ]; + + let findings = run(vec![rule]); + + assert!(findings.is_empty(), "unexpected findings: {findings:?}"); +} + +#[test] +fn repeated_source_globs_are_checked_against_each_applications_entrypoints() { + let findings = run(vec![ + application( + r#" +sourceGlobs: [sources/static.ts, sources/dynamic.ts] +entrypoints: [entrypoints/api.ts] +"#, + ), + application( + r#" +sourceGlobs: [sources/static.ts, sources/dynamic.ts] +entrypoints: [entrypoints/worker.ts] +"#, + ), + ]); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "sources/dynamic.ts"); + assert_eq!(findings[0].target.as_deref(), Some("entrypoints/worker.ts")); +} + +#[test] +fn aggregate_runner_honors_file_suppression() { + let root = fixture(); + let findings = crate::codebase::rules::run_check( + &root, + Some(&root.join("suppression.no-mistakes.yml")), + Some(&root.join("tsconfig.json")), + ) + .unwrap(); + + assert!(findings.is_empty(), "unexpected findings: {findings:?}"); +} diff --git a/crates/no-mistakes/src/codebase/rules/run.rs b/crates/no-mistakes/src/codebase/rules/run.rs index 63a1f1360..ee869ff6f 100644 --- a/crates/no-mistakes/src/codebase/rules/run.rs +++ b/crates/no-mistakes/src/codebase/rules/run.rs @@ -1,9 +1,10 @@ use super::{ forbidden_dependencies, nextjs_no_api_routes, nextjs_no_caching, require_storybook_stories, - 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, REQUIRE_STORYBOOK_STORIES, - SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, + 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, }; use anyhow::Result; use std::path::Path; @@ -11,7 +12,7 @@ use std::path::Path; mod prepared; mod standalone; -pub(crate) use prepared::canonical_graph_plan; +pub use prepared::canonical_graph_plan; #[doc(hidden)] pub use prepared::run_check_with_config_facts_playwright_and_graph; pub use prepared::{run_check_with_config_and_facts_and_playwright, PreparedRulesCheck}; @@ -122,4 +123,5 @@ fn any_codebase_rule_enabled(config: &crate::config::v2::NoMistakesConfig) -> bo || rule_enabled(config, REQUIRE_STORYBOOK_STORIES) || crate::playwright::rules::configured(config) || rule_enabled(config, FORBIDDEN_DEPENDENCIES) + || rule_enabled(config, REQUIRED_ENTRYPOINT_REACHABILITY) } diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared.rs b/crates/no-mistakes/src/codebase/rules/run/prepared.rs index db1f78db4..425577c48 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -1,9 +1,11 @@ use super::{ any_codebase_rule_enabled, forbidden_dependencies, nextjs_no_api_routes, nextjs_no_caching, - require_storybook_stories, 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, - REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, + 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, }; use crate::codebase::dependencies::graph::{DepGraph, GraphBuildPlan}; use anyhow::Result; @@ -33,7 +35,7 @@ pub struct PreparedRulesCheck<'a> { /// Shared-config entry point used by the aggregate `check` command. #[doc(hidden)] -pub(crate) fn canonical_graph_plan( +pub fn canonical_graph_plan( config: &crate::config::v2::NoMistakesConfig, ) -> Option { let mut plan = GraphBuildPlan::default(); @@ -42,6 +44,10 @@ pub(crate) fn canonical_graph_plan( plan.include(GraphBuildPlan::imports_and_workspace()); needed = true; } + if let Some(reachability_plan) = required_entrypoint_reachability::graph_plan(config) { + plan.include(reachability_plan); + needed = true; + } if let Some(forbidden_plan) = forbidden_dependencies::graph_plan(config) { plan.include(forbidden_plan); needed = true; 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 9959e9125..bc5f719c7 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -24,16 +24,16 @@ pub(super) fn run( if !any_codebase_rule_enabled(config) { return Ok(Vec::new()); } - if let Some(forbidden_plan) = forbidden_dependencies::graph_plan(config) { + if let Some(graph_plan) = canonical_graph_plan(config) { let (required_facts, _) = match prepared_graph { Some(prepared) => crate::codebase::dependencies::graph:: - ts_fact_plan_and_context_for_plan_with_prepared(root, forbidden_plan, prepared), + ts_fact_plan_and_context_for_plan_with_prepared(root, graph_plan, prepared), None => crate::codebase::dependencies::graph:: - ts_fact_plan_and_context_for_plan_with_config(root, forbidden_plan, config_path), + ts_fact_plan_and_context_for_plan_with_config(root, graph_plan, config_path), }; if !shared.graph_plan().covers(required_facts) { anyhow::bail!( - "shared check facts are missing graph facts required by {FORBIDDEN_DEPENDENCIES}" + "shared check facts are missing graph facts required by configured codebase rules" ); } } @@ -171,6 +171,21 @@ pub(super) fn run( }, )?); } + if rule_enabled(config, REQUIRED_ENTRYPOINT_REACHABILITY) { + findings.extend(crate::perf_trace::trace( + "rules.required_entrypoint_reachability", + || { + required_entrypoint_reachability::check_with_graph_and_inferred( + root, + config, + shared.graph_file_universe(), + dependency_graph + .expect("required-entrypoint-reachability requires canonical graph"), + inferred_roots, + ) + }, + )?); + } suppress_findings(root, &mut findings, sources); sort_findings(&mut findings); Ok(findings) diff --git a/crates/no-mistakes/src/codebase/rules/run/standalone.rs b/crates/no-mistakes/src/codebase/rules/run/standalone.rs index d5c254062..affb08a51 100644 --- a/crates/no-mistakes/src/codebase/rules/run/standalone.rs +++ b/crates/no-mistakes/src/codebase/rules/run/standalone.rs @@ -1,6 +1,6 @@ use super::{ - any_codebase_rule_enabled, forbidden_dependencies, rule_enabled, PreparedRulesCheck, - FORBIDDEN_DEPENDENCIES, NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, REQUIRE_STORYBOOK_STORIES, + any_codebase_rule_enabled, canonical_graph_plan, rule_enabled, PreparedRulesCheck, + NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, }; use crate::codebase::check_facts::{ @@ -35,9 +35,7 @@ pub(super) fn run_check( Arc::clone(&snapshot), Arc::new(prepared_tsconfig.clone()), )?; - let graph_plan = rule_enabled(&config, FORBIDDEN_DEPENDENCIES) - .then(|| forbidden_dependencies::graph_plan(&config)) - .flatten(); + let graph_plan = canonical_graph_plan(&config); let codebase_config = crate::codebase::config::config_from_loaded_v2(root, config_path, &config); let prepared_graph = graph_plan diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs index 044e2bdb1..73952c62e 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs @@ -13,7 +13,7 @@ struct SharedCheckContext { queues_enabled: bool, unique_exports_enabled: bool, filesystem_rules_enabled: bool, - forbidden_deps_enabled: bool, + graph_rules_enabled: bool, playwright_rules_enabled: bool, graph_plan: Option, } @@ -32,8 +32,7 @@ impl SharedCheckContext { fact_plan, integration_configured, plan_requests_facts, ConfiguredChecks, EnabledChecks, }; use crate::check_tasks::{ - filesystem_rules_configured, forbidden_dependencies_configured, queues_configured, - unique_exports_configured, + filesystem_rules_configured, queues_configured, unique_exports_configured, }; // The aggregate request establishes one normalized root for discovery, graph nodes, @@ -55,12 +54,9 @@ impl SharedCheckContext { let unique_exports_enabled = unique_exports_configured(config); let enabled = ConfiguredChecks::from_config(config); let filesystem_rules_enabled = filesystem_rules_configured(config); - let forbidden_deps_enabled = forbidden_dependencies_configured(config); let playwright_rules_enabled = crate::playwright::rules::configured(config); - let forbidden_graph_plan = forbidden_deps_enabled - .then(|| crate::codebase::rules::forbidden_dependencies::graph_plan(config)) - .flatten(); let graph_plan = crate::codebase::rules::canonical_graph_plan(config); + let graph_rules_enabled = graph_plan.is_some(); let mut playwright_fact_plan = prepared .playwright .as_ref() @@ -91,7 +87,7 @@ impl SharedCheckContext { ), )); } - let mut prepared_graph = forbidden_graph_plan + let mut prepared_graph = graph_plan .map(|graph_plan| { crate::codebase::dependencies::graph::prepare_graph_config( &root, @@ -123,7 +119,7 @@ impl SharedCheckContext { } } if let (Some(graph_plan), Some(prepared_graph)) = - (forbidden_graph_plan, prepared_graph.as_ref()) + (graph_plan, prepared_graph.as_ref()) { let (fact_plan, fact_context) = crate::codebase::dependencies::graph::ts_fact_plan_and_context_for_plan_with_prepared( @@ -143,8 +139,8 @@ impl SharedCheckContext { prepared.visible_paths.as_ref(), ); let needs_shared_facts = - plan_requests_facts(&plan) || playwright_fact_plan.is_some() || forbidden_deps_enabled; - let needs_full_graph_files = forbidden_graph_plan.is_some() || playwright_facts_enabled; + plan_requests_facts(&plan) || playwright_fact_plan.is_some() || graph_rules_enabled; + let needs_full_graph_files = graph_plan.is_some() || playwright_facts_enabled; let needs_graph_files = needs_shared_facts && (needs_full_graph_files || enabled.dynamic_import_rules); let (discovered, graph_files) = if needs_full_graph_files { @@ -183,7 +179,7 @@ impl SharedCheckContext { queues_enabled, unique_exports_enabled, filesystem_rules_enabled, - forbidden_deps_enabled, + graph_rules_enabled, playwright_rules_enabled, graph_plan, }) 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 5f5b5a42b..5c9410f54 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 @@ -60,7 +60,7 @@ impl SharedCheckContext { && self.graph_files.is_empty() && !self.filesystem_rules_enabled && !self.playwright_rules_enabled - && !self.forbidden_deps_enabled + && !self.graph_rules_enabled { return Ok(crate::check_runner::empty_results([None])); } diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index 4c4197812..363f2ab53 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -111,6 +111,7 @@ fn no_mistakes_rules_have_docs() { rules::REQUIRE_FILES_IN_SUBDIRS, rules::REQUIRE_STORYBOOK_STORIES, rules::REQUIRE_TEST_PER_SUBDIR, + rules::REQUIRED_ENTRYPOINT_REACHABILITY, rules::REQUIRED_DOC_SECTION, rules::REQUIRED_LOCAL_DOCS, rules::RUST_MAX_LINES_PER_FILE, diff --git a/docs/rules/README.md b/docs/rules/README.md index a438b42af..8b08443b4 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -52,6 +52,7 @@ rules: | [`require-storybook-stories`](require-storybook-stories.md) | Require Storybook coverage for selected components. | | [`require-test-per-subdir`](require-test-per-subdir.md) | Require tests in each first-level subdirectory. | | [`required-companion-imports`](required-companion-imports.md) | Require companion files to import their paired source. | +| [`required-entrypoint-reachability`](required-entrypoint-reachability.md) | Require selected sources to be runtime-reachable from configured entrypoints. | | [`required-doc-section`](required-doc-section.md) | Require a heading in matching documentation files. | | [`required-local-docs`](required-local-docs.md) | Require local docs beside configured code directories. | | [`rust-max-lines-per-file`](rust-max-lines-per-file.md) | Cap Rust source/test file length. | diff --git a/docs/rules/required-entrypoint-reachability.md b/docs/rules/required-entrypoint-reachability.md new file mode 100644 index 000000000..8ad9ceeac --- /dev/null +++ b/docs/rules/required-entrypoint-reachability.md @@ -0,0 +1,53 @@ +# `required-entrypoint-reachability` + +Requires every source selected by `sourceGlobs` to be runtime-reachable from at +least one configured entrypoint. + +```yaml +rules: + - name: worker-registration + rule: required-entrypoint-reachability + scope: repository + exclude: ["workers/generated/**"] + options: + sourceGlobs: ["workers/**/*.ts"] + entrypoints: ["runtime/register-workers.ts"] + maxDepth: 4 +``` + +Each rule application is independent. The same `sourceGlobs` may therefore be +configured more than once with different entrypoint sets when multiple runtime +registries must each expose the selected sources. + +`sourceGlobs` uses repository-relative paths and, for project-scoped rules, +project-relative paths. Every pattern must match at least one file after the +application's common `include` and `exclude` filters. `entrypoints` are literal +repository-relative file paths and must exist in the analyzed file set. + +Reachability follows runtime value edges only: static imports, runtime dynamic +imports, `require()` calls, local workspace-package imports, and named or star +re-exports. Type-only imports and re-exports do not satisfy the rule. When set, +`maxDepth` limits dependency hops from each entrypoint; a direct import is depth +1. Omitting it allows transitive traversal at any depth. + +Counterexample: a worker module matches `workers/**/*.ts`, but no configured +worker-registration entrypoint imports or re-exports it. A type-only import of +that worker also remains a violation because it emits no runtime load. + +Compliant example: the registration entrypoint directly imports the worker, or +reaches it through runtime barrels, dynamic imports, `require()` calls, or local +workspace entrypoints within the configured depth. + +Fix: import or re-export the missing source from the intended runtime registry. +If the file is intentionally not part of that registry, narrow `sourceGlobs` or +the application's common `exclude` filter. Increase `maxDepth` only when the +deeper dependency chain is itself the intended registration boundary. + +Suppress a deliberate file exception with a documented file directive: + +```ts +// no-mistakes-disable-file required-entrypoint-reachability: loaded by the host platform +``` + +Suppression caveat: reachability proves that a runtime module edge exists; it +does not prove that a particular control-flow branch executes in production. diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/barrels/named.ts b/test-cases/rules/required-entrypoint-reachability/fixture/barrels/named.ts new file mode 100644 index 000000000..93b925794 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/barrels/named.ts @@ -0,0 +1 @@ +export { named } from "../sources/named"; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/barrels/star.ts b/test-cases/rules/required-entrypoint-reachability/fixture/barrels/star.ts new file mode 100644 index 000000000..2790d4d4d --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/barrels/star.ts @@ -0,0 +1 @@ +export * from "../sources/star"; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts new file mode 100644 index 000000000..f62fa22d1 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts @@ -0,0 +1,10 @@ +import "../sources/static"; + +void import("../sources/dynamic"); +require("../sources/required"); + +export { named } from "../barrels/named"; +export * from "../barrels/star"; + +import type { OnlyType } from "../sources/type-only"; +export type { OnlyType }; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/worker.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/worker.ts new file mode 100644 index 000000000..f724f7fff --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/worker.ts @@ -0,0 +1 @@ +import "../sources/static"; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/dynamic.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/dynamic.ts new file mode 100644 index 000000000..a56890142 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/dynamic.ts @@ -0,0 +1 @@ +export const dynamicallyLoaded = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/named.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/named.ts new file mode 100644 index 000000000..3d831e6ad --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/named.ts @@ -0,0 +1 @@ +export const named = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/required.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/required.ts new file mode 100644 index 000000000..6aaa540df --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/required.ts @@ -0,0 +1 @@ +export const requiredAtRuntime = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/star.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/star.ts new file mode 100644 index 000000000..1d754956f --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/star.ts @@ -0,0 +1 @@ +export const starred = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/static.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/static.ts new file mode 100644 index 000000000..cb3a298ad --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/static.ts @@ -0,0 +1 @@ +export const staticallyLoaded = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/suppressed.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/suppressed.ts new file mode 100644 index 000000000..70db93ac2 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/suppressed.ts @@ -0,0 +1,2 @@ +// no-mistakes-disable-file required-entrypoint-reachability: loaded by the platform +export const suppressed = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/type-only.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/type-only.ts new file mode 100644 index 000000000..d9bed3588 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/type-only.ts @@ -0,0 +1,3 @@ +export interface OnlyType { + value: string; +} diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/unreachable.ts b/test-cases/rules/required-entrypoint-reachability/fixture/sources/unreachable.ts new file mode 100644 index 000000000..ddad772d2 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/unreachable.ts @@ -0,0 +1 @@ +export const unreachable = true; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/suppression.no-mistakes.yml b/test-cases/rules/required-entrypoint-reachability/fixture/suppression.no-mistakes.yml new file mode 100644 index 000000000..512eca599 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/suppression.no-mistakes.yml @@ -0,0 +1,6 @@ +rules: + - rule: required-entrypoint-reachability + scope: repository + options: + sourceGlobs: [sources/suppressed.ts] + entrypoints: [entrypoints/api.ts] diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/tsconfig.json b/test-cases/rules/required-entrypoint-reachability/fixture/tsconfig.json new file mode 100644 index 000000000..879d8f6dc --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["**/*.ts"] +} From b4fb2d4fc03a3c8d3cf034943f79da756822650c Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Fri, 7 Aug 2026 23:14:39 -0700 Subject: [PATCH 02/12] fix: satisfy repository rule layout guards --- .../codebase/rules/required_entrypoint_reachability.rs | 10 ---------- .../rules/required_entrypoint_reachability/tests.rs | 9 +++++++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs index 1be320734..01ec29790 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs @@ -23,16 +23,6 @@ pub(crate) fn graph_plan(config: &NoMistakesConfig) -> Option { .then(GraphBuildPlan::imports_and_workspace) } -#[cfg(test)] -pub(crate) fn check_with_graph( - root: &Path, - config: &NoMistakesConfig, - files: &[PathBuf], - graph: &DepGraph, -) -> Result> { - check_with_graph_and_inferred(root, config, files, graph, None) -} - pub(crate) fn check_with_graph_and_inferred( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs index 4c7a6bef1..34bd93b0c 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs @@ -1,4 +1,13 @@ use super::*; + +fn check_with_graph( + root: &Path, + config: &NoMistakesConfig, + files: &[PathBuf], + graph: &DepGraph, +) -> Result> { + check_with_graph_and_inferred(root, config, files, graph, None) +} use crate::config::v2::schema::{RuleDef, RuleScope}; use std::path::{Path, PathBuf}; From 32c71558d7c9ce55b7e6e8f1f3a3466d6a2c17a4 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Fri, 7 Aug 2026 23:26:46 -0700 Subject: [PATCH 03/12] fix: preserve aggregate rule complexity threshold --- .../codebase/rules/run/prepared/execution.rs | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) 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 bc5f719c7..3632cd66f 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -171,22 +171,35 @@ pub(super) fn run( }, )?); } - if rule_enabled(config, REQUIRED_ENTRYPOINT_REACHABILITY) { - findings.extend(crate::perf_trace::trace( - "rules.required_entrypoint_reachability", - || { - required_entrypoint_reachability::check_with_graph_and_inferred( - root, - config, - shared.graph_file_universe(), - dependency_graph - .expect("required-entrypoint-reachability requires canonical graph"), - inferred_roots, - ) - }, - )?); - } + findings.extend(required_entrypoint_reachability_findings( + root, + config, + shared, + dependency_graph, + inferred_roots, + )?); suppress_findings(root, &mut findings, sources); sort_findings(&mut findings); Ok(findings) } + +fn required_entrypoint_reachability_findings( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + shared: &crate::codebase::check_facts::CheckFactMap, + dependency_graph: Option<&DepGraph>, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, +) -> Result> { + if !rule_enabled(config, REQUIRED_ENTRYPOINT_REACHABILITY) { + return Ok(Vec::new()); + } + crate::perf_trace::trace("rules.required_entrypoint_reachability", || { + required_entrypoint_reachability::check_with_graph_and_inferred( + root, + config, + shared.graph_file_universe(), + dependency_graph.expect("required-entrypoint-reachability requires canonical graph"), + inferred_roots, + ) + }) +} From d4911c1be4200fef950cd974e0596e2f5135fdf9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Fri, 7 Aug 2026 23:41:49 -0700 Subject: [PATCH 04/12] fix: isolate aggregate graph rule execution --- .../codebase/rules/run/prepared/execution.rs | 43 +++--------------- .../run/prepared/execution/graph_rules.rs | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs 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 3632cd66f..feb34143e 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -1,6 +1,8 @@ use super::*; +mod graph_rules; mod helpers; +use graph_rules::graph_rule_findings; use helpers::{storybook_findings, suppress_findings}; pub(super) fn run( @@ -155,26 +157,12 @@ pub(super) fn run( }, )?); } - if rule_enabled(config, FORBIDDEN_DEPENDENCIES) { - findings.extend(crate::perf_trace::trace( - "rules.forbidden_dependencies", - || { - forbidden_dependencies::check_with_prepared_facts_and_graph( - root, - config, - config_path, - shared, - prepared_graph, - inferred_roots, - dependency_graph.expect("forbidden-dependencies requires canonical graph"), - ) - }, - )?); - } - findings.extend(required_entrypoint_reachability_findings( + findings.extend(graph_rule_findings( root, config, + config_path, shared, + prepared_graph, dependency_graph, inferred_roots, )?); @@ -182,24 +170,3 @@ pub(super) fn run( sort_findings(&mut findings); Ok(findings) } - -fn required_entrypoint_reachability_findings( - root: &Path, - config: &crate::config::v2::NoMistakesConfig, - shared: &crate::codebase::check_facts::CheckFactMap, - dependency_graph: Option<&DepGraph>, - inferred_roots: Option<&crate::codebase::config::InferredRoots>, -) -> Result> { - if !rule_enabled(config, REQUIRED_ENTRYPOINT_REACHABILITY) { - return Ok(Vec::new()); - } - crate::perf_trace::trace("rules.required_entrypoint_reachability", || { - required_entrypoint_reachability::check_with_graph_and_inferred( - root, - config, - shared.graph_file_universe(), - dependency_graph.expect("required-entrypoint-reachability requires canonical graph"), - inferred_roots, - ) - }) -} diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs new file mode 100644 index 000000000..6ec35ad11 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs @@ -0,0 +1,45 @@ +use super::*; + +pub(super) fn graph_rule_findings( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + config_path: Option<&Path>, + shared: &crate::codebase::check_facts::CheckFactMap, + prepared_graph: Option<&crate::codebase::dependencies::graph::PreparedGraphConfig>, + dependency_graph: Option<&DepGraph>, + inferred_roots: Option<&crate::codebase::config::InferredRoots>, +) -> Result> { + let mut findings = Vec::new(); + if rule_enabled(config, FORBIDDEN_DEPENDENCIES) { + findings.extend(crate::perf_trace::trace( + "rules.forbidden_dependencies", + || { + forbidden_dependencies::check_with_prepared_facts_and_graph( + root, + config, + config_path, + shared, + prepared_graph, + inferred_roots, + dependency_graph.expect("forbidden-dependencies requires canonical graph"), + ) + }, + )?); + } + if rule_enabled(config, REQUIRED_ENTRYPOINT_REACHABILITY) { + findings.extend(crate::perf_trace::trace( + "rules.required_entrypoint_reachability", + || { + required_entrypoint_reachability::check_with_graph_and_inferred( + root, + config, + shared.graph_file_universe(), + dependency_graph + .expect("required-entrypoint-reachability requires canonical graph"), + inferred_roots, + ) + }, + )?); + } + Ok(findings) +} From 8190d6a6b57f86b87e9e02c1f9b0b73c6d86bab6 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Fri, 7 Aug 2026 23:56:09 -0700 Subject: [PATCH 05/12] test: cover reachability configuration edges --- .../required_entrypoint_reachability/tests.rs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs index 34bd93b0c..f08aa30c6 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs @@ -27,7 +27,11 @@ fn application(options: &str) -> RuleDef { } } -fn run(mut rules: Vec) -> Vec { +fn run(rules: Vec) -> Vec { + try_run(rules).unwrap() +} + +fn try_run(mut rules: Vec) -> Result> { let root = fixture(); let files = crate::codebase::dependencies::graph::GraphFiles::discover(&root) .all() @@ -48,7 +52,7 @@ fn run(mut rules: Vec) -> Vec { .unwrap(); let mut config = NoMistakesConfig::default(); config.rules.append(&mut rules); - check_with_graph(&root, &config, &files, &graph).unwrap() + check_with_graph(&root, &config, &files, &graph) } #[test] @@ -134,6 +138,37 @@ entrypoints: [entrypoints/missing.ts] ); } +#[test] +fn accepts_an_absolute_entrypoint_inside_the_repository() { + let entrypoint = fixture().join("entrypoints/api.ts"); + let findings = run(vec![application(&format!( + r#" +sourceGlobs: [sources/static.ts] +entrypoints: ["{}"] +"#, + entrypoint.display() + ))]); + + assert!(findings.is_empty(), "unexpected findings: {findings:?}"); +} + +#[test] +fn rejects_an_invalid_common_include_pattern() { + let mut rule = application( + r#" +sourceGlobs: [sources/static.ts] +entrypoints: [entrypoints/api.ts] +"#, + ); + rule.include = vec!["[".to_string()]; + + let error = try_run(vec![rule]).unwrap_err(); + assert!( + error.to_string().contains("include contains invalid glob"), + "unexpected error: {error:#}" + ); +} + #[test] fn rejects_incomplete_options_and_invalid_source_globs() { let findings = run(vec![ From fd34128afd330da3bd7b2625c5cb4803d7516af1 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 00:36:04 -0700 Subject: [PATCH 06/12] test: cover graph rule error propagation --- .../rules/required_entrypoint_reachability.rs | 5 +- .../required_entrypoint_reachability/tests.rs | 16 ++++++ .../codebase/rules/run/prepared/execution.rs | 5 +- .../run/prepared/execution/graph_rules.rs | 3 ++ .../prepared/execution/graph_rules/tests.rs | 51 +++++++++++++++++++ .../no-mistakes/src/codebase/rules/tests.rs | 11 ++++ .../fixture/invalid-include.no-mistakes.yml | 7 +++ .../fixture/invalid-include.no-mistakes.yml | 7 +++ 8 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules/tests.rs create mode 100644 test-cases/codebase-analysis/forbidden-dependencies-basic/fixture/invalid-include.no-mistakes.yml create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/invalid-include.no-mistakes.yml diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs index 01ec29790..20482a898 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs @@ -139,10 +139,7 @@ fn check_rule_application( let mut reachable = graph .deps_of_in_file_universe(&roots, options.max_depth, Some(&allowed), file_universe) .into_iter() - .filter_map(|entry| match entry.node { - NodeId::File(path) => Some(path), - _ => None, - }) + .filter_map(|entry| entry.node.as_file().map(Path::to_path_buf)) .collect::>(); reachable.extend(entrypoint_paths); let target = entrypoint_labels.join(","); diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs index f08aa30c6..cf3604f37 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs @@ -246,3 +246,19 @@ fn aggregate_runner_honors_file_suppression() { assert!(findings.is_empty(), "unexpected findings: {findings:?}"); } + +#[test] +fn aggregate_runner_propagates_invalid_common_filters() { + let root = fixture(); + let error = crate::codebase::rules::run_check( + &root, + Some(&root.join("invalid-include.no-mistakes.yml")), + Some(&root.join("tsconfig.json")), + ) + .unwrap_err(); + + assert!( + error.to_string().contains("include contains invalid glob"), + "unexpected error: {error:#}" + ); +} 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 feb34143e..be66ce132 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution.rs @@ -157,7 +157,7 @@ pub(super) fn run( }, )?); } - findings.extend(graph_rule_findings( + let graph_findings = graph_rule_findings( root, config, config_path, @@ -165,7 +165,8 @@ pub(super) fn run( prepared_graph, dependency_graph, inferred_roots, - )?); + ); + findings.extend(graph_findings?); suppress_findings(root, &mut findings, sources); sort_findings(&mut findings); Ok(findings) diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs index 6ec35ad11..6b0084578 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules.rs @@ -43,3 +43,6 @@ pub(super) fn graph_rule_findings( } Ok(findings) } + +#[cfg(test)] +mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules/tests.rs b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules/tests.rs new file mode 100644 index 000000000..16b5b95f6 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/run/prepared/execution/graph_rules/tests.rs @@ -0,0 +1,51 @@ +use super::*; +use crate::config::v2::schema::{RuleDef, RuleScope}; +use std::collections::HashMap; + +fn invalid_filter_config(rule: &str, options: &str) -> crate::config::v2::NoMistakesConfig { + let mut config = crate::config::v2::NoMistakesConfig::default(); + config.rules.push(RuleDef { + rule: rule.to_string(), + scope: Some(RuleScope::Repository), + include: vec!["[".to_string()], + options: serde_yaml::from_str(options).unwrap(), + ..Default::default() + }); + config +} + +fn error_from(config: &crate::config::v2::NoMistakesConfig) -> anyhow::Error { + let root = Path::new("/repo"); + let shared = crate::codebase::check_facts::CheckFactMap::default(); + let graph = crate::codebase::dependencies::graph::test_support::from_raw_maps( + root.to_path_buf(), + HashMap::new(), + HashMap::new(), + ); + graph_rule_findings(root, config, None, &shared, None, Some(&graph), None).unwrap_err() +} + +#[test] +fn propagates_forbidden_dependencies_fact_errors() { + let config = invalid_filter_config( + FORBIDDEN_DEPENDENCIES, + "roots: [entrypoints/api.mts]\nforbiddenModules: [sharp]", + ); + let error = error_from(&config); + assert!( + error + .to_string() + .contains("shared check facts are missing graph facts"), + "unexpected error: {error:#}" + ); +} + +#[test] +fn propagates_required_entrypoint_reachability_errors() { + let config = invalid_filter_config( + REQUIRED_ENTRYPOINT_REACHABILITY, + "sourceGlobs: [sources/static.ts]\nentrypoints: [entrypoints/api.ts]", + ); + let error = error_from(&config); + assert!(error.to_string().contains("include contains invalid glob")); +} diff --git a/crates/no-mistakes/src/codebase/rules/tests.rs b/crates/no-mistakes/src/codebase/rules/tests.rs index e0912423c..482823f77 100644 --- a/crates/no-mistakes/src/codebase/rules/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/tests.rs @@ -361,6 +361,17 @@ fn run_check_surfaces_forbidden_dependencies_tsconfig_error() { assert!(format!("{error:#}").contains("does-not-exist.tsconfig.json")); } +#[test] +fn run_check_propagates_forbidden_dependencies_filter_error() { + let root = fixture("codebase-analysis/forbidden-dependencies-basic"); + let config = root.join("invalid-include.no-mistakes.yml"); + let error = run_check(&root, Some(&config), Some(&root.join("tsconfig.json"))).unwrap_err(); + assert!( + error.to_string().contains("include contains invalid glob"), + "unexpected error: {error:#}" + ); +} + #[test] fn run_check_with_facts_executes_forbidden_dependencies_rule() { let root = fixture("codebase-analysis/forbidden-dependencies-basic"); diff --git a/test-cases/codebase-analysis/forbidden-dependencies-basic/fixture/invalid-include.no-mistakes.yml b/test-cases/codebase-analysis/forbidden-dependencies-basic/fixture/invalid-include.no-mistakes.yml new file mode 100644 index 000000000..66bc6f69e --- /dev/null +++ b/test-cases/codebase-analysis/forbidden-dependencies-basic/fixture/invalid-include.no-mistakes.yml @@ -0,0 +1,7 @@ +rules: + - rule: forbidden-dependencies + scope: repository + include: ['['] + options: + roots: [entrypoints/api.mts] + forbiddenModules: [sharp] diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/invalid-include.no-mistakes.yml b/test-cases/rules/required-entrypoint-reachability/fixture/invalid-include.no-mistakes.yml new file mode 100644 index 000000000..01c7acda0 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/invalid-include.no-mistakes.yml @@ -0,0 +1,7 @@ +rules: + - rule: required-entrypoint-reachability + scope: repository + include: ['['] + options: + sourceGlobs: [sources/static.ts] + entrypoints: [entrypoints/api.ts] From 0e01c55864aef3dcf79af7ae465c3026ffbd309d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 01:05:36 -0700 Subject: [PATCH 07/12] test: reject out-of-root reachability entrypoints Co-Authored-By: OpenAI Codex --- .../required_entrypoint_reachability/tests.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs index cf3604f37..6205590d5 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs @@ -152,6 +152,28 @@ entrypoints: ["{}"] assert!(findings.is_empty(), "unexpected findings: {findings:?}"); } +#[test] +fn rejects_an_absolute_entrypoint_outside_the_repository() { + let entrypoint = fixture().parent().unwrap().join("outside.ts"); + assert!(!entrypoint.starts_with(fixture())); + let findings = run(vec![application(&format!( + r#" +sourceGlobs: [sources/static.ts] +entrypoints: ["{}"] +"#, + entrypoint.display() + ))]); + + assert_eq!(findings.len(), 1); + assert_eq!( + findings[0].message, + format!( + "required-entrypoint-reachability: entrypoint `{}` does not exist", + entrypoint.display() + ) + ); +} + #[test] fn rejects_an_invalid_common_include_pattern() { let mut rule = application( From 16c6c74452fc6e734fef713b3ffa7c9ca36e955a Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 02:13:12 -0700 Subject: [PATCH 08/12] fix: preserve graph edge semantics Co-Authored-By: OpenAI Codex --- .../no-mistakes/src/check_runner/run_all.rs | 5 +- crates/no-mistakes/src/check_runner/tests.rs | 31 +++++++++ .../src/codebase/check_facts/map.rs | 4 ++ .../dependencies/args_relationships_filter.rs | 10 ++- .../codebase/dependencies/graph/build_plan.rs | 7 ++- .../graph/edge_import_reachability.rs | 3 +- .../dependencies/graph/edge_imports.rs | 10 ++- .../graph/edge_symbols_exports.rs | 7 ++- .../graph/edge_symbols_helpers.rs | 22 ++++++- .../graph/edge_symbols_reexport_namespaces.rs | 12 ++-- .../graph/edge_symbols_scoped_imports.rs | 14 ++++- .../graph/edge_symbols_star_reexports.rs | 23 +++---- .../graph/lazy_import_neighbors.rs | 12 +++- .../graph/tests/extra_symbol_helpers.rs | 4 +- .../dependencies/graph/tests/module_cases.rs | 2 +- .../dependencies/graph/tests/route_import.rs | 8 +++ .../dependencies/graph/types_edges.rs | 6 ++ .../dependencies/graph/types_edges_sort.rs | 4 ++ .../graph/types_edges_sort/tests.rs | 2 + .../dependencies/output/tests/edge_kinds.rs | 9 +++ .../dependencies/tests/args_relationships.rs | 3 + crates/no-mistakes/src/codebase/rules/mod.rs | 2 + .../rules/required_entrypoint_reachability.rs | 3 +- .../required_entrypoint_reachability/tests.rs | 60 +++++++++++++++++- crates/no-mistakes/src/codebase/rules/run.rs | 2 +- .../src/codebase/rules/run/prepared.rs | 9 +++ .../config/prepared_tests.rs | 4 +- .../with_facts.rs | 15 ++--- .../no-mistakes/src/codebase/rules/tests.rs | 23 +++++++ .../src/codebase/rules/tests/extended.rs | 2 +- .../symbols/impact_collect_targets.rs | 11 ++++ .../analyze_project/context/check_prepare.rs | 5 +- .../analyze_project/context/check_run.rs | 4 ++ .../context/scope_materialize.rs | 9 +++ .../analyze_project/context/scope_prepare.rs | 20 ++++-- .../context/scope_project_reports.rs | 11 ++-- .../analyze_project/context/scope_types.rs | 1 + .../src/napi_api/analyze_project/tests.rs | 63 +++++++++++++++++++ crates/no-mistakes/src/tests/impact.rs | 32 +++++++++- crates/no-mistakes/src/tests/plan_bfs.rs | 4 +- .../tests/cli_codebase_acceptance/graph.rs | 6 ++ .../cli_codebase_acceptance/workspace.rs | 23 +++++++ docs/graph-edges.md | 10 +-- .../rules/required-entrypoint-reachability.md | 11 ++-- .../fixture/.no-mistakes.yml | 14 +++++ .../fixture/skipped/setup.ts | 3 + .../fixture/skipped/target.ts | 1 + .../fixture/skipped/vitest.config.ts | 8 +++ .../fixture/tests/scoped.test.ts | 3 + .../fixture/tsconfig.json | 7 +++ .../fixture/vitest.config.ts | 5 ++ .../fixture/.no-mistakes.yml | 9 +++ .../fixture/entrypoints/runtime.ts | 1 + .../fixture/sources/registered.ts | 1 + .../fixture/sources/unreachable.ts | 1 + .../fixture/tsconfig.json | 7 +++ .../apps/backend/api/workspace-resolve.cjs | 1 + .../import-forms/fixture/require-resolve.js | 1 + .../fixture/entrypoints/api.ts | 2 + .../entrypoints/require-resolve-asset.ts | 1 + .../entrypoints/require-resolve-workspace.ts | 1 + .../fixture/entrypoints/require-resolve.ts | 1 + .../fixture/entrypoints/type-workspace.ts | 3 + .../fixture/package.json | 5 ++ .../fixture/packages/runtime/index.ts | 2 + .../fixture/packages/runtime/package.json | 5 ++ .../fixture/sources/config.json | 3 + 67 files changed, 533 insertions(+), 80 deletions(-) create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/.no-mistakes.yml create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/setup.ts create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/target.ts create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/vitest.config.ts create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tsconfig.json create mode 100644 test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/vitest.config.ts create mode 100644 test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/.no-mistakes.yml create mode 100644 test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/entrypoints/runtime.ts create mode 100644 test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/registered.ts create mode 100644 test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/unreachable.ts create mode 100644 test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/tsconfig.json create mode 100644 test-cases/codebase-analysis/cross-boundary-monorepo/fixture/apps/backend/api/workspace-resolve.cjs create mode 100644 test-cases/codebase-analysis/import-forms/fixture/require-resolve.js create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-asset.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-workspace.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/type-workspace.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/package.json create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/index.ts create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/package.json create mode 100644 test-cases/rules/required-entrypoint-reachability/fixture/sources/config.json diff --git a/crates/no-mistakes/src/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs index bdc3b457d..32b96d93f 100644 --- a/crates/no-mistakes/src/check_runner/run_all.rs +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -30,6 +30,8 @@ pub(crate) fn run_all( let enabled = enabled::ConfiguredChecks::from_config(config); let filesystem_rules_enabled = check_tasks::filesystem_rules_configured(config); let canonical_graph_plan = no_mistakes::codebase::rules::canonical_graph_plan(config); + let graph_requires_full_file_universe = + no_mistakes::codebase::rules::canonical_graph_requires_full_file_universe(config); let playwright_consumers = canonical_graph_plan .map( |plan| no_mistakes::playwright::rules::PlaywrightFactConsumers { @@ -108,7 +110,8 @@ pub(crate) fn run_all( ) }, ); - let needs_full_graph_files = canonical_graph_plan.is_some() || playwright_fact_plan.is_some(); + let needs_full_graph_files = + graph_requires_full_file_universe || playwright_fact_plan.is_some(); let needs_graph_files = needs_shared_facts && (needs_full_graph_files || enabled.dynamic_import_rules); let (discovered, graph_files) = if needs_full_graph_files { diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 7d0474afe..7e7716b17 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -346,6 +346,37 @@ fn run_all_keeps_forbidden_graph_files_outside_filesystem_skips() { ); } +#[test] +fn run_all_keeps_dynamic_import_graph_within_filesystem_skips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture"); + let config = root.join(".no-mistakes.yml"); + let results = run_all(root, Some(config), None).unwrap(); + let finding = results + .rules + .iter() + .find(|finding| finding.file == "tests/scoped.test.ts") + .expect("skipped dynamic import remains reportable as unresolved"); + + assert_eq!(finding.import.as_deref(), Some("../skipped/target")); + assert_eq!(finding.target, None); +} + +#[test] +fn run_all_keeps_reachability_sources_outside_filesystem_skips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture", + ); + let config = root.join(".no-mistakes.yml"); + let results = run_all(root, Some(config), None).unwrap(); + + assert!(results.rules.iter().any(|finding| { + finding.rule == no_mistakes::codebase::rules::REQUIRED_ENTRYPOINT_REACHABILITY + && finding.file == "sources/unreachable.ts" + && finding.message.contains("not runtime-reachable") + })); +} + #[test] fn run_all_keeps_playwright_graph_files_outside_filesystem_skips() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/no-mistakes/src/codebase/check_facts/map.rs b/crates/no-mistakes/src/codebase/check_facts/map.rs index 2e09cc36b..86951eb3b 100644 --- a/crates/no-mistakes/src/codebase/check_facts/map.rs +++ b/crates/no-mistakes/src/codebase/check_facts/map.rs @@ -102,6 +102,10 @@ impl CheckFactMap { self.view_with_supplemental(supplemental, graph_files) } + pub(crate) fn with_graph_file_universe(&self, graph_files: Vec) -> Self { + self.view_with_supplemental(&Self::default(), graph_files) + } + fn view_with_supplemental(&self, supplemental: &Self, graph_files: Vec) -> Self { let mut ts = self.ts.clone(); ts.extend( diff --git a/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs b/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs index 5c156ca8f..d31ad05f0 100644 --- a/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs +++ b/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs @@ -30,13 +30,17 @@ fn non_workflow_relationship_edges(relationship: &RelationshipArg) -> &'static [ EdgeKind::TypeImport, EdgeKind::DynamicImport, EdgeKind::Require, + EdgeKind::RequireResolve, ], RelationshipArg::ImportStatic => &[EdgeKind::Import], RelationshipArg::ImportDynamic => &[EdgeKind::DynamicImport], RelationshipArg::ImportType => &[EdgeKind::TypeImport], - RelationshipArg::ImportRequire => &[EdgeKind::Require], + RelationshipArg::ImportRequire => &[EdgeKind::Require, EdgeKind::RequireResolve], RelationshipArg::RouteImport => &[EdgeKind::RouteImport], - RelationshipArg::Workspace => &[EdgeKind::WorkspaceImport], + RelationshipArg::Workspace => &[ + EdgeKind::WorkspaceImport, + EdgeKind::WorkspaceTypeImport, + ], RelationshipArg::Package => &[EdgeKind::PackageDependency], RelationshipArg::Test => &[ EdgeKind::TestOf, @@ -94,6 +98,7 @@ fn standard_relationship_edges() -> std::collections::HashSet { EdgeKind::TypeImport, EdgeKind::DynamicImport, EdgeKind::Require, + EdgeKind::RequireResolve, EdgeKind::TestOf, EdgeKind::VitestSetup( crate::codebase::dependencies::graph::VitestSetupField::SetupFiles, @@ -108,6 +113,7 @@ fn standard_relationship_edges() -> std::collections::HashSet { EdgeKind::Layout, EdgeKind::MarkdownLink, EdgeKind::WorkspaceImport, + EdgeKind::WorkspaceTypeImport, EdgeKind::PackageDependency, EdgeKind::CiInvocation, EdgeKind::WorkflowJob, diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs index 9e7340d50..a2e34724a 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs @@ -86,9 +86,12 @@ impl GraphBuildPlan { imports: allowed.contains(&EdgeKind::Import) || allowed.contains(&EdgeKind::TypeImport) || allowed.contains(&EdgeKind::DynamicImport) - || allowed.contains(&EdgeKind::Require), + || allowed.contains(&EdgeKind::Require) + || allowed.contains(&EdgeKind::RequireResolve), route_imports: allowed.contains(&EdgeKind::RouteImport), - workspace: allowed.contains(&EdgeKind::WorkspaceImport), + workspace: allowed.contains(&EdgeKind::WorkspaceImport) + || allowed.contains(&EdgeKind::WorkspaceTypeImport) + || allowed.contains(&EdgeKind::RequireResolve), package: allowed.contains(&EdgeKind::PackageDependency), tests: allowed.contains(&EdgeKind::TestOf) || allowed.contains(&EdgeKind::VitestSetup(VitestSetupField::SetupFiles)) diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_import_reachability.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_import_reachability.rs index e14d7e18d..b49f291f0 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_import_reachability.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_import_reachability.rs @@ -3,7 +3,8 @@ fn edge_kind_for_import(import: &ExtractedImport) -> EdgeKind { ImportKind::Static => EdgeKind::Import, ImportKind::Type => EdgeKind::TypeImport, ImportKind::Dynamic => EdgeKind::DynamicImport, - ImportKind::Require | ImportKind::RequireResolve => EdgeKind::Require, + ImportKind::Require => EdgeKind::Require, + ImportKind::RequireResolve => EdgeKind::RequireResolve, } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_imports.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_imports.rs index 348181a56..ee4823271 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_imports.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_imports.rs @@ -36,7 +36,7 @@ fn collect_import_edges( ); if let Some(target) = classification.resolver_path() { let target = graph_files.visible_path(target)?; - return is_indexable(target).then(|| { + return (is_indexable(target) || kind == EdgeKind::RequireResolve).then(|| { ( NodeId::File((*path).clone()), NodeId::File(target.to_path_buf()), @@ -67,6 +67,7 @@ fn collect_asset_edges( .imports .iter() .filter(|imp| import_is_reachable(imp, facts, reachable)) + .filter(|imp| !matches!(imp.kind, ImportKind::Type | ImportKind::RequireResolve)) .filter(|imp| imp.specifier.starts_with('.') || imp.specifier.starts_with('/')) .filter_map(|imp| { resolver.resolve(&imp.specifier, path).and_then(|target| { @@ -113,10 +114,15 @@ fn collect_workspace_edges( .workspace_path() .and_then(|entry| graph_files.visible_path(entry)) .map(|entry| { + let kind = match imp.kind { + ImportKind::Type => EdgeKind::WorkspaceTypeImport, + ImportKind::RequireResolve => EdgeKind::RequireResolve, + _ => EdgeKind::WorkspaceImport, + }; ( NodeId::File((*path).clone()), NodeId::File(entry.to_path_buf()), - EdgeKind::WorkspaceImport, + kind, ) }) }) diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_exports.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_exports.rs index ce3597948..c8cc91fac 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_exports.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_exports.rs @@ -89,8 +89,11 @@ fn collect_direct_reexport_edge( inputs.visible_files, ) { let Some(target) = inputs.graph_files.visible_path(&target) else { return; }; + let kind = workspace_symbol_edge_kind( + export.is_type_only || target_export_is_type(target, imported, inputs.facts), + ); if imported == "*" { - edges.push((from, NodeId::File(target.to_path_buf()), EdgeKind::WorkspaceImport)); + edges.push((from, NodeId::File(target.to_path_buf()), kind)); return; } edges.push(( @@ -99,7 +102,7 @@ fn collect_direct_reexport_edge( file: target.to_path_buf(), symbol: imported.clone(), }, - EdgeKind::WorkspaceImport, + kind, )); } else if !inputs .workspace diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_helpers.rs index 5b5e5cbf4..0df562b4d 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_helpers.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_helpers.rs @@ -75,7 +75,7 @@ fn imported_symbol_map( ImportedSymbolTarget::Symbol { file: target, symbol: import.imported.clone(), - kind: EdgeKind::WorkspaceImport, + kind: workspace_symbol_edge_kind(import.is_type_only), } } else if workspace.recognizes_specifier_from(&import.source, path) { continue; @@ -130,7 +130,7 @@ fn namespace_import_map( ImportedSymbolTarget::Symbol { file, symbol: "*".to_string(), - kind: EdgeKind::WorkspaceImport, + kind: workspace_symbol_edge_kind(import.is_type_only), } } else if workspace.recognizes_specifier_from(&import.source, path) { continue; @@ -175,3 +175,21 @@ fn symbol_edge_kind(is_type_only: bool) -> EdgeKind { EdgeKind::Import } } + +fn workspace_symbol_edge_kind(is_type_only: bool) -> EdgeKind { + if is_type_only { + EdgeKind::WorkspaceTypeImport + } else { + EdgeKind::WorkspaceImport + } +} + +fn with_type_only_edge_kind(kind: EdgeKind, is_type_only: bool) -> EdgeKind { + if !is_type_only { + kind + } else if matches!(kind, EdgeKind::WorkspaceImport | EdgeKind::WorkspaceTypeImport) { + EdgeKind::WorkspaceTypeImport + } else { + EdgeKind::TypeImport + } +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_reexport_namespaces.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_reexport_namespaces.rs index 43c835013..d6040a65b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_reexport_namespaces.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_reexport_namespaces.rs @@ -86,14 +86,14 @@ impl ReexportNamespaceResolver<'_> { self.graph_files.visible_path(&self.workspace.resolve_specifier_from_file_visible( source, barrel, self.visible_files, )?)?.to_path_buf(), - EdgeKind::WorkspaceImport, + workspace_symbol_edge_kind(export.is_type_only), ) }; - let edge_kind = if kind == EdgeKind::TypeImport || export.is_type_only { - EdgeKind::TypeImport - } else { - source_kind - }; + let edge_kind = with_type_only_edge_kind( + source_kind, + matches!(kind, EdgeKind::TypeImport | EdgeKind::WorkspaceTypeImport) + || export.is_type_only, + ); if reexported == "*" { return Some(( NodeId::Symbol { diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_scoped_imports.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_scoped_imports.rs index eb587039e..cbd67650d 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_scoped_imports.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_scoped_imports.rs @@ -49,12 +49,17 @@ fn import_target_with_graph_files( ImportKind::Static => EdgeKind::Import, ImportKind::Type => EdgeKind::TypeImport, ImportKind::Dynamic => EdgeKind::DynamicImport, - ImportKind::Require | ImportKind::RequireResolve => EdgeKind::Require, + ImportKind::Require => EdgeKind::Require, + ImportKind::RequireResolve => EdgeKind::RequireResolve, }; if let Some(target) = resolver.resolve(specifier, path) { let target = graph_files.visible_path(&target)?; let edge_kind = if is_indexable(target) { edge_kind + } else if kind == ImportKind::Type { + return None; + } else if kind == ImportKind::RequireResolve { + EdgeKind::RequireResolve } else { EdgeKind::AssetImport }; @@ -64,7 +69,12 @@ fn import_target_with_graph_files( workspace.resolve_specifier_from_file_visible(specifier, path, visible_files) { let target = graph_files.visible_path(&target)?; - return Some((NodeId::File(target.to_path_buf()), EdgeKind::WorkspaceImport)); + let edge_kind = match kind { + ImportKind::Type => EdgeKind::WorkspaceTypeImport, + ImportKind::RequireResolve => EdgeKind::RequireResolve, + _ => EdgeKind::WorkspaceImport, + }; + return Some((NodeId::File(target.to_path_buf()), edge_kind)); } if workspace.recognizes_specifier_from(specifier, path) { return None; diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_star_reexports.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_star_reexports.rs index bd9f58322..70d31481d 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_star_reexports.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_symbols_star_reexports.rs @@ -111,11 +111,10 @@ fn collect_concrete_star_reexport( if reexported_symbol == "default" || shadowed_exports.contains(&export_key) { return; } - let kind = if target_export.is_type_only || reexport_kind.export_is_type_only { - EdgeKind::TypeImport - } else { - reexport_kind.source_kind - }; + let kind = with_type_only_edge_kind( + reexport_kind.source_kind, + target_export.is_type_only || reexport_kind.export_is_type_only, + ); candidates.push(StarReexportCandidate { target: target.to_path_buf(), symbol: reexported_symbol, @@ -147,11 +146,13 @@ fn collect_nested_star_reexport( let Some((nested, nested_kind)) = nested else { return; }; - let source_kind = if reexport_kind.source_kind == EdgeKind::TypeImport || export.is_type_only { - EdgeKind::TypeImport - } else { - nested_kind - }; + let source_kind = with_type_only_edge_kind( + nested_kind, + matches!( + reexport_kind.source_kind, + EdgeKind::TypeImport | EdgeKind::WorkspaceTypeImport + ) || export.is_type_only, + ); let kind = StarReexportKind { export_is_type_only: reexport_kind.export_is_type_only || export.is_type_only, source_kind, @@ -183,7 +184,7 @@ fn resolve_star_source( .workspace .resolve_specifier_from_file_visible(source, from, inputs.visible_files) .and_then(|target| inputs.graph_files.visible_path(&target).map(Path::to_path_buf)) - .map(|target| (target, EdgeKind::WorkspaceImport)) + .map(|target| (target, workspace_symbol_edge_kind(is_type_only))) } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/lazy_import_neighbors.rs b/crates/no-mistakes/src/codebase/dependencies/graph/lazy_import_neighbors.rs index 3358c1452..735b90442 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/lazy_import_neighbors.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/lazy_import_neighbors.rs @@ -95,9 +95,19 @@ fn import_neighbors_from_facts( workspace, graph_files.visible(), ); + if let Some(target) = classification.workspace_path() { + let target = graph_files.visible_path(target)?; + let kind = match imp.kind { + ImportKind::Type => EdgeKind::WorkspaceTypeImport, + ImportKind::RequireResolve => EdgeKind::RequireResolve, + _ => EdgeKind::WorkspaceImport, + }; + return (is_indexable(target) || kind == EdgeKind::RequireResolve) + .then(|| (NodeId::File(target.to_path_buf()), kind)); + } if let Some(target) = classification.preferred_path() { let target = graph_files.visible_path(target)?; - return is_indexable(target) + return (is_indexable(target) || kind == EdgeKind::RequireResolve) .then(|| (NodeId::File(target.to_path_buf()), kind)); } if classification.is_unresolved_external() { diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_helpers.rs index 89c8ab949..e17fe697c 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_helpers.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_helpers.rs @@ -130,7 +130,7 @@ fn symbol_edge_helpers_cover_defensive_and_workspace_paths() { file: workspace_target.clone(), symbol: "parse".to_string() }, - EdgeKind::WorkspaceImport + EdgeKind::WorkspaceTypeImport ) ); assert_eq!( @@ -192,7 +192,7 @@ fn symbol_edge_helpers_cover_defensive_and_workspace_paths() { file: workspace_target, symbol: "member".to_string() }, - EdgeKind::TypeImport + EdgeKind::WorkspaceTypeImport )) ); assert!(resolve_imported_callee( diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/module_cases.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/module_cases.rs index 44b638f23..1531768bb 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/module_cases.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/module_cases.rs @@ -293,7 +293,7 @@ fn import_fact_kinds_map_to_edge_kinds() { import.kind = ImportKind::Require; assert_eq!(edge_kind_for_import(&import), EdgeKind::Require); import.kind = ImportKind::RequireResolve; - assert_eq!(edge_kind_for_import(&import), EdgeKind::Require); + assert_eq!(edge_kind_for_import(&import), EdgeKind::RequireResolve); } #[test] diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs index 27ad2cffe..b733e4dc7 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs @@ -88,6 +88,14 @@ fn graph_build_plan_from_allowed_covers_each_edge_family() { assert!(!plan.process); assert!(!plan.assets); assert!(!plan.react); + + let require_resolve_only: HashSet<_> = [EdgeKind::RequireResolve].into(); + let plan = GraphBuildPlan::from_allowed(Some(&require_resolve_only)); + assert!(plan.imports); + assert!(plan.workspace); + + let workspace_type_only: HashSet<_> = [EdgeKind::WorkspaceTypeImport].into(); + assert!(GraphBuildPlan::from_allowed(Some(&workspace_type_only)).workspace); } #[test] diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs index 050d7d6bd..20b9541c2 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs @@ -15,6 +15,8 @@ pub enum EdgeKind { RouteImport, /// CommonJS `require("...")` call. Require, + /// CommonJS `require.resolve("...")` lookup, which resolves but does not load a module. + RequireResolve, /// Test correspondence: `foo.mts` ↔ `foo.test.mts`. TestOf, /// Frontend/backend route reference: ref_file → route_def_file. @@ -31,6 +33,8 @@ pub enum EdgeKind { MarkdownLink, /// Cross-workspace package import (via npm workspace resolution). WorkspaceImport, + /// Type-only cross-workspace package import. + WorkspaceTypeImport, /// Dependency declared in a package.json dependency field. PackageDependency, /// CI workflow invokes a binary: `*.yml` → `src/bin/*.rs`. @@ -112,6 +116,7 @@ impl EdgeKind { Self::DynamicImport => Some("dynamic-import"), Self::RouteImport => Some("route-import"), Self::Require => Some("require"), + Self::RequireResolve => Some("require-resolve"), Self::TestOf => Some("test"), Self::RouteRef => Some("route"), Self::QueueEnqueue => Some("queue-enqueue"), @@ -120,6 +125,7 @@ impl EdgeKind { Self::Layout => Some("layout"), Self::MarkdownLink => Some("md"), Self::WorkspaceImport => Some("workspace"), + Self::WorkspaceTypeImport => Some("workspace-type-import"), Self::PackageDependency => Some("package"), Self::CiInvocation => Some("ci"), Self::WorkflowJob => Some("workflow-job"), diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs index 9ef37655b..91ade56e7 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs @@ -7,6 +7,7 @@ pub(super) const fn key(kind: EdgeKind) -> (u8, u8) { | EdgeKind::DynamicImport | EdgeKind::RouteImport | EdgeKind::Require + | EdgeKind::RequireResolve | EdgeKind::TestOf | EdgeKind::RouteRef | EdgeKind::QueueEnqueue @@ -15,6 +16,7 @@ pub(super) const fn key(kind: EdgeKind) -> (u8, u8) { | EdgeKind::Layout | EdgeKind::MarkdownLink | EdgeKind::WorkspaceImport + | EdgeKind::WorkspaceTypeImport | EdgeKind::PackageDependency | EdgeKind::CiInvocation => core(kind), EdgeKind::HttpCall @@ -59,6 +61,8 @@ const fn core(kind: EdgeKind) -> (u8, u8) { EdgeKind::WorkspaceImport => (12, 0), EdgeKind::PackageDependency => (13, 0), EdgeKind::CiInvocation => (14, 0), + EdgeKind::RequireResolve => (37, 0), + EdgeKind::WorkspaceTypeImport => (38, 0), _ => panic!("core edge group is exhaustive"), } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs index 4245d31c1..52854edb8 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs @@ -7,6 +7,8 @@ fn public_sort_key_delegates_all_groups_and_non_vitest_detail_is_none() { assert_eq!(EdgeKind::HttpCall.sort_key(), (15, 0)); assert_eq!(EdgeKind::SwiftImport.sort_key(), (21, 0)); assert_eq!(EdgeKind::WorkflowJob.sort_key(), (30, 0)); + assert_eq!(EdgeKind::RequireResolve.sort_key(), (37, 0)); + assert_eq!(EdgeKind::WorkspaceTypeImport.sort_key(), (38, 0)); assert_eq!( EdgeKind::VitestSetup(VitestSetupField::GlobalSetup).sort_key(), (36, 1) diff --git a/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs b/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs index de261c507..c0bc0cbcc 100644 --- a/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs +++ b/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs @@ -4,6 +4,7 @@ fn edge_kind_str_all_variants() { assert_eq!(EdgeKind::TypeImport.as_str(), "type-import"); assert_eq!(EdgeKind::DynamicImport.as_str(), "dynamic-import"); assert_eq!(EdgeKind::Require.as_str(), "require"); + assert_eq!(EdgeKind::RequireResolve.as_str(), "require-resolve"); assert_eq!(EdgeKind::TestOf.as_str(), "test"); assert_eq!( EdgeKind::VitestSetup( @@ -19,6 +20,10 @@ fn edge_kind_str_all_variants() { assert_eq!(EdgeKind::Layout.as_str(), "layout"); assert_eq!(EdgeKind::MarkdownLink.as_str(), "md"); assert_eq!(EdgeKind::WorkspaceImport.as_str(), "workspace"); + assert_eq!( + EdgeKind::WorkspaceTypeImport.as_str(), + "workspace-type-import" + ); assert_eq!(EdgeKind::PackageDependency.as_str(), "package"); assert_eq!(EdgeKind::CiInvocation.as_str(), "ci"); assert_eq!(EdgeKind::HttpCall.as_str(), "http"); @@ -43,6 +48,7 @@ fn serialized_edge_kinds_are_documented() { EdgeKind::DynamicImport, EdgeKind::RouteImport, EdgeKind::Require, + EdgeKind::RequireResolve, EdgeKind::TestOf, EdgeKind::VitestSetup( crate::codebase::dependencies::graph::VitestSetupField::SetupFiles, @@ -57,6 +63,7 @@ fn serialized_edge_kinds_are_documented() { EdgeKind::Layout, EdgeKind::MarkdownLink, EdgeKind::WorkspaceImport, + EdgeKind::WorkspaceTypeImport, EdgeKind::PackageDependency, EdgeKind::CiInvocation, EdgeKind::HttpCall, @@ -87,6 +94,7 @@ fn serialized_edge_kinds_are_documented() { EdgeKind::DynamicImport => {} EdgeKind::RouteImport => {} EdgeKind::Require => {} + EdgeKind::RequireResolve => {} EdgeKind::TestOf => {} EdgeKind::VitestSetup(_) => {} EdgeKind::RouteRef => {} @@ -96,6 +104,7 @@ fn serialized_edge_kinds_are_documented() { EdgeKind::Layout => {} EdgeKind::MarkdownLink => {} EdgeKind::WorkspaceImport => {} + EdgeKind::WorkspaceTypeImport => {} EdgeKind::PackageDependency => {} EdgeKind::CiInvocation => {} EdgeKind::HttpCall => {} diff --git a/crates/no-mistakes/src/codebase/dependencies/tests/args_relationships.rs b/crates/no-mistakes/src/codebase/dependencies/tests/args_relationships.rs index 1ef7d8768..96e1c283b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/tests/args_relationships.rs +++ b/crates/no-mistakes/src/codebase/dependencies/tests/args_relationships.rs @@ -75,6 +75,7 @@ fn import_maps_to_all_import_forms() { assert!(set.contains(&EdgeKind::TypeImport)); assert!(set.contains(&EdgeKind::DynamicImport)); assert!(set.contains(&EdgeKind::Require)); + assert!(set.contains(&EdgeKind::RequireResolve)); assert!(!set.contains(&EdgeKind::TestOf)); } @@ -103,6 +104,7 @@ fn granular_imports_map_to_respective_edge_kinds() { assert!(!require_set.contains(&EdgeKind::TypeImport)); assert!(!require_set.contains(&EdgeKind::DynamicImport)); assert!(require_set.contains(&EdgeKind::Require)); + assert!(require_set.contains(&EdgeKind::RequireResolve)); } #[test] @@ -134,6 +136,7 @@ fn granular_import_cli_flags_parsed() { fn workspace_maps_to_workspace_import() { let set = relationship_filter(&[RelationshipArg::Workspace]).unwrap(); assert!(set.contains(&EdgeKind::WorkspaceImport)); + assert!(set.contains(&EdgeKind::WorkspaceTypeImport)); } #[test] diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 90296aa90..009172ae9 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -69,6 +69,8 @@ pub use ids::*; #[doc(hidden)] pub use run::canonical_graph_plan; #[doc(hidden)] +pub use run::canonical_graph_requires_full_file_universe; +#[doc(hidden)] pub use run::run_check_with_config_facts_playwright_and_graph; pub use run::{ run_check, run_check_with_config_and_facts_and_playwright, run_check_with_facts, diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs index 20482a898..8dc8f8d66 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability.rs @@ -20,7 +20,7 @@ pub(crate) struct Options { pub(crate) fn graph_plan(config: &NoMistakesConfig) -> Option { config .rule_configured(RULE_ID) - .then(GraphBuildPlan::imports_and_workspace) + .then(|| GraphBuildPlan::from_allowed(Some(&runtime_edge_kinds()))) } pub(crate) fn check_with_graph_and_inferred( @@ -181,6 +181,7 @@ fn runtime_edge_kinds() -> HashSet { EdgeKind::DynamicImport, EdgeKind::Require, EdgeKind::WorkspaceImport, + EdgeKind::AssetImport, ] .into_iter() .collect() diff --git a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs index 6205590d5..6559342c3 100644 --- a/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/required_entrypoint_reachability/tests.rs @@ -33,6 +33,8 @@ fn run(rules: Vec) -> Vec { fn try_run(mut rules: Vec) -> Result> { let root = fixture(); + let mut config = NoMistakesConfig::default(); + config.rules.append(&mut rules); let files = crate::codebase::dependencies::graph::GraphFiles::discover(&root) .all() .to_vec(); @@ -46,12 +48,10 @@ fn try_run(mut rules: Vec) -> Result> { let graph = DepGraph::build_with_plan_and_files( &root, &tsconfig, - GraphBuildPlan::imports_and_workspace(), + graph_plan(&config).expect("reachability graph plan"), &graph_files, ) .unwrap(); - let mut config = NoMistakesConfig::default(); - config.rules.append(&mut rules); check_with_graph(&root, &config, &files, &graph) } @@ -65,6 +65,8 @@ sourceGlobs: - sources/required.ts - sources/named.ts - sources/star.ts + - sources/config.json + - packages/runtime/index.ts entrypoints: [entrypoints/api.ts] "#, )]); @@ -72,6 +74,58 @@ entrypoints: [entrypoints/api.ts] assert!(findings.is_empty(), "unexpected findings: {findings:?}"); } +#[test] +fn rejects_type_only_workspace_imports() { + let findings = run(vec![application( + r#" +sourceGlobs: [packages/runtime/index.ts] +entrypoints: [entrypoints/type-workspace.ts] +"#, + )]); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "packages/runtime/index.ts"); +} + +#[test] +fn rejects_require_resolve_lookups() { + let findings = run(vec![application( + r#" +sourceGlobs: [sources/static.ts] +entrypoints: [entrypoints/require-resolve.ts] +"#, + )]); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "sources/static.ts"); +} + +#[test] +fn rejects_require_resolve_asset_lookups() { + let findings = run(vec![application( + r#" +sourceGlobs: [sources/config.json] +entrypoints: [entrypoints/require-resolve-asset.ts] +"#, + )]); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "sources/config.json"); +} + +#[test] +fn rejects_require_resolve_workspace_lookups() { + let findings = run(vec![application( + r#" +sourceGlobs: [packages/runtime/index.ts] +entrypoints: [entrypoints/require-resolve-workspace.ts] +"#, + )]); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "packages/runtime/index.ts"); +} + #[test] fn rejects_type_only_and_unreachable_sources_in_deterministic_order() { let findings = run(vec![application( diff --git a/crates/no-mistakes/src/codebase/rules/run.rs b/crates/no-mistakes/src/codebase/rules/run.rs index ee869ff6f..0fcd5b28a 100644 --- a/crates/no-mistakes/src/codebase/rules/run.rs +++ b/crates/no-mistakes/src/codebase/rules/run.rs @@ -12,9 +12,9 @@ use std::path::Path; mod prepared; mod standalone; -pub use prepared::canonical_graph_plan; #[doc(hidden)] pub use prepared::run_check_with_config_facts_playwright_and_graph; +pub use prepared::{canonical_graph_plan, canonical_graph_requires_full_file_universe}; pub use prepared::{run_check_with_config_and_facts_and_playwright, PreparedRulesCheck}; pub fn run_check( diff --git a/crates/no-mistakes/src/codebase/rules/run/prepared.rs b/crates/no-mistakes/src/codebase/rules/run/prepared.rs index 425577c48..f5f09c597 100644 --- a/crates/no-mistakes/src/codebase/rules/run/prepared.rs +++ b/crates/no-mistakes/src/codebase/rules/run/prepared.rs @@ -55,6 +55,15 @@ pub fn canonical_graph_plan( needed.then_some(plan) } +/// Whether configured graph-backed rules require files outside the filesystem check scope. +#[doc(hidden)] +pub fn canonical_graph_requires_full_file_universe( + config: &crate::config::v2::NoMistakesConfig, +) -> bool { + required_entrypoint_reachability::graph_plan(config).is_some() + || forbidden_dependencies::graph_plan(config).is_some() +} + pub fn run_check_with_config_and_facts_and_playwright( inputs: PreparedRulesCheck<'_>, ) -> Result> { 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 ce15563c0..7ec1a7aff 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 @@ -31,9 +31,7 @@ fn prepared_config_globs_only_expand_aggregate_candidates() { fn aggregate_rule_uses_prepared_config_without_standalone_discovery() { let source = include_str!("../with_facts.rs"); - assert!( - source.contains("config::prepare_from_visible(root, config, shared.graph_file_universe())") - ); + assert!(source.contains("config::prepare_from_visible(root, config, &files)")); assert!(!source.contains("config::test_filter(")); assert!(!source.contains("config::precompute_setup_data(")); assert!(!source.contains("discover_files(")); 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 599307fee..3b3d7596c 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 @@ -63,15 +63,10 @@ pub(crate) fn check_with_prepared_facts_graph_and_session( session: &std::sync::Arc, ) -> Result> { let files = shared.files().to_vec(); - let visible_files = shared - .graph_file_universe() - .iter() - .cloned() - .collect::>(); - // This is the graph's canonical lexical namespace for the complete - // prepared universe. Reuse it for every scoped resolver result rather - // than rebuilding symlink mappings per import. - let graph_files = GraphFiles::from_files(shared.graph_file_universe().to_vec()); + let visible_files = files.iter().cloned().collect::>(); + // Dynamic-import policy is filesystem-scoped even when another consumer + // shares a graph built from the complete repository universe. + let graph_files = GraphFiles::from_files(files.clone()); let resolver = ScopedImportResolver::new_in_session(tsconfig_catalog, &visible_files, session); let manual_mocks = crate::perf_trace::trace("test_no_unmocked_dynamic_imports.manual_mocks", || { @@ -79,7 +74,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, shared.graph_file_universe()) + config::prepare_from_visible(root, config, &files) })?; let test_files = matching_test_files_with_filter(root, &files, prepared.test_filter()); let setup_data = prepared.setup_data(); diff --git a/crates/no-mistakes/src/codebase/rules/tests.rs b/crates/no-mistakes/src/codebase/rules/tests.rs index 482823f77..0690cd9ee 100644 --- a/crates/no-mistakes/src/codebase/rules/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/tests.rs @@ -47,6 +47,29 @@ fn rule_enabled_accepts_project_rule_without_top_level_options() { assert!(rule_enabled(&config, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS)); } +#[test] +fn full_graph_universe_is_explicit_per_graph_rule() { + fn configured(rule: &str) -> crate::config::v2::NoMistakesConfig { + let mut config = crate::config::v2::NoMistakesConfig::default(); + config.rules.push(RuleDef { + rule: rule.to_string(), + scope: Some(RuleScope::Repository), + ..Default::default() + }); + config + } + + assert!(!canonical_graph_requires_full_file_universe(&configured( + TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, + ))); + assert!(canonical_graph_requires_full_file_universe(&configured( + FORBIDDEN_DEPENDENCIES, + ))); + assert!(canonical_graph_requires_full_file_universe(&configured( + REQUIRED_ENTRYPOINT_REACHABILITY, + ))); +} + #[test] fn run_check_returns_empty_when_rule_is_not_enabled() { let root = std::path::Path::new("/tmp/no-mistakes-empty-rules"); diff --git a/crates/no-mistakes/src/codebase/rules/tests/extended.rs b/crates/no-mistakes/src/codebase/rules/tests/extended.rs index 3ef7ce437..34036e6c6 100644 --- a/crates/no-mistakes/src/codebase/rules/tests/extended.rs +++ b/crates/no-mistakes/src/codebase/rules/tests/extended.rs @@ -183,7 +183,7 @@ fn run_check_with_facts_reports_missing_setup_fact_shapes() { let test = root.join("tests/setup-good.test.mts"); let setup = root.join("tests/setup-vitest.mts"); let mut shared = crate::codebase::check_facts::CheckFactMap { - files: vec![test.clone()], + files: vec![test.clone(), setup.clone()], graph_files: vec![test.clone(), setup.clone()], graph_files_complete: true, graph_plan: crate::codebase::ts_source::facts::TsFactPlan::imports(), diff --git a/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs b/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs index 4ed62f9ae..804b06c7f 100644 --- a/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs +++ b/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs @@ -11,9 +11,20 @@ fn signature_impact_edges() -> HashSet { EdgeKind::DynamicImport, EdgeKind::Require, EdgeKind::WorkspaceImport, + EdgeKind::WorkspaceTypeImport, ]) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signature_impact_traverses_type_only_workspace_edges() { + assert!(signature_impact_edges().contains(&EdgeKind::WorkspaceTypeImport)); + } +} + fn signature_target_symbols( target_file: &Path, target_symbol: &str, diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs index 73952c62e..18a344161 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/check_prepare.rs @@ -56,6 +56,8 @@ impl SharedCheckContext { let filesystem_rules_enabled = filesystem_rules_configured(config); let playwright_rules_enabled = crate::playwright::rules::configured(config); let graph_plan = crate::codebase::rules::canonical_graph_plan(config); + let graph_requires_full_file_universe = + crate::codebase::rules::canonical_graph_requires_full_file_universe(config); let graph_rules_enabled = graph_plan.is_some(); let mut playwright_fact_plan = prepared .playwright @@ -140,7 +142,8 @@ impl SharedCheckContext { ); let needs_shared_facts = plan_requests_facts(&plan) || playwright_fact_plan.is_some() || graph_rules_enabled; - let needs_full_graph_files = graph_plan.is_some() || playwright_facts_enabled; + let needs_full_graph_files = + graph_requires_full_file_universe || playwright_facts_enabled; let needs_graph_files = needs_shared_facts && (needs_full_graph_files || enabled.dynamic_import_rules); let (discovered, graph_files) = if needs_full_graph_files { 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 5c9410f54..00b4af868 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 @@ -64,6 +64,10 @@ impl SharedCheckContext { { return Ok(crate::check_runner::empty_results([None])); } + let scoped_facts = self + .graph_plan + .map(|_| facts.with_graph_file_universe(self.graph_files.clone())); + let facts = scoped_facts.as_ref().unwrap_or(facts); let config = &self.prepared.config; let sources = self.prepared.visible_paths.source_store_for(&self.root); let (react, queues, rules, integration, codebase, filesystem_rules) = diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_materialize.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_materialize.rs index 66e102bd3..af98c77d5 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_materialize.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_materialize.rs @@ -16,11 +16,19 @@ impl PreparedScopePlan { // Config facts were prepared before the batch. Seed them before the // canonical graph so invalidation retains Playwright occurrences. self.traversal.seed_cached_program_facts(&self.configs); + let traversal_graph_files = self.traversal.graph_files().visible(); + let graph_fact_files = graph_facts.graph_file_universe(); + let check_shares_traversal_graph_universe = graph_fact_files.len() + == traversal_graph_files.len() + && graph_fact_files + .iter() + .all(|path| traversal_graph_files.contains(path)); if self .check .as_ref() .and_then(SharedCheckContext::graph_plan) .is_some() + && check_shares_traversal_graph_universe { self.traversal .prepare_canonical_graph_with_check_facts(&graph_facts)?; @@ -43,6 +51,7 @@ impl PreparedScopePlan { import_usages: self.import_usages, server, check: self.check, + check_uses_traversal_graph: check_shares_traversal_graph_universe, playwright: self.playwright, queue_reports: HashMap::new(), queue_indexed_reports: HashMap::new(), diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_prepare.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_prepare.rs index 4fa7ab52f..f6c4bccaf 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_prepare.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_prepare.rs @@ -19,6 +19,10 @@ impl PreparedScopePlan { let (import_usages, import_usage_files) = prepare_import_usage_views(options, &root, &session)?; let build_plan = graph_build_plan(options)?; + let has_non_check_report = options + .reports + .iter() + .any(|request| request.report_type != "check"); let framework_plan = framework_preparation_plan(options, build_plan)?; let include_check_plan = options .reports @@ -117,13 +121,19 @@ impl PreparedScopePlan { files.sort(); files.dedup(); } - check_plan - .graph_context - .set_visible_files(traversal.graph_files().visible().iter().cloned()); let graph_files = check .as_ref() - .map(|check| check.graph_files().to_vec()) - .unwrap_or_default(); + .map(|check| { + if has_non_check_report { + traversal.graph_files().visible().iter().cloned().collect() + } else { + check.graph_files().to_vec() + } + }) + .unwrap_or_else(|| traversal.graph_files().visible().iter().cloned().collect()); + check_plan + .graph_context + .set_visible_files(graph_files.iter().cloned()); let primary_paths = files .iter() .chain(graph_files.iter()) 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 0b8dbe6ad..cd236290d 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 @@ -15,20 +15,17 @@ impl PreparedScope { self.react_report(&request.report_type, &parsed) } "check" => { - let dependency_graph = if self + let check = self .check .as_ref() - .and_then(SharedCheckContext::graph_plan) - .is_some() + .context("check analysis was not prepared")?; + let dependency_graph = if check.graph_plan().is_some() + && self.check_uses_traversal_graph { Some(self.traversal.canonical_graph()?) } else { None }; - let check = self - .check - .as_ref() - .context("check analysis was not prepared")?; Ok(crate::check_runner::json_value(&check.run( &self.facts, dependency_graph.as_ref(), diff --git a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_types.rs b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_types.rs index 4587d63f2..c3f364cfa 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/context/scope_types.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/context/scope_types.rs @@ -11,6 +11,7 @@ struct PreparedScope { import_usages: HashMap, server: Option, check: Option, + check_uses_traversal_graph: bool, playwright: HashMap, queue_reports: HashMap, queue_indexed_reports: HashMap, 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 71d58f2e5..12f09ce86 100644 --- a/crates/no-mistakes/src/napi_api/analyze_project/tests.rs +++ b/crates/no-mistakes/src/napi_api/analyze_project/tests.rs @@ -28,6 +28,38 @@ fn queue_fixture() -> PathBuf { ) } +fn check_runner_fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/check-runner") + .join(name) + .join("fixture"), + ) +} + +fn analyze_project_check_result(root: &PathBuf) -> Value { + let output = analyze_project_json_impl( + json!({ + "root": root, + "config": ".no-mistakes.yml", + "reports": [{ "type": "check" }] + }) + .to_string(), + ) + .unwrap(); + serde_json::from_str::(&output).unwrap()["reports"][0]["result"].clone() +} + +fn standalone_check_result(root: &PathBuf) -> Value { + serde_json::from_str( + &crate::napi_api::check_json_impl( + json!({ "root": root, "config": ".no-mistakes.yml" }).to_string(), + ) + .unwrap(), + ) + .unwrap() +} + #[test] fn analyze_project_value_impl_accepts_parsed_options() { let output = analyze_project_value_impl(json!({ @@ -42,6 +74,37 @@ fn analyze_project_value_impl_accepts_parsed_options() { ); } +#[test] +fn analyze_project_dynamic_import_check_respects_filesystem_skips_with_standalone_parity() { + let root = check_runner_fixture("dynamic-import-respects-filesystem-skip"); + let result = analyze_project_check_result(&root); + + assert_eq!(result, standalone_check_result(&root)); + let finding = result["rules"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["file"] == "tests/scoped.test.ts") + .expect("skipped dynamic import remains reportable as unresolved"); + assert_eq!(finding["import"], "../skipped/target"); + assert!(finding.get("target").is_none()); +} + +#[test] +fn analyze_project_reachability_check_uses_full_graph_with_standalone_parity() { + let root = check_runner_fixture("required-reachability-ignores-filesystem-skip"); + let result = analyze_project_check_result(&root); + + assert_eq!(result, standalone_check_result(&root)); + assert!(result["rules"].as_array().unwrap().iter().any(|finding| { + finding["rule"] == "required-entrypoint-reachability" + && finding["file"] == "sources/unreachable.ts" + && finding["message"] + .as_str() + .is_some_and(|message| message.contains("not runtime-reachable")) + })); +} + #[test] fn analyze_project_check_and_graph_report_share_one_canonical_graph() { let options = parse_options::( diff --git a/crates/no-mistakes/src/tests/impact.rs b/crates/no-mistakes/src/tests/impact.rs index d38e61eeb..52c483cbd 100644 --- a/crates/no-mistakes/src/tests/impact.rs +++ b/crates/no-mistakes/src/tests/impact.rs @@ -312,9 +312,9 @@ fn push_registry_hints( }; let target_rel = relative_path(root, target); for (dependent, kind) in dependents { - // A type-only reference does not "register" a runtime entry, so it must - // not produce a registry hint. - if *kind == EdgeKind::TypeImport { + // Type-only references and `require.resolve` lookups do not register a + // runtime entry, so they must not produce registry hints. + if !is_runtime_registration_edge(kind) { continue; } if let NodeId::File(dep_path) = dependent { @@ -336,6 +336,32 @@ fn push_registry_hints( } } +fn is_runtime_registration_edge(kind: &EdgeKind) -> bool { + !matches!( + kind, + EdgeKind::TypeImport | EdgeKind::WorkspaceTypeImport | EdgeKind::RequireResolve + ) +} + +#[cfg(test)] +mod registry_edge_tests { + use super::*; + + #[test] + fn registry_hints_ignore_non_runtime_dependency_edges() { + for kind in [ + EdgeKind::TypeImport, + EdgeKind::WorkspaceTypeImport, + EdgeKind::RequireResolve, + ] { + assert!(!is_runtime_registration_edge(&kind)); + } + assert!(is_runtime_registration_edge(&EdgeKind::Import)); + assert!(is_runtime_registration_edge(&EdgeKind::WorkspaceImport)); + assert!(is_runtime_registration_edge(&EdgeKind::Require)); + } +} + fn push_warning( root: &Path, curr: &NodeId, diff --git a/crates/no-mistakes/src/tests/plan_bfs.rs b/crates/no-mistakes/src/tests/plan_bfs.rs index 3e79dcab6..aabba5d6b 100644 --- a/crates/no-mistakes/src/tests/plan_bfs.rs +++ b/crates/no-mistakes/src/tests/plan_bfs.rs @@ -174,7 +174,9 @@ pub(crate) fn impact_reason_label(edge: EdgeKind) -> &'static str { | EdgeKind::DynamicImport | EdgeKind::RouteImport | EdgeKind::Require - | EdgeKind::WorkspaceImport => "dependency", + | EdgeKind::RequireResolve + | EdgeKind::WorkspaceImport + | EdgeKind::WorkspaceTypeImport => "dependency", EdgeKind::PackageDependency => "package-json dependency", EdgeKind::RouteRef | EdgeKind::RouteTest => "route", EdgeKind::Layout => "layout", diff --git a/crates/no-mistakes/tests/cli_codebase_acceptance/graph.rs b/crates/no-mistakes/tests/cli_codebase_acceptance/graph.rs index bf1d7e9d7..ff2b2ce0f 100644 --- a/crates/no-mistakes/tests/cli_codebase_acceptance/graph.rs +++ b/crates/no-mistakes/tests/cli_codebase_acceptance/graph.rs @@ -16,6 +16,7 @@ fn import_forms_report_expected_edge_kinds() { ("import-type.mts", "type-import"), ("dynamic.mts", "dynamic-import"), ("require.js", "require"), + ("require-resolve.js", "require-resolve"), ("reexport.mts", "import"), ]; @@ -38,6 +39,7 @@ fn import_forms_report_expected_edge_kinds() { "import-type.mts", "inline-type.mts", "reexport.mts", + "require-resolve.js", "require.js", "static.mts", "type-only.mts", @@ -48,6 +50,10 @@ fn import_forms_report_expected_edge_kinds() { vec!["dynamic-import"] ); assert_eq!(via_kinds(&dependents, "require.js"), vec!["require"]); + assert_eq!( + via_kinds(&dependents, "require-resolve.js"), + vec!["require-resolve"] + ); assert_eq!( via_kinds(&dependents, "inline-type.mts"), vec!["type-import"] diff --git a/crates/no-mistakes/tests/cli_codebase_acceptance/workspace.rs b/crates/no-mistakes/tests/cli_codebase_acceptance/workspace.rs index 0423e3cdf..2487c252e 100644 --- a/crates/no-mistakes/tests/cli_codebase_acceptance/workspace.rs +++ b/crates/no-mistakes/tests/cli_codebase_acceptance/workspace.rs @@ -48,6 +48,29 @@ fn cross_boundary_workspace_and_symbol_contracts() { ], ); assert!(file_paths(&subpath).contains(&"apps/web/pages/subpath.tsx".to_string())); + assert_eq!( + via_kinds(&subpath, "apps/web/pages/subpath.tsx"), + vec!["workspace-type-import"] + ); + + let workspace_require_resolve = run_json( + &root, + &[ + "dependents", + "--tsconfig", + backend_tsconfig.as_ref(), + "--relationship", + "import-require", + "packages/core/src/index.mts", + ], + ); + assert_eq!( + via_kinds( + &workspace_require_resolve, + "apps/backend/api/workspace-resolve.cjs" + ), + vec!["require-resolve"] + ); let alias_deps = run_json( &root, diff --git a/docs/graph-edges.md b/docs/graph-edges.md index ff35fea0e..d0af82310 100644 --- a/docs/graph-edges.md +++ b/docs/graph-edges.md @@ -20,9 +20,11 @@ all` output. | `import` | `Import` | `import`, `import-static` | TS/JS file -> statically imported TS/JS file | [`import-forms/static.mts`](../test-cases/codebase-analysis/import-forms/fixture/static.mts), asserted by `graph_edge_kind_acceptance` | | `type-import` | `TypeImport` | `import`, `import-type` | TS/JS file -> type-only dependency | [`import-forms/type-only.mts`](../test-cases/codebase-analysis/import-forms/fixture/type-only.mts), [`inline-type.mts`](../test-cases/codebase-analysis/import-forms/fixture/inline-type.mts), [`import-type.mts`](../test-cases/codebase-analysis/import-forms/fixture/import-type.mts) | | `dynamic-import` | `DynamicImport` | `import`, `import-dynamic` | TS/JS file -> string-literal `import("...")` target | [`import-forms/dynamic.mts`](../test-cases/codebase-analysis/import-forms/fixture/dynamic.mts) | -| `require` | `Require` | `import`, `import-require` | JS/TS file -> string-literal `require("...")` or `require.resolve("...")` target | [`import-forms/require.js`](../test-cases/codebase-analysis/import-forms/fixture/require.js) | +| `require` | `Require` | `import`, `import-require` | JS/TS file -> string-literal `require("...")` target | [`import-forms/require.js`](../test-cases/codebase-analysis/import-forms/fixture/require.js) | +| `require-resolve` | `RequireResolve` | `import`, `import-require` | JS/TS file -> string-literal `require.resolve("...")` lookup target | [`import-forms/require-resolve.js`](../test-cases/codebase-analysis/import-forms/fixture/require-resolve.js) | | `route-import` | `RouteImport` | `route-import` | TS/JS file -> runtime static import/re-export or literal dynamic-import target, without function-reachability pruning | [`nextjs-selectors/frontend-tsconfig/page.tsx`](../test-cases/nextjs-selectors/frontend-tsconfig/fixture/web/app/page.tsx), asserted by route-reachability tests | | `workspace` | `WorkspaceImport` | `workspace` | TS/JS file -> workspace package entry/export/import target | [`cross-boundary-monorepo`](../test-cases/codebase-analysis/cross-boundary-monorepo), [`graph-missing-edges`](../test-cases/codebase-analysis/graph-missing-edges) | +| `workspace-type-import` | `WorkspaceTypeImport` | `workspace` | TS/JS file -> type-only workspace package entry/export/import target | [`subpath.tsx`](../test-cases/codebase-analysis/cross-boundary-monorepo/fixture/apps/web/pages/subpath.tsx) | | `package` | `PackageDependency` | `package` | `package.json` -> declared workspace package entry or external module node | [`graph-modules`](../test-cases/codebase-analysis/graph-modules) | | `asset` | `AssetImport` | `asset` | TS/JS file -> explicit relative non-code asset import | [`graph-missing-edges/packages/app/src/entry.mts`](../test-cases/codebase-analysis/graph-missing-edges/fixture/packages/app/src/entry.mts) | | `resource` | `Resource` | `resource` | TS/JS consumer -> tracked runtime filesystem resource | fixture-backed resource-impact tests | @@ -61,13 +63,13 @@ all` output. | Filter | Included edge kinds | | --- | --- | -| `import` | `import`, `type-import`, `dynamic-import`, `require` | +| `import` | `import`, `type-import`, `dynamic-import`, `require`, `require-resolve` | | `import-static` | `import` | | `import-type` | `type-import` | | `import-dynamic` | `dynamic-import` | -| `import-require` | `require` | +| `import-require` | `require`, `require-resolve` | | `route-import` | `route-import` | -| `workspace` | `workspace` | +| `workspace` | `workspace`, `workspace-type-import` | | `package` | `package` | | `test` | `test`, `vitest-setup`, `route-test`, `layout`, `selector` | | `route` | `route`, `route-test`, `layout` | diff --git a/docs/rules/required-entrypoint-reachability.md b/docs/rules/required-entrypoint-reachability.md index 8ad9ceeac..dac86d462 100644 --- a/docs/rules/required-entrypoint-reachability.md +++ b/docs/rules/required-entrypoint-reachability.md @@ -22,13 +22,14 @@ registries must each expose the selected sources. `sourceGlobs` uses repository-relative paths and, for project-scoped rules, project-relative paths. Every pattern must match at least one file after the application's common `include` and `exclude` filters. `entrypoints` are literal -repository-relative file paths and must exist in the analyzed file set. +repository-relative file paths or absolute paths within the repository, and must exist in the +analyzed file set. Reachability follows runtime value edges only: static imports, runtime dynamic -imports, `require()` calls, local workspace-package imports, and named or star -re-exports. Type-only imports and re-exports do not satisfy the rule. When set, -`maxDepth` limits dependency hops from each entrypoint; a direct import is depth -1. Omitting it allows transitive traversal at any depth. +imports, `require()` calls, local workspace-package imports, non-code asset imports, and named or +star re-exports. Type-only imports, type-only re-exports, and `require.resolve()` lookups do not +satisfy the rule. When set, `maxDepth` limits dependency hops from each entrypoint; a direct import +is depth 1. Omitting it allows transitive traversal at any depth. Counterexample: a worker module matches `workers/**/*.ts`, but no configured worker-registration entrypoint imports or re-exports it. A type-only import of diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/.no-mistakes.yml b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/.no-mistakes.yml new file mode 100644 index 000000000..daa52ac6f --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/.no-mistakes.yml @@ -0,0 +1,14 @@ +filesystem: + skipDirectories: + - skipped +tests: + vitest: + configs: "**/vitest.config.ts" +rules: + - rule: test-no-unmocked-dynamic-imports + scope: repository + - rule: required-entrypoint-reachability + scope: repository + options: + sourceGlobs: [skipped/target.ts] + entrypoints: [tests/scoped.test.ts] diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/setup.ts b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/setup.ts new file mode 100644 index 000000000..00d093646 --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/setup.ts @@ -0,0 +1,3 @@ +import { vi } from "vitest"; + +vi.mock("./target"); diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/target.ts b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/target.ts new file mode 100644 index 000000000..f4c7edbfe --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/target.ts @@ -0,0 +1 @@ +export const target = "target"; diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/vitest.config.ts b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/vitest.config.ts new file mode 100644 index 000000000..eb865f18b --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/skipped/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["../tests/**/*.test.ts"], + setupFiles: ["./setup.ts"], + }, +}); diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts new file mode 100644 index 000000000..f373cc052 --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts @@ -0,0 +1,3 @@ +test("filesystem skips remain outside dynamic-import reachability", async () => { + await import("../skipped/target"); +}); diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tsconfig.json b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tsconfig.json new file mode 100644 index 000000000..9eca93d6c --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022" + } +} diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/vitest.config.ts b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/vitest.config.ts new file mode 100644 index 000000000..00988cf0f --- /dev/null +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { include: ["tests/**/*.test.ts"] }, +}); diff --git a/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/.no-mistakes.yml b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/.no-mistakes.yml new file mode 100644 index 000000000..ff0e8a560 --- /dev/null +++ b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/.no-mistakes.yml @@ -0,0 +1,9 @@ +filesystem: + skipDirectories: + - sources +rules: + - rule: required-entrypoint-reachability + scope: repository + options: + sourceGlobs: [sources/*.ts] + entrypoints: [entrypoints/runtime.ts] diff --git a/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/entrypoints/runtime.ts b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/entrypoints/runtime.ts new file mode 100644 index 000000000..4bd3cc301 --- /dev/null +++ b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/entrypoints/runtime.ts @@ -0,0 +1 @@ +import "../sources/registered"; diff --git a/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/registered.ts b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/registered.ts new file mode 100644 index 000000000..be4006a4b --- /dev/null +++ b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/registered.ts @@ -0,0 +1 @@ +export const registered = true; diff --git a/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/unreachable.ts b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/unreachable.ts new file mode 100644 index 000000000..ddad772d2 --- /dev/null +++ b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/sources/unreachable.ts @@ -0,0 +1 @@ +export const unreachable = true; diff --git a/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/tsconfig.json b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/tsconfig.json new file mode 100644 index 000000000..9eca93d6c --- /dev/null +++ b/test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022" + } +} diff --git a/test-cases/codebase-analysis/cross-boundary-monorepo/fixture/apps/backend/api/workspace-resolve.cjs b/test-cases/codebase-analysis/cross-boundary-monorepo/fixture/apps/backend/api/workspace-resolve.cjs new file mode 100644 index 000000000..c46840d8a --- /dev/null +++ b/test-cases/codebase-analysis/cross-boundary-monorepo/fixture/apps/backend/api/workspace-resolve.cjs @@ -0,0 +1 @@ +module.exports = require.resolve("@scope/core"); diff --git a/test-cases/codebase-analysis/import-forms/fixture/require-resolve.js b/test-cases/codebase-analysis/import-forms/fixture/require-resolve.js new file mode 100644 index 000000000..5025e1b6f --- /dev/null +++ b/test-cases/codebase-analysis/import-forms/fixture/require-resolve.js @@ -0,0 +1 @@ +exports.targetPath = require.resolve("./target.mts"); diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts index f62fa22d1..65e29235d 100644 --- a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/api.ts @@ -2,6 +2,8 @@ import "../sources/static"; void import("../sources/dynamic"); require("../sources/required"); +import "@fixture/runtime"; +import "../sources/config.json"; export { named } from "../barrels/named"; export * from "../barrels/star"; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-asset.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-asset.ts new file mode 100644 index 000000000..513309683 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-asset.ts @@ -0,0 +1 @@ +require.resolve("../sources/config.json"); diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-workspace.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-workspace.ts new file mode 100644 index 000000000..ac7ef30ab --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve-workspace.ts @@ -0,0 +1 @@ +require.resolve("@fixture/runtime"); diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve.ts new file mode 100644 index 000000000..c205094eb --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/require-resolve.ts @@ -0,0 +1 @@ +require.resolve("../sources/static"); diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/type-workspace.ts b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/type-workspace.ts new file mode 100644 index 000000000..b681e90e7 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/entrypoints/type-workspace.ts @@ -0,0 +1,3 @@ +import type { RuntimeValue } from "@fixture/runtime"; + +export type TypeOnlyWorkspaceValue = RuntimeValue; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/package.json b/test-cases/rules/required-entrypoint-reachability/fixture/package.json new file mode 100644 index 000000000..9bf89f3e4 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/package.json @@ -0,0 +1,5 @@ +{ + "name": "required-entrypoint-reachability-fixture", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/index.ts b/test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/index.ts new file mode 100644 index 000000000..584dadc97 --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/index.ts @@ -0,0 +1,2 @@ +export const runtimeValue = "runtime"; +export type RuntimeValue = typeof runtimeValue; diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/package.json b/test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/package.json new file mode 100644 index 000000000..2ebf7f5bb --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/packages/runtime/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/runtime", + "version": "1.0.0", + "exports": "./index.ts" +} diff --git a/test-cases/rules/required-entrypoint-reachability/fixture/sources/config.json b/test-cases/rules/required-entrypoint-reachability/fixture/sources/config.json new file mode 100644 index 000000000..4e609c71b --- /dev/null +++ b/test-cases/rules/required-entrypoint-reachability/fixture/sources/config.json @@ -0,0 +1,3 @@ +{ + "enabled": true +} From 4226e936fbe9a17824e4fdb6dff1f09045ee901f Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 02:19:37 -0700 Subject: [PATCH 09/12] test: satisfy AST module guards Co-Authored-By: OpenAI Codex --- crates/no-mistakes/src/check_runner/tests.rs | 65 +------------------ .../src/check_runner/tests/graph_scope.rs | 65 +++++++++++++++++++ .../src/codebase/symbols/impact.rs | 2 + .../symbols/impact_collect_targets.rs | 10 --- .../symbols/impact_collect_targets_tests.rs | 6 ++ 5 files changed, 74 insertions(+), 74 deletions(-) create mode 100644 crates/no-mistakes/src/check_runner/tests/graph_scope.rs create mode 100644 crates/no-mistakes/src/codebase/symbols/impact_collect_targets_tests.rs diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 7e7716b17..fc0ede0e3 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -14,6 +14,7 @@ use std::time::Duration; mod architecture; mod config_path; +mod graph_scope; mod integration_gitignore; #[cfg(feature = "test-instrumentation")] mod prepared_parser_cache; @@ -330,70 +331,6 @@ fn run_all_skips_discovery_for_forbidden_deps_only() { ); } -#[test] -fn run_all_keeps_forbidden_graph_files_outside_filesystem_skips() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../test-cases/check-runner/forbidden-deps-ignores-filesystem-skip/fixture"); - let config = root.join(".no-mistakes.yml"); - let results = run_all(root, Some(config), None).unwrap(); - - assert!( - results - .rules - .iter() - .any(|f| f.rule == no_mistakes::codebase::rules::FORBIDDEN_DEPENDENCIES), - "expected forbidden-dependencies finding for file under filesystem skip" - ); -} - -#[test] -fn run_all_keeps_dynamic_import_graph_within_filesystem_skips() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture"); - let config = root.join(".no-mistakes.yml"); - let results = run_all(root, Some(config), None).unwrap(); - let finding = results - .rules - .iter() - .find(|finding| finding.file == "tests/scoped.test.ts") - .expect("skipped dynamic import remains reportable as unresolved"); - - assert_eq!(finding.import.as_deref(), Some("../skipped/target")); - assert_eq!(finding.target, None); -} - -#[test] -fn run_all_keeps_reachability_sources_outside_filesystem_skips() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( - "../../test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture", - ); - let config = root.join(".no-mistakes.yml"); - let results = run_all(root, Some(config), None).unwrap(); - - assert!(results.rules.iter().any(|finding| { - finding.rule == no_mistakes::codebase::rules::REQUIRED_ENTRYPOINT_REACHABILITY - && finding.file == "sources/unreachable.ts" - && finding.message.contains("not runtime-reachable") - })); -} - -#[test] -fn run_all_keeps_playwright_graph_files_outside_filesystem_skips() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../test-cases/check-runner/playwright-graph-ignores-filesystem-skip/fixture"); - let config = root.join(".no-mistakes.yml"); - let results = run_all(root, Some(config), None).unwrap(); - - assert!(!results.rules.iter().any(|finding| { - finding.rule == no_mistakes::playwright::rules::PLAYWRIGHT_COVERAGE - && finding.target.as_deref() == Some("data-testid=save") - })); - assert!(results.rules.iter().any(|finding| { - finding.rule == no_mistakes::playwright::rules::PLAYWRIGHT_COVERAGE - && finding.target.as_deref() == Some("data-testid=delete") - })); -} - #[test] fn run_all_dynamic_import_graph_excludes_gitignored_targets() { let dir = crate::test_support::materialize_gitignore_fixture("transitive-visibility"); diff --git a/crates/no-mistakes/src/check_runner/tests/graph_scope.rs b/crates/no-mistakes/src/check_runner/tests/graph_scope.rs new file mode 100644 index 000000000..f47d53c6c --- /dev/null +++ b/crates/no-mistakes/src/check_runner/tests/graph_scope.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn run_all_keeps_forbidden_graph_files_outside_filesystem_skips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/check-runner/forbidden-deps-ignores-filesystem-skip/fixture"); + let config = root.join(".no-mistakes.yml"); + let results = run_all(root, Some(config), None).unwrap(); + + assert!( + results + .rules + .iter() + .any(|f| f.rule == no_mistakes::codebase::rules::FORBIDDEN_DEPENDENCIES), + "expected forbidden-dependencies finding for file under filesystem skip" + ); +} + +#[test] +fn run_all_keeps_dynamic_import_graph_within_filesystem_skips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture"); + let config = root.join(".no-mistakes.yml"); + let results = run_all(root, Some(config), None).unwrap(); + let finding = results + .rules + .iter() + .find(|finding| finding.file == "tests/scoped.test.ts") + .expect("skipped dynamic import remains reportable as unresolved"); + + assert_eq!(finding.import.as_deref(), Some("../skipped/target")); + assert_eq!(finding.target, None); +} + +#[test] +fn run_all_keeps_reachability_sources_outside_filesystem_skips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../test-cases/check-runner/required-reachability-ignores-filesystem-skip/fixture", + ); + let config = root.join(".no-mistakes.yml"); + let results = run_all(root, Some(config), None).unwrap(); + + assert!(results.rules.iter().any(|finding| { + finding.rule == no_mistakes::codebase::rules::REQUIRED_ENTRYPOINT_REACHABILITY + && finding.file == "sources/unreachable.ts" + && finding.message.contains("not runtime-reachable") + })); +} + +#[test] +fn run_all_keeps_playwright_graph_files_outside_filesystem_skips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test-cases/check-runner/playwright-graph-ignores-filesystem-skip/fixture"); + let config = root.join(".no-mistakes.yml"); + let results = run_all(root, Some(config), None).unwrap(); + + assert!(!results.rules.iter().any(|finding| { + finding.rule == no_mistakes::playwright::rules::PLAYWRIGHT_COVERAGE + && finding.target.as_deref() == Some("data-testid=save") + })); + assert!(results.rules.iter().any(|finding| { + finding.rule == no_mistakes::playwright::rules::PLAYWRIGHT_COVERAGE + && finding.target.as_deref() == Some("data-testid=delete") + })); +} diff --git a/crates/no-mistakes/src/codebase/symbols/impact.rs b/crates/no-mistakes/src/codebase/symbols/impact.rs index 5297b685c..359389e38 100644 --- a/crates/no-mistakes/src/codebase/symbols/impact.rs +++ b/crates/no-mistakes/src/codebase/symbols/impact.rs @@ -20,3 +20,5 @@ include!("impact_output.rs"); #[cfg(test)] mod impact_test_support; +#[cfg(test)] +mod impact_collect_targets_tests; diff --git a/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs b/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs index 804b06c7f..5ef57f34a 100644 --- a/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs +++ b/crates/no-mistakes/src/codebase/symbols/impact_collect_targets.rs @@ -15,16 +15,6 @@ fn signature_impact_edges() -> HashSet { ]) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn signature_impact_traverses_type_only_workspace_edges() { - assert!(signature_impact_edges().contains(&EdgeKind::WorkspaceTypeImport)); - } -} - fn signature_target_symbols( target_file: &Path, target_symbol: &str, diff --git a/crates/no-mistakes/src/codebase/symbols/impact_collect_targets_tests.rs b/crates/no-mistakes/src/codebase/symbols/impact_collect_targets_tests.rs new file mode 100644 index 000000000..f729a001f --- /dev/null +++ b/crates/no-mistakes/src/codebase/symbols/impact_collect_targets_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn signature_impact_traverses_type_only_workspace_edges() { + assert!(signature_impact_edges().contains(&EdgeKind::WorkspaceTypeImport)); +} From 542b965a98279c63af2ee15d3c28ecf7762187f4 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 02:50:20 -0700 Subject: [PATCH 10/12] fix: preserve standalone graph scope Co-Authored-By: OpenAI Codex --- .../src/codebase/rules/run/standalone.rs | 33 +++++++---- .../codebase/rules/run/standalone/tests.rs | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/rules/run/standalone/tests.rs diff --git a/crates/no-mistakes/src/codebase/rules/run/standalone.rs b/crates/no-mistakes/src/codebase/rules/run/standalone.rs index affb08a51..067cbace1 100644 --- a/crates/no-mistakes/src/codebase/rules/run/standalone.rs +++ b/crates/no-mistakes/src/codebase/rules/run/standalone.rs @@ -1,7 +1,7 @@ use super::{ - any_codebase_rule_enabled, canonical_graph_plan, rule_enabled, PreparedRulesCheck, - NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, REQUIRE_STORYBOOK_STORIES, - SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, + any_codebase_rule_enabled, canonical_graph_plan, canonical_graph_requires_full_file_universe, + rule_enabled, PreparedRulesCheck, NEXTJS_NO_API_ROUTES, NEXTJS_NO_CACHING, + REQUIRE_STORYBOOK_STORIES, SERVER_ROUTE_CLIENT_BOUNDARY, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, }; use crate::codebase::check_facts::{ collect_check_facts_with_graph_files_playwright_and_sources, CheckFactPlan, @@ -64,13 +64,7 @@ pub(super) fn run_check( &config.filesystem.skip_directories, &visible_paths, ); - let graph_files = if graph_plan.is_some() { - crate::codebase::ts_source::discover_files_from_visible(root, &[], &visible_paths) - } else if rule_enabled(&config, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS) { - files.clone() - } else { - Vec::new() - }; + let graph_files = standalone_graph_files(root, &config, graph_plan, &visible_paths, &files); let playwright_fact_plan = prepared_playwright .as_ref() .map(crate::playwright::rules::PreparedPlaywrightRules::fact_plan); @@ -110,6 +104,25 @@ pub(super) fn run_check( }) } +fn standalone_graph_files( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + graph_plan: Option, + visible_paths: &[std::path::PathBuf], + scoped_files: &[std::path::PathBuf], +) -> Vec { + if canonical_graph_requires_full_file_universe(config) { + crate::codebase::ts_source::discover_files_from_visible(root, &[], visible_paths) + } else if graph_plan.is_some() { + scoped_files.to_vec() + } else { + Vec::new() + } +} + +#[cfg(test)] +mod tests; + fn standalone_fact_plan(config: &crate::config::v2::NoMistakesConfig) -> CheckFactPlan { let dynamic_imports = rule_enabled(config, TEST_NO_UNMOCKED_DYNAMIC_IMPORTS); let boundary = rule_enabled(config, SERVER_ROUTE_CLIENT_BOUNDARY); diff --git a/crates/no-mistakes/src/codebase/rules/run/standalone/tests.rs b/crates/no-mistakes/src/codebase/rules/run/standalone/tests.rs new file mode 100644 index 000000000..7382a75c7 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/run/standalone/tests.rs @@ -0,0 +1,59 @@ +use super::*; +use crate::config::v2::schema::{RuleDef, RuleScope}; + +fn config_with(rule: &str) -> crate::config::v2::NoMistakesConfig { + let mut config = crate::config::v2::NoMistakesConfig::default(); + config.filesystem.skip_directories = vec!["generated".to_string()]; + config.rules.push(RuleDef { + rule: rule.to_string(), + scope: Some(RuleScope::Repository), + options: if rule == super::super::REQUIRED_ENTRYPOINT_REACHABILITY { + serde_yaml::from_str("sourceGlobs: [generated/worker.ts]\nentrypoints: [src/api.ts]\n") + .unwrap() + } else { + Default::default() + }, + ..Default::default() + }); + config +} + +fn graph_files_for(config: &crate::config::v2::NoMistakesConfig) -> Vec { + let fixture = tempfile::tempdir().unwrap(); + let root = fixture.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::create_dir_all(root.join("generated")).unwrap(); + std::fs::write(root.join("src/api.ts"), "export {};\n").unwrap(); + std::fs::write(root.join("generated/worker.ts"), "export {};\n").unwrap(); + + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(root); + let visible_paths = snapshot.paths_for(root); + let scoped = crate::codebase::ts_source::discover_files_from_visible( + root, + &config.filesystem.skip_directories, + &visible_paths, + ); + standalone_graph_files( + root, + config, + canonical_graph_plan(config), + &visible_paths, + &scoped, + ) +} + +#[test] +fn dynamic_only_graph_files_honor_skipped_directories() { + let files = graph_files_for(&config_with(TEST_NO_UNMOCKED_DYNAMIC_IMPORTS)); + assert_eq!(files.len(), 1); + assert!(files[0].ends_with("src/api.ts")); +} + +#[test] +fn full_universe_graph_files_include_skipped_directories() { + let files = graph_files_for(&config_with(super::super::REQUIRED_ENTRYPOINT_REACHABILITY)); + assert_eq!(files.len(), 2); + assert!(files + .iter() + .any(|file| file.ends_with("generated/worker.ts"))); +} From 24acef8b6b391d35f528d4d63688bb52a282a058 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 03:36:22 -0700 Subject: [PATCH 11/12] test: cover workspace edge-kind branches Co-Authored-By: OpenAI Codex --- .../graph/tests/edge_kind_semantics.rs | 167 ++++++++++++++++++ .../codebase/dependencies/graph/tests/mod.rs | 1 + 2 files changed, 168 insertions(+) create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/tests/edge_kind_semantics.rs diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/edge_kind_semantics.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/edge_kind_semantics.rs new file mode 100644 index 000000000..e4e96f3d2 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/edge_kind_semantics.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::codebase::ts_source::facts::{TsFactMap, TsFileFacts}; +use crate::codebase::ts_symbols::{Export, FileSymbols}; + +#[test] +fn workspace_paths_preserve_runtime_and_non_runtime_edge_kinds() { + let current = p("/repo/packages/app/src/current.mts"); + let asset = p("/repo/packages/app/src/data.json"); + let target = p("/repo/packages/core/src/index.mts"); + let visible = HashSet::from([current.clone(), asset.clone(), target.clone()]); + let graph_files = GraphFiles::from_files(visible.iter().cloned().collect()); + let tsconfig = TsConfig { + dir: p("/repo/packages/app"), + paths: vec![], + paths_dir: p("/repo/packages/app"), + base_url: None, + }; + let resolver = ImportResolver::new(&tsconfig).with_visible(&visible); + let workspace = crate::codebase::workspaces::IndexedWorkspaceMap::from_packages(vec![ + crate::codebase::workspaces::WorkspacePackage { + name: "@fixture/core".to_string(), + dir: p("/repo/packages/core"), + entry: Some(target.clone()), + exports: None, + imports: None, + }, + ]); + + let facts = TsFactMap::from([( + target.clone(), + TsFileFacts { + symbols: Some(FileSymbols { + exports: vec![Export { + name: "Shape".to_string(), + local: None, + kind: ExportKind::TypeAlias, + line: 1, + is_type_only: true, + }], + imports: vec![], + }), + ..TsFileFacts::default() + }, + )]); + let symbols = FileSymbols::default(); + let export_inputs = ExportEdgeInputs { + path: ¤t, + symbols: &symbols, + facts: &facts, + resolver: &resolver, + workspace: &workspace, + visible_files: &visible, + graph_files: &graph_files, + }; + let exports = [ + ("core", "*", EdgeKind::WorkspaceImport), + ("Shape", "Shape", EdgeKind::WorkspaceTypeImport), + ]; + let mut edges = Vec::new(); + for (name, imported, _) in exports { + collect_direct_reexport_edge( + &export_inputs, + &Export { + name: name.to_string(), + local: None, + kind: ExportKind::ReExport { + source: "@fixture/core".to_string(), + imported: imported.to_string(), + }, + line: 1, + is_type_only: false, + }, + name, + &mut edges, + ); + } + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].2, EdgeKind::WorkspaceImport); + assert_eq!(edges[1].2, EdgeKind::WorkspaceTypeImport); + assert_eq!( + edges[0].1, + NodeId::File(target.clone()), + "star re-exports target the workspace file" + ); + assert_eq!( + edges[1].1, + NodeId::Symbol { + file: target.clone(), + symbol: "Shape".to_string(), + }, + "named re-exports target the workspace symbol" + ); + + assert_eq!( + import_target_with_graph_files( + "./data.json", + ImportKind::Type, + ¤t, + &resolver, + &workspace, + &visible, + &graph_files, + ), + None, + "type-only asset imports do not create runtime edges" + ); + assert_eq!( + import_target_with_graph_files( + "./data.json", + ImportKind::RequireResolve, + ¤t, + &resolver, + &workspace, + &visible, + &graph_files, + ), + Some((NodeId::File(asset), EdgeKind::RequireResolve)) + ); + for (kind, expected) in [ + (ImportKind::Type, EdgeKind::WorkspaceTypeImport), + (ImportKind::RequireResolve, EdgeKind::RequireResolve), + ] { + assert_eq!( + import_target_with_graph_files( + "@fixture/core", + kind, + ¤t, + &resolver, + &workspace, + &visible, + &graph_files, + ), + Some((NodeId::File(target.clone()), expected)) + ); + } + + let imports = [(1, ImportKind::Type), (2, ImportKind::RequireResolve)] + .into_iter() + .map(|(line, kind)| ExtractedImport { + specifier: "@fixture/core".to_string(), + kind, + line, + function_scope: None, + side_effect_only: false, + re_export: false, + runtime_reachable: false, + }) + .collect(); + let lazy_neighbors = import_neighbors_from_facts( + ¤t, + &TsFileFacts { + imports, + ..TsFileFacts::default() + }, + &resolver, + &workspace, + &graph_files, + None, + ); + assert_eq!( + lazy_neighbors, + vec![ + (NodeId::File(target.clone()), EdgeKind::RequireResolve), + (NodeId::File(target), EdgeKind::WorkspaceTypeImport), + ] + ); +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs index 8f5715443..b623d4db3 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs @@ -84,6 +84,7 @@ include!("extra_gitignore_pass3.rs"); include!("types.rs"); include!("vitest_setup.rs"); +mod edge_kind_semantics; mod finalization; mod selector_fact_plan; mod selector_optimization; From e718a3a7726a1e395e5579fe6821631a40a21e75 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 8 Aug 2026 03:56:44 -0700 Subject: [PATCH 12/12] test: import vitest fixture API Co-Authored-By: OpenAI Codex --- .../fixture/tests/scoped.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts index f373cc052..4edc016a2 100644 --- a/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts +++ b/test-cases/check-runner/dynamic-import-respects-filesystem-skip/fixture/tests/scoped.test.ts @@ -1,3 +1,5 @@ +import { test } from "vitest"; + test("filesystem skips remain outside dynamic-import reachability", async () => { await import("../skipped/target"); });