diff --git a/crates/no-mistakes/src/check_parallel.rs b/crates/no-mistakes/src/check_parallel.rs index 6223a239c..e4d1ec91f 100644 --- a/crates/no-mistakes/src/check_parallel.rs +++ b/crates/no-mistakes/src/check_parallel.rs @@ -48,6 +48,10 @@ pub(crate) struct DomainCheckInputs<'a> { pub(crate) codebase_config: &'a no_mistakes::codebase::config::Config, pub(crate) vitest_projects: Option<&'a no_mistakes::codebase::rules::PreparedVitestProjectCatalog>, + pub(crate) workflow_documents: + Option<&'a no_mistakes::codebase::ci_workflows::ParsedWorkflowSet>, + pub(crate) tsconfig_gate_project_inputs: + Option<&'a no_mistakes::codebase::rules::tsconfig_gate_coverage::ProjectSourceInputs>, } pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults { @@ -76,6 +80,8 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults let config = inputs.config; let codebase_config = inputs.codebase_config; let vitest_projects = inputs.vitest_projects; + let workflow_documents = inputs.workflow_documents; + let tsconfig_gate_project_inputs = inputs.tsconfig_gate_project_inputs; let ((react, queues), (rules, (integration, (codebase, filesystem_rules)))) = rayon::join( || { @@ -163,9 +169,14 @@ pub(crate) fn run_domain_checks(inputs: DomainCheckInputs<'_>) -> DomainResults config, filesystem_rules_enabled, discovered_files, - visible_paths, - sources, - vitest_projects, + no_mistakes::codebase::rules::filesystem_dispatch::PreparedFilesystemRuleInputs { + snapshot: visible_paths, + sources, + vitest_catalog: vitest_projects, + workflow_documents, + tsconfig_gate_project_inputs, + config_path: config_path.as_deref(), + }, ) }, ) diff --git a/crates/no-mistakes/src/check_runner.rs b/crates/no-mistakes/src/check_runner.rs index 84992b4d8..0d72f3c3d 100644 --- a/crates/no-mistakes/src/check_runner.rs +++ b/crates/no-mistakes/src/check_runner.rs @@ -1,211 +1,11 @@ -use crate::check_parallel::{run_domain_checks, DomainCheckInputs}; -use crate::check_tasks; -use anyhow::{Context, Result}; -use enabled::{fact_plan, integration_configured, plan_requests_facts}; -use no_mistakes::codebase::check_facts::collect_check_facts_with_graph_files_playwright_sources_and_session; -use std::path::PathBuf; - pub(crate) mod enabled; mod forbidden_plan; pub(crate) mod prepared; mod results; +mod run_all; pub(crate) use results::{complete_domain_checks, empty_results, json_value, CheckResults}; - -pub(crate) fn run_all( - root: PathBuf, - config_path: Option, - tsconfig_path: Option, -) -> Result { - let root = root.canonicalize().unwrap_or(root); - let session = no_mistakes::codebase::analysis_session::AnalysisSession::new( - no_mistakes::diagnostics::current(), - ); - let prepared = prepared::prepare_with_session( - &session, - &root, - config_path.as_deref(), - tsconfig_path.as_deref(), - )?; - let config = &prepared.config; - let queues_enabled = check_tasks::queues_configured(config); - 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 = if forbidden_deps_enabled { - no_mistakes::codebase::rules::forbidden_dependencies::graph_plan(config) - } else { - None - }; - let playwright_consumers = forbidden_graph_plan - .map( - |plan| no_mistakes::playwright::rules::PlaywrightFactConsumers { - graph_selectors: plan.playwright_selectors, - graph_routes: plan.playwright_routes, - }, - ) - .unwrap_or_default(); - let mut playwright_fact_plan = match prepared.playwright.as_ref() { - Some(prepared) => Some(prepared.fact_plan()), - None => no_mistakes::playwright::rules::fact_plan_for_consumers( - &root, - config_path.as_deref(), - config, - playwright_consumers, - ) - .context("failed to prepare Playwright shared facts")?, - }; - let integration_enabled = integration_configured(config); - let react_enabled = prepared.react.enabled(); - let mut plan = fact_plan(enabled::EnabledChecks { - react: react_enabled, - queue: queues_enabled, - queue_factory_names: config.queues.factories.clone(), - dynamic_import_rules: enabled.dynamic_import_rules, - boundary_rules: enabled.boundary_rules, - nextjs_api_routes: enabled.nextjs_api_routes, - nextjs_caching: enabled.nextjs_caching, - storybook_stories: enabled.storybook_stories, - integration: integration_enabled, - unique_exports: unique_exports_enabled, - }); - if integration_enabled { - plan.integration_runner_configs = Some(std::sync::Arc::new( - no_mistakes::integration_tests::prepare_runner_configs_with_catalog( - &root, - config, - prepared.visible_paths.paths_for(&root).as_ref(), - std::sync::Arc::clone(&prepared.tsconfig_catalog), - prepared.visible_paths.source_store_for(&root), - ), - )); - } - let prepared_graph = forbidden_plan::prepare( - &root, - config, - forbidden_plan::PreparedInputs { - codebase_config: &prepared.codebase_config, - tsconfig: &prepared.tsconfig, - visible_paths: prepared.visible_paths.as_ref(), - }, - forbidden_graph_plan, - &mut playwright_fact_plan, - &mut plan, - )?; - let needs_shared_facts = - forbidden_deps_enabled || playwright_fact_plan.is_some() || plan_requests_facts(&plan); - if !needs_shared_facts - && !filesystem_rules_enabled - && !no_mistakes::playwright::rules::configured(config) - { - return Ok(empty_results([None])); - } - let skip_directories = config.filesystem.skip_directories.clone(); - let needs_full_graph_files = forbidden_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 (views, discover_duration) = no_mistakes::diagnostics::measure_if_enabled( - "discovery", - no_mistakes::diagnostics::TimingKind::Serial, - || { - crate::check_discovery::discover_check_file_views_from_snapshot( - &root, - config, - &skip_directories, - unique_exports_enabled, - prepared.visible_paths.as_ref(), - ) - }, - ); - let (discovered, graph_files) = if needs_full_graph_files { - (views.filesystem, views.graph) - } else if needs_graph_files { - // The dynamic-import rule traverses the same filesystem-scoped - // visible universe it analyzes. Supplying that universe explicitly - // keeps prepared graph construction strict without a fallback parse. - let graph_files = views.filesystem.clone(); - (views.filesystem, graph_files) - } else { - (views.filesystem, Vec::new()) - }; - // When only filesystem rules are enabled, no TS/JS parsing is needed. - let sources = prepared.visible_paths.source_store_for(&root); - let ((fs_files, facts), facts_duration) = no_mistakes::diagnostics::measure_if_enabled( - "parse", - no_mistakes::diagnostics::TimingKind::Serial, - || { - if needs_shared_facts { - let fs = if filesystem_rules_enabled { - discovered.clone() - } else { - Vec::new() - }; - let f = collect_check_facts_with_graph_files_playwright_sources_and_session( - &session, - &root, - (discovered, graph_files), - plan, - playwright_fact_plan, - std::sync::Arc::clone(&sources), - ); - (fs, f) - } else { - (discovered, Default::default()) - } - }, - ); - // Fact collectors stop scheduling work at the deadline. Reject their - // partial maps before rules can turn missing facts into incomplete output. - no_mistakes::invocation::check_timeout()?; - - let (react, queues, rules, integration, codebase, filesystem_rules) = - run_domain_checks(DomainCheckInputs { - session: session.clone(), - root: &root, - config_path: &config_path, - tsconfig_path: &tsconfig_path, - react_enabled, - queues_enabled, - integration_enabled, - unique_exports_enabled, - filesystem_rules_enabled, - discovered_files: &fs_files, - facts: &facts, - prepared_playwright: prepared.playwright.as_ref(), - prepared_react: &prepared.react, - prepared_graph: prepared_graph.as_ref(), - dependency_graph: None, - prepared_tsconfig: &prepared.tsconfig, - prepared_tsconfig_catalog: &prepared.tsconfig_catalog, - visible_paths: prepared.visible_paths.as_ref(), - sources: std::sync::Arc::clone(&sources), - inferred_roots: &prepared.inferred_roots, - config, - codebase_config: &prepared.codebase_config, - vitest_projects: prepared.vitest_projects.as_ref(), - }); - no_mistakes::invocation::check_timeout()?; - - results::finalize_domain_checks(results::FinalizeInput { - root: &root, - config, - filesystem_files: &fs_files, - sources: &sources, - filesystem_rules_enabled, - react_warning: None, - discover_duration, - facts_duration, - completed: complete_domain_checks(( - react, - queues, - rules, - integration, - codebase, - filesystem_rules, - ))?, - }) -} +pub(crate) use run_all::run_all; #[cfg(test)] mod tests; diff --git a/crates/no-mistakes/src/check_runner/forbidden_plan.rs b/crates/no-mistakes/src/check_runner/forbidden_plan.rs index 22d96d7de..f15cdc08c 100644 --- a/crates/no-mistakes/src/check_runner/forbidden_plan.rs +++ b/crates/no-mistakes/src/check_runner/forbidden_plan.rs @@ -10,6 +10,8 @@ pub(super) struct PreparedInputs<'a> { pub(super) codebase_config: &'a no_mistakes::codebase::config::Config, pub(super) tsconfig: &'a TsConfig, pub(super) visible_paths: &'a VisiblePathSnapshot, + pub(super) workflow_documents: + Option<&'a std::sync::Arc>, } pub(super) fn prepare( @@ -20,7 +22,7 @@ pub(super) fn prepare( playwright_fact_plan: &mut Option, plan: &mut CheckFactPlan, ) -> Result> { - let prepared_graph = graph_plan + let mut prepared_graph = graph_plan .map(|graph_plan| { no_mistakes::codebase::dependencies::graph::prepare_graph_config( root, @@ -31,6 +33,9 @@ pub(super) fn prepare( ) }) .transpose()?; + if let Some(prepared) = prepared_graph.as_mut() { + prepared.set_workflow_documents(inputs.workflow_documents.cloned()); + } if let Some(graph_playwright) = prepared_graph .as_ref() .map(|graph| graph.playwright_fact_plan(root, inputs.tsconfig, inputs.visible_paths)) diff --git a/crates/no-mistakes/src/check_runner/prepared.rs b/crates/no-mistakes/src/check_runner/prepared.rs index c07ef6f99..99fd945a7 100644 --- a/crates/no-mistakes/src/check_runner/prepared.rs +++ b/crates/no-mistakes/src/check_runner/prepared.rs @@ -8,12 +8,17 @@ pub(crate) struct PreparedCheckInputs { pub(crate) visible_paths: Arc, pub(crate) inferred_roots: no_mistakes::codebase::config::InferredRoots, pub(crate) config: NoMistakesConfig, + pub(crate) config_path: Option, pub(crate) codebase_config: no_mistakes::codebase::config::Config, pub(crate) playwright: Option, pub(crate) react: no_mistakes::react_traits::PreparedReactCheck, pub(crate) tsconfig: no_mistakes::codebase::ts_resolver::TsConfig, pub(crate) tsconfig_catalog: Arc, pub(crate) vitest_projects: Option, + pub(crate) workflow_documents: + Option>, + pub(crate) tsconfig_gate_project_inputs: + Option, } pub(super) fn prepare_with_session( @@ -27,14 +32,15 @@ pub(super) fn prepare_with_session( no_mistakes::diagnostics::TimingKind::Serial, || session.visible_paths(root), ); - let config = session.config(root, config_path)?; + let (config, effective_config_path) = session.config_with_path(root, config_path)?; let tsconfig = session.tsconfig(root, tsconfig_path)?; let workspace = (tsconfig_path.is_none() - || no_mistakes::playwright::rules::configured(&config)) + || no_mistakes::playwright::rules::configured(&config) + || config.rule_configured(no_mistakes::codebase::rules::TSCONFIG_GATE_COVERAGE)) .then(|| session.workspace(root)); prepare_from_shared( root, - config_path, + effective_config_path.as_deref(), tsconfig_path, visible_paths, config.as_ref().clone(), @@ -119,15 +125,44 @@ pub(crate) fn prepare_from_shared( &tsconfig_catalog, ) }); + let workflow_documents = (config + .rule_configured(no_mistakes::codebase::rules::VITEST_CI_PATH_COVERAGE) + || config.rule_configured(no_mistakes::codebase::rules::TSCONFIG_GATE_COVERAGE) + || config.rule_configured(no_mistakes::codebase::rules::FORBIDDEN_DEPENDENCIES)) + .then(|| { + Arc::new( + no_mistakes::codebase::ci_workflows::ParsedWorkflowSet::load_from_snapshot_and_sources( + root, + &config.ci, + visible_paths.as_ref(), + &sources, + ), + ) + }); + let tsconfig_gate_project_inputs = config + .rule_configured(no_mistakes::codebase::rules::TSCONFIG_GATE_COVERAGE) + .then(|| { + no_mistakes::codebase::rules::tsconfig_gate_coverage::prepare_project_source_inputs( + root, + root_paths.as_ref(), + &sources, + workspace + .as_deref() + .expect("tsconfig gate coverage requires a workspace projection"), + ) + }); Ok(PreparedCheckInputs { visible_paths, inferred_roots, config, + config_path: config_path.map(Path::to_path_buf), codebase_config, playwright, react, tsconfig, tsconfig_catalog, vitest_projects, + workflow_documents, + tsconfig_gate_project_inputs, }) } diff --git a/crates/no-mistakes/src/check_runner/run_all.rs b/crates/no-mistakes/src/check_runner/run_all.rs new file mode 100644 index 000000000..bbf43372c --- /dev/null +++ b/crates/no-mistakes/src/check_runner/run_all.rs @@ -0,0 +1,197 @@ +use super::{ + complete_domain_checks, empty_results, enabled, forbidden_plan, prepared, results, CheckResults, +}; +use crate::check_parallel::{run_domain_checks, DomainCheckInputs}; +use crate::check_tasks; +use anyhow::{Context, Result}; +use enabled::{fact_plan, integration_configured, plan_requests_facts}; +use no_mistakes::codebase::check_facts::collect_check_facts_with_graph_files_playwright_sources_and_session; +use std::path::PathBuf; + +pub(crate) fn run_all( + root: PathBuf, + config_path: Option, + tsconfig_path: Option, +) -> Result { + let root = root.canonicalize().unwrap_or(root); + let session = no_mistakes::codebase::analysis_session::AnalysisSession::new( + no_mistakes::diagnostics::current(), + ); + let prepared = prepared::prepare_with_session( + &session, + &root, + config_path.as_deref(), + tsconfig_path.as_deref(), + )?; + let config_path = prepared.config_path.clone(); + let config = &prepared.config; + let queues_enabled = check_tasks::queues_configured(config); + 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 + .map( + |plan| no_mistakes::playwright::rules::PlaywrightFactConsumers { + graph_selectors: plan.playwright_selectors, + graph_routes: plan.playwright_routes, + }, + ) + .unwrap_or_default(); + let mut playwright_fact_plan = match prepared.playwright.as_ref() { + Some(prepared) => Some(prepared.fact_plan()), + None => no_mistakes::playwright::rules::fact_plan_for_consumers( + &root, + config_path.as_deref(), + config, + playwright_consumers, + ) + .context("failed to prepare Playwright shared facts")?, + }; + let integration_enabled = integration_configured(config); + let react_enabled = prepared.react.enabled(); + let mut plan = fact_plan(enabled::EnabledChecks { + react: react_enabled, + queue: queues_enabled, + queue_factory_names: config.queues.factories.clone(), + dynamic_import_rules: enabled.dynamic_import_rules, + boundary_rules: enabled.boundary_rules, + nextjs_api_routes: enabled.nextjs_api_routes, + nextjs_caching: enabled.nextjs_caching, + storybook_stories: enabled.storybook_stories, + integration: integration_enabled, + unique_exports: unique_exports_enabled, + }); + if integration_enabled { + plan.integration_runner_configs = Some(std::sync::Arc::new( + no_mistakes::integration_tests::prepare_runner_configs_with_catalog( + &root, + config, + prepared.visible_paths.paths_for(&root).as_ref(), + std::sync::Arc::clone(&prepared.tsconfig_catalog), + prepared.visible_paths.source_store_for(&root), + ), + )); + } + let prepared_graph = forbidden_plan::prepare( + &root, + config, + forbidden_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, + &mut playwright_fact_plan, + &mut plan, + )?; + let needs_shared_facts = + forbidden_deps_enabled || playwright_fact_plan.is_some() || plan_requests_facts(&plan); + if !needs_shared_facts + && !filesystem_rules_enabled + && !no_mistakes::playwright::rules::configured(config) + { + return Ok(empty_results([None])); + } + let (views, discover_duration) = no_mistakes::diagnostics::measure_if_enabled( + "discovery", + no_mistakes::diagnostics::TimingKind::Serial, + || { + crate::check_discovery::discover_check_file_views_from_snapshot( + &root, + config, + &config.filesystem.skip_directories, + unique_exports_enabled, + prepared.visible_paths.as_ref(), + ) + }, + ); + let needs_full_graph_files = forbidden_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 { + (views.filesystem, views.graph) + } else if needs_graph_files { + let graph_files = views.filesystem.clone(); + (views.filesystem, graph_files) + } else { + (views.filesystem, Vec::new()) + }; + let sources = prepared.visible_paths.source_store_for(&root); + let ((fs_files, facts), facts_duration) = no_mistakes::diagnostics::measure_if_enabled( + "parse", + no_mistakes::diagnostics::TimingKind::Serial, + || { + if needs_shared_facts { + let fs = if filesystem_rules_enabled { + discovered.clone() + } else { + Vec::new() + }; + let facts = collect_check_facts_with_graph_files_playwright_sources_and_session( + &session, + &root, + (discovered, graph_files), + plan, + playwright_fact_plan, + std::sync::Arc::clone(&sources), + ); + (fs, facts) + } else { + (discovered, Default::default()) + } + }, + ); + no_mistakes::invocation::check_timeout()?; + let (react, queues, rules, integration, codebase, filesystem_rules) = + run_domain_checks(DomainCheckInputs { + session: session.clone(), + root: &root, + config_path: &config_path, + tsconfig_path: &tsconfig_path, + react_enabled, + queues_enabled, + integration_enabled, + unique_exports_enabled, + filesystem_rules_enabled, + discovered_files: &fs_files, + facts: &facts, + prepared_playwright: prepared.playwright.as_ref(), + prepared_react: &prepared.react, + prepared_graph: prepared_graph.as_ref(), + dependency_graph: None, + prepared_tsconfig: &prepared.tsconfig, + prepared_tsconfig_catalog: &prepared.tsconfig_catalog, + visible_paths: prepared.visible_paths.as_ref(), + sources: std::sync::Arc::clone(&sources), + inferred_roots: &prepared.inferred_roots, + config, + codebase_config: &prepared.codebase_config, + vitest_projects: prepared.vitest_projects.as_ref(), + workflow_documents: prepared.workflow_documents.as_deref(), + tsconfig_gate_project_inputs: prepared.tsconfig_gate_project_inputs.as_ref(), + }); + no_mistakes::invocation::check_timeout()?; + results::finalize_domain_checks(results::FinalizeInput { + root: &root, + config, + filesystem_files: &fs_files, + sources: &sources, + filesystem_rules_enabled, + react_warning: None, + discover_duration, + facts_duration, + completed: complete_domain_checks(( + react, + queues, + rules, + integration, + codebase, + filesystem_rules, + ))?, + }) +} diff --git a/crates/no-mistakes/src/check_runner/tests.rs b/crates/no-mistakes/src/check_runner/tests.rs index 2fb824540..7d0474afe 100644 --- a/crates/no-mistakes/src/check_runner/tests.rs +++ b/crates/no-mistakes/src/check_runner/tests.rs @@ -1,4 +1,4 @@ -use super::enabled::EnabledChecks; +use super::enabled::{fact_plan, integration_configured, EnabledChecks}; use super::*; use crate::check_parallel::DomainResults; use crate::check_tasks::CheckTask; @@ -13,6 +13,7 @@ use std::path::PathBuf; use std::time::Duration; mod architecture; +mod config_path; mod integration_gitignore; #[cfg(feature = "test-instrumentation")] mod prepared_parser_cache; @@ -81,15 +82,45 @@ fn aggregate_html_id_rule_targets_keep_coverage_isolated() { } #[test] -fn empty_results_records_cli_side_channels() { - let results = results::empty_results([Some("warning".to_string())]); - assert!(!results.warnings.is_empty()); - assert!(!results.timings.is_empty()); - assert!(results.react.is_empty()); - assert!(results.queues.is_empty()); - assert!(results.rules.is_empty()); - assert!(results.integration.is_empty()); - assert!(results.codebase.is_empty()); +fn run_all_contextualizes_playwright_fact_plan_preparation_failures() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check-runner/invalid-playwright-fact-plan"); + + let error = match run_all(root, None, None) { + Ok(_) => panic!("missing Playwright config unexpectedly produced a check result"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("failed to prepare Playwright shared facts"), + "{error:#}" + ); +} + +#[test] +fn disabled_filesystem_check_returns_no_findings_without_dispatching_rules() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check-runner/empty"); + let config = no_mistakes::config::v2::NoMistakesConfig::default(); + let snapshot = no_mistakes::codebase::ts_source::VisiblePathSnapshot::from_paths(&root, &[]); + let task = crate::check_tasks::run_filesystem_rules_check( + &root, + &config, + false, + &[], + no_mistakes::codebase::rules::filesystem_dispatch::PreparedFilesystemRuleInputs { + snapshot: &snapshot, + vitest_catalog: None, + sources: snapshot.source_store_for(&root), + workflow_documents: None, + tsconfig_gate_project_inputs: None, + config_path: None, + }, + ) + .unwrap(); + + assert!(task.findings.is_empty()); } #[test] diff --git a/crates/no-mistakes/src/check_runner/tests/architecture.rs b/crates/no-mistakes/src/check_runner/tests/architecture.rs index 31d710eb8..04ddd2a44 100644 --- a/crates/no-mistakes/src/check_runner/tests/architecture.rs +++ b/crates/no-mistakes/src/check_runner/tests/architecture.rs @@ -1,6 +1,9 @@ #[test] fn aggregate_check_injects_prepared_config_into_every_domain() { - let runner = include_str!("../../check_runner.rs"); + let runner = concat!( + include_str!("../../check_runner.rs"), + include_str!("../run_all.rs"), + ); let prepared = include_str!("../prepared.rs"); let forbidden_plan = include_str!("../forbidden_plan.rs"); let parallel = include_str!("../../check_parallel.rs"); @@ -41,7 +44,7 @@ fn aggregate_check_injects_prepared_config_into_every_domain() { // would bypass request-wide config/tsconfig reuse even though it looks locally self-contained. assert_eq!( prepared - .matches("session.config(root, config_path)?") + .matches("session.config_with_path(root, config_path)?") .count(), 1 ); @@ -66,7 +69,10 @@ fn aggregate_check_injects_prepared_config_into_every_domain() { #[test] fn aggregate_framework_root_inference_reuses_precomputed_visible_roots() { let prepared = include_str!("../prepared.rs"); - let runner = include_str!("../../check_runner.rs"); + let runner = concat!( + include_str!("../../check_runner.rs"), + include_str!("../run_all.rs"), + ); let discovery = include_str!("../../check_discovery.rs"); let rules = concat!( include_str!("../../codebase/rules/run/prepared.rs"), @@ -102,7 +108,10 @@ fn aggregate_framework_root_inference_reuses_precomputed_visible_roots() { fn aggregate_vitest_ci_coverage_reuses_the_request_snapshot() { let prepared = include_str!("../prepared.rs"); let tasks = check_task_sources(); - let dispatcher = include_str!("../../codebase/rules/filesystem_dispatch.rs"); + let dispatcher = concat!( + include_str!("../../codebase/rules/filesystem_dispatch.rs"), + include_str!("../../codebase/rules/filesystem_dispatch/execute.rs"), + ); let catalog = include_str!("../../codebase/rules/vitest_project_catalog.rs"); let mapping = include_str!("../../codebase/rules/vitest_project_mapping/project_sources.rs"); let coverage = include_str!("../../codebase/rules/vitest_ci_path_coverage/projects.rs"); @@ -115,7 +124,14 @@ fn aggregate_vitest_ci_coverage_reuses_the_request_snapshot() { ); assert!(tasks.contains("run_filesystem_rules_with_config_snapshot_catalog_and_sources")); assert!(dispatcher.contains("check_with_files_and_catalog")); - assert!(dispatcher.contains("check_with_files_from_snapshot_catalog_and_sources")); + assert!(dispatcher.contains("check_with_files_from_snapshot_catalog_sources_and_workflows")); + assert_eq!( + prepared + .matches("ParsedWorkflowSet::load_from_snapshot_and_sources(") + .count(), + 1 + ); + assert!(prepared.contains("TSCONFIG_GATE_COVERAGE")); assert_eq!( catalog .matches("load_projects_from_visible_with_catalog(") @@ -168,7 +184,7 @@ fn aggregate_prepared_domains_do_not_reload_the_unified_config() { // value onward; direct loading here would split the cache from other request consumers. assert_eq!( aggregate - .matches("session.config(root, config_path)?") + .matches("session.config_with_path(root, config_path)?") .count(), 1 ); diff --git a/crates/no-mistakes/src/check_runner/tests/config_path.rs b/crates/no-mistakes/src/check_runner/tests/config_path.rs new file mode 100644 index 000000000..b0a2a3145 --- /dev/null +++ b/crates/no-mistakes/src/check_runner/tests/config_path.rs @@ -0,0 +1,59 @@ +use super::*; + +#[test] +fn empty_results_records_cli_side_channels() { + let results = results::empty_results([Some("warning".to_string())]); + assert!(!results.warnings.is_empty()); + assert!(!results.timings.is_empty()); + assert!(results.react.is_empty()); + assert!(results.queues.is_empty()); + assert!(results.rules.is_empty()); + assert!(results.integration.is_empty()); + assert!(results.codebase.is_empty()); +} + +#[test] +fn run_all_returns_empty_results_when_no_check_domain_is_configured() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/check-runner/empty"); + + let results = run_all(root, None, None).unwrap(); + + assert!(results.react.is_empty()); + assert!(results.queues.is_empty()); + assert!(results.rules.is_empty()); + assert!(results.integration.is_empty()); + assert!(results.codebase.is_empty()); +} + +#[test] +fn auto_discovered_config_path_reaches_findings_and_suppressions() { + let fixture_root = |name: &str| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/tsconfig-gate-coverage") + .join(name) + }; + let results = run_all(fixture_root("auto-config-path"), None, None).unwrap(); + assert_eq!(results.rules.len(), 1, "{:?}", results.rules); + assert_eq!(results.rules[0].rule, "tsconfig-gate-coverage"); + assert_eq!(results.rules[0].file, ".no-mistakes.yaml"); + assert_eq!( + results.rules[0].target.as_deref(), + Some("missing/tsconfig.json") + ); + let direct = no_mistakes::codebase::rules::filesystem_dispatch::run_filesystem_rules( + &fixture_root("auto-config-path"), + None, + ) + .unwrap(); + assert_eq!(direct, results.rules); + + let suppressed = run_all(fixture_root("auto-config-suppression"), None, None).unwrap(); + assert!(suppressed.rules.is_empty(), "{:?}", suppressed.rules); + let direct_suppressed = + no_mistakes::codebase::rules::filesystem_dispatch::run_filesystem_rules( + &fixture_root("auto-config-suppression"), + None, + ) + .unwrap(); + assert!(direct_suppressed.is_empty(), "{direct_suppressed:?}"); +} diff --git a/crates/no-mistakes/src/check_tasks/filesystem.rs b/crates/no-mistakes/src/check_tasks/filesystem.rs index 895feb0c9..9560af9ae 100644 --- a/crates/no-mistakes/src/check_tasks/filesystem.rs +++ b/crates/no-mistakes/src/check_tasks/filesystem.rs @@ -37,6 +37,7 @@ const FILESYSTEM_RULE_IDS: &[&str] = &[ rules::STRUCTURED_CONFIG_POLICY, rules::TEST_EMAIL_DOMAIN_POLICY, rules::TSCONFIG_ALIAS_FOLDER_MAPPING, + rules::TSCONFIG_GATE_COVERAGE, rules::VITEST_CI_PATH_COVERAGE, rules::VITEST_PROJECT_MAPPING, rules::VITEST_TEST_CORRESPONDENCE, @@ -48,9 +49,7 @@ pub(crate) fn run_filesystem_rules_check( config: &NoMistakesConfig, enabled: bool, files: &[PathBuf], - visible_paths: &no_mistakes::codebase::ts_source::VisiblePathSnapshot, - sources: std::sync::Arc, - vitest_projects: Option<&rules::PreparedVitestProjectCatalog>, + prepared: rules::filesystem_dispatch::PreparedFilesystemRuleInputs<'_>, ) -> Result>> { let (findings, duration) = no_mistakes::diagnostics::measure_if_enabled( "analysis.filesystem_rules", @@ -58,12 +57,7 @@ pub(crate) fn run_filesystem_rules_check( || -> Result<_> { Ok(if enabled { rules::run_filesystem_rules_with_config_snapshot_catalog_and_sources( - root, - config, - files, - visible_paths, - vitest_projects, - sources, + root, config, files, prepared, )? } else { Vec::new() diff --git a/crates/no-mistakes/src/codebase/analysis_dataset.rs b/crates/no-mistakes/src/codebase/analysis_dataset.rs index 40dfce392..e8c9cb206 100644 --- a/crates/no-mistakes/src/codebase/analysis_dataset.rs +++ b/crates/no-mistakes/src/codebase/analysis_dataset.rs @@ -5,6 +5,8 @@ use manifest_cache::ManifestCache; mod manifest_cache; +type LoadedConfig = (Arc, Option); + /// Immutable request-scoped ownership boundary for discovered files and source text. /// /// Derived facts, graphs, and indexes live in the request contexts that consume this @@ -68,6 +70,13 @@ impl AnalysisDataset { &self, config_path: Option<&Path>, ) -> anyhow::Result> { + self.config_with_path(config_path).map(|(config, _)| config) + } + + pub(crate) fn config_with_path( + &self, + config_path: Option<&Path>, + ) -> anyhow::Result { self.increment("manifest.requests", 1); let visible_paths = self.paths_for(&self.root); let selector_key = manifest_key(&self.root, config_path); @@ -112,9 +121,10 @@ impl AnalysisDataset { } else { self.increment("manifest.cache_hits", 1); } - loaded + let config = loaded .value - .map_err(|error| anyhow::anyhow!(error.to_string())) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok((config, effective_path.as_deref().map(Path::to_path_buf))) } pub(crate) fn tsconfig( diff --git a/crates/no-mistakes/src/codebase/analysis_session/io.rs b/crates/no-mistakes/src/codebase/analysis_session/io.rs index 424061827..3d01e434b 100644 --- a/crates/no-mistakes/src/codebase/analysis_session/io.rs +++ b/crates/no-mistakes/src/codebase/analysis_session/io.rs @@ -23,6 +23,16 @@ impl AnalysisSession { self.dataset(root).config(config_path) } + /// Return the invocation's canonical configuration and selected source path. + #[doc(hidden)] + pub fn config_with_path( + &self, + root: &Path, + config_path: Option<&Path>, + ) -> anyhow::Result<(Arc, Option)> { + self.dataset(root).config_with_path(config_path) + } + /// Return the invocation's canonical, memoized TypeScript configuration. #[doc(hidden)] pub fn tsconfig( diff --git a/crates/no-mistakes/src/codebase/ci_workflows/tests.rs b/crates/no-mistakes/src/codebase/ci_workflows/tests.rs index 60d89edbd..38f4e8760 100644 --- a/crates/no-mistakes/src/codebase/ci_workflows/tests.rs +++ b/crates/no-mistakes/src/codebase/ci_workflows/tests.rs @@ -1,5 +1,28 @@ use super::*; +fn workflow_fixture_root(name: &str) -> std::path::PathBuf { + crate::codebase::ts_resolver::normalize_path( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/ci-workflows") + .join(name), + ) +} + +#[test] +fn projects_preparsed_workflows_to_the_requested_paths() { + let root = workflow_fixture_root("project-paths"); + let workflows = ParsedWorkflowSet::from_paths( + &root, + [ + root.join(".github/workflows/a.yml"), + root.join(".github/workflows/b.yml"), + ], + ); + let projected = workflows.project_paths(&root, [root.join(".github/workflows/b.yml")]); + assert_eq!(projected.documents.len(), 1); + assert_eq!(projected.documents[0].path, ".github/workflows/b.yml"); +} + // ── extract_binary_names ───────────────────────────────────────────── #[test] diff --git a/crates/no-mistakes/src/codebase/ci_workflows/workflow_set.rs b/crates/no-mistakes/src/codebase/ci_workflows/workflow_set.rs index 55fe3637a..a58077fa2 100644 --- a/crates/no-mistakes/src/codebase/ci_workflows/workflow_set.rs +++ b/crates/no-mistakes/src/codebase/ci_workflows/workflow_set.rs @@ -7,7 +7,7 @@ //! `ci impact` and `ci env` distinguish read and parse warnings. use crate::codebase::ci_graph::{discover_workflow_files_from_snapshot, relative_slash}; -use crate::codebase::ts_source::VisiblePathSnapshot; +use crate::codebase::ts_source::{SourceStore, VisiblePathSnapshot}; use crate::config::v2::schema::CiConfig; use rayon::prelude::*; use serde_yaml::Value; @@ -54,9 +54,22 @@ impl ParsedWorkflowSet { /// Reuses an invocation's visibility snapshot for workflow discovery. #[doc(hidden)] pub fn load_from_snapshot(root: &Path, ci: &CiConfig, snapshot: &VisiblePathSnapshot) -> Self { - Self::from_paths( + let sources = snapshot.source_store_for(root); + Self::load_from_snapshot_and_sources(root, ci, snapshot, &sources) + } + + /// Reuses both an invocation's visibility snapshot and canonical source store. + #[doc(hidden)] + pub fn load_from_snapshot_and_sources( + root: &Path, + ci: &CiConfig, + snapshot: &VisiblePathSnapshot, + sources: &SourceStore, + ) -> Self { + Self::from_paths_and_sources( root, discover_workflow_files_from_snapshot(root, ci, snapshot), + sources, ) } @@ -66,6 +79,23 @@ impl ParsedWorkflowSet { /// discovered file universe to `ci.workflow_dirs`, so it keeps the /// repository's one-discovery-pass invariant. pub fn from_paths(root: &Path, paths: impl IntoIterator) -> Self { + let paths: Vec = paths + .into_iter() + .collect::>() + .into_iter() + .collect(); + let snapshot = VisiblePathSnapshot::from_paths(root, &paths); + let sources = snapshot.source_store_for(root); + Self::from_paths_and_sources(root, paths, &sources) + } + + /// Parse a caller-provided workflow universe through prepared request sources. + #[doc(hidden)] + pub fn from_paths_and_sources( + root: &Path, + paths: impl IntoIterator, + sources: &SourceStore, + ) -> Self { let paths: Vec = paths .into_iter() .collect::>() @@ -75,7 +105,8 @@ impl ParsedWorkflowSet { .into_par_iter() .map(|absolute| { let path = relative_slash(root, &absolute); - let value = std::fs::read_to_string(&absolute) + let value = sources + .read_path(&absolute) .map_err(|error| WorkflowDocumentError { kind: WorkflowDocumentErrorKind::Read, message: error.to_string(), @@ -94,4 +125,21 @@ impl ParsedWorkflowSet { documents.sort_by(|left, right| left.path.cmp(&right.path)); Self { documents } } + + /// Project already-parsed documents onto an output-specific path universe. + #[doc(hidden)] + pub fn project_paths(&self, root: &Path, paths: impl IntoIterator) -> Self { + let selected = paths + .into_iter() + .map(|path| relative_slash(root, &path)) + .collect::>(); + Self { + documents: self + .documents + .iter() + .filter(|document| selected.contains(&document.path)) + .cloned() + .collect(), + } + } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/builder.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder.rs index f185f6daf..c1dbff4b6 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder.rs @@ -111,6 +111,7 @@ impl DepGraph { swift_facts, import_resolution_cache, visible_paths, + workflow_documents: prepared.workflow_documents(), }, facts, SuppliedFactPolicy::RequireComplete, @@ -142,6 +143,7 @@ impl DepGraph { swift_facts: Some(swift_facts), import_resolution_cache: None, visible_paths: None, + workflow_documents: prepared.workflow_documents(), }, None, SuppliedFactPolicy::RequireComplete, @@ -173,6 +175,7 @@ impl DepGraph { swift_facts: None, import_resolution_cache: None, visible_paths: None, + workflow_documents: None, }, facts, SuppliedFactPolicy::FillSparse, diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/builder_check_facts.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder_check_facts.rs index cffab6ba1..142d2efcc 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder_check_facts.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder_check_facts.rs @@ -98,6 +98,7 @@ impl DepGraph { swift_facts: None, import_resolution_cache: None, visible_paths: None, + workflow_documents: prepared.workflow_documents(), }, Some(facts as &dyn TsFactLookup), SuppliedFactPolicy::RequireComplete, @@ -135,6 +136,7 @@ impl DepGraph { swift_facts: None, import_resolution_cache: None, visible_paths: None, + workflow_documents: None, }, Some(facts as &dyn TsFactLookup), SuppliedFactPolicy::RequireComplete, diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs index 9f0992330..f098614e8 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs @@ -12,6 +12,7 @@ struct GraphEdgeBuildInputs<'a> { swift_facts: Option<&'a crate::codebase::swift::SwiftFactMap>, import_resolution_cache: Option<&'a crate::codebase::ts_resolver::ImportResolutionCache>, visible_paths: Option<&'a crate::codebase::ts_source::VisiblePathSnapshot>, + workflow_documents: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, } fn parsed_imports_for_plan<'a>( diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs index 921299e81..77264eba5 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs @@ -23,8 +23,14 @@ fn collect_remaining_edges( let ci = config_options .map(|options| &options.ci) .unwrap_or(&default_ci); - let parsed_workflows = (plan.ci || plan.workflow_topology) - .then(|| parsed_workflows_for_graph(root, &graph_files.all, ci)); + let parsed_workflows = (plan.ci || plan.workflow_topology).then(|| { + parsed_workflows_for_graph( + root, + &graph_files.all, + ci, + edge_inputs.workflow_documents, + ) + }); crate::invocation::check_timeout()?; crate::perf_trace::trace("graph.markdown", || { diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology.rs index 34065dcd8..d273ce619 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology.rs @@ -2,6 +2,7 @@ fn parsed_workflows_for_graph( root: &Path, all_files: &[PathBuf], ci: &crate::config::v2::schema::CiConfig, + prepared: Option<&crate::codebase::ci_workflows::ParsedWorkflowSet>, ) -> crate::codebase::ci_workflows::ParsedWorkflowSet { let root = crate::codebase::ts_resolver::normalize_path(root); let workflow_dirs: HashSet = ci @@ -9,18 +10,25 @@ fn parsed_workflows_for_graph( .iter() .map(|directory| crate::codebase::ts_resolver::normalize_path(&root.join(directory))) .collect(); - let paths = all_files.iter().filter(|path| { - path.parent() - .is_some_and(|parent| workflow_dirs.contains(parent)) - && path - .extension() - .and_then(std::ffi::OsStr::to_str) - .is_some_and(|extension| { - extension.eq_ignore_ascii_case("yml") - || extension.eq_ignore_ascii_case("yaml") - }) - }); - crate::codebase::ci_workflows::ParsedWorkflowSet::from_paths(&root, paths.cloned()) + let paths = all_files + .iter() + .filter(|path| { + path.parent() + .is_some_and(|parent| workflow_dirs.contains(parent)) + && path + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("yml") + || extension.eq_ignore_ascii_case("yaml") + }) + }) + .cloned() + .collect::>(); + match prepared { + Some(prepared) => prepared.project_paths(&root, paths), + None => crate::codebase::ci_workflows::ParsedWorkflowSet::from_paths(&root, paths), + } } fn collect_workflow_topology_edges( @@ -35,9 +43,7 @@ fn collect_workflow_topology_edges( let workflow_files: HashSet = topology .workflows .iter() - .map(|workflow| { - crate::codebase::ts_resolver::normalize_path(&root.join(&workflow.path)) - }) + .map(|workflow| crate::codebase::ts_resolver::normalize_path(&root.join(&workflow.path))) .collect(); let action_dirs: Vec = ci .action_dirs @@ -71,19 +77,11 @@ fn collect_workflow_topology_edges( step: step.index as usize, }; steps.insert((job.id.clone(), step.index as usize), step_node.clone()); - edges.push(( - job_node.clone(), - step_node.clone(), - EdgeKind::WorkflowStep, - )); + edges.push((job_node.clone(), step_node.clone(), EdgeKind::WorkflowStep)); if let Some(target) = step.uses.as_deref().and_then(|target| { resolve_local_action_descriptor(&root, target, &universe, &action_dirs) }) { - edges.push(( - step_node, - NodeId::File(target), - EdgeKind::WorkflowUses, - )); + edges.push((step_node, NodeId::File(target), EdgeKind::WorkflowUses)); } } } @@ -105,11 +103,7 @@ fn collect_workflow_topology_edges( .get(&edge.from) .filter(|_| workflow_files.contains(&target)) { - edges.push(( - from.clone(), - NodeId::File(target), - EdgeKind::WorkflowUses, - )); + edges.push((from.clone(), NodeId::File(target), EdgeKind::WorkflowUses)); } } WorkflowTopologyEdge::Artifact(edge) => { @@ -156,7 +150,9 @@ fn add_workflow_run_edges( if !jobs.contains_key(&job_id) { continue; } - let Some(raw_steps) = raw_job.get("steps").and_then(serde_yaml::Value::as_sequence) + let Some(raw_steps) = raw_job + .get("steps") + .and_then(serde_yaml::Value::as_sequence) else { continue; }; @@ -183,3 +179,7 @@ fn add_workflow_run_edges( } } } + +#[cfg(test)] +#[path = "edge_workflow_topology/tests.rs"] +mod edge_workflow_topology_tests; diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology/tests.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology/tests.rs new file mode 100644 index 000000000..a5f0ba529 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_workflow_topology/tests.rs @@ -0,0 +1,26 @@ +use super::*; + +fn fixture_root() -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/graph/workflow-topology-prepared"), + ) +} + +#[test] +fn prepared_workflows_are_projected_to_the_graph_workflow_universe() { + let root = fixture_root(); + let workflow = root.join(".github/workflows/ci.yml"); + let prepared = + crate::codebase::ci_workflows::ParsedWorkflowSet::from_paths(&root, [workflow.clone()]); + + let projected = parsed_workflows_for_graph( + &root, + &[workflow], + &crate::config::v2::schema::CiConfig::default(), + Some(&prepared), + ); + + assert_eq!(projected.documents.len(), 1); + assert_eq!(projected.documents[0].path, ".github/workflows/ci.yml"); +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/files_config_prepared.rs b/crates/no-mistakes/src/codebase/dependencies/graph/files_config_prepared.rs index d2fec503f..81354f5bf 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/files_config_prepared.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/files_config_prepared.rs @@ -4,6 +4,8 @@ pub struct PreparedGraphConfig { options: Option, playwright_settings: Option, workspace: std::sync::Arc, + workflow_documents: + Option>, } impl PreparedGraphConfig { @@ -38,6 +40,21 @@ impl PreparedGraphConfig { self.workspace.as_ref() } + pub(crate) fn workflow_documents( + &self, + ) -> Option<&crate::codebase::ci_workflows::ParsedWorkflowSet> { + self.workflow_documents.as_deref() + } + + /// Supply request-prepared workflow documents for graph projections. + #[doc(hidden)] + pub fn set_workflow_documents( + &mut self, + documents: Option>, + ) { + self.workflow_documents = documents; + } + } #[doc(hidden)] @@ -126,6 +143,7 @@ fn prepare_graph_config_inner( .unwrap_or_default(), ) }), + workflow_documents: None, }) } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_path_full_graph.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_path_full_graph.rs index faba632ee..0c87e5822 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_path_full_graph.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_path_full_graph.rs @@ -124,9 +124,8 @@ fn ts_fact_plan_and_context_for_plan_with_config_uses_explicit_config_path() { #[test] fn prepared_graph_playwright_edges_use_explicit_loaded_config() { - let root = crate::codebase::ts_resolver::normalize_path(&fixture( - "playwright-config-path-graph", - )); + let root = + crate::codebase::ts_resolver::normalize_path(&fixture("playwright-config-path-graph")); let all_files = GraphFiles::discover(&root).all; let plan = GraphBuildPlan { playwright_routes: true, @@ -156,6 +155,13 @@ fn prepared_graph_playwright_edges_use_explicit_loaded_config() { paths_dir: root.clone(), base_url: None, }; + assert!( + prepared + .playwright_fact_plan(&root, &tsconfig, &visible) + .unwrap() + .is_some(), + "prepared graph settings must build a reusable Playwright fact plan" + ); let graph = DepGraph::build_with_plan_file_list_prepared_config_and_check_facts( &root, &tsconfig, @@ -182,20 +188,14 @@ fn prepared_graph_playwright_edges_use_explicit_loaded_config() { #[test] fn playwright_route_edges_use_explicit_config_path() { - let root = crate::codebase::ts_resolver::normalize_path(&fixture( - "playwright-config-path-graph", - )); + let root = + crate::codebase::ts_resolver::normalize_path(&fixture("playwright-config-path-graph")); let all_files = GraphFiles::discover(&root).all; assert!(collect_playwright_route_edges(&root, None, &all_files, None).is_empty()); let custom_config = root.join("custom.no-mistakes.yml"); - let edges = collect_playwright_route_edges( - &root, - Some(&custom_config), - &all_files, - None, - ); + let edges = collect_playwright_route_edges(&root, Some(&custom_config), &all_files, None); let test = NodeId::File(root.join("tests/e2e/app.spec.ts")); let page = NodeId::File(root.join("web/app/page.tsx")); let layout = NodeId::File(root.join("web/app/layout.tsx")); diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/workflow_topology_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/workflow_topology_edges.rs index 63b2daef2..ce507181a 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/workflow_topology_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/workflow_topology_edges.rs @@ -133,6 +133,69 @@ fn workflow_topology_builds_job_step_uses_and_run_edges() { )); } +#[test] +fn prepared_check_fact_graph_reuses_preparsed_workflows() { + let source = workflow_topology_fixture(); + let fixture = crate::test_support::materialize_saved_fixture(&source); + let root = crate::codebase::ts_resolver::normalize_path(fixture.path()); + let all_files = GraphFiles::discover(&root).all; + let plan = GraphBuildPlan { + workflow_topology: true, + ..GraphBuildPlan::default() + }; + let visible = crate::codebase::ts_source::VisiblePathSnapshot::new(&root); + let sources = visible.source_store_for(&root); + let config = crate::config::v2::load_v2_config(&root, None).unwrap(); + let codebase_config = crate::codebase::config::config_from_loaded_v2(&root, None, &config); + let mut prepared = prepare_graph_config(&root, plan, &codebase_config, &config, &visible) + .expect("workflow graph config prepares"); + let workflows = std::sync::Arc::new( + crate::codebase::ci_workflows::ParsedWorkflowSet::load_from_snapshot_and_sources( + &root, &config.ci, &visible, &sources, + ), + ); + let reads_after_workflow_preparation = sources.physical_read_count(); + prepared.set_workflow_documents(Some(workflows)); + + // The graph's file universe was already fixed above. If this entrypoint + // reparses workflows, the removed document has no jobs and loses this edge. + std::fs::remove_file(root.join(".github/workflows/main.yml")).unwrap(); + + let (fact_plan, fact_context) = + ts_fact_plan_and_context_for_plan_with_prepared(&root, plan, &prepared); + let facts = crate::codebase::check_facts::collect_check_facts( + &root, + all_files.clone(), + crate::codebase::check_facts::CheckFactPlan { + graph: fact_plan, + graph_context: fact_context, + ..Default::default() + }, + ); + let graph = DepGraph::build_with_plan_file_list_prepared_config_and_check_facts( + &root, + &TsConfig::default(), + plan, + all_files, + None, + &facts, + &prepared, + ) + .expect("prepared workflow graph builds after its source file is unavailable"); + + assert_eq!( + sources.physical_read_count(), + reads_after_workflow_preparation, + "graph construction must reuse the request's parsed workflow documents" + ); + assert!(graph_has_edge( + &graph, + workflow_step(&root, "build", 0), + NodeId::File(root.join("scripts/direct.mjs")), + EdgeKind::WorkflowRun, + )); +} + #[test] fn workflow_artifacts_connect_exact_producer_and_consumer_steps() { let root = workflow_topology_fixture(); diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs index fc831f4ac..7599feb02 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch.rs @@ -1,6 +1,5 @@ use anyhow::Result; use std::path::{Path, PathBuf}; -use std::sync::Mutex; use super::{ agents_md_max_size, banned_paths, banned_renamed_files, config_path_references, @@ -11,14 +10,16 @@ use super::{ package_json_workspace_coverage, production_dependency_declarations, require_files_in_subdirs, require_test_per_subdir, required_companion_imports, required_local_docs, rust_rules_combined, shellcheck_runner, strict_package_layout, structured_config_policy, test_email_domain_policy, - tsconfig_alias_folder_mapping, vitest_ci_path_coverage, vitest_project_mapping, - vitest_test_correspondence, workspace_package_cycles, + tsconfig_alias_folder_mapping, tsconfig_gate_coverage, vitest_ci_path_coverage, + vitest_project_mapping, vitest_test_correspondence, workspace_package_cycles, }; mod candidate_helpers; mod candidate_index; mod entrypoints; +mod execute; mod inventory; +mod metadata; mod preserved; mod run_rule; #[macro_use] @@ -34,8 +35,8 @@ use super::{ REQUIRED_LOCAL_DOCS, REQUIRE_FILES_IN_SUBDIRS, REQUIRE_TEST_PER_SUBDIR, RUST_MAX_LINES_PER_FILE, RUST_NO_INLINE_ALLOWS, RUST_NO_INLINE_TESTS, SHELLCHECK_RUNNER, STRICT_PACKAGE_LAYOUT, STRUCTURED_CONFIG_POLICY, TEST_EMAIL_DOMAIN_POLICY, - TSCONFIG_ALIAS_FOLDER_MAPPING, VITEST_CI_PATH_COVERAGE, VITEST_PROJECT_MAPPING, - VITEST_TEST_CORRESPONDENCE, WORKSPACE_PACKAGE_CYCLES, + TSCONFIG_ALIAS_FOLDER_MAPPING, TSCONFIG_GATE_COVERAGE, VITEST_CI_PATH_COVERAGE, + VITEST_PROJECT_MAPPING, VITEST_TEST_CORRESPONDENCE, WORKSPACE_PACKAGE_CYCLES, }; pub use entrypoints::{ run_filesystem_rules, run_filesystem_rules_with_config, @@ -43,7 +44,7 @@ pub use entrypoints::{ run_filesystem_rules_with_config_snapshot_and_vitest_catalog, run_filesystem_rules_with_files, run_filesystem_rules_with_visible_and_snapshot, }; -const GITHUB_ACTIONS_PINNED_HASH: &str = github_actions_pinned_hash::RULE_ID; +pub(super) const GITHUB_ACTIONS_PINNED_HASH: &str = github_actions_pinned_hash::RULE_ID; macro_rules! define_filesystem_rule_ids { ($($id:expr => $call:path),* $(,)?) => { @@ -56,151 +57,15 @@ macro_rules! define_filesystem_rule_ids { RUST_NO_INLINE_ALLOWS, VITEST_PROJECT_MAPPING, VITEST_CI_PATH_COVERAGE, + TSCONFIG_GATE_COVERAGE, ]; }; } -filesystem_rules!(define_filesystem_rule_ids); - -#[doc(hidden)] -pub fn run_filesystem_rules_with_config_snapshot_catalog_and_sources( - root: &Path, - config: &crate::config::v2::NoMistakesConfig, - files: &[PathBuf], - snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, - vitest_catalog: Option<&super::PreparedVitestProjectCatalog>, - sources: std::sync::Arc, -) -> Result> { - let acc = Mutex::new(Vec::new()); - let metadata_files = if rule_enabled(config, FORBIDDEN_WORKSPACE_CLOSURE) - || rule_enabled(config, PRODUCTION_DEPENDENCY_DECLARATIONS) - { - let mut metadata_files = files.to_vec(); - metadata_files.extend(snapshot.paths_for(root).iter().cloned()); - metadata_files.sort(); - metadata_files.dedup(); - metadata_files - } else { - Vec::new() - }; - let candidates = candidate_index::RuleCandidateIndex::prepare_with_inventory( - root, - config, - files, - &snapshot.tracked_paths_from(files), - &metadata_files, - Some(inventory::tracked_inventory_with_markdown_project_roots( - root, config, snapshot, - )), - ); - inventory::register_trusted_external_candidates(root, config, &candidates, &sources); - macro_rules! run_rules { - ($($id:expr => $call:path),* $(,)?) => { - rayon::scope(|s| { - $( - if rule_enabled(config, $id) { - s.spawn(|_| { - let res = run_rule::run_rule_with_sources( - $id, - $call, - root, - config, - candidates.candidates($id), - &sources, - ); - acc.lock().expect("mutex poisoned").push(($id, res)); - }); - } - )* - if rule_enabled(config, MARKDOWN_REACHABILITY) { - s.spawn(|_| { - let res = markdown_reachability::check_with_files_and_sources( - root, - config, - candidates.candidates(MARKDOWN_REACHABILITY), - &sources, - ); - acc.lock() - .expect("mutex poisoned") - .push((MARKDOWN_REACHABILITY, res)); - }); - } - if rule_enabled(config, MARKDOWN_STRUCTURE_BUDGET) { - s.spawn(|_| { - let res = markdown_structure_budget::check_with_files_and_sources( - root, - config, - candidates.candidates(MARKDOWN_STRUCTURE_BUDGET), - &sources, - ); - acc.lock() - .expect("mutex poisoned") - .push((MARKDOWN_STRUCTURE_BUDGET, res)); - }); - } - if registry::rust_rules_enabled(config) { - s.spawn(|_| { - let res = rust_rules_combined::check_with_files_and_sources( - root, - config, - candidates.rust_candidates(), - candidates.exclusive_rust_candidates(), - &sources, - ); - acc.lock().expect("mutex poisoned").push(("rust-rules-combined", res)); - }); - } - if rule_enabled(config, VITEST_PROJECT_MAPPING) { - s.spawn(|_| { - let res = vitest_project_mapping::check_with_files_and_catalog( - root, - config, - candidates.candidates(VITEST_PROJECT_MAPPING), - vitest_catalog, - ); - acc.lock() - .expect("mutex poisoned") - .push((VITEST_PROJECT_MAPPING, res)); - }); - } - if rule_enabled(config, VITEST_CI_PATH_COVERAGE) { - s.spawn(|_| { - let res = vitest_ci_path_coverage::check_with_files_from_snapshot_catalog_and_sources( - root, - config, - candidates.candidates(VITEST_CI_PATH_COVERAGE), - snapshot, - vitest_catalog, - &sources, - ); - acc.lock() - .expect("mutex poisoned") - .push((VITEST_CI_PATH_COVERAGE, res)); - }); - } - }); - }; - } - filesystem_rules!(run_rules); - let mut results = acc.into_inner().expect("mutex poisoned"); - results.sort_unstable_by_key(|(id, _)| *id); - let mut findings = Vec::new(); - for (_, r) in results { - findings.extend(r?); - } - suppress_rule_findings_with_sources_except( - root, - &mut findings, - &sources, - &[ - RUST_MAX_LINES_PER_FILE, - RUST_NO_INLINE_TESTS, - RUST_NO_INLINE_ALLOWS, - ], - ); - super::sort_findings(&mut findings); - Ok(findings) -} +crate::filesystem_rules!(define_filesystem_rule_ids); +pub use execute::{ + run_filesystem_rules_with_config_snapshot_catalog_and_sources, PreparedFilesystemRuleInputs, +}; #[cfg(test)] mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs index a7337aae9..b7b502da7 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/candidate_index/tests.rs @@ -129,7 +129,10 @@ fn rust_exclusivity_tracks_enabled_non_rust_candidate_overlap() { #[test] fn dispatch_prepares_one_index_and_only_reads_preclassified_views() { - let dispatch = include_str!("../../filesystem_dispatch.rs"); + let dispatch = concat!( + include_str!("../../filesystem_dispatch.rs"), + include_str!("../execute.rs"), + ); assert_eq!(dispatch.matches("RuleCandidateIndex::prepare").count(), 1); assert_eq!(dispatch.matches("filesystem_rule_files(").count(), 0); @@ -299,9 +302,14 @@ fn markdown_inventory_keeps_external_project_docs_but_skips_generated_directorie &root, &config, &files, - &snapshot, - None, - Arc::clone(&sources), + super::super::PreparedFilesystemRuleInputs { + snapshot: &snapshot, + vitest_catalog: None, + sources: Arc::clone(&sources), + workflow_documents: None, + tsconfig_gate_project_inputs: None, + config_path: None, + }, ) .unwrap(); let pairs = findings diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs index 36dfa2022..ac7870a63 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/entrypoints.rs @@ -11,8 +11,8 @@ pub fn run_filesystem_rules_with_files( config_path: Option<&Path>, files: &[PathBuf], ) -> Result> { - let config = crate::config::v2::load_v2_config(root, config_path)?; - run_filesystem_rules_with_config(root, &config, files) + let (config, effective_path) = crate::config::v2::load_v2_config_with_path(root, config_path)?; + run_filesystem_rules_with_config_and_path(root, &config, effective_path.as_deref(), files) } /// Run filesystem rules with a caller-supplied visible work list and the @@ -24,12 +24,18 @@ pub fn run_filesystem_rules_with_visible_and_snapshot( visible_files: &[PathBuf], snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, ) -> Result> { - let config = crate::config::v2::load_v2_config_from_visible( + let (config, effective_path) = crate::config::v2::load_v2_config_with_path_from_visible( root, config_path, &snapshot.paths_for(root), )?; - run_filesystem_rules_with_config_and_snapshot(root, &config, visible_files, snapshot) + run_filesystem_rules_with_config_snapshot_and_path( + root, + &config, + effective_path.as_deref(), + visible_files, + snapshot, + ) } /// Standalone entry point: discover files once, then reuse the with-files @@ -37,7 +43,11 @@ pub fn run_filesystem_rules_with_visible_and_snapshot( pub fn run_filesystem_rules(root: &Path, config_path: Option<&Path>) -> Result> { let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(root); let visible_paths = snapshot.paths_for(root); - let config = crate::config::v2::load_v2_config_from_visible(root, config_path, &visible_paths)?; + let (config, effective_path) = crate::config::v2::load_v2_config_with_path_from_visible( + root, + config_path, + &visible_paths, + )?; if !FILESYSTEM_RULE_IDS .iter() .any(|rule_id| rule_enabled(&config, rule_id)) @@ -52,7 +62,13 @@ pub fn run_filesystem_rules(root: &Path, config_path: Option<&Path>) -> Result, + files: &[PathBuf], +) -> Result> { + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::from_paths(root, files); + run_filesystem_rules_with_config_snapshot_and_path(root, config, config_path, files, &snapshot) +} + #[doc(hidden)] pub fn run_filesystem_rules_with_config_and_snapshot( root: &Path, config: &crate::config::v2::NoMistakesConfig, files: &[PathBuf], snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, +) -> Result> { + run_filesystem_rules_with_config_snapshot_and_path(root, config, None, files, snapshot) +} + +fn run_filesystem_rules_with_config_snapshot_and_path( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + config_path: Option<&Path>, + files: &[PathBuf], + snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, +) -> Result> { + run_filesystem_rules_with_config_snapshot_path_and_catalog( + root, + config, + config_path, + files, + snapshot, + None, + ) +} + +fn run_filesystem_rules_with_config_snapshot_path_and_catalog( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + config_path: Option<&Path>, + files: &[PathBuf], + snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, + vitest_catalog: Option<&crate::codebase::rules::PreparedVitestProjectCatalog>, ) -> Result> { let root = crate::codebase::ts_resolver::normalize_path(root); - run_filesystem_rules_with_config_snapshot_and_vitest_catalog( - &root, config, files, snapshot, None, + let sources = snapshot.source_store_for(&root); + let workflows = + rule_enabled(config, crate::codebase::rules::TSCONFIG_GATE_COVERAGE).then(|| { + crate::codebase::ci_workflows::ParsedWorkflowSet::load_from_snapshot_and_sources( + &root, &config.ci, snapshot, &sources, + ) + }); + let project_inputs = rule_enabled(config, crate::codebase::rules::TSCONFIG_GATE_COVERAGE) + .then(|| { + let workspace = + crate::codebase::workspaces::load_indexed_from_source_store(&root, &sources)?; + Ok::<_, anyhow::Error>( + crate::codebase::rules::tsconfig_gate_coverage::prepare_project_source_inputs( + &root, + snapshot.paths_for(&root).as_ref(), + &sources, + &workspace, + ), + ) + }) + .transpose()?; + super::run_filesystem_rules_with_config_snapshot_catalog_and_sources( + &root, + config, + files, + super::PreparedFilesystemRuleInputs { + snapshot, + vitest_catalog, + sources, + workflow_documents: workflows.as_ref(), + tsconfig_gate_project_inputs: project_inputs.as_ref(), + config_path, + }, ) } @@ -86,12 +171,12 @@ pub fn run_filesystem_rules_with_config_snapshot_and_vitest_catalog( snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, vitest_catalog: Option<&crate::codebase::rules::PreparedVitestProjectCatalog>, ) -> Result> { - super::run_filesystem_rules_with_config_snapshot_catalog_and_sources( + run_filesystem_rules_with_config_snapshot_path_and_catalog( root, config, + None, files, snapshot, vitest_catalog, - snapshot.source_store_for(root), ) } diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs new file mode 100644 index 000000000..c9fc44aea --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/execute.rs @@ -0,0 +1,194 @@ +use super::*; +use anyhow::Result; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +type ResultAccumulator = Mutex>)>>; + +struct RuleRunInputs<'a> { + root: &'a Path, + config: &'a crate::config::v2::NoMistakesConfig, + snapshot: &'a crate::codebase::ts_source::VisiblePathSnapshot, + vitest_catalog: Option<&'a super::super::PreparedVitestProjectCatalog>, + sources: &'a std::sync::Arc, + workflow_documents: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, + tsconfig_gate_project_inputs: Option<&'a tsconfig_gate_coverage::ProjectSourceInputs>, + config_path: Option<&'a Path>, + candidates: &'a candidate_index::RuleCandidateIndex, + acc: &'a ResultAccumulator, +} + +/// Request-scoped inputs prepared once and shared by filesystem rules. +#[doc(hidden)] +pub struct PreparedFilesystemRuleInputs<'a> { + pub snapshot: &'a crate::codebase::ts_source::VisiblePathSnapshot, + pub vitest_catalog: Option<&'a super::super::PreparedVitestProjectCatalog>, + pub sources: std::sync::Arc, + pub workflow_documents: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, + pub tsconfig_gate_project_inputs: Option<&'a tsconfig_gate_coverage::ProjectSourceInputs>, + pub config_path: Option<&'a Path>, +} + +#[doc(hidden)] +pub fn run_filesystem_rules_with_config_snapshot_catalog_and_sources( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + files: &[PathBuf], + prepared: PreparedFilesystemRuleInputs<'_>, +) -> Result> { + let PreparedFilesystemRuleInputs { + snapshot, + vitest_catalog, + sources, + workflow_documents, + tsconfig_gate_project_inputs, + config_path, + } = prepared; + let acc = Mutex::new(Vec::new()); + let metadata_files = metadata::metadata_files(root, config, files, snapshot); + let candidates = candidate_index::RuleCandidateIndex::prepare_with_inventory( + root, + config, + files, + &snapshot.tracked_paths_from(files), + &metadata_files, + Some(inventory::tracked_inventory_with_markdown_project_roots( + root, config, snapshot, + )), + ); + inventory::register_trusted_external_candidates(root, config, &candidates, &sources); + run_enabled_rules(&RuleRunInputs { + root, + config, + snapshot, + vitest_catalog, + sources: &sources, + workflow_documents, + tsconfig_gate_project_inputs, + config_path, + candidates: &candidates, + acc: &acc, + }); + let mut results = acc.into_inner().expect("mutex poisoned"); + results.sort_unstable_by_key(|(id, _)| *id); + let mut findings = Vec::new(); + for (_, result) in results { + findings.extend(result?); + } + suppress_rule_findings_with_sources_except( + root, + &mut findings, + &sources, + &[ + RUST_MAX_LINES_PER_FILE, + RUST_NO_INLINE_TESTS, + RUST_NO_INLINE_ALLOWS, + ], + ); + super::super::sort_findings(&mut findings); + Ok(findings) +} + +fn run_enabled_rules(inputs: &RuleRunInputs<'_>) { + macro_rules! run_rules { ($($id:expr => $call:path),* $(,)?) => { rayon::scope(|scope| { $( if rule_enabled(inputs.config, $id) { scope.spawn(|_| { let result = run_rule::run_rule_with_sources($id, $call, inputs.root, inputs.config, inputs.candidates.candidates($id), inputs.sources); inputs.acc.lock().expect("mutex poisoned").push(($id, result)); }); } )*; spawn_special_rules(scope, inputs); }); }; } + crate::filesystem_rules!(run_rules); +} + +fn spawn_special_rules<'a>(scope: &rayon::Scope<'a>, inputs: &'a RuleRunInputs<'a>) { + let RuleRunInputs { + root, + config, + snapshot, + vitest_catalog, + sources, + workflow_documents, + tsconfig_gate_project_inputs, + config_path, + candidates, + acc, + } = *inputs; + if rule_enabled(config, MARKDOWN_REACHABILITY) { + scope.spawn(|_| { + let result = markdown_reachability::check_with_files_and_sources( + root, + config, + candidates.candidates(MARKDOWN_REACHABILITY), + sources, + ); + acc.lock() + .expect("mutex poisoned") + .push((MARKDOWN_REACHABILITY, result)); + }); + } + if rule_enabled(config, MARKDOWN_STRUCTURE_BUDGET) { + scope.spawn(|_| { + let result = markdown_structure_budget::check_with_files_and_sources( + root, + config, + candidates.candidates(MARKDOWN_STRUCTURE_BUDGET), + sources, + ); + acc.lock() + .expect("mutex poisoned") + .push((MARKDOWN_STRUCTURE_BUDGET, result)); + }); + } + if registry::rust_rules_enabled(config) { + scope.spawn(|_| { + let result = rust_rules_combined::check_with_files_and_sources( + root, + config, + candidates.rust_candidates(), + candidates.exclusive_rust_candidates(), + sources, + ); + acc.lock() + .expect("mutex poisoned") + .push(("rust-rules-combined", result)); + }); + } + if rule_enabled(config, VITEST_PROJECT_MAPPING) { + scope.spawn(move |_| { + let result = vitest_project_mapping::check_with_files_and_catalog( + root, + config, + candidates.candidates(VITEST_PROJECT_MAPPING), + vitest_catalog, + ); + acc.lock() + .expect("mutex poisoned") + .push((VITEST_PROJECT_MAPPING, result)); + }); + } + if rule_enabled(config, VITEST_CI_PATH_COVERAGE) { + scope.spawn(move |_| { let result = vitest_ci_path_coverage::check_with_files_from_snapshot_catalog_sources_and_workflows(root, config, candidates.candidates(VITEST_CI_PATH_COVERAGE), snapshot, vitest_catalog, sources, workflow_documents); acc.lock().expect("mutex poisoned").push((VITEST_CI_PATH_COVERAGE, result)); }); + } + if rule_enabled(config, TSCONFIG_GATE_COVERAGE) { + scope.spawn(move |_| { + let result = workflow_documents + .zip(tsconfig_gate_project_inputs) + .map_or_else( + || { + Err(anyhow::anyhow!( + "prepared workflow documents and project inputs are required for {TSCONFIG_GATE_COVERAGE}" + )) + }, + |(workflows, project_source_inputs)| { + tsconfig_gate_coverage::check_with_prepared( + root, + config, + tsconfig_gate_coverage::PreparedInputs { + tracked_paths: snapshot.tracked_paths_for(root).as_ref(), + workflows, + project_source_inputs, + sources, + config_path, + }, + ) + }); + acc.lock() + .expect("mutex poisoned") + .push((TSCONFIG_GATE_COVERAGE, result)); + }); + } +} diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/metadata.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/metadata.rs new file mode 100644 index 000000000..e3921ad26 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/metadata.rs @@ -0,0 +1,19 @@ +use super::*; + +pub(super) fn metadata_files( + root: &Path, + config: &crate::config::v2::NoMistakesConfig, + files: &[PathBuf], + snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, +) -> Vec { + if !rule_enabled(config, FORBIDDEN_WORKSPACE_CLOSURE) + && !rule_enabled(config, PRODUCTION_DEPENDENCY_DECLARATIONS) + { + return Vec::new(); + } + let mut metadata_files = files.to_vec(); + metadata_files.extend(snapshot.paths_for(root).iter().cloned()); + metadata_files.sort(); + metadata_files.dedup(); + metadata_files +} diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/registry.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/registry.rs index 7520541fa..ff8310c85 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/registry.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/registry.rs @@ -1,3 +1,4 @@ +#[macro_export] macro_rules! filesystem_rules { ($macro:ident) => { $macro! { diff --git a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs index b4b87a8fe..041757d74 100644 --- a/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/filesystem_dispatch/tests.rs @@ -68,6 +68,56 @@ fn dispatch_standalone_covers_all_rule_branches() { ); } +#[test] +fn prebuilt_snapshot_catalog_entrypoint_accepts_an_empty_catalog() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/check-runner/empty"); + let config = crate::config::v2::NoMistakesConfig::default(); + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::from_paths(&root, &[]); + + let findings = run_filesystem_rules_with_config_snapshot_and_vitest_catalog( + &root, + &config, + &[], + &snapshot, + None, + ) + .unwrap(); + + assert!(findings.is_empty()); +} + +#[test] +fn prepared_dispatch_rejects_tsconfig_gate_without_workflow_documents() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/tsconfig-gate-coverage/missing-ci"); + let config_path = root.join(".no-mistakes.yml"); + let config = crate::config::v2::load_v2_config(&root, Some(&config_path)).unwrap(); + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::from_paths(&root, &[]); + + let error = super::run_filesystem_rules_with_config_snapshot_catalog_and_sources( + &root, + &config, + &[], + PreparedFilesystemRuleInputs { + snapshot: &snapshot, + vitest_catalog: None, + sources: snapshot.source_store_for(&root), + workflow_documents: None, + tsconfig_gate_project_inputs: None, + config_path: Some(&config_path), + }, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("prepared workflow documents and project inputs are required"), + "{error:#}" + ); +} + #[test] fn pre_discovered_entrypoints_do_not_start_another_discovery_snapshot() { let entrypoints = include_str!("entrypoints.rs"); @@ -294,9 +344,14 @@ fn aggregate_drops_exclusive_rust_sources_without_global_suppression_rereads() { &root, &config, &files, - &snapshot, - None, - std::sync::Arc::clone(&sources), + PreparedFilesystemRuleInputs { + snapshot: &snapshot, + vitest_catalog: None, + sources: std::sync::Arc::clone(&sources), + workflow_documents: None, + tsconfig_gate_project_inputs: None, + config_path: None, + }, ) .unwrap(); @@ -355,9 +410,14 @@ fn aggregate_finding_and_suppression_share_one_physical_read() { &root, &config, &files, - &snapshot, - None, - std::sync::Arc::clone(&sources), + PreparedFilesystemRuleInputs { + snapshot: &snapshot, + vitest_catalog: None, + sources: std::sync::Arc::clone(&sources), + workflow_documents: None, + tsconfig_gate_project_inputs: None, + config_path: None, + }, ) .unwrap(); diff --git a/crates/no-mistakes/src/codebase/rules/ids.rs b/crates/no-mistakes/src/codebase/rules/ids.rs index c1e138a8d..8f7a04f53 100644 --- a/crates/no-mistakes/src/codebase/rules/ids.rs +++ b/crates/no-mistakes/src/codebase/rules/ids.rs @@ -35,6 +35,7 @@ pub use super::structured_config_policy::RULE_ID as STRUCTURED_CONFIG_POLICY; pub use super::test_email_domain_policy::RULE_ID as TEST_EMAIL_DOMAIN_POLICY; pub use super::test_no_unmocked_dynamic_imports::RULE_ID as TEST_NO_UNMOCKED_DYNAMIC_IMPORTS; pub use super::tsconfig_alias_folder_mapping::RULE_ID as TSCONFIG_ALIAS_FOLDER_MAPPING; +pub use super::tsconfig_gate_coverage::RULE_ID as TSCONFIG_GATE_COVERAGE; pub use super::vitest_ci_path_coverage::RULE_ID as VITEST_CI_PATH_COVERAGE; pub use super::vitest_project_mapping::RULE_ID as VITEST_PROJECT_MAPPING; pub use super::vitest_test_correspondence::RULE_ID as VITEST_TEST_CORRESPONDENCE; diff --git a/crates/no-mistakes/src/codebase/rules/mod.rs b/crates/no-mistakes/src/codebase/rules/mod.rs index 3465881da..19641a166 100644 --- a/crates/no-mistakes/src/codebase/rules/mod.rs +++ b/crates/no-mistakes/src/codebase/rules/mod.rs @@ -39,6 +39,7 @@ pub mod structured_config_policy; pub mod test_email_domain_policy; pub mod test_no_unmocked_dynamic_imports; pub mod tsconfig_alias_folder_mapping; +pub mod tsconfig_gate_coverage; pub mod vitest_ci_path_coverage; mod vitest_project_catalog; pub mod vitest_project_mapping; diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage.rs new file mode 100644 index 000000000..c8b64055c --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage.rs @@ -0,0 +1,191 @@ +//! Ensure every tracked TypeScript project is registered in CI and local checks. +//! +//! The rule deliberately recognizes only a small, static command grammar. It +//! reports missing registrations instead of guessing through shell indirection +//! or expressions, so its results remain deterministic and actionable. + +mod application; +mod command_scan; +mod no_check; +mod workflow; + +use super::RuleFinding; +use crate::codebase::ci_workflows::ParsedWorkflowSet; +use crate::codebase::ts_source::relative_slash_path; +use crate::config::v2::schema::{CheckFileArgs, NoMistakesConfig}; +use anyhow::Result; +use application::{scan_application, Options}; +use command_scan::scan_argv_for_typechecked_projects; +use no_check::non_enforcing_tsconfigs; +use rayon::prelude::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use workflow::{ci_typechecked_projects, workflow_load_findings}; + +pub const RULE_ID: &str = "tsconfig-gate-coverage"; +#[doc(hidden)] +pub type ProjectSourceInputs = BTreeMap>; + +/// Request-owned inputs supplied by the aggregate check runner. +/// +/// `tracked_paths` is intentionally an inventory, rather than a +/// [`crate::codebase::ts_resolver::TsConfigCatalog`]: compiler helper configs +/// such as `tsconfig.tools.json` are part of this policy even when they do not +/// own imports for ordinary codebase resolution. +pub(crate) struct PreparedInputs<'a> { + pub(crate) tracked_paths: &'a [PathBuf], + pub(crate) workflows: &'a ParsedWorkflowSet, + pub(crate) project_source_inputs: &'a ProjectSourceInputs, + /// The owner of all rule source reads, including workflow documents and + /// effective `compilerOptions.noCheck` resolution for tracked tsconfigs. + pub(crate) sources: &'a crate::codebase::ts_source::SourceStore, + /// The loaded config path, used for configuration diagnostics and + /// suppressions. `None` renders as the conventional `.no-mistakes.yml`. + pub(crate) config_path: Option<&'a Path>, +} + +/// Run the rule with request-prepared tracked paths and workflow documents. +/// +/// The check runner is responsible for invoking this only when the rule is +/// configured and for supplying a source-store-backed [`ParsedWorkflowSet`]. +pub(crate) fn check_with_prepared( + root: &Path, + config: &NoMistakesConfig, + prepared: PreparedInputs<'_>, +) -> Result> { + let tracked = tracked_tsconfigs(root, prepared.tracked_paths); + let non_enforcing = non_enforcing_tsconfigs(root, &tracked, prepared.sources); + let ci_projects = + ci_typechecked_projects(prepared.workflows, &tracked, prepared.project_source_inputs); + let local_projects = local_typechecked_projects(config); + let config_file = config_file(root, prepared.config_path); + let workflow_errors = workflow_load_findings(prepared.workflows); + + let all: Result>> = config + .rule_applications(RULE_ID) + .into_par_iter() + .map(|rule| { + let opts: Options = rule.rule_options(); + let target_roots = super::target_roots(root, config, rule); + let skip = super::skip_dir_set(config); + let candidates = tracked + .iter() + .filter(|candidate| { + let path = root.join(candidate); + super::file_allowed_by_roots_and_skip(root, &skip, &path, &target_roots) + }) + .cloned() + .collect::>(); + let candidates = super::path_filter::filter_rule_files( + root, + config, + rule, + &candidates + .iter() + .map(|candidate| root.join(candidate)) + .collect::>(), + )?; + let candidates = candidates + .iter() + .map(|path| relative_slash_path(root, path)) + .collect::>(); + Ok(scan_application( + &opts, + &tracked, + &candidates, + &ci_projects, + &local_projects, + &non_enforcing, + &config_file, + )) + }) + .collect(); + let mut findings = all?.into_iter().flatten().collect::>(); + findings.extend(workflow_errors); + super::sort_findings(&mut findings); + Ok(findings) +} + +#[doc(hidden)] +pub fn prepare_project_source_inputs( + root: &Path, + tracked_paths: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, + workspace: &crate::codebase::workspaces::IndexedWorkspaceMap, +) -> ProjectSourceInputs { + let tracked = tracked_tsconfigs(root, tracked_paths); + let config_paths = tracked + .iter() + .map(|path| root.join(path)) + .collect::>(); + let membership = crate::codebase::ts_resolver::TsConfigCatalog::project_source_membership( + root, + &config_paths, + tracked_paths, + sources, + workspace, + ); + tracked + .into_iter() + .map(|config| { + let absolute = crate::codebase::ts_resolver::normalize_path(&root.join(&config)); + let mut inputs = membership + .get(&absolute) + .into_iter() + .flat_map(|paths| paths.iter()) + .map(|path| relative_slash_path(root, path)) + .collect::>(); + if inputs.is_empty() { + inputs.insert(config.clone()); + } + (config, inputs) + }) + .collect() +} + +fn tracked_tsconfigs(root: &Path, paths: &[PathBuf]) -> BTreeSet { + paths + .iter() + .filter_map(|path| { + let rel = relative_slash_path(root, path); + command_scan::normalize_repo_relative(&rel) + .filter(|normalized| is_tsconfig_path(normalized)) + }) + .collect() +} + +fn is_tsconfig_path(path: &str) -> bool { + if path.split('/').any(|component| component == "node_modules") { + return false; + } + let name = path.rsplit('/').next().unwrap_or_default(); + name == "tsconfig.json" + || name + .strip_prefix("tsconfig.") + .is_some_and(|suffix| !suffix.is_empty() && suffix.ends_with(".json")) +} + +fn local_typechecked_projects(config: &NoMistakesConfig) -> BTreeSet { + config + .checks + .commands + .iter() + .filter(|command| command.always && command.file_args == CheckFileArgs::None) + .flat_map(|command| scan_argv_for_typechecked_projects(&command.command, ".")) + .collect() +} + +fn config_file(root: &Path, config_path: Option<&Path>) -> String { + config_path.map_or_else( + || ".no-mistakes.yml".to_string(), + |path| { + relative_slash_path( + &crate::codebase::ts_resolver::normalize_path(root), + &crate::codebase::ts_resolver::normalize_path(path), + ) + }, + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/application.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/application.rs new file mode 100644 index 000000000..7f8f2fba8 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/application.rs @@ -0,0 +1,169 @@ +use super::{command_scan, is_tsconfig_path, RuleFinding, RULE_ID}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(serde::Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub(super) struct Options { + /// Reasoned exemptions keyed by the repository-relative tsconfig path. + pub(super) allow_projects: BTreeMap, +} + +pub(super) fn scan_application( + opts: &Options, + tracked: &BTreeSet, + candidates: &BTreeSet, + ci_projects: &BTreeSet, + local_projects: &BTreeSet, + non_enforcing: &BTreeSet, + config_file: &str, +) -> Vec { + let ci_projects = resolve_gate_projects_against_tracked(ci_projects, tracked); + let local_projects = resolve_gate_projects_against_tracked(local_projects, tracked); + let (allowlist, mut findings) = validate_allowlist(opts, tracked, config_file); + for project in candidates { + if allowlist.contains(project) { + continue; + } + if non_enforcing.contains(project) { + findings.push(project_finding( + project, + format!( + "{project}: effective compilerOptions.noCheck is true, so tsc cannot enforce ordinary type errors; remove or disable noCheck, or add a reasoned allowProjects entry" + ), + )); + continue; + } + if !ci_projects.contains(project) { + findings.push(project_finding( + project, + format!( + "{project}: tsconfig has no CI typecheck registration; add a static `tsc --noEmit --project {project}` command to a configured workflow, or add a reasoned `allowProjects` entry" + ), + )); + } + if !local_projects.contains(project) { + findings.push(project_finding( + project, + format!( + "{project}: tsconfig has no local typecheck registration; add an `always: true` `checks.commands` entry with `fileArgs: none` that statically runs `tsc --noEmit --project {project}`, or add a reasoned `allowProjects` entry" + ), + )); + } + } + findings +} + +/// Resolve the directory form accepted by `tsc --project` only when the +/// request's tracked tsconfig inventory proves that interpretation. This keeps +/// static command parsing path-only while correctly handling directories such +/// as `app.json` that a filename-suffix heuristic would misclassify. +pub(super) fn resolve_gate_projects_against_tracked( + gate_projects: &BTreeSet, + tracked: &BTreeSet, +) -> BTreeSet { + gate_projects + .iter() + .map(|project| resolve_gate_project_against_tracked(project, tracked)) + .collect() +} + +pub(super) fn resolve_gate_project_against_tracked( + project: &str, + tracked: &BTreeSet, +) -> String { + if tracked.contains(project) { + return project.to_string(); + } + let directory_config = if project == "." { + "tsconfig.json".to_string() + } else { + format!("{project}/tsconfig.json") + }; + if tracked.contains(&directory_config) { + directory_config + } else { + project.to_string() + } +} + +fn validate_allowlist( + opts: &Options, + tracked: &BTreeSet, + config_file: &str, +) -> (BTreeSet, Vec) { + let mut normalized = BTreeMap::::new(); + let mut accepted = BTreeSet::new(); + let mut findings = Vec::new(); + for (raw_path, reason) in &opts.allow_projects { + let Some(path) = command_scan::normalize_repo_relative(raw_path) else { + findings.push(config_finding( + config_file, + raw_path, + format!( + "allowProjects entry `{raw_path}` must be a static repository-relative tsconfig path" + ), + )); + continue; + }; + if !is_tsconfig_path(&path) { + findings.push(config_finding( + config_file, + raw_path, + format!("allowProjects entry `{raw_path}` is not a tsconfig path"), + )); + continue; + } + if reason.trim().is_empty() { + findings.push(config_finding( + config_file, + raw_path, + format!("allowProjects entry `{raw_path}` must include a non-empty reason"), + )); + continue; + } + if let Some(first) = normalized.insert(path.clone(), raw_path.clone()) { + findings.push(config_finding( + config_file, + raw_path, + format!( + "allowProjects entries `{first}` and `{raw_path}` normalize to the same path `{path}`" + ), + )); + continue; + } + if !tracked.contains(&path) { + findings.push(config_finding( + config_file, + raw_path, + format!( + "stale allowProjects entry `{raw_path}` does not name a tracked tsconfig; remove it" + ), + )); + continue; + } + accepted.insert(path); + } + (accepted, findings) +} + +pub(super) fn project_finding(file: &str, message: String) -> RuleFinding { + RuleFinding { + rule: RULE_ID.to_string(), + file: file.to_string(), + line: 1, + message, + import: None, + target: None, + } +} + +fn config_finding(file: &str, target: &str, message: String) -> RuleFinding { + RuleFinding { + rule: RULE_ID.to_string(), + file: file.to_string(), + line: 1, + message, + import: None, + target: Some(target.to_string()), + } +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan.rs new file mode 100644 index 000000000..d3ea1c496 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan.rs @@ -0,0 +1,174 @@ +//! Static command recognition shared by workflow and configured local gates. + +mod shell; +mod tsc_arguments; + +use shell::local_shell_command; +use tsc_arguments::project_argument; + +/// Normalize one static repository-relative path into slash form. +/// Parent traversals, absolute paths, backslashes, and shell expansion syntax +/// are intentionally unresolved. A rule finding then asks the user to express +/// the project command statically instead of guessing its runtime value. +pub(crate) fn normalize_repo_relative(raw: &str) -> Option { + if raw.is_empty() + || raw.starts_with('/') + || raw.starts_with('~') + || raw.contains('\\') + || raw.contains(['$', '`', '(', ')']) + { + return None; + } + let mut parts = Vec::new(); + for part in raw.split('/') { + match part { + "" | "." => {} + ".." => return None, + part => parts.push(part), + } + } + Some(if parts.is_empty() { + ".".to_string() + } else { + parts.join("/") + }) +} + +/// Scan a static workflow `run:` body for project-mode `tsc --noEmit` checks. +/// +/// Only sequential newline, `&&`, and `;` segments are recognized. Shell +/// interpolation, substitutions, pipes, conditionals, and arbitrary wrappers +/// are not evaluated. A reachability-affecting control command rejects the +/// whole body instead of trying to model shell execution. GitHub Actions runs +/// workflow shells with failure propagation, unlike an arbitrary local `sh -c`. +pub(crate) fn scan_shell_for_typechecked_projects(script: &str, initial_cwd: &str) -> Vec { + shell::scan_shell_body_for_typechecked_projects(script, initial_cwd, true) +} + +/// Scan a workflow shell body with the failure behavior selected by its +/// effective GitHub Actions shell. +pub(crate) fn scan_workflow_shell_for_typechecked_projects( + script: &str, + initial_cwd: &str, + failure_enforced: bool, +) -> Vec { + shell::scan_shell_body_for_typechecked_projects(script, initial_cwd, failure_enforced) +} + +/// Scan one configured argv command. A shell script is accepted only for a +/// static `bash|sh ... -c ` form; all other argv commands are parsed +/// as direct static command tokens. +pub(crate) fn scan_argv_for_typechecked_projects(argv: &[String], cwd: &str) -> Vec { + if let Some((script, failure_enforced)) = local_shell_command(argv) { + return shell::scan_shell_body_for_typechecked_projects(script, cwd, failure_enforced); + } + let Some(cwd) = normalize_repo_relative(cwd) else { + return Vec::new(); + }; + scan_tokens(argv, &cwd).into_iter().collect() +} + +fn scan_tokens(tokens: &[String], cwd: &str) -> Vec { + let Some((command, command_cwd, argument_start)) = command_and_cwd(tokens, cwd) else { + return Vec::new(); + }; + if !is_tsc(command) { + return Vec::new(); + } + let Some(project) = project_argument(&tokens[argument_start..]) else { + return Vec::new(); + }; + join_relative(&command_cwd, &project).into_iter().collect() +} + +fn command_and_cwd<'a>(tokens: &'a [String], cwd: &str) -> Option<(&'a str, String, usize)> { + match tokens.first()?.as_str() { + "pnpm" => { + let mut index = 1; + let mut command_cwd = cwd.to_string(); + if let Some(value) = tokens + .get(index) + .and_then(|token| token.strip_prefix("--dir=")) + { + command_cwd = join_relative(cwd, value)?; + index += 1; + } else if tokens.get(index).is_some_and(|token| token == "--dir") { + command_cwd = join_relative(cwd, tokens.get(index + 1)?)?; + index += 2; + } + (tokens.get(index)? == "exec").then_some(( + tokens.get(index + 1)?.as_str(), + command_cwd, + index + 2, + )) + } + command => Some((command, cwd.to_string(), 1)), + } +} + +fn is_tsc(command: &str) -> bool { + // `node_modules/.bin/tsc` is the project-local TypeScript shim. Do not + // trust arbitrary paths ending in `tsc`: they can be unrelated wrappers. + matches!( + command, + "tsc" | "node_modules/.bin/tsc" | "./node_modules/.bin/tsc" + ) +} + +fn join_relative(base: &str, raw: &str) -> Option { + let raw = normalize_repo_relative(raw)?; + let joined = if base == "." { + raw + } else { + format!("{base}/{raw}") + }; + normalize_repo_relative(&joined) +} + +fn static_tokens(segment: &str) -> Option> { + if segment.is_empty() + || segment.contains("||") + || segment.contains('|') + || segment.contains(['$', '`', '\\']) + { + return None; + } + let mut tokens = Vec::new(); + let mut chars = segment.chars().peekable(); + while chars.peek().is_some() { + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + let Some(first) = chars.next() else { + break; + }; + let mut token = String::new(); + if matches!(first, '\'' | '"') { + let quote = first; + let mut closed = false; + for ch in chars.by_ref() { + if ch == quote { + closed = true; + break; + } + token.push(ch); + } + if !closed || chars.peek().is_some_and(|ch| !ch.is_whitespace()) { + return None; + } + } else { + token.push(first); + while chars.peek().is_some_and(|ch| !ch.is_whitespace()) { + token.push(chars.next().expect("peeked character exists")); + } + } + if token.is_empty() { + return None; + } + tokens.push(token); + } + Some(tokens) +} + +#[cfg(test)] +mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/shell.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/shell.rs new file mode 100644 index 000000000..638aef589 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/shell.rs @@ -0,0 +1,186 @@ +use super::{join_relative, normalize_repo_relative, scan_tokens, static_tokens}; + +mod comments; + +use comments::strip_static_comments; + +pub(super) fn scan_shell_body_for_typechecked_projects( + script: &str, + initial_cwd: &str, + mut failure_enforced: bool, +) -> Vec { + let script = strip_static_comments(script); + if contains_unsupported_multiline_shell_construct(&script) { + return Vec::new(); + } + let mut cwd = normalize_repo_relative(initial_cwd); + let mut projects = Vec::new(); + let groups = script + .split(['\n', ';']) + .filter(|segment| !segment.trim().is_empty()) + .collect::>(); + for (group_index, group) in groups.iter().enumerate() { + let segments = group + .split("&&") + .filter(|segment| !segment.trim().is_empty()) + .collect::>(); + let final_group = group_index + 1 == groups.len(); + if segments.len() > 1 && !final_group { + return Vec::new(); + } + for segment in segments { + let Some(tokens) = static_tokens(segment) else { + continue; + }; + let first = tokens + .first() + .expect("a nonblank static shell segment has at least one token"); + if is_unsupported_control_command(first) + || disables_failure_enforcement(&tokens) + || enables_non_executing_mode(&tokens) + || is_unsupported_working_directory_command(&tokens) + { + return Vec::new(); + } + failure_enforced |= enables_failure_enforcement(&tokens); + if first == "cd" { + cwd = (tokens.len() == 2) + .then(|| { + tokens.get(1).and_then(|path| { + cwd.as_ref().and_then(|base| join_relative(base, path)) + }) + }) + .flatten(); + continue; + } + let Some(base) = cwd.as_deref() else { + continue; + }; + if failure_enforced || final_group { + projects.extend(scan_tokens(&tokens, base)); + } + } + } + projects.sort(); + projects.dedup(); + projects +} + +/// The scanner tracks only `cd `. Directory-stack +/// commands and malformed `cd` forms make a later command's cwd ambiguous, so +/// reject the whole body instead of crediting it against the wrong tsconfig. +fn is_unsupported_working_directory_command(tokens: &[String]) -> bool { + match tokens.first().map(String::as_str) { + Some("pushd" | "popd" | "dirs") => true, + Some("cd") => { + tokens.len() != 2 + || normalize_repo_relative(tokens.get(1).expect("cd has an argument")).is_none() + } + _ => false, + } +} + +fn contains_unsupported_multiline_shell_construct(script: &str) -> bool { + if script.contains("<<") { + return true; + } + let mut quote = None; + for character in script.chars() { + match quote { + Some(active) if character == active => quote = None, + Some(_) if matches!(character, '\n' | ';' | '&') => return true, + Some(_) => {} + None if matches!(character, '\'' | '"') => quote = Some(character), + None if matches!(character, '{' | '}') => return true, + None => {} + } + } + false +} + +fn is_unsupported_control_command(command: &str) -> bool { + matches!(command, "!" | "exit" | "return" | "false") +} + +fn disables_failure_enforcement(tokens: &[String]) -> bool { + if tokens.first().is_none_or(|command| command != "set") { + return false; + } + match tokens.get(1).map(String::as_str) { + Some(option) if option.starts_with('+') && option.contains('e') => true, + Some("+o") => tokens.get(2).is_some_and(|option| option == "errexit"), + _ => false, + } +} + +fn enables_failure_enforcement(tokens: &[String]) -> bool { + if tokens.first().is_none_or(|command| command != "set") { + return false; + } + match tokens.get(1).map(String::as_str) { + Some(option) if option.starts_with('-') && option.contains('e') => true, + Some("-o") => tokens.get(2).is_some_and(|option| option == "errexit"), + _ => false, + } +} + +fn enables_non_executing_mode(tokens: &[String]) -> bool { + if tokens.first().is_none_or(|command| command != "set") { + return false; + } + match tokens.get(1).map(String::as_str) { + Some(option) if option.starts_with('-') && option.contains('n') => true, + Some("-o") => tokens.get(2).is_some_and(|option| option == "noexec"), + _ => false, + } +} + +/// Parse the explicit local `bash|sh ... -c ` shape. Unlike Actions, +/// local shells start without failure propagation unless `-e`/`errexit` is +/// present; the scanner can still credit a final `tsc` command. +pub(super) fn local_shell_command(argv: &[String]) -> Option<(&str, bool)> { + if !matches!(argv.first()?.as_str(), "bash" | "sh") { + return None; + } + let mut failure_enforced = false; + let mut index = 1; + while let Some(argument) = argv.get(index) { + if argument == "-c" { + if index + 2 != argv.len() { + return None; + } + return Some((argv.get(index + 1)?.as_str(), failure_enforced)); + } + if argument == "-o" || argument == "+o" { + let option = argv.get(index + 1)?; + if option != "errexit" { + return None; + } + failure_enforced = argument == "-o"; + index += 2; + continue; + } + let (prefix, options) = argument + .strip_prefix('-') + .map(|options| ("-", options)) + .or_else(|| argument.strip_prefix('+').map(|options| ("+", options)))?; + if options.is_empty() || !options.chars().all(|option| option.is_ascii_alphabetic()) { + return None; + } + if options.contains('n') || options.contains('D') { + return None; + } + if options.contains('c') { + if prefix != "-" || options.matches('c').count() != 1 || index + 2 != argv.len() { + return None; + } + failure_enforced |= options.contains('e'); + return Some((argv.get(index + 1)?.as_str(), failure_enforced)); + } + if options.contains('e') { + failure_enforced = prefix == "-"; + } + index += 1; + } + None +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/shell/comments.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/shell/comments.rs new file mode 100644 index 000000000..adc4b4c88 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/shell/comments.rs @@ -0,0 +1,37 @@ +pub(super) fn strip_static_comments(script: &str) -> String { + let mut output = String::with_capacity(script.len()); + let mut single_quoted = false; + let mut double_quoted = false; + let mut escaped = false; + let mut comment = false; + for character in script.chars() { + if comment { + if character == '\n' { + comment = false; + output.push(character); + } + continue; + } + if escaped { + escaped = false; + } else if character == '\\' && !single_quoted { + escaped = true; + } else if character == '\'' && !double_quoted { + single_quoted = !single_quoted; + } else if character == '"' && !single_quoted { + double_quoted = !double_quoted; + } else if character == '#' + && !single_quoted + && !double_quoted + && output + .chars() + .next_back() + .is_none_or(|previous| previous.is_whitespace() || ";|&()<>".contains(previous)) + { + comment = true; + continue; + } + output.push(character); + } + output +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/tests.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/tests.rs new file mode 100644 index 000000000..51caaed05 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/command_scan/tests.rs @@ -0,0 +1,486 @@ +use super::*; + +mod review; + +#[test] +fn shell_scanner_tracks_cd_and_pnpm_dir() { + assert_eq!( + scan_shell_for_typechecked_projects( + "cd app; pnpm exec tsc --noEmit; pnpm --dir tools exec tsc --noEmit --project tsconfig.tools.json", + ".", + ), + vec!["app/tools/tsconfig.tools.json", "app/tsconfig.json"] + ); +} + +#[test] +fn shell_scanner_skips_whitespace_only_segments() { + assert_eq!( + scan_shell_for_typechecked_projects(" \n tsc --noEmit", "."), + vec!["tsconfig.json"] + ); +} + +#[test] +fn dynamic_and_indirect_commands_do_not_count() { + for script in [ + "\"$ROOT_BIN/tsc\" --noEmit --project app/tsconfig.json", + "runner tsc --noEmit --project app/tsconfig.json", + "tsc --noEmit | tee result", + "tsc --noEmit || exit 1", + "'tsc'--noEmit", + "'unterminated", + "''", + "cd app ignored && tsc --noEmit", + ] { + assert!( + scan_shell_for_typechecked_projects(script, ".").is_empty(), + "{script}" + ); + } + assert!(scan_shell_for_typechecked_projects("tsc --noEmit", "../outside").is_empty()); + assert!(scan_shell_for_typechecked_projects("cd ../outside && tsc --noEmit", ".").is_empty()); + assert!(scan_shell_for_typechecked_projects("tsc --project app/tsconfig.json", ".").is_empty()); +} + +#[test] +fn shell_scanner_rejects_reachability_control_commands_without_modeling_them() { + for script in [ + "exit 0; tsc --noEmit --project app/tsconfig.json", + "false && tsc --noEmit --project app/tsconfig.json", + "return; tsc --noEmit --project app/tsconfig.json", + "tsc --noEmit --project app/tsconfig.json && exit 0", + ] { + assert!( + scan_shell_for_typechecked_projects(script, ".").is_empty(), + "{script}" + ); + } + + assert_eq!( + scan_shell_for_typechecked_projects( + "cd app; tsc --noEmit; cd tools; tsc --noEmit --project tsconfig.tools.json", + ".", + ), + vec!["app/tools/tsconfig.tools.json", "app/tsconfig.json"] + ); +} + +#[test] +fn shell_scanner_rejects_failure_enforcement_mutations() { + for script in [ + "set +e; tsc --noEmit --project app/tsconfig.json", + "set +eu; tsc --noEmit --project app/tsconfig.json", + "set +o errexit; tsc --noEmit --project app/tsconfig.json", + "tsc --noEmit --project app/tsconfig.json; set +e", + ] { + assert!( + scan_shell_for_typechecked_projects(script, ".").is_empty(), + "{script}" + ); + } + assert_eq!( + scan_shell_for_typechecked_projects("set -e; tsc --noEmit", "."), + vec!["tsconfig.json"] + ); + assert_eq!( + scan_shell_for_typechecked_projects("set -o errexit; tsc --noEmit", "."), + vec!["tsconfig.json"] + ); + assert_eq!( + scan_shell_for_typechecked_projects("set -u; tsc --noEmit", "."), + vec!["tsconfig.json"] + ); +} + +#[test] +fn shell_scanner_rejects_unsupported_working_directory_commands() { + for script in [ + "pushd app; tsc --noEmit", + "tsc --noEmit; popd", + "dirs; tsc --noEmit", + "cd app ignored; tsc --noEmit", + ] { + assert!( + scan_shell_for_typechecked_projects(script, ".").is_empty(), + "{script}" + ); + } +} + +#[test] +fn local_shell_gates_require_failure_propagation_or_a_final_typecheck() { + let masked = vec![ + "bash".into(), + "-c".into(), + "tsc --noEmit; echo ignored failure".into(), + ]; + assert!( + scan_argv_for_typechecked_projects(&masked, ".").is_empty(), + "{masked:?}" + ); + assert_eq!( + scan_argv_for_typechecked_projects( + &[ + "sh".into(), + "-c".into(), + "tsc --noEmit && echo success".into(), + ], + ".", + ), + vec!["tsconfig.json"] + ); + + for argv in [ + vec!["bash".into(), "-c".into(), "cd app; tsc --noEmit".into()], + vec![ + "bash".into(), + "-ec".into(), + "cd app; tsc --noEmit; echo reached only after a passing typecheck".into(), + ], + vec![ + "sh".into(), + "-o".into(), + "errexit".into(), + "-c".into(), + "cd app; tsc --noEmit; echo reached only after a passing typecheck".into(), + ], + vec![ + "sh".into(), + "-c".into(), + "set -e; cd app; tsc --noEmit; echo reached only after a passing typecheck".into(), + ], + ] { + assert_eq!( + scan_argv_for_typechecked_projects(&argv, "."), + vec!["app/tsconfig.json"], + "{argv:?}" + ); + } +} + +#[test] +fn local_shell_parser_rejects_ambiguous_options_and_tracks_errexit() { + let to_argv = |values: &[&str]| -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + }; + + assert_eq!( + local_shell_command(&to_argv(&["sh", "-o", "errexit", "-c", "script"])), + Some(("script", true)) + ); + assert_eq!( + local_shell_command(&to_argv(&["bash", "+e", "-c", "script"])), + Some(("script", false)) + ); + assert_eq!( + local_shell_command(&to_argv(&["bash", "+o", "errexit", "-c", "script"])), + Some(("script", false)) + ); + assert_eq!( + local_shell_command(&to_argv(&["bash", "-ec", "script"])), + Some(("script", true)) + ); + assert_eq!( + local_shell_command(&to_argv(&["bash", "-u", "-c", "script"])), + Some(("script", false)) + ); + for values in [ + ["node", "-c", "script"].as_slice(), + ["bash", "-c", "script", "extra"].as_slice(), + ["bash", "-o"].as_slice(), + ["bash", "+o", "nounset", "-c", "script"].as_slice(), + ["bash", "command"].as_slice(), + ["bash", "-"].as_slice(), + ["bash", "-1"].as_slice(), + ["bash", "+c", "script"].as_slice(), + ["bash", "-e"].as_slice(), + ] { + assert_eq!(local_shell_command(&to_argv(values)), None, "{values:?}"); + } +} + +#[test] +fn shell_scanner_rejects_heredocs_and_multiline_quoted_bodies() { + for script in [ + "cat <<'SCRIPT'\ntsc --noEmit\nSCRIPT", + "tsc --noEmit < Option { + let mut project = None; + let mut no_emit = false; + let mut index = 0; + while let Some(argument) = arguments.get(index) { + if argument == "--noCheck=false" { + } else if argument == "--noCheck" { + if arguments + .get(index + 1) + .is_some_and(|value| value == "false") + { + index += 1; + } else { + return None; + } + } else if argument.starts_with("--noCheck=") || is_non_typechecking_mode(argument) { + return None; + } else if argument == "--noEmit" { + no_emit = true; + } else if matches!(argument.as_str(), "--project" | "-p") { + let value = arguments.get(index + 1)?; + if value.starts_with('-') || project.replace(value.clone()).is_some() { + return None; + } + index += 1; + } else if let Some(raw_option) = argument.strip_prefix("--") { + let (option, inline_value) = raw_option + .split_once('=') + .map_or((raw_option, None), |(option, value)| (option, Some(value))); + let value_kind = option_value_kind(option)?; + match (value_kind, inline_value) { + (_, Some("")) => return None, + (OptionValueKind::Required, Some(_)) => {} + (OptionValueKind::Required, None) => { + let value = arguments.get(index + 1)?; + if value.starts_with('-') { + return None; + } + index += 1; + } + (OptionValueKind::OptionalBoolean, Some("true" | "false")) => {} + (OptionValueKind::OptionalBoolean, Some(_)) => return None, + (OptionValueKind::OptionalBoolean, None) + if arguments + .get(index + 1) + .is_some_and(|value| matches!(value.as_str(), "true" | "false")) => + { + index += 1; + } + (OptionValueKind::OptionalBoolean, None) => {} + } + } else { + return None; + } + index += 1; + } + no_emit.then(|| project.unwrap_or_else(|| "tsconfig.json".to_string())) +} + +fn is_non_typechecking_mode(argument: &str) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + matches!( + option, + "--listFilesOnly" + | "--ignoreConfig" + | "--showConfig" + | "--help" + | "-h" + | "--version" + | "-v" + | "--init" + ) +} + +/// Static `tsc` options that consume one following token. This deliberately +/// excludes project-mode-breaking flags and leaves unknown value-taking forms +/// unresolved rather than mistaking a source input for configuration. +enum OptionValueKind { + Required, + OptionalBoolean, +} + +fn option_value_kind(option: &str) -> Option { + matches!( + option, + "baseUrl" + | "charset" + | "declarationDir" + | "generateCpuProfile" + | "generateTrace" + | "importsNotUsedAsValues" + | "jsx" + | "jsxFactory" + | "jsxFragmentFactory" + | "jsxImportSource" + | "lib" + | "locale" + | "mapRoot" + | "maxNodeModuleJsDepth" + | "module" + | "moduleDetection" + | "moduleResolution" + | "newLine" + | "outDir" + | "outFile" + | "paths" + | "plugins" + | "reactNamespace" + | "rootDir" + | "rootDirs" + | "sourceRoot" + | "target" + | "tsBuildInfoFile" + | "typeRoots" + | "types" + ) + .then_some(OptionValueKind::Required) + .or_else(|| { + matches!( + option, + "allowArbitraryExtensions" + | "allowImportingTsExtensions" + | "allowJs" + | "allowSyntheticDefaultImports" + | "allowUnreachableCode" + | "allowUnusedLabels" + | "alwaysStrict" + | "checkJs" + | "composite" + | "declaration" + | "declarationMap" + | "downlevelIteration" + | "emitBOM" + | "emitDeclarationOnly" + | "erasableSyntaxOnly" + | "esModuleInterop" + | "exactOptionalPropertyTypes" + | "experimentalDecorators" + | "forceConsistentCasingInFileNames" + | "importHelpers" + | "incremental" + | "inlineSourceMap" + | "inlineSources" + | "isolatedDeclarations" + | "isolatedModules" + | "listEmittedFiles" + | "listFiles" + | "noEmitHelpers" + | "noEmitOnError" + | "noErrorTruncation" + | "noFallthroughCasesInSwitch" + | "noImplicitAny" + | "noImplicitOverride" + | "noImplicitReturns" + | "noImplicitThis" + | "noImplicitUseStrict" + | "noLib" + | "noPropertyAccessFromIndexSignature" + | "noResolve" + | "noStrictGenericChecks" + | "noUncheckedIndexedAccess" + | "noUnusedLocals" + | "noUnusedParameters" + | "preserveConstEnums" + | "preserveSymlinks" + | "preserveValueImports" + | "pretty" + | "removeComments" + | "resolveJsonModule" + | "rewriteRelativeImportExtensions" + | "skipDefaultLibCheck" + | "skipLibCheck" + | "sourceMap" + | "strict" + | "strictBindCallApply" + | "strictBuiltinIteratorReturn" + | "strictFunctionTypes" + | "strictNullChecks" + | "strictPropertyInitialization" + | "stripInternal" + | "traceResolution" + | "useDefineForClassFields" + | "useUnknownInCatchVariables" + | "verbatimModuleSyntax" + ) + .then_some(OptionValueKind::OptionalBoolean) + }) +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/no_check.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/no_check.rs new file mode 100644 index 000000000..66724ded2 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/no_check.rs @@ -0,0 +1,144 @@ +//! Prepared-source resolution of effective `compilerOptions.noCheck` values. +//! +//! This rule needs only one compiler option, so it deliberately does not build +//! another tsconfig catalog. It follows local `extends` files through the +//! request's `SourceStore`, preserving one read/cache identity for the check. + +use crate::codebase::ts_source::SourceStore; +use std::collections::{BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; + +/// Return projects whose gate commands cannot prove full typechecking. +/// +/// A literal effective `noCheck: true` is non-enforcing. Unresolved configs are +/// left to TypeScript itself: a read or parse failure makes `tsc` fail rather +/// than silently accepting ordinary type errors. +pub(super) fn non_enforcing_tsconfigs( + root: &Path, + tracked: &BTreeSet, + sources: &SourceStore, +) -> BTreeSet { + use rayon::prelude::*; + use std::collections::HashSet; + + tracked + .par_iter() + .filter(|project| { + matches!( + effective_no_check(root, &root.join(project), sources, &mut HashSet::new()), + Ok(Some(true)) + ) + }) + .cloned() + .collect() +} + +fn effective_no_check( + root: &Path, + path: &Path, + sources: &SourceStore, + loading: &mut HashSet, +) -> Result, ()> { + let path = crate::codebase::ts_resolver::normalize_path(path); + if !loading.insert(path.clone()) { + return Err(()); + } + let result = effective_no_check_inner(root, &path, sources, loading); + loading.remove(&path); + result +} + +fn effective_no_check_inner( + root: &Path, + path: &Path, + sources: &SourceStore, + loading: &mut HashSet, +) -> Result, ()> { + let source = sources.read_path(path).map_err(|_| ())?; + let parsed: Option = + jsonc_parser::parse_to_serde_value(&source, &jsonc_parser::ParseOptions::default()) + .map_err(|_| ())?; + let value = parsed.unwrap_or(serde_json::Value::Null); + let dir = path.parent().ok_or(())?; + let mut inherited = None; + for extends in extends_values(&value)? { + let extended = resolve_extends(root, dir, &extends, sources)?; + if let Some(value) = effective_no_check(root, &extended, sources, loading)? { + inherited = Some(value); + } + } + Ok(own_no_check(&value)?.or(inherited)) +} + +fn extends_values(value: &serde_json::Value) -> Result, ()> { + match value.get("extends") { + None => Ok(Vec::new()), + Some(serde_json::Value::String(path)) => Ok(vec![path.clone()]), + Some(serde_json::Value::Array(paths)) => paths + .iter() + .map(|path| path.as_str().map(ToString::to_string).ok_or(())) + .collect(), + Some(_) => Err(()), + } +} + +fn resolve_extends( + root: &Path, + dir: &Path, + extends: &str, + sources: &SourceStore, +) -> Result { + if !extends.starts_with('.') { + return resolve_package_extends(root, dir, extends, sources); + } + let candidate = crate::codebase::ts_resolver::normalize_path(&dir.join(extends)); + if candidate.extension() == Some(std::ffi::OsStr::new("json")) + || sources + .inventory() + .id_for_normalized_path(&candidate) + .is_some() + { + return Ok(candidate); + } + let mut file = candidate.as_os_str().to_os_string(); + file.push(".json"); + Ok(PathBuf::from(file)) +} + +fn resolve_package_extends( + root: &Path, + dir: &Path, + extends: &str, + sources: &SourceStore, +) -> Result { + let mut current = Some(dir); + while let Some(base) = current.filter(|base| base.starts_with(root)) { + let candidate = + crate::codebase::ts_resolver::normalize_path(&base.join("node_modules").join(extends)); + let mut json = candidate.as_os_str().to_os_string(); + json.push(".json"); + for config in [ + candidate.clone(), + PathBuf::from(json), + candidate.join("tsconfig.json"), + ] { + if sources.read_path(&config).is_ok() { + return Ok(config); + } + } + current = base.parent(); + } + Err(()) +} + +fn own_no_check(value: &serde_json::Value) -> Result, ()> { + let Some(compiler_options) = value.get("compilerOptions") else { + return Ok(None); + }; + let compiler_options = compiler_options.as_object().ok_or(())?; + match compiler_options.get("noCheck") { + None => Ok(None), + Some(serde_json::Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(()), + } +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests.rs new file mode 100644 index 000000000..d6d9e6b70 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests.rs @@ -0,0 +1,531 @@ +use super::application::resolve_gate_projects_against_tracked; +use super::workflow::{ + ci_typechecked_projects, default_working_directory, effective_working_directory, +}; +use super::*; +use crate::codebase::ci_workflows::{ + ParsedWorkflowDocument, ParsedWorkflowSet, WorkflowDocumentError, WorkflowDocumentErrorKind, +}; +use crate::config::v2::{ + schema::{RuleDef, RuleScope}, + NoMistakesConfig, +}; +use serde_yaml::Value; +use std::collections::BTreeMap; + +mod fixture_policy; +mod no_check; +mod workflow; + +fn project_inputs(tracked: &BTreeSet) -> ProjectSourceInputs { + tracked + .iter() + .map(|project| (project.clone(), BTreeSet::from([project.clone()]))) + .collect() +} + +fn fixture_root(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/tsconfig-gate-coverage") + .join(name), + ) +} + +fn config(root: &Path) -> NoMistakesConfig { + crate::config::v2::load_v2_config(root, Some(&root.join(".no-mistakes.yml"))).unwrap() +} + +fn findings(root: &Path, config: &NoMistakesConfig) -> Vec { + check(root, config).unwrap() +} + +fn add_static_runners(workflow: &mut Value) { + workflow.as_mapping_mut().expect("workflow mapping").insert( + Value::String("on".to_string()), + Value::String("push".to_string()), + ); + let jobs = workflow + .get_mut("jobs") + .and_then(Value::as_mapping_mut) + .expect("workflow jobs mapping"); + for job in jobs.values_mut() { + job.as_mapping_mut().expect("workflow job mapping").insert( + Value::String("runs-on".to_string()), + Value::String("ubuntu-latest".to_string()), + ); + } +} + +fn check(root: &Path, config: &NoMistakesConfig) -> anyhow::Result> { + let paths = crate::codebase::ts_source::discover_files(root, &[]); + let workflows = ParsedWorkflowSet::load(root, &config.ci); + let sources = super::super::source_store_for_files(&paths); + let workspace = crate::codebase::workspaces::load_indexed_from_source_store(root, &sources)?; + let project_source_inputs = prepare_project_source_inputs(root, &paths, &sources, &workspace); + check_with_prepared( + root, + config, + PreparedInputs { + tracked_paths: &paths, + workflows: &workflows, + project_source_inputs: &project_source_inputs, + sources: &sources, + config_path: Some(&root.join(".no-mistakes.yml")), + }, + ) +} + +#[test] +fn unread_tsconfigs_defer_to_tsc_without_rereading_prepared_sources() { + let source = fixture_root("no-check"); + let fixture = crate::test_support::materialize_saved_fixture(&source); + let root = crate::codebase::ts_resolver::normalize_path(fixture.path()); + let config = config(&root); + let paths = crate::codebase::ts_source::discover_files(&root, &[]); + let workflows = ParsedWorkflowSet::load(&root, &config.ci); + let sources = super::super::source_store_for_files(&paths); + let workspace = + crate::codebase::workspaces::load_indexed_from_source_store(&root, &sources).unwrap(); + let project_source_inputs = prepare_project_source_inputs(&root, &paths, &sources, &workspace); + std::fs::remove_file(root.join("override/tsconfig.json")).unwrap(); + + let prepared = PreparedInputs { + tracked_paths: &paths, + workflows: &workflows, + project_source_inputs: &project_source_inputs, + sources: &sources, + config_path: Some(&root.join(".no-mistakes.yml")), + }; + let report = check_with_prepared(&root, &config, prepared).unwrap(); + assert!(report + .iter() + .all(|finding| finding.file != "override/tsconfig.json")); + let reads_after_first_check = sources.physical_read_count(); + + let report = check_with_prepared( + &root, + &config, + PreparedInputs { + tracked_paths: &paths, + workflows: &workflows, + project_source_inputs: &project_source_inputs, + sources: &sources, + config_path: Some(&root.join(".no-mistakes.yml")), + }, + ) + .unwrap(); + assert!(report + .iter() + .all(|finding| finding.file != "override/tsconfig.json")); + assert_eq!(sources.physical_read_count(), reads_after_first_check); +} + +#[test] +fn directory_project_arguments_resolve_only_against_tracked_tsconfigs() { + let tracked = BTreeSet::from([ + "app/tsconfig.json".to_string(), + "app.json/tsconfig.json".to_string(), + "tsconfig.json".to_string(), + ]); + let gate_projects = BTreeSet::from([ + "app".to_string(), + "app.json".to_string(), + ".".to_string(), + "missing".to_string(), + ]); + + assert_eq!( + resolve_gate_projects_against_tracked(&gate_projects, &tracked), + BTreeSet::from([ + "app/tsconfig.json".to_string(), + "app.json/tsconfig.json".to_string(), + "missing".to_string(), + "tsconfig.json".to_string(), + ]) + ); +} + +#[test] +fn reports_each_missing_gate_on_the_uncovered_project() { + let root = fixture_root("missing-ci"); + let report = findings(&root, &config(&root)); + assert_eq!(report.len(), 1, "{report:#?}"); + assert_eq!(report[0].file, "tools/tsconfig.tools.json"); + assert_eq!(report[0].line, 1); + assert!(report[0].message.contains("no CI typecheck registration")); +} + +#[test] +fn append_style_or_non_always_commands_do_not_count_as_local_gates() { + let root = fixture_root("missing-local"); + let report = findings(&root, &config(&root)); + assert_eq!(report.len(), 1, "{report:#?}"); + assert_eq!(report[0].file, "app/tsconfig.json"); + assert!(report[0] + .message + .contains("no local typecheck registration")); +} + +#[test] +fn workflow_defaults_step_directories_and_shell_cwds_are_resolved() { + let root = fixture_root("working-directories"); + let report = findings(&root, &config(&root)); + assert!(report.is_empty(), "unexpected findings: {report:#?}"); +} + +#[test] +fn non_enforcing_or_non_runnable_workflow_commands_do_not_cover_projects() { + let root = fixture_root("non-enforcing-workflow"); + let report = findings(&root, &config(&root)); + assert_eq!(report.len(), 11, "{report:#?}"); + for project in [ + "disabled-job/tsconfig.json", + "disabled-step/tsconfig.json", + "nonblocking-job/tsconfig.json", + "nonblocking-step/tsconfig.json", + "expression/tsconfig.json", + "constant-nonblocking-step/tsconfig.json", + "failure-mode-mutated/tsconfig.json", + "non-posix-shell/tsconfig.json", + "missing-runner/tsconfig.json", + "dynamic-runner/tsconfig.json", + "implicit-windows-shell/tsconfig.json", + ] { + assert!(report.iter().any(|finding| { + finding.file == project && finding.message.contains("no CI typecheck registration") + })); + } + // Unresolved expressions fail open as enforcing; the rule does not guess + // whether a dynamic condition will disable the gate at runtime. + assert!(report + .iter() + .all(|finding| finding.file != "dynamic-expression/tsconfig.json")); +} + +#[test] +fn validates_allowlist_reasons_staleness_and_normalized_collisions() { + let root = fixture_root("allowlist-errors"); + let report = findings(&root, &config(&root)); + assert!(report.iter().any(|finding| { + finding.file == ".no-mistakes.yml" && finding.message.contains("non-empty reason") + })); + assert!(report + .iter() + .any(|finding| finding.message.contains("stale allowProjects entry"))); + assert!(report + .iter() + .any(|finding| finding.message.contains("static repository-relative"))); + assert!(report + .iter() + .any(|finding| finding.message.contains("is not a tsconfig path"))); + assert!(report + .iter() + .any(|finding| finding.message.contains("normalize to the same path"))); + assert!(report.iter().any(|finding| { + finding.file == "allowed/tsconfig.json" + && finding.message.contains("no CI typecheck registration") + })); +} + +#[test] +fn reasoned_allowlist_entry_exempts_an_auxiliary_project_from_both_gates() { + let root = fixture_root("allowlist-pass"); + let report = findings(&root, &config(&root)); + assert!(report.is_empty(), "unexpected findings: {report:#?}"); +} + +#[test] +fn ignores_node_modules_and_reports_malformed_workflows_once() { + let root = fixture_root("malformed-workflow"); + let report = findings(&root, &config(&root)); + assert_eq!( + report + .iter() + .filter(|finding| finding.file == ".github/workflows/bad.yml") + .count(), + 1, + "{report:#?}" + ); + assert!(report + .iter() + .all(|finding| !finding.file.contains("node_modules"))); +} + +#[test] +fn normal_rule_filtering_keeps_the_prepared_api_scope_aware() { + let root = fixture_root("missing-ci"); + let mut config = config(&root); + config.rules = vec![RuleDef { + rule: RULE_ID.to_string(), + scope: Some(RuleScope::Repository), + include: vec!["app/**".to_string()], + ..Default::default() + }]; + let report = findings(&root, &config); + assert!(report.is_empty(), "unexpected findings: {report:#?}"); +} + +#[test] +fn invalid_rule_filter_is_returned_without_partial_coverage_findings() { + let root = fixture_root("missing-ci"); + let mut config = config(&root); + config.rules = vec![RuleDef { + rule: RULE_ID.to_string(), + scope: Some(RuleScope::Repository), + include: vec!["[".to_string()], + ..Default::default() + }]; + assert!(check(&root, &config).is_err()); +} + +#[test] +fn tsconfig_inventory_keeps_only_tracked_compiler_configs() { + let root = Path::new("/repo"); + let paths = vec![ + root.join("tsconfig.json"), + root.join("tools/tsconfig.build.json"), + root.join("node_modules/library/tsconfig.json"), + root.join("tsconfig"), + ]; + assert_eq!( + tracked_tsconfigs(root, &paths), + BTreeSet::from([ + "tools/tsconfig.build.json".to_string(), + "tsconfig.json".to_string() + ]) + ); + assert!(is_tsconfig_path("nested/tsconfig.extra.json")); + assert!(!is_tsconfig_path("tsconfig.")); + assert!(!is_tsconfig_path("nested/tsconfig.json.bak")); +} + +#[test] +fn pure_helpers_keep_config_and_workflow_boundaries_static() { + let configured: Value = + serde_yaml::from_str("defaults:\n run:\n working-directory: packages/app\n").unwrap(); + let dynamic: Value = + serde_yaml::from_str("defaults:\n run:\n working-directory: ${{ matrix.package }}\n") + .unwrap(); + assert_eq!(default_working_directory(&configured), Some("packages/app")); + assert_eq!( + effective_working_directory(&configured, Some(".".into())), + Some("packages/app".into()) + ); + assert_eq!( + effective_working_directory(&dynamic, Some(".".into())), + None + ); + assert_eq!( + effective_working_directory(&Value::Null, Some("fallback".into())), + Some("fallback".into()) + ); + assert_eq!(config_file(Path::new("/repo"), None), ".no-mistakes.yml"); + assert_eq!( + config_file( + Path::new("/repo"), + Some(Path::new("/repo/config/no-mistakes.yml")) + ), + "config/no-mistakes.yml" + ); +} + +#[test] +fn workflow_load_errors_are_rendered_for_both_failure_kinds() { + let workflows = ParsedWorkflowSet { + documents: vec![ + ParsedWorkflowDocument { + path: ".github/workflows/read.yml".into(), + value: Err(WorkflowDocumentError { + kind: WorkflowDocumentErrorKind::Read, + message: "permission denied".into(), + }), + }, + ParsedWorkflowDocument { + path: ".github/workflows/parse.yml".into(), + value: Err(WorkflowDocumentError { + kind: WorkflowDocumentErrorKind::Parse, + message: "invalid YAML".into(), + }), + }, + ], + }; + let findings = workflow_load_findings(&workflows); + assert_eq!(findings.len(), 2); + assert!(findings[0] + .message + .contains("could not parse workflow YAML")); + assert!(findings[1].message.contains("could not read workflow file")); +} + +#[test] +fn ci_scanner_skips_workflow_shapes_without_static_runnable_steps() { + let incomplete: Value = serde_yaml::from_str( + "on: push\njobs:\n no-steps:\n runs-on: ubuntu-latest\n incomplete:\n runs-on: ubuntu-latest\n steps:\n - working-directory: ${{ matrix.dir }}\n run: tsc --noEmit\n - name: no command\n", + ) + .unwrap(); + let workflows = ParsedWorkflowSet { + documents: vec![ + ParsedWorkflowDocument { + path: ".github/workflows/no-jobs.yml".into(), + value: Ok(Value::Null), + }, + ParsedWorkflowDocument { + path: ".github/workflows/incomplete.yml".into(), + value: Ok(incomplete), + }, + ], + }; + assert!(ci_typechecked_projects(&workflows, &BTreeSet::new(), &BTreeMap::new()).is_empty()); +} + +#[test] +fn ci_scanner_honors_static_posix_shell_overrides_and_defaults() { + let mut workflow: Value = serde_yaml::from_str( + "defaults:\n run:\n shell: python\njobs:\n workflow-default-python:\n steps:\n - run: tsc --noEmit --project workflow-default-python/tsconfig.json\n job-default-bash-template:\n defaults:\n run:\n shell: 'bash --noprofile --norc -eo pipefail {0}'\n steps:\n - run: tsc --noEmit --project job-default-bash-template/tsconfig.json\n step-override-sh-template:\n steps:\n - shell: 'sh -e {0}'\n run: tsc --noEmit --project step-override-sh-template/tsconfig.json\n unsupported-template:\n defaults:\n run:\n shell: bash\n steps:\n - shell: 'bash -c {0}'\n run: tsc --noEmit --project unsupported-template/tsconfig.json\n dynamic-shell:\n steps:\n - shell: ${{ matrix.shell }}\n run: tsc --noEmit --project dynamic-shell/tsconfig.json\n", + ) + .unwrap(); + add_static_runners(&mut workflow); + let workflows = ParsedWorkflowSet { + documents: vec![ParsedWorkflowDocument { + path: ".github/workflows/shells.yml".into(), + value: Ok(workflow), + }], + }; + + let expected = BTreeSet::from([ + "job-default-bash-template/tsconfig.json".to_string(), + "step-override-sh-template/tsconfig.json".to_string(), + ]); + assert_eq!( + ci_typechecked_projects(&workflows, &expected, &project_inputs(&expected)), + expected + ); +} + +#[test] +fn ci_scanner_rejects_an_empty_shell_setting() { + let mut workflow: Value = serde_yaml::from_str( + "jobs:\n empty-shell:\n steps:\n - shell: ''\n run: tsc --noEmit --project app/tsconfig.json\n", + ) + .unwrap(); + add_static_runners(&mut workflow); + let workflows = ParsedWorkflowSet { + documents: vec![ParsedWorkflowDocument { + path: ".github/workflows/empty-shell.yml".into(), + value: Ok(workflow), + }], + }; + let tracked = BTreeSet::from(["app/tsconfig.json".to_string()]); + assert!(ci_typechecked_projects(&workflows, &tracked, &project_inputs(&tracked)).is_empty()); +} + +#[test] +fn ci_scanner_accepts_only_execution_preserving_shell_template_flags() { + let mut workflow: Value = serde_yaml::from_str( + "jobs:\n bare-bash:\n steps:\n - shell: bash\n run: tsc --noEmit --project bare-bash/tsconfig.json\n bash-flags:\n steps:\n - shell: 'bash -eu -o pipefail {0}'\n run: tsc --noEmit --project bash-flags/tsconfig.json\n sh-flags:\n steps:\n - shell: 'sh -ux {0}'\n run: tsc --noEmit --project sh-flags/tsconfig.json\n syntax-check-only:\n steps:\n - shell: 'bash -n {0}'\n run: tsc --noEmit --project syntax-check-only/tsconfig.json\n version-only:\n steps:\n - shell: 'bash --version {0}'\n run: tsc --noEmit --project version-only/tsconfig.json\n shell-without-script-template:\n steps:\n - shell: 'bash -e'\n run: tsc --noEmit --project shell-without-script-template/tsconfig.json\n sh-pipefail:\n steps:\n - shell: 'sh -o pipefail {0}'\n run: tsc --noEmit --project sh-pipefail/tsconfig.json\n empty-short-flag:\n steps:\n - shell: 'bash - {0}'\n run: tsc --noEmit --project empty-short-flag/tsconfig.json\n bare-template-word:\n steps:\n - shell: 'bash pipefail {0}'\n run: tsc --noEmit --project bare-template-word/tsconfig.json\n", + ) + .unwrap(); + add_static_runners(&mut workflow); + let workflows = ParsedWorkflowSet { + documents: vec![ParsedWorkflowDocument { + path: ".github/workflows/template-flags.yml".into(), + value: Ok(workflow), + }], + }; + + let expected = BTreeSet::from([ + "bare-bash/tsconfig.json".to_string(), + "bash-flags/tsconfig.json".to_string(), + "sh-flags/tsconfig.json".to_string(), + ]); + assert_eq!( + ci_typechecked_projects(&workflows, &expected, &project_inputs(&expected)), + expected + ); +} + +#[test] +fn ci_scanner_requires_static_runners_and_shell_failure_propagation() { + let workflow: Value = serde_yaml::from_str( + "on: push\njobs:\n implicit-shell:\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project implicit-shell/tsconfig.json; echo later\n builtin-bash:\n runs-on: ubuntu-latest\n steps:\n - shell: bash\n run: tsc --noEmit --project builtin-bash/tsconfig.json; echo later\n custom-final-typecheck:\n runs-on: ubuntu-latest\n steps:\n - shell: 'bash {0}'\n run: echo first; tsc --noEmit --project custom-final-typecheck/tsconfig.json\n custom-masked-typecheck:\n runs-on: ubuntu-latest\n steps:\n - shell: 'bash {0}'\n run: tsc --noEmit --project custom-masked-typecheck/tsconfig.json; echo later\n custom-errexit:\n runs-on: ubuntu-latest\n steps:\n - shell: 'bash -e {0}'\n run: tsc --noEmit --project custom-errexit/tsconfig.json; echo later\n custom-errexit-option:\n runs-on: ubuntu-latest\n steps:\n - shell: 'sh -o errexit {0}'\n run: tsc --noEmit --project custom-errexit-option/tsconfig.json; echo later\n missing-runner:\n steps:\n - run: tsc --noEmit --project missing-runner/tsconfig.json\n dynamic-runner:\n runs-on: ${{ matrix.os }}\n steps:\n - run: tsc --noEmit --project dynamic-runner/tsconfig.json\n bare-self-hosted:\n runs-on: self-hosted\n steps:\n - run: tsc --noEmit --project bare-self-hosted/tsconfig.json\n label-array-runner:\n runs-on: [self-hosted, linux]\n steps:\n - run: tsc --noEmit --project label-array-runner/tsconfig.json\n dynamic-label-array-runner:\n runs-on: [self-hosted, '${{ matrix.os }}']\n steps:\n - run: tsc --noEmit --project dynamic-label-array-runner/tsconfig.json\n implicit-windows:\n runs-on: Windows-2025\n steps:\n - run: tsc --noEmit --project implicit-windows/tsconfig.json\n implicit-self-hosted-windows:\n runs-on: [self-hosted, windows]\n steps:\n - run: tsc --noEmit --project implicit-self-hosted-windows/tsconfig.json\n explicit-bash-windows:\n runs-on: windows-latest\n steps:\n - shell: bash\n run: tsc --noEmit --project explicit-bash-windows/tsconfig.json\n", + ) + .unwrap(); + let workflows = ParsedWorkflowSet { + documents: vec![ParsedWorkflowDocument { + path: ".github/workflows/runners-and-shells.yml".into(), + value: Ok(workflow), + }], + }; + + let expected = BTreeSet::from([ + "builtin-bash/tsconfig.json".to_string(), + "custom-errexit-option/tsconfig.json".to_string(), + "custom-errexit/tsconfig.json".to_string(), + "custom-final-typecheck/tsconfig.json".to_string(), + "explicit-bash-windows/tsconfig.json".to_string(), + "implicit-shell/tsconfig.json".to_string(), + "label-array-runner/tsconfig.json".to_string(), + ]); + assert_eq!( + ci_typechecked_projects(&workflows, &expected, &project_inputs(&expected)), + expected + ); +} + +#[test] +fn application_scan_combines_allowlist_and_missing_gate_findings() { + let tracked = BTreeSet::from(["app/tsconfig.json".to_string()]); + let options = Options { + allow_projects: BTreeMap::from([ + ("missing/tsconfig.json".to_string(), "obsolete".to_string()), + ("app/tsconfig.json".to_string(), "".to_string()), + ]), + }; + let findings = scan_application( + &options, + &tracked, + &tracked, + &BTreeSet::new(), + &BTreeSet::new(), + &BTreeSet::new(), + ".no-mistakes.yml", + ); + assert_eq!(findings.len(), 4, "{findings:#?}"); + assert!(findings + .iter() + .any(|finding| finding.target.as_deref() == Some("missing/tsconfig.json"))); + assert!(findings + .iter() + .any(|finding| finding.message.contains("no CI typecheck registration"))); + assert!(findings + .iter() + .any(|finding| finding.message.contains("no local typecheck registration"))); +} + +#[test] +fn blank_allowlist_reasons_do_not_claim_normalized_paths() { + let tracked = BTreeSet::from(["app/tsconfig.json".to_string()]); + let options = Options { + allow_projects: BTreeMap::from([ + ("./app/tsconfig.json".to_string(), "".to_string()), + ( + "app/tsconfig.json".to_string(), + "reasoned exemption".to_string(), + ), + ]), + }; + + let findings = scan_application( + &options, + &tracked, + &tracked, + &BTreeSet::new(), + &BTreeSet::new(), + &BTreeSet::new(), + ".no-mistakes.yml", + ); + + assert_eq!(findings.len(), 1, "{findings:#?}"); + assert!(findings[0].message.contains("non-empty reason")); +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/fixture_policy.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/fixture_policy.rs new file mode 100644 index 000000000..c887a7067 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/fixture_policy.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn directory_project_arguments_cover_tracked_primary_tsconfig_in_ci_and_local_checks() { + let root = fixture_root("pass"); + let report = findings(&root, &config(&root)); + assert!(report.is_empty(), "unexpected findings: {report:#?}"); +} + +#[test] +fn workflow_paths_must_cover_every_source_selected_by_the_project() { + let negative = fixture_root("path-filter-sources-negative"); + let report = findings(&negative, &config(&negative)); + assert_eq!(report.len(), 1, "{report:#?}"); + assert_eq!(report[0].file, "app/tsconfig.json"); + assert!( + report[0].message.contains("no CI typecheck registration"), + "{report:#?}" + ); + + let positive = fixture_root("path-filter-sources-positive"); + let report = findings(&positive, &config(&positive)); + assert!(report.is_empty(), "unexpected findings: {report:#?}"); +} + +#[test] +fn no_check_tsconfigs_do_not_credit_ci_or_local_gates() { + let root = fixture_root("no-check"); + let report = findings(&root, &config(&root)); + + assert_eq!(report.len(), 2, "{report:#?}"); + for project in ["direct/tsconfig.json", "inherited/tsconfig.json"] { + let finding = report + .iter() + .find(|finding| finding.file == project) + .unwrap_or_else(|| panic!("missing {project} finding: {report:#?}")); + assert!(finding.message.contains("compilerOptions.noCheck is true")); + } + assert!(report.iter().all(|finding| !matches!( + finding.file.as_str(), + "override/tsconfig.json" | "invalid/tsconfig.json" + ))); +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/no_check.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/no_check.rs new file mode 100644 index 000000000..600a6e0e1 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/no_check.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn resolution_defers_ambiguous_configs_and_resolves_local_extensionless_bases() { + let root = fixture_root("no-check-edge-cases"); + let paths = crate::codebase::ts_source::discover_files(&root, &[]); + let sources = super::super::super::source_store_for_files(&paths); + // Malformed, cyclic, missing, and package-based extends deliberately defer to tsc. + let tracked = BTreeSet::from([ + "bad-array/tsconfig.json".to_string(), + "bad-compiler-options/tsconfig.json".to_string(), + "bad-extends/tsconfig.json".to_string(), + "bad-no-check/tsconfig.json".to_string(), + "cycle/tsconfig.json".to_string(), + "directory-base/tsconfig.json".to_string(), + "dotted-file-base/tsconfig.json".to_string(), + "empty/tsconfig.json".to_string(), + "file-base/tsconfig.json".to_string(), + "missing-base/tsconfig.json".to_string(), + "missing-package-base/tsconfig.json".to_string(), + "package-base/tsconfig.json".to_string(), + ]); + + assert_eq!( + non_enforcing_tsconfigs(&root, &tracked, &sources), + BTreeSet::from([ + "dotted-file-base/tsconfig.json".to_string(), + "file-base/tsconfig.json".to_string(), + "package-base/tsconfig.json".to_string(), + ]) + ); +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/workflow.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/workflow.rs new file mode 100644 index 000000000..dffa2a1d2 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/tests/workflow.rs @@ -0,0 +1,94 @@ +use super::*; + +fn project_inputs(tracked: &BTreeSet) -> ProjectSourceInputs { + tracked + .iter() + .map(|project| (project.clone(), BTreeSet::from([project.clone()]))) + .collect() +} + +#[test] +fn ci_scanner_credits_only_workflows_with_file_triggers() { + let workflow = |path: &str, yaml: &str| ParsedWorkflowDocument { + path: path.to_string(), + value: Ok(serde_yaml::from_str(yaml).unwrap()), + }; + let job = |project: &str| { + format!( + "jobs:\n typecheck:\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project {project}/tsconfig.json\n" + ) + }; + let workflows = ParsedWorkflowSet { + documents: vec![ + workflow("missing.yml", &job("missing")), + workflow("empty.yml", "on: push"), + workflow( + "manual.yml", + &format!("on: workflow_dispatch\n{}", job("manual")), + ), + workflow( + "scheduled.yml", + &format!("on: schedule\n{}", job("scheduled")), + ), + workflow( + "pull-request.yml", + &format!("on: pull_request\n{}", job("pull-request")), + ), + workflow( + "filtered-out.yml", + &format!("on:\n push:\n paths: [docs/**]\n{}", job("app")), + ), + workflow( + "filtered-in.yml", + "on:\n push:\n paths: [filtered-app/**]\njobs:\n typecheck:\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project filtered-app\n", + ), + ], + }; + let tracked = BTreeSet::from([ + "app/tsconfig.json".to_string(), + "filtered-app/tsconfig.json".to_string(), + "pull-request/tsconfig.json".to_string(), + ]); + + assert_eq!( + ci_typechecked_projects(&workflows, &tracked, &project_inputs(&tracked)), + BTreeSet::from([ + "filtered-app/tsconfig.json".to_string(), + "pull-request/tsconfig.json".to_string(), + ]) + ); +} + +#[test] +fn ci_scanner_excludes_jobs_blocked_by_static_needs() { + let workflow = serde_yaml::from_str( + "on: push\njobs:\n setup:\n if: false\n direct-blocked:\n needs: setup\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project direct-blocked/tsconfig.json\n transitive-blocked:\n needs: direct-blocked\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project transitive-blocked/tsconfig.json\n literal-true-blocked:\n needs: setup\n if: true\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project literal-true-blocked/tsconfig.json\n always-continues:\n needs: setup\n if: '${{ always() }}'\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project always-continues/tsconfig.json\n cancelled-continues:\n needs: setup\n if: '!cancelled()'\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project cancelled-continues/tsconfig.json\n soft-failing:\n continue-on-error: true\n runs-on: ubuntu-latest\n steps:\n - run: false\n downstream:\n needs: soft-failing\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit --project downstream/tsconfig.json\n", + ) + .unwrap(); + let workflows = ParsedWorkflowSet { + documents: vec![ParsedWorkflowDocument { + path: "needs.yml".to_string(), + value: Ok(workflow), + }], + }; + let tracked = [ + "always-continues/tsconfig.json", + "cancelled-continues/tsconfig.json", + "direct-blocked/tsconfig.json", + "downstream/tsconfig.json", + "literal-true-blocked/tsconfig.json", + "transitive-blocked/tsconfig.json", + ] + .into_iter() + .map(str::to_string) + .collect(); + + assert_eq!( + ci_typechecked_projects(&workflows, &tracked, &project_inputs(&tracked)), + BTreeSet::from([ + "always-continues/tsconfig.json".to_string(), + "cancelled-continues/tsconfig.json".to_string(), + "downstream/tsconfig.json".to_string(), + ]) + ); +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow.rs new file mode 100644 index 000000000..3501abe6e --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow.rs @@ -0,0 +1,189 @@ +mod runtime; + +use super::{ + application::{project_finding, resolve_gate_project_against_tracked}, + command_scan, RuleFinding, +}; +use crate::codebase::ci_graph::{ + parse::parse_workflow_value, + triggers::{CompiledTriggers, TriggerMatch}, +}; +use crate::codebase::ci_workflows::{ParsedWorkflowSet, WorkflowDocumentErrorKind}; +use runtime::{ + effective_shell, has_static_runnable_runs_on, runs_on_can_default_to_windows, + shell_failure_enforced, +}; +use serde_yaml::Value; +use std::collections::BTreeSet; + +pub(super) fn ci_typechecked_projects( + workflows: &ParsedWorkflowSet, + tracked: &BTreeSet, + project_source_inputs: &super::ProjectSourceInputs, +) -> BTreeSet { + let mut projects = BTreeSet::new(); + for document in &workflows.documents { + let Ok(workflow) = document.value.as_ref() else { + continue; + }; + let trigger_model = parse_workflow_value(workflow, &document.path); + let triggers = CompiledTriggers::new(&trigger_model); + let workflow_cwd = effective_working_directory(workflow, Some(".".to_string())); + let workflow_shell = effective_shell(workflow, None); + let Some(jobs) = workflow.get("jobs").and_then(Value::as_mapping) else { + continue; + }; + let skipped_jobs = statically_skipped_jobs(jobs); + for (job_id, job) in jobs { + if job_id + .as_str() + .is_some_and(|job_id| skipped_jobs.contains(job_id)) + { + continue; + } + if statically_not_enforcing(job) || !has_static_runnable_runs_on(job) { + continue; + } + let Some(steps) = job.get("steps").and_then(Value::as_sequence) else { + continue; + }; + let job_cwd = effective_working_directory(job, workflow_cwd.clone()); + let job_shell = effective_shell(job, workflow_shell.clone()); + for step in steps { + if statically_not_enforcing(step) { + continue; + } + let step_cwd = match step.get("working-directory").and_then(Value::as_str) { + Some(raw) => command_scan::normalize_repo_relative(raw), + None => job_cwd.clone(), + }; + let Some(cwd) = step_cwd else { + continue; + }; + let Some(run) = step.get("run").and_then(Value::as_str) else { + continue; + }; + let shell = effective_shell(step, job_shell.clone()); + if shell.is_none() && runs_on_can_default_to_windows(job) { + continue; + } + let Some(failure_enforced) = shell_failure_enforced(shell.as_deref()) else { + continue; + }; + let scanned_projects = if failure_enforced { + command_scan::scan_shell_for_typechecked_projects(run, &cwd) + } else { + command_scan::scan_workflow_shell_for_typechecked_projects(run, &cwd, false) + }; + for project in scanned_projects { + let project = resolve_gate_project_against_tracked(&project, tracked); + if project_source_inputs.get(&project).is_some_and(|inputs| { + inputs.iter().all(|input| { + matches!( + triggers.evaluate(input).0, + TriggerMatch::Matched | TriggerMatch::Always + ) + }) + }) { + projects.insert(project); + } + } + } + } + } + projects +} + +fn statically_skipped_jobs(jobs: &serde_yaml::Mapping) -> BTreeSet { + let mut skipped = BTreeSet::new(); + loop { + let mut changed = false; + for (job_id, job) in jobs { + let Some(job_id) = job_id.as_str() else { + continue; + }; + let directly_disabled = static_bool(job.get("if")) == Some(false); + let blocked_by_need = !continues_after_skipped_need(job) + && crate::codebase::workflow_topology::value_primitives::string_list( + job.get("needs"), + ) + .iter() + .any(|need| skipped.contains(need)); + if (directly_disabled || blocked_by_need) && skipped.insert(job_id.to_string()) { + changed = true; + } + } + if !changed { + return skipped; + } + } +} + +fn continues_after_skipped_need(job: &Value) -> bool { + job.get("if") + .and_then(Value::as_str) + .is_some_and(|expression| { + matches!( + expression.trim(), + "always()" | "${{ always() }}" | "!cancelled()" | "${{ !cancelled() }}" + ) + }) +} + +/// A static disabled or non-blocking YAML node cannot enforce a typecheck. +/// Only exact boolean expressions are resolved; all other expressions remain +/// unresolved so the rule stays deterministic. +fn statically_not_enforcing(value: &Value) -> bool { + static_bool(value.get("if")) == Some(false) + || static_bool(value.get("continue-on-error")) == Some(true) +} + +fn static_bool(value: Option<&Value>) -> Option { + match value { + Some(Value::Bool(value)) => Some(*value), + Some(Value::String(expression)) => match expression.trim() { + "${{ false }}" => Some(false), + "${{ true }}" => Some(true), + _ => None, + }, + _ => None, + } +} + +pub(super) fn default_working_directory(value: &Value) -> Option<&str> { + value + .get("defaults") + .and_then(|defaults| defaults.get("run")) + .and_then(|run| run.get("working-directory")) + .and_then(Value::as_str) +} + +pub(super) fn effective_working_directory( + value: &Value, + fallback: Option, +) -> Option { + match default_working_directory(value) { + Some(raw) => command_scan::normalize_repo_relative(raw), + None => fallback, + } +} + +pub(crate) fn workflow_load_findings(workflows: &ParsedWorkflowSet) -> Vec { + let mut findings = workflows + .documents + .iter() + .filter_map(|document| document.value.as_ref().err().map(|error| (document, error))) + .map(|(document, error)| { + let detail = match error.kind { + WorkflowDocumentErrorKind::Read => "could not read workflow file", + WorkflowDocumentErrorKind::Parse => "could not parse workflow YAML", + }; + project_finding( + &document.path, + format!("{}: {detail}: {}", document.path, error.message), + ) + }) + .collect::>(); + findings.sort(); + findings +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/runtime.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/runtime.rs new file mode 100644 index 000000000..5ae07c81a --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/runtime.rs @@ -0,0 +1,156 @@ +use serde_yaml::Value; + +#[cfg(test)] +mod tests; + +/// A CI job cannot provide a typecheck gate unless Actions can schedule it on +/// a statically known runner. Reusable-workflow jobs use `uses:` rather than +/// `steps:` and are excluded separately by the step requirement. +pub(super) fn has_static_runnable_runs_on(job: &Value) -> bool { + match job.get("runs-on") { + Some(Value::String(label)) => is_static_runner_label(label), + Some(Value::Sequence(labels)) => { + !labels.is_empty() + && labels + .iter() + .all(|label| label.as_str().is_some_and(is_static_runner_label)) + } + _ => false, + } +} + +fn is_static_runner_label(label: &str) -> bool { + !label.trim().is_empty() && !label.contains("${{") +} + +/// An unspecified Actions shell is PowerShell on Windows. Only reject this +/// known incompatible default; an explicit supported `bash`/`sh` override is +/// still safe to analyze on the same runner. +pub(super) fn runs_on_can_default_to_windows(job: &Value) -> bool { + match job.get("runs-on") { + Some(Value::String(label)) => labels_can_default_to_windows([label.as_str()]), + Some(Value::Sequence(labels)) => { + labels_can_default_to_windows(labels.iter().filter_map(Value::as_str)) + } + _ => false, + } +} + +fn labels_can_default_to_windows<'a>(labels: impl IntoIterator) -> bool { + let mut self_hosted = false; + let mut known_posix = false; + for label in labels { + if is_windows_runner_label(label) { + return true; + } + self_hosted |= label.trim().eq_ignore_ascii_case("self-hosted"); + known_posix |= is_posix_runner_label(label); + } + self_hosted && !known_posix +} + +fn is_windows_runner_label(label: &str) -> bool { + let label = label.trim(); + label.eq_ignore_ascii_case("windows") + || label + .get(.."windows-".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("windows-")) +} + +fn is_posix_runner_label(label: &str) -> bool { + let label = label.trim(); + ["linux", "ubuntu", "macos"].iter().any(|os| { + label.eq_ignore_ascii_case(os) + || label + .get(..os.len() + 1) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(&format!("{os}-"))) + }) +} + +fn default_shell(value: &Value) -> Option<&str> { + value + .get("defaults") + .and_then(|defaults| defaults.get("run")) + .and_then(|run| run.get("shell")) + .and_then(Value::as_str) +} + +/// Returns the most-specific static shell setting. `None` means GitHub +/// Actions' implicit shell, which preserves the rule's existing behavior. +pub(super) fn effective_shell(value: &Value, fallback: Option) -> Option { + match value.get("shell").and_then(Value::as_str) { + Some(shell) => Some(shell.to_string()), + None => default_shell(value).map(str::to_string).or(fallback), + } +} + +/// Return whether a supported shell preserves failures for every command in a +/// multi-command body. Built-in and implicit Actions shells provide `-e`; a +/// custom template must express `-e` or `-o errexit` itself. +pub(super) fn shell_failure_enforced(shell: Option<&str>) -> Option { + let Some(shell) = shell else { + return Some(true); + }; + let mut tokens = shell.split_ascii_whitespace(); + let command = tokens.next()?; + if !matches!(command, "bash" | "sh") { + return None; + } + let args = tokens.collect::>(); + args.is_empty() + .then_some(true) + .or_else(|| execution_preserving_shell_template_failure_enforced(command, &args)) +} + +fn execution_preserving_shell_template_failure_enforced( + command: &str, + arguments: &[&str], +) -> Option { + if arguments.last() != Some(&"{0}") { + return None; + } + let options = &arguments[..arguments.len() - 1]; + let mut index = 0; + let mut failure_enforced = false; + while let Some(option) = options.get(index) { + match *option { + "--noprofile" | "--norc" if command == "bash" => index += 1, + option + if command == "bash" + && is_bash_pipefail_option(option) + && options.get(index + 1) == Some(&"pipefail") => + { + failure_enforced |= option.contains('e'); + index += 2; + } + "-o" if options.get(index + 1) == Some(&"errexit") => { + failure_enforced = true; + index += 2; + } + option if let Some(enforced) = execution_preserving_short_option(option) => { + failure_enforced |= enforced; + index += 1; + } + _ => return None, + } + } + Some(failure_enforced) +} + +fn is_bash_pipefail_option(option: &str) -> bool { + let Some(flags) = option.strip_prefix('-') else { + return false; + }; + let Some(prefix) = flags.strip_suffix('o') else { + return false; + }; + prefix.chars().all(|flag| matches!(flag, 'e' | 'u' | 'x')) +} + +/// `-e`, `-u`, and `-x` only affect error handling or diagnostics. `-o` is +/// handled separately so it can be limited to Bash's execution-safe pipefail. +fn execution_preserving_short_option(option: &str) -> Option { + let flags = option.strip_prefix('-')?; + (!flags.is_empty() && flags.chars().all(|flag| matches!(flag, 'e' | 'u' | 'x'))) + .then_some(flags.contains('e')) +} diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/runtime/tests.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/runtime/tests.rs new file mode 100644 index 000000000..118932af1 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/runtime/tests.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn missing_runner_cannot_imply_a_windows_default() { + assert!(!runs_on_can_default_to_windows(&Value::Null)); +} + +#[test] +fn bare_self_hosted_runner_keeps_the_implicit_shell_indeterminate() { + for yaml in ["runs-on: self-hosted", "runs-on: [self-hosted]"] { + let job: Value = serde_yaml::from_str(yaml).unwrap(); + assert!(runs_on_can_default_to_windows(&job), "{yaml}"); + } + for yaml in [ + "runs-on: ubuntu-latest", + "runs-on: [self-hosted, linux]", + "runs-on: [self-hosted, macOS-14]", + ] { + let job: Value = serde_yaml::from_str(yaml).unwrap(); + assert!(!runs_on_can_default_to_windows(&job), "{yaml}"); + } +} diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage.rs index b5481ec02..5c4ceb47a 100644 --- a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage.rs +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage.rs @@ -1,19 +1,18 @@ mod coverage_paths; +mod findings; mod globs; mod projects; +mod scan; mod workflow_filters; use super::RuleFinding; -use crate::config::v2::schema::NoMistakesConfig; -use anyhow::Result; -use coverage_paths::{coverage_paths, CoveragePath}; -use globs::selected_by_paths_filter; -use projects::{coverage_units_with_catalog, CoverageUnit}; -use rayon::prelude::*; +use projects::CoverageUnit; use serde::Deserialize; -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; -use workflow_filters::{ci_filters_from_snapshot_with_sources, WorkflowSelector}; +use std::collections::BTreeMap; +use workflow_filters::WorkflowSelector; + +pub use scan::check_with_files; +pub(crate) use scan::check_with_files_from_snapshot_catalog_sources_and_workflows; pub const RULE_ID: &str = "vitest-ci-path-coverage"; @@ -28,184 +27,5 @@ pub(crate) struct Options { pub(crate) explicit_projects_only: bool, } -#[doc(hidden)] -pub fn check_with_files( - root: &Path, - config: &NoMistakesConfig, - all_files: &[PathBuf], -) -> Result> { - let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::from_paths(root, all_files); - check_with_files_from_snapshot_and_catalog(root, config, all_files, &snapshot, None) -} - -pub(crate) fn check_with_files_from_snapshot_and_catalog( - root: &Path, - config: &NoMistakesConfig, - all_files: &[PathBuf], - snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, - catalog: Option<&super::PreparedVitestProjectCatalog>, -) -> Result> { - let sources = snapshot.source_store_for(root); - check_with_files_from_snapshot_catalog_and_sources( - root, config, all_files, snapshot, catalog, &sources, - ) -} - -pub(crate) fn check_with_files_from_snapshot_catalog_and_sources( - root: &Path, - config: &NoMistakesConfig, - all_files: &[PathBuf], - snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, - catalog: Option<&super::PreparedVitestProjectCatalog>, - sources: &crate::codebase::ts_source::SourceStore, -) -> Result> { - let all: Result>> = config - .rule_applications(RULE_ID) - .into_par_iter() - .map(|rule| -> Result> { - let opts: Options = rule.rule_options(); - let target_roots = super::target_roots(root, config, rule); - let skip = super::skip_dir_set(config); - let files: Vec = all_files - .iter() - .filter(|p| super::file_allowed_by_roots_and_skip(root, &skip, p, &target_roots)) - .cloned() - .collect(); - let files = super::path_filter::filter_rule_files(root, config, rule, &files)?; - scan_with_catalog_and_sources( - root, - config, - (&opts, &files, all_files), - snapshot, - catalog, - sources, - ) - }) - .collect(); - let mut findings: Vec = all?.into_iter().flatten().collect(); - super::sort_findings(&mut findings); - Ok(findings) -} - -fn scan_with_catalog_and_sources( - root: &Path, - config: &NoMistakesConfig, - inputs: (&Options, &[PathBuf], &[PathBuf]), - snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, - catalog: Option<&super::PreparedVitestProjectCatalog>, - sources: &crate::codebase::ts_source::SourceStore, -) -> Result> { - let (opts, files, all_files) = inputs; - if files.is_empty() && all_files.is_empty() { - return Ok(Vec::new()); - } - - let (filters, mut findings) = - ci_filters_from_snapshot_with_sources(root, config, &opts.workflows, snapshot, sources); - let filters_by_name = filters.iter().fold( - BTreeMap::<&str, Vec<&workflow_filters::CiFilter>>::new(), - |mut acc, filter| { - acc.entry(filter.name.as_str()).or_default().push(filter); - acc - }, - ); - let fallback_file = filters - .first() - .map(|filter| filter.workflow.as_str()) - .unwrap_or(".github/workflows"); - - for unit in coverage_units_with_catalog(root, config, opts, catalog)? { - let path_files = if unit.source.uses_all_files() { - all_files - } else { - files - }; - let paths = coverage_paths(root, &unit, path_files)?; - if paths.is_empty() { - continue; - } - let mapped_names = mapped_filter_names(opts, &unit.project); - let mapped_filters = mapped_names - .iter() - .flat_map(|name| { - filters_by_name - .get(name.as_str()) - .into_iter() - .flatten() - .copied() - }) - .collect::>(); - if mapped_filters.is_empty() { - findings.push(missing_mapping_finding(fallback_file, &unit)); - continue; - } - for path in paths { - if mapped_filters.iter().any(|filter| { - filter.workflow_allows(&path.rel) - && selected_by_paths_filter(&filter.compiled, filter.quantifier, &path.rel) - }) { - continue; - } - findings.push(missed_path_finding(&mapped_filters, &unit, path)); - } - } - findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.message.cmp(&b.message))); - Ok(findings) -} - -fn mapped_filter_names(opts: &Options, project: &str) -> Vec { - opts.project_filters - .get(project) - .filter(|names| !names.is_empty()) - .cloned() - .unwrap_or_else(|| vec![project.to_string()]) -} - -fn missing_mapping_finding(file: &str, unit: &CoverageUnit) -> RuleFinding { - RuleFinding { - rule: RULE_ID.to_string(), - file: file.to_string(), - line: 1, - message: format!( - "Vitest project `{}` {} paths are not mapped to any CI path filter; configure options.projectFilters.{}", - unit.project, unit.source.label(), unit.project - ), - import: None, - target: Some(unit.project.clone()), - } -} - -fn missed_path_finding( - filters: &[&workflow_filters::CiFilter], - unit: &CoverageUnit, - path: CoveragePath, -) -> RuleFinding { - let filter_list = filters - .iter() - .map(|filter| format!("{}:{}", filter.workflow, filter.name)) - .collect::>() - .into_iter() - .collect::>() - .join(", "); - RuleFinding { - rule: RULE_ID.to_string(), - file: filters[0].workflow.clone(), - line: 1, - message: format!( - "{}: Vitest project `{}` {}{} is not covered by CI path filters: {filter_list}", - path.rel, - unit.project, - unit.source.label(), - if path.synthetic { - " glob witness path" - } else { - " path" - } - ), - import: None, - target: Some(path.rel), - } -} - #[cfg(test)] mod tests; diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/findings.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/findings.rs new file mode 100644 index 000000000..e24d3daf7 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/findings.rs @@ -0,0 +1,37 @@ +use super::super::RuleFinding; +use super::{ + coverage_paths::CoveragePath, projects::CoverageUnit, workflow_filters::CiFilter, RULE_ID, +}; +use std::collections::BTreeSet; + +pub(super) fn missed_path( + filters: &[&CiFilter], + unit: &CoverageUnit, + path: CoveragePath, +) -> RuleFinding { + let filter_list = filters + .iter() + .map(|filter| format!("{}:{}", filter.workflow, filter.name)) + .collect::>() + .into_iter() + .collect::>() + .join(", "); + RuleFinding { + rule: RULE_ID.to_string(), + file: filters[0].workflow.clone(), + line: 1, + message: format!( + "{}: Vitest project `{}` {}{} is not covered by CI path filters: {filter_list}", + path.rel, + unit.project, + unit.source.label(), + if path.synthetic { + " glob witness path" + } else { + " path" + } + ), + import: None, + target: Some(path.rel), + } +} diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/scan.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/scan.rs new file mode 100644 index 000000000..c75647894 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/scan.rs @@ -0,0 +1,186 @@ +use super::super::RuleFinding; +use super::{ + coverage_paths::coverage_paths, + findings::missed_path, + globs::selected_by_paths_filter, + projects::{coverage_units_with_catalog, CoverageUnit}, + workflow_filters::{ + self, ci_filters_from_parsed_with_sources, ci_filters_from_snapshot_with_sources, + }, + Options, RULE_ID, +}; +use crate::config::v2::schema::NoMistakesConfig; +use anyhow::Result; +use rayon::prelude::*; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub(super) struct ScanInputs<'a> { + pub(super) root: &'a Path, + pub(super) config: &'a NoMistakesConfig, + pub(super) opts: &'a Options, + pub(super) files: &'a [PathBuf], + pub(super) all_files: &'a [PathBuf], + pub(super) snapshot: &'a crate::codebase::ts_source::VisiblePathSnapshot, + pub(super) catalog: Option<&'a super::super::PreparedVitestProjectCatalog>, + pub(super) sources: &'a crate::codebase::ts_source::SourceStore, + pub(super) workflows: Option<&'a crate::codebase::ci_workflows::ParsedWorkflowSet>, +} + +#[doc(hidden)] +pub fn check_with_files( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], +) -> Result> { + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::from_paths(root, all_files); + check_with_files_from_snapshot_and_catalog(root, config, all_files, &snapshot, None) +} +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn check_with_files_from_snapshot_and_catalog( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], + snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, + catalog: Option<&super::super::PreparedVitestProjectCatalog>, +) -> Result> { + let sources = snapshot.source_store_for(root); + check_with_files_from_snapshot_catalog_and_sources( + root, config, all_files, snapshot, catalog, &sources, + ) +} +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn check_with_files_from_snapshot_catalog_and_sources( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], + snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, + catalog: Option<&super::super::PreparedVitestProjectCatalog>, + sources: &crate::codebase::ts_source::SourceStore, +) -> Result> { + check_with_files_from_snapshot_catalog_sources_and_workflows( + root, config, all_files, snapshot, catalog, sources, None, + ) +} +pub(crate) fn check_with_files_from_snapshot_catalog_sources_and_workflows( + root: &Path, + config: &NoMistakesConfig, + all_files: &[PathBuf], + snapshot: &crate::codebase::ts_source::VisiblePathSnapshot, + catalog: Option<&super::super::PreparedVitestProjectCatalog>, + sources: &crate::codebase::ts_source::SourceStore, + workflows: Option<&crate::codebase::ci_workflows::ParsedWorkflowSet>, +) -> Result> { + let all: Result>> = config + .rule_applications(RULE_ID) + .into_par_iter() + .map(|rule| { + let opts: Options = rule.rule_options(); + let roots = super::super::target_roots(root, config, rule); + let skip = super::super::skip_dir_set(config); + let files = all_files + .iter() + .filter(|p| super::super::file_allowed_by_roots_and_skip(root, &skip, p, &roots)) + .cloned() + .collect::>(); + let files = super::super::path_filter::filter_rule_files(root, config, rule, &files)?; + scan(ScanInputs { + root, + config, + opts: &opts, + files: &files, + all_files, + snapshot, + catalog, + sources, + workflows, + }) + }) + .collect(); + let mut findings = all?.into_iter().flatten().collect(); + super::super::sort_findings(&mut findings); + Ok(findings) +} +pub(super) fn scan(inputs: ScanInputs<'_>) -> Result> { + let ScanInputs { + root, + config, + opts, + files, + all_files, + snapshot, + catalog, + sources, + workflows, + } = inputs; + if files.is_empty() && all_files.is_empty() { + return Ok(Vec::new()); + } + let (filters, mut findings) = workflows + .map(|workflows| { + ci_filters_from_parsed_with_sources(root, &opts.workflows, workflows, sources) + }) + .unwrap_or_else(|| { + ci_filters_from_snapshot_with_sources(root, config, &opts.workflows, snapshot, sources) + }); + let by_name = filters.iter().fold( + BTreeMap::<&str, Vec<&workflow_filters::CiFilter>>::new(), + |mut map, filter| { + map.entry(&filter.name).or_default().push(filter); + map + }, + ); + let fallback = filters + .first() + .map(|filter| filter.workflow.as_str()) + .unwrap_or(".github/workflows"); + for unit in coverage_units_with_catalog(root, config, opts, catalog)? { + let paths = coverage_paths( + root, + &unit, + if unit.source.uses_all_files() { + all_files + } else { + files + }, + )?; + let filters = mapped_filters(opts, &unit.project, &by_name); + if filters.is_empty() { + if !paths.is_empty() { + findings.push(missing_mapping_finding(fallback, &unit)); + } + continue; + } + for path in paths { + if !filters.iter().any(|filter| { + filter.workflow_allows(&path.rel) + && selected_by_paths_filter(&filter.compiled, filter.quantifier, &path.rel) + }) { + findings.push(missed_path(&filters, &unit, path)); + } + } + } + findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.message.cmp(&b.message))); + Ok(findings) +} +fn mapped_filters<'a>( + opts: &Options, + project: &str, + by_name: &'a BTreeMap<&str, Vec<&'a workflow_filters::CiFilter>>, +) -> Vec<&'a workflow_filters::CiFilter> { + mapped_filter_names(opts, project) + .iter() + .flat_map(|name| by_name.get(name.as_str()).into_iter().flatten().copied()) + .collect() +} + +pub(super) fn mapped_filter_names(opts: &Options, project: &str) -> Vec { + opts.project_filters + .get(project) + .filter(|names| !names.is_empty()) + .cloned() + .unwrap_or_else(|| vec![project.to_string()]) +} +pub(super) fn missing_mapping_finding(file: &str, unit: &CoverageUnit) -> RuleFinding { + RuleFinding { rule: RULE_ID.to_string(), file: file.to_string(), line: 1, message: format!("Vitest project `{}` {} paths are not mapped to any CI path filter; configure options.projectFilters.{}", unit.project, unit.source.label(), unit.project), import: None, target: Some(unit.project.clone()) } +} diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/tests.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/tests.rs index 4d473a5da..05f2b46df 100644 --- a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/tests.rs @@ -1,4 +1,11 @@ +use super::scan::{ + check_with_files_from_snapshot_and_catalog, + check_with_files_from_snapshot_catalog_sources_and_workflows, mapped_filter_names, + missing_mapping_finding, scan as scan_inputs, ScanInputs, +}; use super::*; +use crate::config::v2::schema::NoMistakesConfig; +use anyhow::Result; fn scan_with_catalog( root: &Path, @@ -8,7 +15,17 @@ fn scan_with_catalog( catalog: Option<&super::super::PreparedVitestProjectCatalog>, ) -> Result> { let sources = snapshot.source_store_for(root); - scan_with_catalog_and_sources(root, config, inputs, snapshot, catalog, &sources) + scan_inputs(ScanInputs { + root, + config, + opts: inputs.0, + files: inputs.1, + all_files: inputs.2, + snapshot, + catalog, + sources: &sources, + workflows: None, + }) } fn scan( @@ -107,6 +124,56 @@ fn prepared_vitest_catalog_matches_standalone_coverage_loading() { assert_eq!(prepared, standalone); } +#[test] +fn prepared_workflows_use_the_scan_path_without_rereading_workflow_sources() { + let root = fixture_root("fixture"); + let config = load_v2_config(&root, Some(&root.join(".no-mistakes.yml"))).unwrap(); + let all_files = files(&root); + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::new(&root); + let sources = snapshot.source_store_for(&root); + let workflows = + crate::codebase::ci_workflows::ParsedWorkflowSet::load_from_snapshot_and_sources( + &root, &config.ci, &snapshot, &sources, + ); + let reads_after_workflow_preparation = sources.physical_read_count(); + + let prepared = check_with_files_from_snapshot_catalog_sources_and_workflows( + &root, + &config, + &all_files, + &snapshot, + None, + &sources, + Some(&workflows), + ) + .unwrap(); + + assert_eq!( + sources.physical_read_count(), + reads_after_workflow_preparation, + "the scan path must consume prepared workflow documents without rereading them" + ); + assert_eq!( + prepared, + check_with_files(&root, &config, &all_files).unwrap() + ); +} + +#[test] +fn invalid_rule_path_filter_returns_its_configuration_error() { + let root = fixture_root("fixture"); + let mut config = load_v2_config(&root, Some(&root.join(".no-mistakes.yml"))).unwrap(); + config + .rules + .iter_mut() + .find(|rule| rule.rule == RULE_ID) + .expect("fixture should include vitest-ci-path-coverage rule") + .include = vec!["[".to_string()]; + + let error = check_with_files(&root, &config, &files(&root)).unwrap_err(); + assert!(error.to_string().contains("invalid glob"), "{error:#}"); +} + #[test] fn full_suite_trigger_inputs_are_checked_even_when_rule_files_are_scoped() { let root = fixture_root("fixture"); diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters.rs index bb02abe5a..345cb0614 100644 --- a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters.rs +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters.rs @@ -1,3 +1,4 @@ +mod extract; mod step; mod values; mod workflow_paths; @@ -6,12 +7,11 @@ use super::{ globs::{selected_by, PredicateQuantifier}, RuleFinding, RULE_ID, }; -use crate::codebase::ci_graph::{discover_workflow_files_from_snapshot, relative_slash}; +use crate::codebase::ci_graph::discover_workflow_files_from_snapshot; +use crate::codebase::ci_workflows::{ParsedWorkflowSet, WorkflowDocumentErrorKind}; use crate::config::v2::schema::NoMistakesConfig; use serde::Deserialize; -use serde_yaml::Value; use std::path::Path; -use step::{collect_step_filters_with_sources, StepContext}; use workflow_paths::{workflow_path_filters, WorkflowPathFilters}; #[cfg(test)] @@ -55,36 +55,40 @@ pub(super) fn ci_filters_from_snapshot_with_sources( ) } -fn ci_filters_from_paths( +pub(super) fn ci_filters_from_parsed_with_sources( root: &Path, selectors: &[WorkflowSelector], - workflow_files: Vec, + parsed: &ParsedWorkflowSet, sources: &crate::codebase::ts_source::SourceStore, ) -> (Vec, Vec) { let mut filters = Vec::new(); let mut findings = Vec::new(); - for path in workflow_files { - let rel = relative_slash(root, &path); + for document in &parsed.documents { + let rel = &document.path; if !selectors.is_empty() && !selectors .iter() - .any(|selector| selector.path.is_empty() || selector.path == rel) + .any(|selector| selector.path.is_empty() || selector.path == *rel) { continue; } - let source = match sources.read_path(&path) { - Ok(source) => source, + let value = match &document.value { + Ok(value) => value, Err(error) => { + let action = match error.kind { + WorkflowDocumentErrorKind::Read => "read workflow file", + WorkflowDocumentErrorKind::Parse => "parse workflow YAML", + }; findings.push(workflow_finding( - &rel, - format!("{rel}: could not read workflow file: {error}"), + rel, + format!("{rel}: could not {action}: {}", error.message), None, )); continue; } }; let (workflow_filters, workflow_findings) = - extract_filters_from_workflow_with_sources(root, &rel, &source, selectors, sources); + extract::from_value(root, rel, value, selectors, sources); filters.extend(workflow_filters); findings.extend(workflow_findings); } @@ -92,63 +96,40 @@ fn ci_filters_from_paths( (filters, findings) } -fn extract_filters_from_workflow_with_sources( +fn ci_filters_from_paths( root: &Path, - rel: &str, - source: &str, selectors: &[WorkflowSelector], + workflow_files: Vec, sources: &crate::codebase::ts_source::SourceStore, ) -> (Vec, Vec) { - let value: Value = match serde_yaml::from_str(source) { - Ok(value) => value, - Err(error) => { - return ( - Vec::new(), - vec![workflow_finding( - rel, - format!("{rel}: could not parse workflow YAML: {error}"), - None, - )], - ); - } - }; let mut filters = Vec::new(); let mut findings = Vec::new(); - let workflow_paths = workflow_path_filters(&value); - let Some(jobs) = value.get("jobs").and_then(Value::as_mapping) else { - return (filters, findings); - }; - for (job_key, job) in jobs { - let job_id = job_key.as_str().unwrap_or_default(); - let Some(steps) = job.get("steps").and_then(Value::as_sequence) else { + for path in workflow_files { + let rel = crate::codebase::ci_graph::relative_slash(root, &path); + if !selectors.is_empty() + && !selectors + .iter() + .any(|selector| selector.path.is_empty() || selector.path == rel) + { continue; - }; - for step in steps { - let step_id = step.get("id").and_then(Value::as_str).unwrap_or_default(); - if !selectors.is_empty() - && !selectors.iter().any(|selector| { - (selector.path.is_empty() || selector.path == rel) - && (selector.job.is_empty() || selector.job == job_id) - && (selector.step_id.is_empty() || selector.step_id == step_id) - }) - { + } + let source = match sources.read_path(&path) { + Ok(source) => source, + Err(error) => { + findings.push(workflow_finding( + &rel, + format!("{rel}: could not read workflow file: {error}"), + None, + )); continue; } - collect_step_filters_with_sources( - root, - StepContext { - rel, - job_id, - step_id, - workflow_paths: &workflow_paths, - }, - step, - sources, - &mut filters, - &mut findings, - ); - } + }; + let (workflow_filters, workflow_findings) = + extract::from_workflow(root, &rel, &source, selectors, sources); + filters.extend(workflow_filters); + findings.extend(workflow_findings); } + filters.sort_by(|a, b| (&a.workflow, &a.name).cmp(&(&b.workflow, &b.name))); (filters, findings) } diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/extract.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/extract.rs new file mode 100644 index 000000000..116fc424c --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/extract.rs @@ -0,0 +1,75 @@ +use super::{ + step::{collect_step_filters_with_sources, StepContext}, + workflow_finding, workflow_path_filters, CiFilter, RuleFinding, WorkflowSelector, +}; +use serde_yaml::Value; + +pub(super) fn from_workflow( + root: &std::path::Path, + rel: &str, + source: &str, + selectors: &[WorkflowSelector], + sources: &crate::codebase::ts_source::SourceStore, +) -> (Vec, Vec) { + let value: Value = match serde_yaml::from_str(source) { + Ok(value) => value, + Err(error) => { + return ( + Vec::new(), + vec![workflow_finding( + rel, + format!("{rel}: could not parse workflow YAML: {error}"), + None, + )], + ) + } + }; + from_value(root, rel, &value, selectors, sources) +} + +pub(super) fn from_value( + root: &std::path::Path, + rel: &str, + value: &Value, + selectors: &[WorkflowSelector], + sources: &crate::codebase::ts_source::SourceStore, +) -> (Vec, Vec) { + let mut filters = Vec::new(); + let mut findings = Vec::new(); + let workflow_paths = workflow_path_filters(value); + let Some(jobs) = value.get("jobs").and_then(Value::as_mapping) else { + return (filters, findings); + }; + for (job_key, job) in jobs { + let job_id = job_key.as_str().unwrap_or_default(); + let Some(steps) = job.get("steps").and_then(Value::as_sequence) else { + continue; + }; + for step in steps { + let step_id = step.get("id").and_then(Value::as_str).unwrap_or_default(); + if !selectors.is_empty() + && !selectors.iter().any(|selector| { + (selector.path.is_empty() || selector.path == rel) + && (selector.job.is_empty() || selector.job == job_id) + && (selector.step_id.is_empty() || selector.step_id == step_id) + }) + { + continue; + } + collect_step_filters_with_sources( + root, + StepContext { + rel, + job_id, + step_id, + workflow_paths: &workflow_paths, + }, + step, + sources, + &mut filters, + &mut findings, + ); + } + } + (filters, findings) +} diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests.rs index 287921d13..3c3a349bf 100644 --- a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests.rs @@ -1,5 +1,8 @@ +use super::extract::from_workflow as extract_filters_from_workflow_with_sources; use super::*; +mod parsed; + fn ci_filters_from_snapshot( root: &Path, config: &NoMistakesConfig, diff --git a/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests/parsed.rs b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests/parsed.rs new file mode 100644 index 000000000..00c2d39e2 --- /dev/null +++ b/crates/no-mistakes/src/codebase/rules/vitest_ci_path_coverage/workflow_filters/tests/parsed.rs @@ -0,0 +1,56 @@ +use super::super::{ci_filters_from_parsed_with_sources, CiFilter, WorkflowSelector}; +use crate::codebase::ci_workflows::ParsedWorkflowSet; +use crate::codebase::rules::RuleFinding; +use std::path::{Path, PathBuf}; + +fn fixture_root() -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/rules/vitest-ci-path-coverage/parsed-workflow-errors"), + ) +} + +fn parsed_filters( + root: &Path, + paths: &[PathBuf], + selectors: &[WorkflowSelector], +) -> (Vec, Vec) { + let snapshot = crate::codebase::ts_source::VisiblePathSnapshot::from_paths(root, paths); + let sources = snapshot.source_store_for(root); + let parsed = ParsedWorkflowSet::from_paths(root, paths.iter().cloned()); + ci_filters_from_parsed_with_sources(root, selectors, &parsed, &sources) +} + +#[test] +fn errors_keep_read_and_parse_context() { + let root = fixture_root(); + let paths = [ + root.join(".github/workflows/bad.yml"), + root.join(".github/workflows/missing.yml"), + ]; + let (filters, findings) = parsed_filters(&root, &paths, &[]); + assert!(filters.is_empty()); + assert_eq!(findings.len(), 2, "{findings:#?}"); + assert!(findings + .iter() + .any(|finding| finding.message.contains("could not parse workflow YAML"))); + assert!(findings + .iter() + .any(|finding| finding.message.contains("could not read workflow file"))); +} + +#[test] +fn selectors_skip_nonmatching_documents_before_loading_errors() { + let root = fixture_root(); + let paths = [root.join(".github/workflows/bad.yml")]; + let (filters, findings) = parsed_filters( + &root, + &paths, + &[WorkflowSelector { + path: ".github/workflows/selected.yml".to_string(), + ..Default::default() + }], + ); + assert!(filters.is_empty()); + assert!(findings.is_empty()); +} diff --git a/crates/no-mistakes/src/codebase/ts_resolver/catalog.rs b/crates/no-mistakes/src/codebase/ts_resolver/catalog.rs index 8529e9bb5..a3725d3b1 100644 --- a/crates/no-mistakes/src/codebase/ts_resolver/catalog.rs +++ b/crates/no-mistakes/src/codebase/ts_resolver/catalog.rs @@ -5,6 +5,7 @@ use std::sync::Mutex; include!("catalog_types.rs"); include!("catalog_api.rs"); +include!("catalog_membership.rs"); include!("catalog_selection.rs"); include!("catalog_paths.rs"); include!("catalog_builder.rs"); diff --git a/crates/no-mistakes/src/codebase/ts_resolver/catalog_membership.rs b/crates/no-mistakes/src/codebase/ts_resolver/catalog_membership.rs new file mode 100644 index 000000000..94242aff8 --- /dev/null +++ b/crates/no-mistakes/src/codebase/ts_resolver/catalog_membership.rs @@ -0,0 +1,36 @@ +impl TsConfigCatalog { + /// Project each explicitly requested tsconfig onto the visible source files + /// selected by its `files`/`include`/`exclude` matcher. + pub(crate) fn project_source_membership( + root: &Path, + config_paths: &[PathBuf], + visible_paths: &[PathBuf], + sources: &crate::codebase::ts_source::SourceStore, + workspace: &crate::codebase::workspaces::IndexedWorkspaceMap, + ) -> BTreeMap> { + let requested = config_paths + .iter() + .map(|path| normalize_path(path)) + .collect::>(); + let catalog = Self::from_visible_and_sources_with_workspace( + root, + config_paths, + visible_paths, + sources, + workspace, + ); + catalog + .configs + .iter() + .filter(|config| requested.contains(&config.path)) + .map(|config| { + let members = visible_paths + .iter() + .map(|path| normalize_path(path)) + .filter(|path| config.matcher.owns_source(path)) + .collect(); + (config.path.clone(), members) + }) + .collect() + } +} diff --git a/crates/no-mistakes/src/config/v2/discover.rs b/crates/no-mistakes/src/config/v2/discover.rs index 0916f9672..f0bad00bb 100644 --- a/crates/no-mistakes/src/config/v2/discover.rs +++ b/crates/no-mistakes/src/config/v2/discover.rs @@ -21,6 +21,7 @@ pub(crate) fn automatic_v2_config_paths(root: &Path) -> Vec { .collect() } +mod check_commands; mod targeted_triggers; /// Load the unified `.no-mistakes.yml` (or a recognized legacy config) from @@ -31,15 +32,22 @@ mod targeted_triggers; /// 2. `.no-mistakes.{yaml,yml,json,jsonc}` in `root`. /// 3. Empty default. pub fn load_v2_config(root: &Path, cli_config: Option<&Path>) -> Result { + load_v2_config_with_path(root, cli_config).map(|(config, _)| config) +} + +pub(crate) fn load_v2_config_with_path( + root: &Path, + cli_config: Option<&Path>, +) -> Result<(NoMistakesConfig, Option)> { if cli_config.is_some() { - return load_v2_config_from_visible(root, cli_config, &[]); + return load_v2_config_with_path_from_visible(root, cli_config, &[]); } if let Some((path, source)) = find_by_stems(root, V2_STEMS)? { - return parse_v2_config(&source, &path); + return Ok((parse_v2_config(&source, &path)?, Some(path))); } - Ok(NoMistakesConfig::default()) + Ok((NoMistakesConfig::default(), None)) } /// Load config while reusing a request's canonical visible-path candidates. @@ -51,21 +59,29 @@ pub fn load_v2_config_from_visible( cli_config: Option<&Path>, visible_paths: &[PathBuf], ) -> Result { + load_v2_config_with_path_from_visible(root, cli_config, visible_paths).map(|(config, _)| config) +} + +pub(crate) fn load_v2_config_with_path_from_visible( + root: &Path, + cli_config: Option<&Path>, + visible_paths: &[PathBuf], +) -> Result<(NoMistakesConfig, Option)> { if let Some(path) = cli_config { let resolved = resolve(root, path); if !resolved.exists() { anyhow::bail!("config file does not exist: {}", resolved.display()); } let source = std::fs::read_to_string(&resolved)?; - return parse_v2_config(&source, &resolved); + return Ok((parse_v2_config(&source, &resolved)?, Some(resolved))); } if let Some(path) = find_automatic_config_path_from_visible(root, V2_STEMS, visible_paths)? { let source = std::fs::read_to_string(&path)?; - return parse_v2_config(&source, &path); + return Ok((parse_v2_config(&source, &path)?, Some(path))); } - Ok(NoMistakesConfig::default()) + Ok((NoMistakesConfig::default(), None)) } #[doc(hidden)] @@ -151,6 +167,7 @@ fn validate_v2_config(config: &NoMistakesConfig, path: &Path) -> Result<()> { validate_globs(&rule.include, &format!("rules[{index}].include"))?; validate_globs(&rule.exclude, &format!("rules[{index}].exclude"))?; } + check_commands::validate(config)?; targeted_triggers::validate(config, path)?; validate_playwright_selector_wrappers(&config.tests.playwright.selectors.wrappers)?; Ok(()) diff --git a/crates/no-mistakes/src/config/v2/discover/check_commands.rs b/crates/no-mistakes/src/config/v2/discover/check_commands.rs new file mode 100644 index 000000000..fdf056517 --- /dev/null +++ b/crates/no-mistakes/src/config/v2/discover/check_commands.rs @@ -0,0 +1,26 @@ +use super::*; + +pub(super) fn validate(config: &NoMistakesConfig) -> Result<()> { + for (index, command) in config.checks.commands.iter().enumerate() { + if command + .command + .first() + .is_none_or(|executable| executable.trim().is_empty()) + { + anyhow::bail!( + "checks.commands[{index}].command must start with a non-blank executable token" + ); + } + if command.always && command.file_args != super::super::schema::CheckFileArgs::None { + anyhow::bail!( + "checks.commands[{index}].always runs once for the whole project, so file selection is invalid; set fileArgs: none" + ); + } + if command.always && (!command.include.is_empty() || !command.exclude.is_empty()) { + anyhow::bail!( + "checks.commands[{index}].always runs once for the whole project, so file selection is invalid; remove include and exclude" + ); + } + } + Ok(()) +} diff --git a/crates/no-mistakes/src/config/v2/mod.rs b/crates/no-mistakes/src/config/v2/mod.rs index cfafaf03f..7377895d4 100644 --- a/crates/no-mistakes/src/config/v2/mod.rs +++ b/crates/no-mistakes/src/config/v2/mod.rs @@ -5,7 +5,8 @@ pub mod view; pub(crate) use discover::{ effective_v2_config_path_from_visible, load_v2_config_from_selected_source_store, - load_v2_config_from_source_store, + load_v2_config_from_source_store, load_v2_config_with_path, + load_v2_config_with_path_from_visible, }; pub use discover::{find_config_root, load_v2_config, load_v2_config_from_visible}; pub use schema::NoMistakesConfig; diff --git a/crates/no-mistakes/src/config/v2/schema/ci_checks.rs b/crates/no-mistakes/src/config/v2/schema/ci_checks.rs index bb5734fd0..55a9bdbbd 100644 --- a/crates/no-mistakes/src/config/v2/schema/ci_checks.rs +++ b/crates/no-mistakes/src/config/v2/schema/ci_checks.rs @@ -43,6 +43,8 @@ pub struct CheckCommandDef { pub command: Vec, /// How matched file paths are added to the command invocation. pub file_args: CheckFileArgs, + /// Run this whole-project command even when no changed files match it. + pub always: bool, } impl Default for CheckCommandDef { @@ -53,6 +55,7 @@ impl Default for CheckCommandDef { exclude: Vec::new(), command: Vec::new(), file_args: CheckFileArgs::Append, + always: false, } } } diff --git a/crates/no-mistakes/src/config/v2/tests.rs b/crates/no-mistakes/src/config/v2/tests.rs index 032614954..022791168 100644 --- a/crates/no-mistakes/src/config/v2/tests.rs +++ b/crates/no-mistakes/src/config/v2/tests.rs @@ -246,6 +246,7 @@ checks: assert_eq!(cfg.checks.commands.len(), 2); // Missing fileArgs defaults to Append (exercises CheckCommandDef::default). assert_eq!(cfg.checks.commands[0].file_args, CheckFileArgs::Append); + assert!(!cfg.checks.commands[0].always); assert_eq!(cfg.checks.commands[1].file_args, CheckFileArgs::None); // Serialize → deserialize is stable. @@ -254,6 +255,54 @@ checks: assert_eq!(cfg, reparsed); } +#[test] +fn always_checks_require_whole_project_commands_without_globs() { + let missing_command = super::discover::parse_v2_config_quiet( + "checks:\n commands:\n - name: invalid\n always: true\n fileArgs: none\n", + Path::new("invalid.yml"), + ) + .unwrap_err(); + assert!(missing_command + .to_string() + .contains("checks.commands[0].command must start with a non-blank executable token")); + + let blank_executable = super::discover::parse_v2_config_quiet( + "checks:\n commands:\n - name: invalid\n command: [' ']\n", + Path::new("invalid.yml"), + ) + .unwrap_err(); + assert!(blank_executable + .to_string() + .contains("checks.commands[0].command must start with a non-blank executable token")); + + let invalid_file_args = super::discover::parse_v2_config_quiet( + "checks:\n commands:\n - name: invalid\n always: true\n command: [echo, always]\n", + Path::new("invalid.yml"), + ) + .unwrap_err(); + assert!(invalid_file_args + .to_string() + .contains("checks.commands[0].always runs once for the whole project, so file selection is invalid; set fileArgs: none")); + + let invalid_globs = super::discover::parse_v2_config_quiet( + "checks:\n commands:\n - name: invalid\n always: true\n include: [\"src/**/*.ts\"]\n command: [echo, always]\n fileArgs: none\n", + Path::new("invalid.yml"), + ) + .unwrap_err(); + assert!(invalid_globs + .to_string() + .contains("checks.commands[0].always runs once for the whole project, so file selection is invalid; remove include and exclude")); + + let invalid_exclude = super::discover::parse_v2_config_quiet( + "checks:\n commands:\n - name: invalid\n always: true\n exclude: [\"generated/**\"]\n command: [echo, always]\n fileArgs: none\n", + Path::new("invalid.yml"), + ) + .unwrap_err(); + assert!(invalid_exclude + .to_string() + .contains("checks.commands[0].always runs once for the whole project, so file selection is invalid; remove include and exclude")); +} + #[test] fn project_and_rule_path_filters_parse() { let cfg = load_v2_config(&fixture("rule-path-filters"), None).unwrap(); diff --git a/crates/no-mistakes/src/impacted_checks.rs b/crates/no-mistakes/src/impacted_checks.rs index 96af8aadc..2d2a2cbf0 100644 --- a/crates/no-mistakes/src/impacted_checks.rs +++ b/crates/no-mistakes/src/impacted_checks.rs @@ -61,6 +61,9 @@ pub struct ImpactedChecksArgs { /// Shorthand for --format json. #[arg(long, default_value_t = false, conflicts_with = "format")] pub(crate) json: bool, + /// Return configured generic commands only; skip test-framework discovery and selection. + #[arg(long, default_value_t = false)] + pub(crate) generic_only: bool, /// Legacy programmatic timing switch. CLI timing flags are root-global. #[arg(skip)] pub(crate) timings: bool, diff --git a/crates/no-mistakes/src/impacted_checks/generate/generic.rs b/crates/no-mistakes/src/impacted_checks/generate/generic.rs index 6ebd8db2b..ba569020c 100644 --- a/crates/no-mistakes/src/impacted_checks/generate/generic.rs +++ b/crates/no-mistakes/src/impacted_checks/generate/generic.rs @@ -14,15 +14,19 @@ pub(in crate::impacted_checks) fn generic_checks( for def in &config.checks.commands { let include = build_globset(&def.include)?; let exclude = build_globset(&def.exclude)?; - let matched: Vec = changed_files - .iter() - .filter(|file| { - include.as_ref().is_some_and(|set| set.is_match(file)) - && exclude.as_ref().is_none_or(|set| !set.is_match(file)) - }) - .cloned() - .collect(); - if matched.is_empty() { + let matched: Vec = if def.always { + changed_files.to_vec() + } else { + changed_files + .iter() + .filter(|file| { + include.as_ref().is_some_and(|set| set.is_match(file)) + && exclude.as_ref().is_none_or(|set| !set.is_match(file)) + }) + .cloned() + .collect() + }; + if matched.is_empty() && !def.always { continue; } let mut command = def.command.clone(); diff --git a/crates/no-mistakes/src/impacted_checks/generate/prepare.rs b/crates/no-mistakes/src/impacted_checks/generate/prepare.rs index 172182a85..29ae57e4f 100644 --- a/crates/no-mistakes/src/impacted_checks/generate/prepare.rs +++ b/crates/no-mistakes/src/impacted_checks/generate/prepare.rs @@ -51,8 +51,11 @@ impl PreparedImpactedChecks { pub(super) fn prepare_impacted_checks(args: &ImpactedChecksArgs) -> Result { let plan_args = super::args::plan_args_for(args, None); let inputs = PreparedTestPlanInputs::prepare(&plan_args)?; - let frameworks = - configured_frameworks(&inputs.root, &inputs.config, inputs.root_visible_paths()); + let frameworks = if args.generic_only { + Default::default() + } else { + configured_frameworks(&inputs.root, &inputs.config, inputs.root_visible_paths()) + }; let changed_files = sorted_unique( inputs .collected @@ -69,7 +72,7 @@ pub(super) fn prepare_impacted_checks(args: &ImpactedChecksArgs) -> Result PathBuf { ) } +fn generic_only_config() -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/impacted-checks/generic-only.no-mistakes.yml"), + ) +} + fn args(files: &[&str]) -> ImpactedChecksArgs { ImpactedChecksArgs { files: files.iter().map(PathBuf::from).collect(), @@ -46,6 +54,7 @@ fn args(files: &[&str]) -> ImpactedChecksArgs { diff_content: None, format: None, json: false, + generic_only: false, timings: false, } } @@ -68,6 +77,7 @@ fn fanout_args(root: &Path) -> ImpactedChecksArgs { diff_content: None, format: None, json: false, + generic_only: false, timings: false, } } @@ -124,20 +134,6 @@ fn all_environments_skip_the_shared_graph() { assert_eq!(stats.graph_builds, 0); } -#[test] -fn generic_only_repository_skips_test_file_discovery_and_graph() { - let mut a = args(&["src/value.ts"]); - a.root = generic_only_fixture(); - // Test-only inputs remain irrelevant when no test framework is present. - a.tsconfig = Some(a.root.join("missing-tsconfig.json")); - - let (report, stats) = generate_impacted_checks_with_stats(&a).unwrap(); - - assert_eq!(command_strings(&report), vec!["eslint src/value.ts"]); - assert_eq!(stats.framework_discoveries, 0); - assert_eq!(stats.graph_builds, 0); -} - #[test] fn source_change_yields_test_lint_and_typecheck() { let report = generate_impacted_checks(&args(&["src/foo.ts"])).unwrap(); @@ -228,73 +224,6 @@ fn dedupe_checks_merges_files_for_same_command() { assert_eq!(out[0].files, vec!["a.ts".to_string(), "b.ts".to_string()]); } -#[test] -fn generic_checks_excludes_deleted_from_append() { - use crate::config::v2::schema::{CheckCommandDef, CheckFileArgs}; - let mut config = NoMistakesConfig::default(); - config.checks.commands = vec![ - CheckCommandDef { - name: "eslint".to_string(), - include: vec!["**/*.ts".to_string()], - command: vec!["eslint".to_string()], - file_args: CheckFileArgs::Append, - ..Default::default() - }, - CheckCommandDef { - name: "only-deleted".to_string(), - include: vec!["gone/**".to_string()], - command: vec!["lint".to_string()], - file_args: CheckFileArgs::Append, - ..Default::default() - }, - CheckCommandDef { - name: "tsc".to_string(), - include: vec!["**/*.ts".to_string()], - command: vec!["tsc".to_string()], - file_args: CheckFileArgs::None, - ..Default::default() - }, - ]; - let changed = vec![ - "a.ts".to_string(), - "b.ts".to_string(), - "gone/x.ts".to_string(), - ]; - let deleted: BTreeSet = ["a.ts".to_string(), "gone/x.ts".to_string()] - .into_iter() - .collect(); - let checks = generic_checks(&config, &changed, &deleted).unwrap(); - // Append: deleted files are dropped from the per-file args. - let eslint = checks.iter().find(|c| c.name == "eslint").unwrap(); - assert_eq!( - eslint.command, - vec!["eslint".to_string(), "b.ts".to_string()] - ); - // Append where every match is deleted: skipped entirely. - assert!(!checks.iter().any(|c| c.name == "only-deleted")); - // Whole-project check still triggers despite the deletion. - assert!(checks.iter().any(|c| c.name == "tsc")); -} - -#[test] -fn generic_checks_normalizes_dot_slash_globs() { - use crate::config::v2::schema::{CheckCommandDef, CheckFileArgs}; - let mut config = NoMistakesConfig::default(); - config.checks.commands = vec![CheckCommandDef { - name: "eslint".to_string(), - include: vec!["./src/**/*.ts".to_string()], - command: vec!["eslint".to_string()], - file_args: CheckFileArgs::Append, - ..Default::default() - }]; - let checks = generic_checks(&config, &["src/foo.ts".to_string()], &BTreeSet::new()).unwrap(); - assert_eq!(checks.len(), 1); - assert_eq!( - checks[0].command, - vec!["eslint".to_string(), "src/foo.ts".to_string()] - ); -} - #[test] fn framework_present_detects_config_file() { let autodetect = crate::codebase::ts_resolver::normalize_path( diff --git a/crates/no-mistakes/src/impacted_checks/tests/generic_checks.rs b/crates/no-mistakes/src/impacted_checks/tests/generic_checks.rs new file mode 100644 index 000000000..22c839d27 --- /dev/null +++ b/crates/no-mistakes/src/impacted_checks/tests/generic_checks.rs @@ -0,0 +1,113 @@ +use super::*; +use crate::config::v2::schema::{CheckCommandDef, CheckFileArgs}; + +#[test] +fn generic_only_repository_skips_test_file_discovery_and_graph() { + let mut args = args(&["src/value.ts"]); + args.root = generic_only_fixture(); + args.tsconfig = Some(args.root.join("missing-tsconfig.json")); + let (report, stats) = generate_impacted_checks_with_stats(&args).unwrap(); + assert_eq!(command_strings(&report), ["eslint src/value.ts"]); + assert_eq!((stats.framework_discoveries, stats.graph_builds), (0, 0)); +} +#[test] +fn explicit_generic_only_skips_configured_frameworks_and_keeps_changed_files() { + let mut args = args(&["src/value.ts"]); + args.root = multi_framework_fixture(); + args.config = Some(generic_only_config()); + args.generic_only = true; + let mut timing = timing::TimingTracker::new(false, true); + let (report, stats) = + super::super::generate_impacted_checks_with_timing(&args, &mut timing).unwrap(); + timing.finish_total(); + assert_eq!( + command_strings(&report), + ["echo always", "pnpm exec eslint"] + ); + assert!(report + .checks + .iter() + .all(|check| check.kind == CheckKind::Generic)); + assert_eq!(report.changed_files, ["src/value.ts"]); + assert!(report.warnings.is_empty() && !report.fallback_triggered); + assert_eq!((stats.framework_discoveries, stats.graph_builds), (0, 0)); + assert_eq!( + timing + .into_timings() + .unwrap() + .into_iter() + .map(|item| item.phase) + .collect::>(), + ["prepare", "generic-checks", "total"] + ); +} +#[test] +fn generic_checks_excludes_deleted_from_append() { + let mut config = NoMistakesConfig::default(); + config.checks.commands = vec![ + CheckCommandDef { + name: "eslint".into(), + include: vec!["**/*.ts".into()], + command: vec!["eslint".into()], + file_args: CheckFileArgs::Append, + ..Default::default() + }, + CheckCommandDef { + name: "only-deleted".into(), + include: vec!["gone/**".into()], + command: vec!["lint".into()], + file_args: CheckFileArgs::Append, + ..Default::default() + }, + CheckCommandDef { + name: "tsc".into(), + include: vec!["**/*.ts".into()], + command: vec!["tsc".into()], + file_args: CheckFileArgs::None, + ..Default::default() + }, + ]; + let changed = vec!["a.ts".into(), "b.ts".into(), "gone/x.ts".into()]; + let deleted = ["a.ts".into(), "gone/x.ts".into()].into_iter().collect(); + let checks = generic_checks(&config, &changed, &deleted).unwrap(); + assert_eq!( + checks + .iter() + .find(|check| check.name == "eslint") + .unwrap() + .command, + ["eslint", "b.ts"] + ); + assert!(!checks.iter().any(|check| check.name == "only-deleted")); + assert!(checks.iter().any(|check| check.name == "tsc")); +} +#[test] +fn generic_checks_normalizes_dot_slash_globs() { + let mut config = NoMistakesConfig::default(); + config.checks.commands = vec![CheckCommandDef { + name: "eslint".into(), + include: vec!["./src/**/*.ts".into()], + command: vec!["eslint".into()], + file_args: CheckFileArgs::Append, + ..Default::default() + }]; + let checks = generic_checks(&config, &["src/foo.ts".into()], &BTreeSet::new()).unwrap(); + assert_eq!(checks[0].command, ["eslint", "src/foo.ts"]); +} +#[test] +fn generic_checks_always_emits_for_empty_changes_and_keeps_normalized_files() { + let mut config = NoMistakesConfig::default(); + config.checks.commands = vec![CheckCommandDef { + name: "always".into(), + command: vec!["echo".into(), "always".into()], + file_args: CheckFileArgs::None, + always: true, + ..Default::default() + }]; + let changed = vec!["src/a.ts".into(), "src/b.ts".into()]; + let checks = generic_checks(&config, &changed, &BTreeSet::new()).unwrap(); + assert_eq!(checks[0].files, changed); + let empty = generic_checks(&config, &[], &BTreeSet::new()).unwrap(); + assert_eq!(empty.len(), 1); + assert!(empty[0].files.is_empty()); +} diff --git a/crates/no-mistakes/src/impacted_checks/tests/runner_isolation.rs b/crates/no-mistakes/src/impacted_checks/tests/runner_isolation.rs index 5f2dfe4d2..4420e8645 100644 --- a/crates/no-mistakes/src/impacted_checks/tests/runner_isolation.rs +++ b/crates/no-mistakes/src/impacted_checks/tests/runner_isolation.rs @@ -25,6 +25,7 @@ fn impacted_checks_reuse_one_parse_pass_without_cross_runner_tests() { diff_content: None, format: None, json: false, + generic_only: false, timings: false, }; 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 43286fe45..044e2bdb1 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 @@ -91,7 +91,7 @@ impl SharedCheckContext { ), )); } - let prepared_graph = forbidden_graph_plan + let mut prepared_graph = forbidden_graph_plan .map(|graph_plan| { crate::codebase::dependencies::graph::prepare_graph_config( &root, @@ -102,6 +102,9 @@ impl SharedCheckContext { ) }) .transpose()?; + if let Some(graph) = prepared_graph.as_mut() { + graph.set_workflow_documents(prepared.workflow_documents.clone()); + } if let Some(graph_playwright) = prepared_graph .as_ref() .map(|graph| { 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 8d3aa6b47..329c23131 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 @@ -87,6 +87,11 @@ impl SharedCheckContext { config, codebase_config: &self.prepared.codebase_config, vitest_projects: self.prepared.vitest_projects.as_ref(), + workflow_documents: self.prepared.workflow_documents.as_deref(), + tsconfig_gate_project_inputs: self + .prepared + .tsconfig_gate_project_inputs + .as_ref(), }); let completed = crate::check_runner::complete_domain_checks(( react, diff --git a/crates/no-mistakes/src/napi_api/cli_parity_builders.rs b/crates/no-mistakes/src/napi_api/cli_parity_builders.rs index 64ed39e39..89bae6ba6 100644 --- a/crates/no-mistakes/src/napi_api/cli_parity_builders.rs +++ b/crates/no-mistakes/src/napi_api/cli_parity_builders.rs @@ -55,10 +55,7 @@ pub(crate) fn build_why_args( ) -> AnyhowResult { let test = options.test.context("test is required")?; Ok(crate::tests::WhyArgs { - root: options - .root - .map(PathBuf::from) - .unwrap_or_else(|| ".".into()), + root: options.root.map(PathBuf::from).unwrap_or_else(|| ".".into()), config: options.config.map(PathBuf::from), tsconfig: options.tsconfig.map(PathBuf::from), test: PathBuf::from(test), @@ -100,7 +97,10 @@ pub(crate) fn build_impacted_checks_args( ) -> crate::impacted_checks::ImpactedChecksArgs { crate::impacted_checks::ImpactedChecksArgs { files: Vec::new(), - root: options.root.map(PathBuf::from).unwrap_or_else(|| ".".into()), + root: options + .root + .map(PathBuf::from) + .unwrap_or_else(|| ".".into()), config: options.config.map(PathBuf::from), tsconfig: options.tsconfig.map(PathBuf::from), base: options.base, @@ -112,6 +112,7 @@ pub(crate) fn build_impacted_checks_args( diff_content: options.diff, format: None, json: false, + generic_only: options.generic_only, // N-API timings are collected into the structured response by the // binding; they must never print CLI progress to the Node process. timings: false, diff --git a/crates/no-mistakes/src/napi_api/options_ci.rs b/crates/no-mistakes/src/napi_api/options_ci.rs index a63ad0cf3..59fd2fd72 100644 --- a/crates/no-mistakes/src/napi_api/options_ci.rs +++ b/crates/no-mistakes/src/napi_api/options_ci.rs @@ -39,5 +39,6 @@ pub(crate) struct ImpactedChecksOptions { pub(crate) changed_files: Vec, pub(crate) changed_files_file: Option, pub(crate) diff: Option, + pub(crate) generic_only: bool, pub(crate) timings: bool, } diff --git a/crates/no-mistakes/src/napi_api/tests/ci.rs b/crates/no-mistakes/src/napi_api/tests/ci.rs index c138ef8b8..413f2f3de 100644 --- a/crates/no-mistakes/src/napi_api/tests/ci.rs +++ b/crates/no-mistakes/src/napi_api/tests/ci.rs @@ -86,6 +86,40 @@ fn impacted_checks_json_returns_checks() { .any(|check| check["name"] == "vitest")); } +#[test] +fn impacted_checks_json_generic_only_skips_test_frameworks() { + let root = impacted_checks_multi_framework_root(); + let config = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/impacted-checks/generic-only.no-mistakes.yml"); + let options = json!({ + "root": root, + "config": config, + "changedFiles": ["src/value.ts"], + "genericOnly": true, + "timings": true, + }) + .to_string(); + let value: serde_json::Value = + serde_json::from_str(&impacted_checks_json_impl(options).unwrap()).unwrap(); + + assert!(value["checks"] + .as_array() + .unwrap() + .iter() + .all(|check| check["kind"] == "generic")); + assert_eq!(value["warnings"], json!([])); + assert_eq!(value["fallback_triggered"], false); + assert_eq!( + value["timings"] + .as_array() + .unwrap() + .iter() + .map(|timing| timing["phase"].as_str().unwrap()) + .collect::>(), + vec!["prepare", "generic-checks", "total"], + ); +} + #[test] fn impacted_checks_json_timings_are_opt_in_and_ordered() { let root = impacted_checks_multi_framework_root(); diff --git a/crates/no-mistakes/tests/cli_ci.rs b/crates/no-mistakes/tests/cli_ci.rs index 17801ecbb..6212525f6 100644 --- a/crates/no-mistakes/tests/cli_ci.rs +++ b/crates/no-mistakes/tests/cli_ci.rs @@ -76,6 +76,37 @@ fn impacted_checks_lists_commands() { assert!(stdout(&output).contains("vitest --project unit")); } +#[test] +fn impacted_checks_generic_only_skips_test_commands() { + let root = case("impacted-checks/multi-framework"); + let config = case("../fixtures/impacted-checks/generic-only.no-mistakes.yml"); + let output = run(&[ + "impacted-checks", + "src/value.ts", + "--root", + root.to_str().unwrap(), + "--config", + config.to_str().unwrap(), + "--generic-only", + "--format", + "json", + ]); + + assert!(output.status.success(), "{}", stderr(&output)); + let report: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(report["warnings"], serde_json::json!([])); + assert_eq!(report["fallback_triggered"], false); + assert_eq!( + report["checks"] + .as_array() + .unwrap() + .iter() + .map(|check| check["kind"].as_str().unwrap()) + .collect::>(), + vec!["generic", "generic"] + ); +} + #[test] fn impacted_checks_multi_file_json_covers_every_configured_framework() { let root = case("impacted-checks/multi-framework"); diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index c1c901774..c93d2f985 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -121,6 +121,7 @@ fn no_mistakes_rules_have_docs() { rules::TEST_EMAIL_DOMAIN_POLICY, rules::TEST_NO_UNMOCKED_DYNAMIC_IMPORTS, rules::TSCONFIG_ALIAS_FOLDER_MAPPING, + rules::TSCONFIG_GATE_COVERAGE, unique_exports::RULE_ID, rules::VITEST_TEST_CORRESPONDENCE, ]; diff --git a/cspell.config.yaml b/cspell.config.yaml index 2f72a3c5c..a9947ba84 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -24,6 +24,7 @@ words: - codecov - emnapi - eprintln + - errexit - extglob - gitignore - glibc @@ -38,9 +39,12 @@ words: - markdown - napi - nextjs + - noprofile + - norc - Ong - oxlint - PCRE + - pipefail - pnpm - refspec - refspecs diff --git a/docs/cli/impacted-checks.md b/docs/cli/impacted-checks.md index 39328ac95..6021bdfce 100644 --- a/docs/cli/impacted-checks.md +++ b/docs/cli/impacted-checks.md @@ -9,6 +9,7 @@ config block. no-mistakes impacted-checks src/api/handler.ts --format paths no-mistakes impacted-checks --base origin/main --json no-mistakes impacted-checks src/api/handler.ts --json --timings +no-mistakes impacted-checks src/api/handler.ts --generic-only --format json ``` ## Options @@ -22,6 +23,7 @@ no-mistakes impacted-checks src/api/handler.ts --json --timings | `--changed-file` | Specific changed file (repeatable). | | `--changed-files` | File listing changed files, one per line. | | `--diff` | Unified diff file. | +| `--generic-only` | Return configured `checks.commands` entries only; skip test-framework discovery and selection. | | `--format` | Output format: `json`, `md`, `yml`, `paths`, `human`. | | `--json` | Shorthand for `--format json`. | | `--timings` | Emit analysis phase durations to stderr. | @@ -40,10 +42,18 @@ Changed files may also be passed as positional arguments. Playwright generic discovery could match its filename. - Each `checks.commands` entry whose `include` globs match a changed file produces a `generic` check. `fileArgs: append` adds the matched files as - trailing arguments; `fileArgs: none` runs the command once. + trailing arguments; `fileArgs: none` runs the command once. An `always: true` + command runs even when no files changed and must use `fileArgs: none` without + include/exclude globs. - Commands are deduped and sorted. If the test-plan engine triggers a full-suite fallback (e.g. a global config change), `fallback_triggered` is set. +`--generic-only` still collects and normalizes changed files, but bypasses all +configured test frameworks and the test-plan finish step. Its report contains +only `generic` checks, an empty `warnings` array, and `fallback_triggered: false`. +With timings enabled, its stable phases are `prepare`, `generic-checks`, and +`total`. + `--timings` emits one deterministic diagnostics block after analysis. Stable phase names include `prepare`, `discover.`, `select.`, `generic-checks`, and `total`, plus diff --git a/docs/configuration/checks.md b/docs/configuration/checks.md index 19378b626..ac8b1d2f5 100644 --- a/docs/configuration/checks.md +++ b/docs/configuration/checks.md @@ -16,6 +16,10 @@ checks: include: ["**/*.ts"] command: ["pnpm", "exec", "tsc", "--noEmit"] fileArgs: none + - name: repository-policy + always: true + command: ["pnpm", "run", "repo-policy"] + fileArgs: none ``` | Key | Default | Description | @@ -25,7 +29,11 @@ checks: | `exclude` | `[]` | File globs that suppress the command. | | `command` | `[]` | Command tokens, e.g. `[pnpm, exec, eslint]`. | | `fileArgs` | `append` | `append` adds each matched file as a trailing argument; `none` runs the command once regardless of which files matched. | +| `always` | `false` | Run a whole-project command even with no changed files. It requires `fileArgs: none` and empty `include` and `exclude` lists. | A command is emitted only when at least one changed file matches `include` and -is not excluded. Use `fileArgs: none` for whole-project checks (typecheck, -format-check) and `fileArgs: append` for per-file linters. +is not excluded, unless it sets `always: true`. Always commands receive the +normalized changed-file list in their result metadata, including an empty list. +Use `fileArgs: none` for whole-project checks (typecheck, format-check) and +`fileArgs: append` for per-file linters. +Every `command` must start with a non-blank executable token. diff --git a/docs/node-api.md b/docs/node-api.md index 0183f25af..569c680de 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -197,6 +197,7 @@ const { impactedChecks } = require("no-mistakes"); const report = await impactedChecks({ root: process.cwd(), changedFiles: ["src/api.mts"], + genericOnly: true, timings: true, }); @@ -208,6 +209,11 @@ durations. The lazy `graph` phase is present only when dependency analysis is needed. The property is omitted by default. Unlike CLI `--timings`, Node timing collection does not print progress to stderr. +Set `genericOnly: true` to return only configured `checks.commands` entries. +It preserves changed-file collection but skips test-framework discovery and +selection; its report has no warnings or full-suite fallback, and timed calls +report `prepare`, `generic-checks`, then `total`. + ## Invocation Lock And Timeouts Every async analysis function except `version()` accepts these common options: diff --git a/docs/rules/README.md b/docs/rules/README.md index b773a9c8a..90a2a662e 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -63,6 +63,7 @@ rules: | [`test-email-domain-policy`](test-email-domain-policy.md) | Ban configured email domains in tracked fixtures and docs. | | [`test-no-unmocked-dynamic-imports`](test-no-unmocked-dynamic-imports.md) | Require dynamic imports in tests to be mocked. | | [`tsconfig-alias-folder-mapping`](tsconfig-alias-folder-mapping.md) | Enforce alias/folder consistency. | +| [`tsconfig-gate-coverage`](tsconfig-gate-coverage.md) | Require tracked TypeScript projects to have CI and local typecheck registrations. | | [`unique-exports`](unique-exports.md) | Prevent ambiguous duplicate public export names. | | [`vitest-ci-path-coverage`](vitest-ci-path-coverage.md) | Require Vitest inputs to be covered by CI path filters. | | [`vitest-project-mapping`](vitest-project-mapping.md) | Require Vitest tests to map to exactly one project. | diff --git a/docs/rules/tsconfig-gate-coverage.md b/docs/rules/tsconfig-gate-coverage.md new file mode 100644 index 000000000..b6817b97e --- /dev/null +++ b/docs/rules/tsconfig-gate-coverage.md @@ -0,0 +1,113 @@ +# `tsconfig-gate-coverage` + +Requires every tracked `tsconfig.json` or `tsconfig.*.json` outside +`node_modules` to be registered in both a configured GitHub Actions workflow +and a local whole-project typecheck command. + +```yaml +checks: + commands: + - name: typecheck-web + command: [pnpm, --dir, web, exec, tsc, --noEmit] + fileArgs: none + always: true + +rules: + - rule: tsconfig-gate-coverage + scope: repository + options: + allowProjects: + web/tsconfig.dependency-cruiser.json: Used only by dependency-cruiser, not tsc. +``` + +The rule recognizes static `tsc --noEmit` commands in workflow `run:` steps +and `checks.commands`. It supports `--project ` and `-p `, a +default `tsconfig.json` relative to the effective +working directory, sequential `cd` commands, and +`pnpm --dir exec tsc`. Workflow working directories +may come from workflow/job `defaults.run.working-directory` or a step's +`working-directory`. +Only step-based jobs with a non-empty, static `runs-on` string or label array +count; missing, dynamic, or reusable-workflow jobs do not. +The containing workflow must declare at least one file-triggered `push`, +`pull_request`, or `pull_request_target` event whose path filters allow every +visible TypeScript/JavaScript source selected by that project's +`files`/`include`/`exclude` settings. Projects with no known source files fall +back to the tracked tsconfig path. Manual, scheduled, reusable, empty, tag-only, and +path-filtered-out workflows cannot provide a repository typecheck gate. For +example, `paths: [app/tsconfig.json]` cannot cover `app/src/index.ts`; add +`app/**` or an +unfiltered applicable event. + +Workflow commands run only when their effective shell is GitHub Actions' +implicit shell or a static `bash`/`sh` form. The rule honors workflow and job +`defaults.run.shell` plus a step-level `shell` override; static shell templates +must invoke `bash` or `sh`, pass the script as `{0}`, and use only +execution-preserving flags: `-e`, `-u`, `-x`, and Bash's `-o pipefail`, +`--noprofile`, and `--norc`. This includes GitHub Actions' standard +`bash --noprofile --norc -eo pipefail {0}` and `sh -e {0}` templates. +Other shells (such as `python`, PowerShell, or `cmd`) and dynamic/custom shell +forms do not count; neither do non-executing modes such as `bash -n {0}`. +Implicit and built-in `bash`/`sh` shells propagate failures. Custom templates +must include `-e` or `-o errexit` to credit a typecheck before a later command; +without it, only a final `tsc` command counts. +An implicit shell does not count for statically Windows-labeled runners +(`windows-*` or a self-hosted `windows` label), because GitHub Actions defaults +those runners to PowerShell; specify a supported `bash` or `sh` shell instead. +A bare `self-hosted` label is also rejected with an implicit shell because its +operating system is not statically known; add a `linux`/`macos` label or an +explicit supported shell. + +Literal YAML `if: false` and `continue-on-error: true` values, plus exact +constant expressions `${{ false }}` and `${{ true }}`, on a workflow job or +step do not count as CI registrations because they cannot enforce a typecheck. +Other expressions in either field remain unresolved and are not evaluated by +this static rule. + +A job blocked by a statically skipped `needs` dependency, including a +transitive dependency, does not count. Exact `always()` and `!cancelled()` job +conditions explicitly continue after a skipped need. A dependency with +`continue-on-error: true` is non-enforcing itself but is not treated as +skipped for downstream jobs. + +A project whose effective local `compilerOptions.noCheck` is `true` does not +count as typechecked, even when both commands are registered. Remove or disable +`noCheck`, or document an intentional non-typechecking project with +`allowProjects`. Local and installed-package `extends` chains are resolved +through the prepared source store; unresolved configs are left for `tsc` to +reject. + +Counterexample: `packages/api/tsconfig.json` exists, but its `tsc --noEmit` +command appears only in a local command catalog. CI can therefore miss type +errors in that package. + +Fix: add the matching static typecheck command to a configured workflow and a +`checks.commands` entry with `always: true` and `fileArgs: none`. +Auxiliary configs that intentionally are not compiler projects need a +non-empty reason in `options.allowProjects`; stale, blank, invalid, or +normalization-colliding entries fail the rule. + +Dynamic shell expansion, command substitution, arbitrary wrapper scripts, +paths outside the repository, and other unresolved command forms do not count +as registrations. Express such a command statically if it is intended to +provide this gate. +Shell bodies containing `exit`, `return`, `false`, or a failure-mode mutation +such as `set +e` are also rejected as a whole because the rule does not model +shell reachability or option state. Negated pipelines and bodies with unquoted shell comments, +quoted command separators, or shell function/group braces, and local shell +invocations that enable a +non-executing mode such as `bash -n`, are rejected rather +than credited heuristically. A typecheck before another command in an `&&` +list is rejected when a later top-level command could mask a failed or skipped +typecheck. A final static `&&` list remains recognized. + +Informational, setup, or config-bypassing commands (`--showConfig`, +`--help`/`-h`, `--version`/`-v`, `--init`, enabled `--noCheck`, +`--listFilesOnly`, and `--ignoreConfig`) do not count, even when combined with +`--noEmit`, because they do not fully typecheck the project. Explicit +`--noCheck false` and `--noCheck=false` forms remain typechecking modes. + +Findings use line 1 of the tsconfig, workflow, or configuration file. Use a +top-of-file `no-mistakes-disable-file tsconfig-gate-coverage` directive only +when an intentional exception cannot be represented with a reasoned +`allowProjects` entry. diff --git a/fixtures/check-runner/empty/.no-mistakes.yml b/fixtures/check-runner/empty/.no-mistakes.yml new file mode 100644 index 000000000..22817d2a9 --- /dev/null +++ b/fixtures/check-runner/empty/.no-mistakes.yml @@ -0,0 +1 @@ +version: 2 diff --git a/fixtures/check-runner/invalid-playwright-fact-plan/.no-mistakes.yml b/fixtures/check-runner/invalid-playwright-fact-plan/.no-mistakes.yml new file mode 100644 index 000000000..ab523305d --- /dev/null +++ b/fixtures/check-runner/invalid-playwright-fact-plan/.no-mistakes.yml @@ -0,0 +1,13 @@ +projects: + web: + type: nextjs + root: web + +tests: + playwright: + configs: playwright.config.ts + +rules: + - rule: playwright-coverage + projects: + - web diff --git a/fixtures/ci-workflows/project-paths/.github/workflows/a.yml b/fixtures/ci-workflows/project-paths/.github/workflows/a.yml new file mode 100644 index 000000000..ca35ab7b1 --- /dev/null +++ b/fixtures/ci-workflows/project-paths/.github/workflows/a.yml @@ -0,0 +1,7 @@ +name: A +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo a diff --git a/fixtures/ci-workflows/project-paths/.github/workflows/b.yml b/fixtures/ci-workflows/project-paths/.github/workflows/b.yml new file mode 100644 index 000000000..874b9e0a4 --- /dev/null +++ b/fixtures/ci-workflows/project-paths/.github/workflows/b.yml @@ -0,0 +1,7 @@ +name: B +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo b diff --git a/fixtures/graph/workflow-topology-prepared/.github/workflows/ci.yml b/fixtures/graph/workflow-topology-prepared/.github/workflows/ci.yml new file mode 100644 index 000000000..f0bea626f --- /dev/null +++ b/fixtures/graph/workflow-topology-prepared/.github/workflows/ci.yml @@ -0,0 +1,7 @@ +name: CI +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ready diff --git a/fixtures/impacted-checks/generic-only.no-mistakes.yml b/fixtures/impacted-checks/generic-only.no-mistakes.yml new file mode 100644 index 000000000..1c82417cb --- /dev/null +++ b/fixtures/impacted-checks/generic-only.no-mistakes.yml @@ -0,0 +1,32 @@ +checks: + commands: + - name: eslint + include: ["src/**/*.ts"] + command: [pnpm, exec, eslint] + fileArgs: none + - name: always + always: true + command: [echo, always] + fileArgs: none + +tests: + vitest: + projects: + unit: + include: + - src/**/*.test.ts + playwright: + projects: + e2e: + include: + - e2e/**/*.spec.ts + swift: + packages: + - swift/App + dotnet: + projects: + app: + project: dotnet/src/App/App.csproj + app-tests: + project: dotnet/tests/App.Tests/App.Tests.csproj + test: true diff --git a/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/.no-mistakes.yml new file mode 100644 index 000000000..ea0b01a41 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/.no-mistakes.yml @@ -0,0 +1,17 @@ +checks: + commands: + - name: typecheck-allowed + command: [tsc, --noEmit, --project, allowed/tsconfig.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository + options: + allowProjects: + allowed/tsconfig.json: "" + gone/tsconfig.json: stale entry + ../outside/tsconfig.json: invalid path + docs/readme.json: not a tsconfig + ./collision/tsconfig.json: first spelling + collision/tsconfig.json: second spelling diff --git a/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/allowed/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/allowed/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/allowed/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/collision/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/collision/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/allowlist-errors/collision/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/allowlist-pass/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/allowlist-pass/.no-mistakes.yml new file mode 100644 index 000000000..10d74f89a --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/allowlist-pass/.no-mistakes.yml @@ -0,0 +1,6 @@ +rules: + - rule: tsconfig-gate-coverage + scope: repository + options: + allowProjects: + tooling/tsconfig.tools.json: Used only by a static analysis tool. diff --git a/fixtures/rules/tsconfig-gate-coverage/allowlist-pass/tooling/tsconfig.tools.json b/fixtures/rules/tsconfig-gate-coverage/allowlist-pass/tooling/tsconfig.tools.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/allowlist-pass/tooling/tsconfig.tools.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/auto-config-path/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/auto-config-path/.github/workflows/ci.yml new file mode 100644 index 000000000..3d6dc653b --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/auto-config-path/.github/workflows/ci.yml @@ -0,0 +1,6 @@ +on: push +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project app/tsconfig.json diff --git a/fixtures/rules/tsconfig-gate-coverage/auto-config-path/.no-mistakes.yaml b/fixtures/rules/tsconfig-gate-coverage/auto-config-path/.no-mistakes.yaml new file mode 100644 index 000000000..f402ca6e2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/auto-config-path/.no-mistakes.yaml @@ -0,0 +1,12 @@ +checks: + commands: + - name: typecheck-app + command: [tsc, --noEmit, --project, app/tsconfig.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository + options: + allowProjects: + missing/tsconfig.json: stale regression entry diff --git a/fixtures/rules/tsconfig-gate-coverage/auto-config-path/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/auto-config-path/app/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/auto-config-path/app/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/.github/workflows/ci.yml new file mode 100644 index 000000000..3d6dc653b --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/.github/workflows/ci.yml @@ -0,0 +1,6 @@ +on: push +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project app/tsconfig.json diff --git a/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/.no-mistakes.yaml b/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/.no-mistakes.yaml new file mode 100644 index 000000000..f0cc9c5a1 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/.no-mistakes.yaml @@ -0,0 +1,13 @@ +# no-mistakes-disable-file tsconfig-gate-coverage +checks: + commands: + - name: typecheck-app + command: [tsc, --noEmit, --project, app/tsconfig.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository + options: + allowProjects: + missing/tsconfig.json: intentionally suppressed regression entry diff --git a/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/app/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/auto-config-suppression/app/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/.github/workflows/bad.yml b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/.github/workflows/bad.yml new file mode 100644 index 000000000..b786b7b15 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/.github/workflows/bad.yml @@ -0,0 +1 @@ +jobs: { broken: {{ invalid diff --git a/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/.no-mistakes.yml new file mode 100644 index 000000000..593c47d77 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/.no-mistakes.yml @@ -0,0 +1,9 @@ +checks: + commands: + - name: typecheck-app + command: [tsc, --noEmit, --project, app/tsconfig.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/app/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/app/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/node_modules/ignored/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/node_modules/ignored/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/malformed-workflow/node_modules/ignored/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-ci/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/missing-ci/.github/workflows/ci.yml new file mode 100644 index 000000000..7e92fef40 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-ci/.github/workflows/ci.yml @@ -0,0 +1,7 @@ +name: CI +on: push +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: pnpm --dir app exec tsc --noEmit diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-ci/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/missing-ci/.no-mistakes.yml new file mode 100644 index 000000000..581b34dd8 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-ci/.no-mistakes.yml @@ -0,0 +1,13 @@ +checks: + commands: + - name: typecheck-app + command: [pnpm, --dir, app, exec, tsc, --noEmit] + fileArgs: none + always: true + - name: typecheck-tools + command: [pnpm, exec, tsc, --noEmit, --project, tools/tsconfig.tools.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-ci/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/missing-ci/app/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-ci/app/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-ci/tools/tsconfig.tools.json b/fixtures/rules/tsconfig-gate-coverage/missing-ci/tools/tsconfig.tools.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-ci/tools/tsconfig.tools.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-local/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/missing-local/.github/workflows/ci.yml new file mode 100644 index 000000000..372e2e483 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-local/.github/workflows/ci.yml @@ -0,0 +1,7 @@ +name: CI +on: push +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project app/tsconfig.json diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-local/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/missing-local/.no-mistakes.yml new file mode 100644 index 000000000..90f9e8e13 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-local/.no-mistakes.yml @@ -0,0 +1,12 @@ +checks: + commands: + - name: app-typecheck-but-append + include: [app/**/*.ts] + command: [tsc, --noEmit, --project, app/tsconfig.json] + fileArgs: append + - name: app-typecheck-but-no-include + command: [tsc, --noEmit, --project, app/tsconfig.json] + fileArgs: none +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/missing-local/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/missing-local/app/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/missing-local/app/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-array/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-array/tsconfig.json new file mode 100644 index 000000000..44e1341cb --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-array/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": [42] +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-compiler-options/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-compiler-options/tsconfig.json new file mode 100644 index 000000000..af71de529 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-compiler-options/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": [] +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-extends/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-extends/tsconfig.json new file mode 100644 index 000000000..07fa14454 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-extends/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": 42 +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-no-check/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-no-check/tsconfig.json new file mode 100644 index 000000000..1d6fe2a54 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/bad-no-check/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": "true" + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/directory/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/directory/tsconfig.json new file mode 100644 index 000000000..42e84bcd2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/directory/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/dotted.base.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/dotted.base.json new file mode 100644 index 000000000..42e84bcd2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/dotted.base.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/file.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/file.json new file mode 100644 index 000000000..42e84bcd2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/base/file.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/cycle/other.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/cycle/other.json new file mode 100644 index 000000000..fc8520e73 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/cycle/other.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/cycle/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/cycle/tsconfig.json new file mode 100644 index 000000000..7e3be9790 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/cycle/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./other.json" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/directory-base/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/directory-base/tsconfig.json new file mode 100644 index 000000000..6cf466775 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/directory-base/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../base/directory" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/dotted-file-base/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/dotted-file-base/tsconfig.json new file mode 100644 index 000000000..a32b451ad --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/dotted-file-base/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../base/dotted.base" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/empty/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/empty/tsconfig.json new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/empty/tsconfig.json @@ -0,0 +1 @@ + diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/file-base/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/file-base/tsconfig.json new file mode 100644 index 000000000..725ba3acb --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/file-base/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../base/file" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/missing-base/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/missing-base/tsconfig.json new file mode 100644 index 000000000..086520ebd --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/missing-base/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../base/missing" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/missing-package-base/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/missing-package-base/tsconfig.json new file mode 100644 index 000000000..eaa8ca608 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/missing-package-base/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@missing/config" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/package-base/node_modules/@scope/tsconfig/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/package-base/node_modules/@scope/tsconfig/tsconfig.json new file mode 100644 index 000000000..42e84bcd2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/package-base/node_modules/@scope/tsconfig/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/package-base/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/package-base/tsconfig.json new file mode 100644 index 000000000..3df40c444 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check-edge-cases/package-base/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@scope/tsconfig" +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/no-check/.github/workflows/ci.yml new file mode 100644 index 000000000..cd6f528fa --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: CI +on: push +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project direct/tsconfig.json + - run: tsc --noEmit --project inherited/tsconfig.json + - run: tsc --noEmit --project override/tsconfig.json + - run: tsc --noEmit --project invalid/tsconfig.json diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/no-check/.no-mistakes.yml new file mode 100644 index 000000000..67d0f529d --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/.no-mistakes.yml @@ -0,0 +1,21 @@ +checks: + commands: + - name: direct + command: [tsc, --noEmit, --project, direct/tsconfig.json] + fileArgs: none + always: true + - name: inherited + command: [tsc, --noEmit, --project, inherited/tsconfig.json] + fileArgs: none + always: true + - name: override + command: [tsc, --noEmit, --project, override/tsconfig.json] + fileArgs: none + always: true + - name: invalid + command: [tsc, --noEmit, --project, invalid/tsconfig.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/base/base.json b/fixtures/rules/tsconfig-gate-coverage/no-check/base/base.json new file mode 100644 index 000000000..42e84bcd2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/base/base.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/base/neutral.json b/fixtures/rules/tsconfig-gate-coverage/no-check/base/neutral.json new file mode 100644 index 000000000..aee0ec940 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/base/neutral.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "strict": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/direct/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check/direct/tsconfig.json new file mode 100644 index 000000000..42e84bcd2 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/direct/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "noCheck": true + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/inherited/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check/inherited/tsconfig.json new file mode 100644 index 000000000..deaf4fbc4 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/inherited/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": ["../base/base.json", "../base/neutral.json"] +} diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/invalid/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check/invalid/tsconfig.json new file mode 100644 index 000000000..679bc25fc --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/invalid/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { + "noCheck": true, diff --git a/fixtures/rules/tsconfig-gate-coverage/no-check/override/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/no-check/override/tsconfig.json new file mode 100644 index 000000000..c85c7bfde --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/no-check/override/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../base/base.json", + "compilerOptions": { + "noCheck": false + } +} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/.github/workflows/ci.yml new file mode 100644 index 000000000..d0e88fbf7 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI +on: push +jobs: + disabled-job: + if: false + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project disabled-job/tsconfig.json + disabled-step: + runs-on: ubuntu-latest + steps: + - if: false + run: tsc --noEmit --project disabled-step/tsconfig.json + nonblocking-job: + continue-on-error: true + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project nonblocking-job/tsconfig.json + nonblocking-step: + runs-on: ubuntu-latest + steps: + - continue-on-error: true + run: tsc --noEmit --project nonblocking-step/tsconfig.json + expression: + if: '${{ false }}' + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project expression/tsconfig.json + constant-nonblocking-step: + runs-on: ubuntu-latest + steps: + - continue-on-error: '${{ true }}' + run: tsc --noEmit --project constant-nonblocking-step/tsconfig.json + dynamic-expression: + if: "${{ github.ref == 'refs/heads/main' }}" + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit --project dynamic-expression/tsconfig.json + failure-mode-mutated: + runs-on: ubuntu-latest + steps: + - run: | + set +e + tsc --noEmit --project failure-mode-mutated/tsconfig.json + non-posix-shell: + runs-on: ubuntu-latest + steps: + - shell: python + run: tsc --noEmit --project non-posix-shell/tsconfig.json + missing-runner: + steps: + - run: tsc --noEmit --project missing-runner/tsconfig.json + dynamic-runner: + runs-on: '${{ matrix.runner }}' + steps: + - run: tsc --noEmit --project dynamic-runner/tsconfig.json + implicit-windows-shell: + runs-on: windows-latest + steps: + - run: tsc --noEmit --project implicit-windows-shell/tsconfig.json diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/.no-mistakes.yml new file mode 100644 index 000000000..4cdd33353 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/.no-mistakes.yml @@ -0,0 +1,53 @@ +checks: + commands: + - name: typecheck-disabled-job + command: [tsc, --noEmit, --project, disabled-job/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-disabled-step + command: [tsc, --noEmit, --project, disabled-step/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-nonblocking-job + command: [tsc, --noEmit, --project, nonblocking-job/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-nonblocking-step + command: [tsc, --noEmit, --project, nonblocking-step/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-expression + command: [tsc, --noEmit, --project, expression/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-constant-nonblocking-step + command: [tsc, --noEmit, --project, constant-nonblocking-step/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-dynamic-expression + command: [tsc, --noEmit, --project, dynamic-expression/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-failure-mode-mutated + command: [tsc, --noEmit, --project, failure-mode-mutated/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-non-posix-shell + command: [tsc, --noEmit, --project, non-posix-shell/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-missing-runner + command: [tsc, --noEmit, --project, missing-runner/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-dynamic-runner + command: [tsc, --noEmit, --project, dynamic-runner/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-implicit-windows-shell + command: [tsc, --noEmit, --project, implicit-windows-shell/tsconfig.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/constant-nonblocking-step/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/constant-nonblocking-step/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/constant-nonblocking-step/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/disabled-job/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/disabled-job/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/disabled-job/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/disabled-step/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/disabled-step/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/disabled-step/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/dynamic-expression/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/dynamic-expression/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/dynamic-expression/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/dynamic-runner/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/dynamic-runner/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/dynamic-runner/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/expression/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/expression/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/expression/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/failure-mode-mutated/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/failure-mode-mutated/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/failure-mode-mutated/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/implicit-windows-shell/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/implicit-windows-shell/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/implicit-windows-shell/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/missing-runner/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/missing-runner/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/missing-runner/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/non-posix-shell/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/non-posix-shell/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/non-posix-shell/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/nonblocking-job/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/nonblocking-job/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/nonblocking-job/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/nonblocking-step/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/nonblocking-step/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/non-enforcing-workflow/nonblocking-step/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/pass/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/pass/.github/workflows/ci.yml new file mode 100644 index 000000000..8af195ae5 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/pass/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: push +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: pnpm exec tsc --noEmit --project app + - run: pnpm exec tsc --noEmit --project tools/tsconfig.tools.json diff --git a/fixtures/rules/tsconfig-gate-coverage/pass/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/pass/.no-mistakes.yml new file mode 100644 index 000000000..9b78a2847 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/pass/.no-mistakes.yml @@ -0,0 +1,13 @@ +checks: + commands: + - name: typecheck-app + command: [pnpm, exec, tsc, --noEmit, --project, app] + fileArgs: none + always: true + - name: typecheck-tools + command: [pnpm, exec, tsc, --noEmit, --project, tools/tsconfig.tools.json] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/pass/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/pass/app/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/pass/app/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/pass/tools/tsconfig.tools.json b/fixtures/rules/tsconfig-gate-coverage/pass/tools/tsconfig.tools.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/pass/tools/tsconfig.tools.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/.github/workflows/ci.yml new file mode 100644 index 000000000..b3f672fa1 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +on: + push: + paths: [app/tsconfig.json] +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: pnpm exec tsc --noEmit --project app diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/.no-mistakes.yml new file mode 100644 index 000000000..8b066d93e --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/.no-mistakes.yml @@ -0,0 +1,9 @@ +checks: + commands: + - name: typecheck-app + command: [pnpm, exec, tsc, --noEmit, --project, app] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/app/src/index.ts b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/app/src/index.ts new file mode 100644 index 000000000..e7a91fcd0 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/app/src/index.ts @@ -0,0 +1 @@ +export const coveredByProject = true; diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/app/tsconfig.json new file mode 100644 index 000000000..761f51a5c --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-negative/app/tsconfig.json @@ -0,0 +1 @@ +{"include":["src"]} diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/.github/workflows/ci.yml new file mode 100644 index 000000000..9afd466a4 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +on: + push: + paths: [app/src/**] +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - run: pnpm exec tsc --noEmit --project app diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/.no-mistakes.yml new file mode 100644 index 000000000..8b066d93e --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/.no-mistakes.yml @@ -0,0 +1,9 @@ +checks: + commands: + - name: typecheck-app + command: [pnpm, exec, tsc, --noEmit, --project, app] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/ignored.ts b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/ignored.ts new file mode 100644 index 000000000..7104022ae --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/ignored.ts @@ -0,0 +1,2 @@ +// Deliberately outside `include`; this path must not constrain workflow coverage. +export const ignoredByProject = true; diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/src/index.ts b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/src/index.ts new file mode 100644 index 000000000..e7a91fcd0 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/src/index.ts @@ -0,0 +1 @@ +export const coveredByProject = true; diff --git a/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/tsconfig.json new file mode 100644 index 000000000..761f51a5c --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/path-filter-sources-positive/app/tsconfig.json @@ -0,0 +1 @@ +{"include":["src"]} diff --git a/fixtures/rules/tsconfig-gate-coverage/working-directories/.github/workflows/ci.yml b/fixtures/rules/tsconfig-gate-coverage/working-directories/.github/workflows/ci.yml new file mode 100644 index 000000000..3776efa95 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/working-directories/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: push +defaults: + run: + working-directory: workflow-default +jobs: + workflow-default: + runs-on: ubuntu-latest + steps: + - run: tsc --noEmit + job-default: + runs-on: ubuntu-latest + defaults: + run: + working-directory: job-default + steps: + - run: pnpm exec tsc --noEmit + step-default: + runs-on: ubuntu-latest + steps: + - working-directory: step-default + run: tsc --noEmit + cd-project: + runs-on: ubuntu-latest + steps: + # Overrides the workflow default before the inline cd changes it again. + - working-directory: . + run: cd cd-project && tsc --noEmit diff --git a/fixtures/rules/tsconfig-gate-coverage/working-directories/.no-mistakes.yml b/fixtures/rules/tsconfig-gate-coverage/working-directories/.no-mistakes.yml new file mode 100644 index 000000000..1f3e2b016 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/working-directories/.no-mistakes.yml @@ -0,0 +1,21 @@ +checks: + commands: + - name: typecheck-workflow-default + command: [bash, -c, cd workflow-default && tsc --noEmit] + fileArgs: none + always: true + - name: typecheck-job-default + command: [pnpm, --dir=job-default, exec, tsc, --noEmit] + fileArgs: none + always: true + - name: typecheck-step-default + command: [tsc, --noEmit, --project, step-default/tsconfig.json] + fileArgs: none + always: true + - name: typecheck-cd-project + command: [bash, -c, cd cd-project && tsc --noEmit] + fileArgs: none + always: true +rules: + - rule: tsconfig-gate-coverage + scope: repository diff --git a/fixtures/rules/tsconfig-gate-coverage/working-directories/cd-project/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/working-directories/cd-project/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/working-directories/cd-project/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/working-directories/job-default/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/working-directories/job-default/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/working-directories/job-default/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/working-directories/step-default/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/working-directories/step-default/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/working-directories/step-default/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/tsconfig-gate-coverage/working-directories/workflow-default/tsconfig.json b/fixtures/rules/tsconfig-gate-coverage/working-directories/workflow-default/tsconfig.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/fixtures/rules/tsconfig-gate-coverage/working-directories/workflow-default/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/rules/vitest-ci-path-coverage/parsed-workflow-errors/.github/workflows/bad.yml b/fixtures/rules/vitest-ci-path-coverage/parsed-workflow-errors/.github/workflows/bad.yml new file mode 100644 index 000000000..bc0243d94 --- /dev/null +++ b/fixtures/rules/vitest-ci-path-coverage/parsed-workflow-errors/.github/workflows/bad.yml @@ -0,0 +1,3 @@ +name: malformed +jobs: + test: [ diff --git a/packages/no-mistakes/ci-types.d.ts b/packages/no-mistakes/ci-types.d.ts index 74f3bd511..9ea6be698 100644 --- a/packages/no-mistakes/ci-types.d.ts +++ b/packages/no-mistakes/ci-types.d.ts @@ -32,6 +32,8 @@ export interface ImpactedChecksOptions { changedFiles?: string[]; changedFilesFile?: string; diff?: string; + /** Return configured generic commands only; skip test-framework discovery and selection. */ + genericOnly?: boolean; /** Include ordered analysis phase timings in the returned report. */ timings?: boolean; } diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 0d9a61fdd..56efb7175 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -134,6 +134,7 @@ scope the review and `rg` to inspect exact argument objects such as | Which workflows define or reference this env var? | `no-mistakes ci env --format json` | | What are the workflow edges, job runner/timeout/permission settings, env declarations, or static secret-name use sites? | `no-mistakes ci topology --format json` | | What local validation commands should I run for these changed files? | `no-mistakes impacted-checks --format paths` | +| Which configured generic validation commands apply, without test selection? | `no-mistakes impacted-checks --generic-only --format json` | | Which queue producer/worker files are connected? | `no-mistakes queues related ` | | Are queue producers/workers unmatched? | `no-mistakes queues check` | | What server routes exist? | `no-mistakes server routes` |