From 9e948910601d4651d48df4c8b5f5497cfb02d229 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 11:45:41 -0700 Subject: [PATCH 01/16] feat: camelCase-only TestPlan Node API with includeGlob CLI JSON stays snake_case. Node testsPlan/testsImpact and analyzeProject plan reports now camelize keys, expose optional executionTargets.name, and filter selected tests with includeGlob/--include-glob. Co-authored-by: Cursor --- .../src/impacted_checks/generate/args.rs | 1 + .../src/napi_api/cli_parity_builders.rs | 1 + .../src/napi_api/options_flow_tests.rs | 1 + crates/no-mistakes/src/tests/args.rs | 4 ++ .../src/tests/configured_plan/tests.rs | 3 + crates/no-mistakes/src/tests/mod.rs | 3 + .../src/tests/plan/changed_inventory.rs | 17 +++++- crates/no-mistakes/src/tests/plan_finish.rs | 30 ++++++++-- .../src/tests/plan_finish/tests.rs | 56 +++++++++++++++---- .../src/tests/plan_resources_tests.rs | 1 + .../src/tests/prepared_plan/tests.rs | 1 + crates/no-mistakes/src/tests/why.rs | 1 + docs/cli/tests-plan.md | 11 +++- docs/node-api.md | 4 ++ packages/no-mistakes/index.js | 12 +++- packages/no-mistakes/planning.js | 25 +++++++++ packages/no-mistakes/test-types.d.ts | 49 ++++++++-------- 17 files changed, 173 insertions(+), 47 deletions(-) diff --git a/crates/no-mistakes/src/impacted_checks/generate/args.rs b/crates/no-mistakes/src/impacted_checks/generate/args.rs index e14f7288f..85ca34a6f 100644 --- a/crates/no-mistakes/src/impacted_checks/generate/args.rs +++ b/crates/no-mistakes/src/impacted_checks/generate/args.rs @@ -70,6 +70,7 @@ pub(crate) fn plan_args_for( format: None, json: false, include_comment: false, + include_glob: Vec::new(), } } 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 60d4ffbfe..d29c8f042 100644 --- a/crates/no-mistakes/src/napi_api/cli_parity_builders.rs +++ b/crates/no-mistakes/src/napi_api/cli_parity_builders.rs @@ -56,6 +56,7 @@ pub(crate) fn build_plan_args(options: TestsPlanOptions) -> AnyhowResult, pub(crate) direct_test_owner: bool, pub(crate) include_comment: bool, + pub(crate) include_glob: Vec, } #[derive(Debug, Default, Deserialize)] diff --git a/crates/no-mistakes/src/tests/args.rs b/crates/no-mistakes/src/tests/args.rs index ddcc752a0..9f7d47101 100644 --- a/crates/no-mistakes/src/tests/args.rs +++ b/crates/no-mistakes/src/tests/args.rs @@ -130,6 +130,10 @@ pub(crate) struct PlanArgs { /// Include the markdown PR comment on the plan JSON (`comment` field). #[arg(long = "include-comment", default_value_t = false)] pub(crate) include_comment: bool, + + /// Keep only selected tests whose relative path matches one of these globs. + #[arg(long = "include-glob")] + pub(crate) include_glob: Vec, } #[derive(Args, Debug, Clone)] diff --git a/crates/no-mistakes/src/tests/configured_plan/tests.rs b/crates/no-mistakes/src/tests/configured_plan/tests.rs index 034237a63..49b685ea0 100644 --- a/crates/no-mistakes/src/tests/configured_plan/tests.rs +++ b/crates/no-mistakes/src/tests/configured_plan/tests.rs @@ -30,6 +30,7 @@ fn vitest_setup_args(root: PathBuf, changed_file: Vec) -> PlanArgs { format: None, json: false, include_comment: false, + include_glob: Vec::new(), } } @@ -388,6 +389,7 @@ fn dependency_trigger_ignores_changed_test_discovery_errors_for_source_changes() format: None, json: false, include_comment: false, + include_glob: Vec::new(), }; let prepared = crate::tests::prepared_plan::PreparedTestPlanRequest::prepare(&plan_args) .expect("fixture request should prepare"); @@ -481,6 +483,7 @@ fn explicit_ignored_changed_sources_impact_visible_tests_without_ignored_shadows format: None, json: false, include_comment: false, + include_glob: Vec::new(), }) .unwrap(); let selected = plan diff --git a/crates/no-mistakes/src/tests/mod.rs b/crates/no-mistakes/src/tests/mod.rs index c88574927..2fb3a6947 100644 --- a/crates/no-mistakes/src/tests/mod.rs +++ b/crates/no-mistakes/src/tests/mod.rs @@ -55,6 +55,9 @@ pub struct GroupedExecutionTarget { pub config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, + /// Path-prefix display name, such as a Swift package root. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub base_command: Vec, /// Runner flags without test file paths. pub runner_args: Vec, diff --git a/crates/no-mistakes/src/tests/plan/changed_inventory.rs b/crates/no-mistakes/src/tests/plan/changed_inventory.rs index 73e7d1c85..dc00ab63a 100644 --- a/crates/no-mistakes/src/tests/plan/changed_inventory.rs +++ b/crates/no-mistakes/src/tests/plan/changed_inventory.rs @@ -1,4 +1,5 @@ use super::{generate_plan_with_prepared_inner, PlanArgs, Result, TestPlan}; +use crate::codebase::rules::path_filter::GlobMatcher; pub(crate) fn generate_plan_with_prepared( args: &PlanArgs, @@ -7,6 +8,20 @@ pub(crate) fn generate_plan_with_prepared( ) -> Result { let mut plan = generate_plan_with_prepared_inner(args, prepared, timing)?; plan.changed_files = prepared.changed_file_inventory(); - plan.finish(args.include_comment); + retain_include_glob(&mut plan, &args.include_glob)?; + plan.finish(args.include_comment, &prepared.config.tests.swift.packages); Ok(plan) } + +fn retain_include_glob(plan: &mut TestPlan, patterns: &[String]) -> Result<()> { + if patterns.is_empty() { + return Ok(()); + } + let matcher = GlobMatcher::new(patterns, "includeGlob")?; + plan.selected_tests + .retain(|test| matcher.is_match(&test.test_file)); + for group in &mut plan.groups { + group.selected.retain(|file| matcher.is_match(file)); + } + Ok(()) +} diff --git a/crates/no-mistakes/src/tests/plan_finish.rs b/crates/no-mistakes/src/tests/plan_finish.rs index 4ec47f3ed..0aa20aa0e 100644 --- a/crates/no-mistakes/src/tests/plan_finish.rs +++ b/crates/no-mistakes/src/tests/plan_finish.rs @@ -3,30 +3,42 @@ use no_mistakes::codebase::test_discovery::TestExecutionTarget; use std::collections::BTreeMap; impl TestPlan { - pub(crate) fn finish(&mut self, include_comment: bool) { - self.execution_targets = grouped_execution_targets(&self.selected_tests); + pub(crate) fn finish(&mut self, include_comment: bool, prefixes: &[String]) { + self.execution_targets = grouped_execution_targets(&self.selected_tests, prefixes); if include_comment { self.comment = Some(super::comment::render_markdown_plan(self)); } } } -type ExecutionGroupKey = (String, Option, Option, Vec); +type ExecutionGroupKey = ( + String, + Option, + Option, + Vec, + Option, +); -fn grouped_execution_targets(selected: &[super::SelectedTest]) -> Vec { +fn grouped_execution_targets( + selected: &[super::SelectedTest], + prefixes: &[String], +) -> Vec { let mut groups: BTreeMap = BTreeMap::new(); for test in selected { for target in &test.targets { + let name = prefix_name(&test.test_file, prefixes); let key = ( target.runner.clone(), target.config.clone(), target.project.clone(), target.base_command.clone(), + name.clone(), ); let group = groups.entry(key).or_insert_with(|| GroupedExecutionTarget { runner: target.runner.clone(), config: target.config.clone(), project: target.project.clone(), + name, base_command: target.base_command.clone(), runner_args: runner_args_without_file(target, &test.test_file), test_files: Vec::new(), @@ -39,6 +51,16 @@ fn grouped_execution_targets(selected: &[super::SelectedTest]) -> Vec Option { + prefixes + .iter() + .map(|prefix| prefix.trim_end_matches('/')) + .filter(|prefix| !prefix.is_empty()) + .filter(|prefix| file == *prefix || file.starts_with(&format!("{prefix}/"))) + .max_by_key(|prefix| prefix.len()) + .map(str::to_string) +} + fn runner_args_without_file(target: &TestExecutionTarget, test_file: &str) -> Vec { let mut args = target.runner_args.clone(); if args.len() >= 2 && args[args.len() - 2] == "--test" { diff --git a/crates/no-mistakes/src/tests/plan_finish/tests.rs b/crates/no-mistakes/src/tests/plan_finish/tests.rs index 1bd00f4dc..3ff0846e2 100644 --- a/crates/no-mistakes/src/tests/plan_finish/tests.rs +++ b/crates/no-mistakes/src/tests/plan_finish/tests.rs @@ -27,16 +27,19 @@ fn selected(file: &str, targets: Vec) -> SelectedTest { /// `base_command`. #[test] fn mixed_python_runners_keep_separate_execution_targets() { - let groups = grouped_execution_targets(&[ - selected( - "pkg/tests.py", - vec![target("python", Some("pkg"), &["python", "-m", "unittest"])], - ), - selected( - "pkg/test_foo.py", - vec![target("python", Some("pkg"), &["pytest"])], - ), - ]); + let groups = grouped_execution_targets( + &[ + selected( + "pkg/tests.py", + vec![target("python", Some("pkg"), &["python", "-m", "unittest"])], + ), + selected( + "pkg/test_foo.py", + vec![target("python", Some("pkg"), &["pytest"])], + ), + ], + &[], + ); assert_eq!(groups.len(), 2); let mut commands: Vec<_> = groups.into_iter().map(|group| group.base_command).collect(); @@ -63,8 +66,10 @@ fn nested_package_relative_runner_args_are_stripped_when_grouping() { ); dart.runner_args = vec!["test/user_test.dart".into()]; dart.config = Some("packages/app".into()); - let groups = - grouped_execution_targets(&[selected("packages/app/test/user_test.dart", vec![dart])]); + let groups = grouped_execution_targets( + &[selected("packages/app/test/user_test.dart", vec![dart])], + &[], + ); assert_eq!(groups.len(), 1); assert!(groups[0].runner_args.is_empty()); assert_eq!( @@ -72,3 +77,30 @@ fn nested_package_relative_runner_args_are_stripped_when_grouping() { vec!["packages/app/test/user_test.dart".to_string()] ); } + +#[test] +fn path_prefixes_split_and_name_execution_targets() { + let groups = grouped_execution_targets( + &[ + selected( + "swift-clients/core/Tests/A.swift", + vec![target("swift", None, &["swift", "test"])], + ), + selected( + "swift-clients/ui/Tests/B.swift", + vec![target("swift", None, &["swift", "test"])], + ), + ], + &["swift-clients/core".into(), "swift-clients/ui".into()], + ); + assert_eq!(groups.len(), 2); + let mut names: Vec<_> = groups.into_iter().map(|group| group.name).collect(); + names.sort(); + assert_eq!( + names, + vec![ + Some("swift-clients/core".into()), + Some("swift-clients/ui".into()) + ] + ); +} diff --git a/crates/no-mistakes/src/tests/plan_resources_tests.rs b/crates/no-mistakes/src/tests/plan_resources_tests.rs index c42655d95..1258942c5 100644 --- a/crates/no-mistakes/src/tests/plan_resources_tests.rs +++ b/crates/no-mistakes/src/tests/plan_resources_tests.rs @@ -31,6 +31,7 @@ fn resource_plan_args(root: &Path, changed: PathBuf) -> PlanArgs { format: None, json: true, include_comment: false, + include_glob: Vec::new(), } } diff --git a/crates/no-mistakes/src/tests/prepared_plan/tests.rs b/crates/no-mistakes/src/tests/prepared_plan/tests.rs index a09e70649..64d84b953 100644 --- a/crates/no-mistakes/src/tests/prepared_plan/tests.rs +++ b/crates/no-mistakes/src/tests/prepared_plan/tests.rs @@ -27,6 +27,7 @@ fn framework_args(root: &Path, framework: TestFramework) -> PlanArgs { format: None, json: false, include_comment: false, + include_glob: Vec::new(), } } diff --git a/crates/no-mistakes/src/tests/why.rs b/crates/no-mistakes/src/tests/why.rs index 77a6a69f9..8f1d00f81 100644 --- a/crates/no-mistakes/src/tests/why.rs +++ b/crates/no-mistakes/src/tests/why.rs @@ -189,6 +189,7 @@ fn run_live_analysis( format: None, json: true, include_comment: false, + include_glob: Vec::new(), }; let plan = generate_plan(&plan_args)?; diff --git a/docs/cli/tests-plan.md b/docs/cli/tests-plan.md index 153cbf579..9c1386efd 100644 --- a/docs/cli/tests-plan.md +++ b/docs/cli/tests-plan.md @@ -56,6 +56,13 @@ returning an empty plan: Node's `testsPlan()` rejects with the same stable code and message instead of resolving to an empty plan. +JSON plans from the CLI keep snake_case keys. The Node `testsPlan()` / +`testsImpact()` APIs return camelCase only (`changedFiles`, `selectedTests`, +`executionTargets`, `fallbackTriggered`). `executionTargets` is the CI +contract: tests grouped by runner, config, project, and optional path-prefix +`name` (Swift packages). `--include-glob` / `includeGlob` keeps only selected +tests whose relative path matches. + JSON plans include `changed_files`, the sorted, deduplicated, root-relative inventory prepared by that invocation. It is present even when no tests are selected and retains deleted paths plus both sides of detected renames and @@ -68,8 +75,8 @@ an in-root target keeps its lexical path in `changed_files`, while dependency analysis follows the resolved target. Key options: `--root`, `--config`, `--tsconfig`, `--environment`, -`--limit-percent`, `--limit-files`, `--global-config-fallback`, `--format`, and -`--json`. +`--limit-percent`, `--limit-files`, `--global-config-fallback`, +`--include-comment`, `--include-glob`, `--format`, and `--json`. `--format explain` renders a deterministic, human-readable plan: the normalized changed-file inventory (including files that selected no tests), selected test diff --git a/docs/node-api.md b/docs/node-api.md index bed5a0b4e..9329559ea 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -414,4 +414,8 @@ addon avoids UTF-16 string copies at the N-API boundary. - Omit `tsconfig` to use automatic per-workspace resolution; pass it explicitly only to force one config for debugging or compatibility. - Use `analyzeProject()` when several reports share the same root/config. + Batch `testsPlan` with other reports in one `analyzeProject({ reports })` + call so they share the machine-wide lock. `testsPlan()` / `testsImpact()` + return camelCase `executionTargets` (optional `name` for path-prefix + groups) and accept `includeGlob`. - Prefer structured API results over parsing human CLI output. diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 3bedbdde0..aa856aa45 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -59,13 +59,23 @@ const jsonApis = createJsonApis({ symbols: "symbolsJson", }); +async function analyzeProject(options) { + const result = await jsonApis.analyzeProject(options); + for (const report of result.reports || []) { + if (report.type === "testsPlan" || report.type === "testsImpact") { + report.result = planning.camelizeValue(report.result); + } + } + return result; +} + async function version() { return native.version(); } module.exports.createWorkflowTopologyIndex = createWorkflowTopologyIndex; module.exports.version = version; -module.exports.analyzeProject = jsonApis.analyzeProject; +module.exports.analyzeProject = analyzeProject; module.exports.callSites = jsonApis.callSites; module.exports.check = jsonApis.check; module.exports.resolveConfig = jsonApis.resolveConfig; diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index 3e585e8bb..7759dbc5f 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -16,6 +16,20 @@ function createJsonApis(descriptors) { ); } +function camelizeKey(key) { + return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function camelizeValue(value) { + if (Array.isArray(value)) return value.map(camelizeValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [camelizeKey(key), camelizeValue(nested)]), + ); + } + return value; +} + async function testsComment(options) { const input = Buffer.from(JSON.stringify(options || {})); return String(await native.testsCommentMarkdown(input)); @@ -44,8 +58,19 @@ const jsonApis = createJsonApis({ testsWhy: "testsWhyJson", }); +async function testsPlan(options) { + return camelizeValue(await jsonApis.testsPlan(options)); +} + +async function testsImpact(options) { + return camelizeValue(await jsonApis.testsImpact(options)); +} + module.exports = { + camelizeValue, testsComment, testsGraphMermaid, ...jsonApis, + testsImpact, + testsPlan, }; diff --git a/packages/no-mistakes/test-types.d.ts b/packages/no-mistakes/test-types.d.ts index 7cd37e5e1..cf437c2aa 100644 --- a/packages/no-mistakes/test-types.d.ts +++ b/packages/no-mistakes/test-types.d.ts @@ -43,6 +43,8 @@ interface TestsPlanOptionsBase { globalConfigFallback?: boolean; /** Include the markdown PR comment as `comment` on the returned plan. */ includeComment?: boolean; + /** Keep only selected tests whose relative path matches one of these globs. */ + includeGlob?: string[]; } /** @@ -92,18 +94,12 @@ export interface TestsTargetsOptions { export interface TestPlan { /** Complete deterministic changed-file inventory, relative to the request root. */ - changed_files: string[]; - /** @deprecated Prefer `changedFiles` once both keys are present. */ - changedFiles?: string[]; - selected_tests: SelectedTest[]; - selectedTests?: SelectedTest[]; + changedFiles: string[]; + selectedTests: SelectedTest[]; groups?: TestPlanGroup[]; warnings: TestPlanWarning[]; - fallback_triggered: boolean; - fallbackTriggered?: boolean; - fallback_reason?: string | null; + fallbackTriggered: boolean; fallbackReason?: string | null; - execution_targets?: GroupedExecutionTarget[]; executionTargets?: GroupedExecutionTarget[]; comment?: string | null; } @@ -112,16 +108,15 @@ export interface GroupedExecutionTarget { runner: TestPlanFramework; config?: string | null; project?: string | null; - baseCommand?: string[]; - base_command: string[]; - runnerArgs?: string[]; - runner_args: string[]; - testFiles?: string[]; - test_files: string[]; + /** Path-prefix display name, such as a Swift package root. */ + name?: string; + baseCommand: string[]; + runnerArgs: string[]; + testFiles: string[]; } export interface SelectedTest { - test_file: string; + testFile: string; confidence: "low" | "medium" | "high"; reasons: ImpactReason[]; targets?: TestExecutionTarget[]; @@ -133,24 +128,26 @@ export interface TestExecutionTarget { /** True when config is a Vitest workspace/project-array source rendered with --workspace. */ workspace?: boolean; project?: string | null; - base_command: string[]; - runner_args: string[]; + /** Path-prefix display name, such as a Swift package root. */ + name?: string; + baseCommand: string[]; + runnerArgs: string[]; } export interface ImpactReason { - changed_file: string; + changedFile: string; path: string[]; via: string[]; /** When present, aligns index-for-index with `via`. */ - via_details?: Array; + viaDetails?: Array; } export type ImpactEdgeDetail = ResourceImpactEdgeDetail | VitestSetupImpactEdgeDetail; export interface ResourceImpactEdgeDetail { type: "resource"; - consumer_file: string; - call_sites: ResourceCallSite[]; + consumerFile: string; + callSites: ResourceCallSite[]; } export interface VitestSetupImpactEdgeDetail { @@ -159,7 +156,7 @@ export interface VitestSetupImpactEdgeDetail { } export interface ResourceCallSite { - call_kind: ResourceCallKind; + callKind: ResourceCallKind; line: number; } @@ -221,10 +218,8 @@ export interface WhyStep { detail?: ImpactEdgeDetail | null; } -/** A current or pre-`changed_files` plan accepted by saved-plan document APIs. */ -export type SavedTestPlan = Omit & { - changed_files?: string[]; -}; +/** A current or pre-`changedFiles` plan accepted by saved-plan document APIs. */ +export type SavedTestPlan = TestPlan; export interface TestsPlanDocumentOptions { plan?: string; From 25762a47a7ab7069136b873bed036c0fa74db4aa Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 11:47:43 -0700 Subject: [PATCH 02/16] feat: add --profile ci and memoize ciTopology in-process CLI --profile ci forces unbounded command and lock timeouts. Node profile: \"ci\" is stripped from command JSON, and ciTopology() reuses in-process results keyed by root, config path, and mtime. Co-authored-by: Cursor --- crates/no-mistakes/src/invocation.rs | 18 +++++++++++-- .../src/invocation/napi_options.rs | 9 +++++++ crates/no-mistakes/src/invocation/tests.rs | 20 ++++++++++++++ .../src/invocation/tests/napi_options.rs | 12 +++++++++ crates/no-mistakes/tests/cli_invocation.rs | 1 + docs/cli/README.md | 5 ++++ docs/node-api.md | 10 ++++--- packages/no-mistakes/index.js | 26 ++++++++++++++++++- packages/no-mistakes/invocation-types.d.ts | 2 ++ 9 files changed, 96 insertions(+), 7 deletions(-) diff --git a/crates/no-mistakes/src/invocation.rs b/crates/no-mistakes/src/invocation.rs index 393b52fd8..12436c773 100644 --- a/crates/no-mistakes/src/invocation.rs +++ b/crates/no-mistakes/src/invocation.rs @@ -51,6 +51,12 @@ fn deadline_test_lock() -> &'static std::sync::Mutex<()> { LOCK.get_or_init(|| std::sync::Mutex::new(())) } +#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +enum InvocationProfile { + /// Unbounded command and lock wait (`timeout: 0`, `lock-timeout: 0`). + Ci, +} + #[derive(clap::Args, Debug, Clone, Copy)] pub struct InvocationArgs { /// Maximum command execution time in seconds; 0 disables the deadline. @@ -67,6 +73,9 @@ pub struct InvocationArgs { /// Fail immediately when another no-mistakes invocation holds the lock. #[arg(long, global = true)] fail_on_lock: bool, + /// Named timeout defaults. `ci` sets `--timeout 0 --lock-timeout 0`. + #[arg(long, value_enum, global = true)] + profile: Option, } impl Default for InvocationArgs { @@ -75,15 +84,20 @@ impl Default for InvocationArgs { timeout: DEFAULT_TIMEOUT_SECONDS, lock_timeout: DEFAULT_TIMEOUT_SECONDS, fail_on_lock: false, + profile: None, } } } impl InvocationArgs { pub fn options(self) -> InvocationOptions { + let (timeout, lock_timeout) = match self.profile { + Some(InvocationProfile::Ci) => (0, 0), + None => (self.timeout, self.lock_timeout), + }; InvocationOptions { - timeout: nonzero_seconds(self.timeout), - lock_timeout: nonzero_seconds(self.lock_timeout), + timeout: nonzero_seconds(timeout), + lock_timeout: nonzero_seconds(lock_timeout), fail_on_lock: self.fail_on_lock, jobs: None, } diff --git a/crates/no-mistakes/src/invocation/napi_options.rs b/crates/no-mistakes/src/invocation/napi_options.rs index d17022d7c..07b487efa 100644 --- a/crates/no-mistakes/src/invocation/napi_options.rs +++ b/crates/no-mistakes/src/invocation/napi_options.rs @@ -50,6 +50,15 @@ pub fn extract_napi_options_value( } }; let jobs = take_jobs(object)?; + match object.remove("profile") { + None | Some(Value::Null) => {} + Some(Value::String(value)) if value == "ci" => {} + Some(_) => { + return Err(anyhow!( + "invalid options JSON: profile must be \"ci\" when set" + )) + } + } Ok(( value, InvocationOptions { diff --git a/crates/no-mistakes/src/invocation/tests.rs b/crates/no-mistakes/src/invocation/tests.rs index 1b9d92bb0..ddfb69189 100644 --- a/crates/no-mistakes/src/invocation/tests.rs +++ b/crates/no-mistakes/src/invocation/tests.rs @@ -47,6 +47,7 @@ fn cli_defaults_and_zero_values_have_napi_parity() { timeout: 0, lock_timeout: 0, fail_on_lock: true, + profile: None, } .options(), InvocationOptions { @@ -58,6 +59,25 @@ fn cli_defaults_and_zero_values_have_napi_parity() { ); } +#[test] +fn ci_profile_disables_timeouts() { + assert_eq!( + InvocationArgs { + timeout: DEFAULT_TIMEOUT_SECONDS, + lock_timeout: DEFAULT_TIMEOUT_SECONDS, + fail_on_lock: false, + profile: Some(InvocationProfile::Ci), + } + .options(), + InvocationOptions { + timeout: None, + lock_timeout: None, + fail_on_lock: false, + jobs: None, + } + ); +} + #[test] fn disabled_deadline_allows_timeout_check() { let _serial = deadline_test_lock() diff --git a/crates/no-mistakes/src/invocation/tests/napi_options.rs b/crates/no-mistakes/src/invocation/tests/napi_options.rs index 3ec7f91cc..4b75fb6ec 100644 --- a/crates/no-mistakes/src/invocation/tests/napi_options.rs +++ b/crates/no-mistakes/src/invocation/tests/napi_options.rs @@ -40,6 +40,17 @@ fn napi_missing_controls_disable_timeouts() { assert_eq!(options.jobs, None); } +#[test] +fn napi_profile_ci_is_stripped() { + let (json, options) = extract_napi_options(r#"{"profile":"ci","root":"."}"#).unwrap(); + assert_eq!(options.timeout, None); + assert_eq!(options.lock_timeout, None); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + serde_json::json!({"root":"."}) + ); +} + #[test] fn napi_jobs_parses_non_negative_integer_or_null() { let (_, options) = extract_napi_options(r#"{"jobs":4}"#).unwrap(); @@ -59,6 +70,7 @@ fn napi_controls_validate_types() { r#"{"failOnLock":1}"#, r#"{"jobs":-1}"#, r#"{"jobs":"4"}"#, + r#"{"profile":"local"}"#, "[]", "not-json", ] { diff --git a/crates/no-mistakes/tests/cli_invocation.rs b/crates/no-mistakes/tests/cli_invocation.rs index 21e8bb580..39b8da658 100644 --- a/crates/no-mistakes/tests/cli_invocation.rs +++ b/crates/no-mistakes/tests/cli_invocation.rs @@ -68,6 +68,7 @@ fn invocation_help_documents_independent_timeouts_and_lock_failure() { assert!(help.contains("--timeout ")); assert!(help.contains("--lock-timeout ")); assert!(help.contains("--fail-on-lock")); + assert!(help.contains("--profile")); assert!(help.contains("[default: 30]")); } diff --git a/docs/cli/README.md b/docs/cli/README.md index 0833c088b..d708b9d36 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -23,6 +23,11 @@ or after the command name: is `30`; `0` waits indefinitely. - `--fail-on-lock` fails immediately when another invocation holds the lock, overriding `--lock-timeout`. +- `--profile ci` sets `--timeout 0 --lock-timeout 0` for CI jobs that should + wait for the machine-wide lock and run without a command deadline. Node + `profile: "ci"` is stripped the same way; omitted Node timeouts are already + unbounded. `ciTopology()` memoizes in-process by resolved root and config + mtime so repeated calls in one process do not re-parse workflows. Command and lock-wait timeouts exit with status `124`. Immediate lock contention and lock setup errors exit with status `2`. Errors are written to diff --git a/docs/node-api.md b/docs/node-api.md index 9329559ea..f7b0cb4b2 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -414,8 +414,10 @@ addon avoids UTF-16 string copies at the N-API boundary. - Omit `tsconfig` to use automatic per-workspace resolution; pass it explicitly only to force one config for debugging or compatibility. - Use `analyzeProject()` when several reports share the same root/config. - Batch `testsPlan` with other reports in one `analyzeProject({ reports })` - call so they share the machine-wide lock. `testsPlan()` / `testsImpact()` - return camelCase `executionTargets` (optional `name` for path-prefix - groups) and accept `includeGlob`. + Batch `testsPlan` and `ciTopology` in one `analyzeProject({ reports })` call + so they share the machine-wide lock. `testsPlan()` returns camelCase + `executionTargets` (optional `name` for path-prefix groups) and accepts + `includeGlob`. `ciTopology()` is memoized in-process by root and config + mtime; pass `profile: "ci"` (or CLI `--profile ci`) for unbounded + timeouts. - Prefer structured API results over parsing human CLI output. diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index aa856aa45..b69c758b0 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -5,6 +5,8 @@ const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node"); const planning = require("./planning"); const { createWorkflowTopologyIndex } = require("./workflow-topology-index"); +const fs = require("node:fs"); +const path = require("node:path"); async function callJson(fn, options) { const input = Buffer.from(JSON.stringify(options || {})); @@ -69,6 +71,28 @@ async function analyzeProject(options) { return result; } +const topologyMemo = new Map(); + +async function ciTopology(options) { + const root = path.resolve((options && options.root) || process.cwd()); + const configPath = path.resolve(root, (options && options.config) || ".no-mistakes.yml"); + let mtime = 0; + try { + mtime = fs.statSync(configPath).mtimeMs; + } catch { + mtime = 0; + } + const key = `${root}\0${configPath}\0${mtime}`; + const cached = topologyMemo.get(key); + if (cached) return cached; + const pending = jsonApis.ciTopology(options).catch((error) => { + topologyMemo.delete(key); + throw error; + }); + topologyMemo.set(key, pending); + return pending; +} + async function version() { return native.version(); } @@ -81,7 +105,7 @@ module.exports.check = jsonApis.check; module.exports.resolveConfig = jsonApis.resolveConfig; module.exports.ciEnv = jsonApis.ciEnv; module.exports.ciImpact = jsonApis.ciImpact; -module.exports.ciTopology = jsonApis.ciTopology; +module.exports.ciTopology = ciTopology; module.exports.dataPw = jsonApis.dataPw; module.exports.deadExports = jsonApis.deadExports; module.exports.dependencies = jsonApis.dependencies; diff --git a/packages/no-mistakes/invocation-types.d.ts b/packages/no-mistakes/invocation-types.d.ts index 2f160942b..4275219f5 100644 --- a/packages/no-mistakes/invocation-types.d.ts +++ b/packages/no-mistakes/invocation-types.d.ts @@ -11,6 +11,8 @@ export interface InvocationOptions { * `0` uses the CPU count, matching CLI `--jobs 0`. */ jobs?: number | null; + /** `ci` sets unbounded command and lock timeouts. CLI `--profile ci` does the same. */ + profile?: "ci"; } export type WithInvocationOptions = T & InvocationOptions; From 35a377fe3baf4a71550e30be7a5f8b2ceca32cc4 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:03:27 -0700 Subject: [PATCH 03/16] fix: keep Node plan documents round-trippable after camelCase Decamelize planJson for comment/graph APIs, camelize testsTargets/testsWhy/ testsGraph results, name Swift prefixes only on Swift runners, and align the declaration tests plus includeGlob docs with testsPlan-only. Co-authored-by: Cursor --- crates/no-mistakes/src/tests/plan_finish.rs | 6 ++- .../src/tests/plan_finish/tests.rs | 13 ++++++ docs/cli/tests-plan.md | 4 +- docs/node-api.md | 4 +- packages/no-mistakes/planning.js | 45 ++++++++++++++++--- packages/no-mistakes/scripts/api.test.js | 7 +-- 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/no-mistakes/src/tests/plan_finish.rs b/crates/no-mistakes/src/tests/plan_finish.rs index 0aa20aa0e..114daf813 100644 --- a/crates/no-mistakes/src/tests/plan_finish.rs +++ b/crates/no-mistakes/src/tests/plan_finish.rs @@ -26,7 +26,11 @@ fn grouped_execution_targets( let mut groups: BTreeMap = BTreeMap::new(); for test in selected { for target in &test.targets { - let name = prefix_name(&test.test_file, prefixes); + let name = if target.runner == "swift" { + prefix_name(&test.test_file, prefixes) + } else { + None + }; let key = ( target.runner.clone(), target.config.clone(), diff --git a/crates/no-mistakes/src/tests/plan_finish/tests.rs b/crates/no-mistakes/src/tests/plan_finish/tests.rs index 3ff0846e2..1fe7fab8f 100644 --- a/crates/no-mistakes/src/tests/plan_finish/tests.rs +++ b/crates/no-mistakes/src/tests/plan_finish/tests.rs @@ -104,3 +104,16 @@ fn path_prefixes_split_and_name_execution_targets() { ] ); } + +#[test] +fn path_prefixes_name_only_swift_execution_targets() { + let groups = grouped_execution_targets( + &[selected( + "swift-clients/core/web.test.ts", + vec![target("vitest", None, &["vitest", "run"])], + )], + &["swift-clients/core".into()], + ); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].name, None); +} diff --git a/docs/cli/tests-plan.md b/docs/cli/tests-plan.md index 9c1386efd..5bf1774e7 100644 --- a/docs/cli/tests-plan.md +++ b/docs/cli/tests-plan.md @@ -60,8 +60,8 @@ JSON plans from the CLI keep snake_case keys. The Node `testsPlan()` / `testsImpact()` APIs return camelCase only (`changedFiles`, `selectedTests`, `executionTargets`, `fallbackTriggered`). `executionTargets` is the CI contract: tests grouped by runner, config, project, and optional path-prefix -`name` (Swift packages). `--include-glob` / `includeGlob` keeps only selected -tests whose relative path matches. +`name` (Swift packages). `--include-glob` / `includeGlob` on `testsPlan()` +keeps only selected tests whose relative path matches. JSON plans include `changed_files`, the sorted, deduplicated, root-relative inventory prepared by that invocation. It is present even when no tests are diff --git a/docs/node-api.md b/docs/node-api.md index 9329559ea..2c537bff2 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -416,6 +416,6 @@ addon avoids UTF-16 string copies at the N-API boundary. - Use `analyzeProject()` when several reports share the same root/config. Batch `testsPlan` with other reports in one `analyzeProject({ reports })` call so they share the machine-wide lock. `testsPlan()` / `testsImpact()` - return camelCase `executionTargets` (optional `name` for path-prefix - groups) and accept `includeGlob`. + return camelCase `executionTargets` (optional `name` for Swift path-prefix + groups). `includeGlob` is a `testsPlan()` option. - Prefer structured API results over parsing human CLI output. diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index 7759dbc5f..7d7c78c65 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -20,23 +20,43 @@ function camelizeKey(key) { return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } -function camelizeValue(value) { - if (Array.isArray(value)) return value.map(camelizeValue); +function decamelizeKey(key) { + return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +} + +function mapKeys(value, mapKey) { + if (Array.isArray(value)) return value.map((item) => mapKeys(item, mapKey)); if (value && typeof value === "object") { return Object.fromEntries( - Object.entries(value).map(([key, nested]) => [camelizeKey(key), camelizeValue(nested)]), + Object.entries(value).map(([key, nested]) => [mapKey(key), mapKeys(nested, mapKey)]), ); } return value; } +function camelizeValue(value) { + return mapKeys(value, camelizeKey); +} + +function decamelizeValue(value) { + return mapKeys(value, decamelizeKey); +} + +function decamelizePlanOptions(options) { + const next = { ...(options || {}) }; + if (next.planJson && typeof next.planJson === "object") { + next.planJson = decamelizeValue(next.planJson); + } + return next; +} + async function testsComment(options) { - const input = Buffer.from(JSON.stringify(options || {})); + const input = Buffer.from(JSON.stringify(decamelizePlanOptions(options))); return String(await native.testsCommentMarkdown(input)); } async function testsGraphMermaid(options) { - const input = Buffer.from(JSON.stringify(options || {})); + const input = Buffer.from(JSON.stringify(decamelizePlanOptions(options))); return String(await native.testsGraphMermaid(input)); } @@ -66,11 +86,26 @@ async function testsImpact(options) { return camelizeValue(await jsonApis.testsImpact(options)); } +async function testsTargets(options) { + return camelizeValue(await jsonApis.testsTargets(options)); +} + +async function testsWhy(options) { + return camelizeValue(await jsonApis.testsWhy(options)); +} + +async function testsGraph(options) { + return camelizeValue(await jsonApis.testsGraph(decamelizePlanOptions(options))); +} + module.exports = { camelizeValue, testsComment, testsGraphMermaid, ...jsonApis, + testsGraph, testsImpact, testsPlan, + testsTargets, + testsWhy, }; diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 7f0246c3b..f4ab8dabc 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -572,11 +572,8 @@ test("test plan declarations require current results but accept saved legacy pla declarations, /export interface TestPlan \{\n \/\*\* Complete deterministic changed-file inventory/, ); - assert.match(declarations, /\n changed_files: string\[\];/); - assert.match( - declarations, - /export type SavedTestPlan = Omit & \{\n changed_files\?: string\[\];\n\};/, - ); + assert.match(declarations, /\n changedFiles: string\[\];/); + assert.match(declarations, /export type SavedTestPlan = TestPlan;/); assert.match(declarations, /planJson\?: SavedTestPlan \| string;/); assert.match(declarations, /export type TestsPlanOptions =/); assert.match( From ea5f6db34c90a838672e2f64377d3ff0aa1c5ec1 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:15:36 -0700 Subject: [PATCH 04/16] docs: resolve node-api merge conflict markers The stacked merge left conflict markers in Agent Defaults; keep both the camelCase TestPlan notes and ciTopology memo / profile ci docs. Co-authored-by: Cursor --- docs/node-api.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/node-api.md b/docs/node-api.md index c66f5ae72..0c3a40f7e 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -414,17 +414,10 @@ addon avoids UTF-16 string copies at the N-API boundary. - Omit `tsconfig` to use automatic per-workspace resolution; pass it explicitly only to force one config for debugging or compatibility. - Use `analyzeProject()` when several reports share the same root/config. -<<<<<<< HEAD Batch `testsPlan` and `ciTopology` in one `analyzeProject({ reports })` call - so they share the machine-wide lock. `testsPlan()` returns camelCase - `executionTargets` (optional `name` for path-prefix groups) and accepts - `includeGlob`. `ciTopology()` is memoized in-process by root and config - mtime; pass `profile: "ci"` (or CLI `--profile ci`) for unbounded - timeouts. -======= - Batch `testsPlan` with other reports in one `analyzeProject({ reports })` - call so they share the machine-wide lock. `testsPlan()` / `testsImpact()` - return camelCase `executionTargets` (optional `name` for Swift path-prefix - groups). `includeGlob` is a `testsPlan()` option. ->>>>>>> feat/testplan-js-contract + so they share the machine-wide lock. `testsPlan()` / `testsImpact()` return + camelCase `executionTargets` (optional `name` for Swift path-prefix groups). + `includeGlob` is a `testsPlan()` option. `ciTopology()` is memoized + in-process by root and config mtime; pass `profile: "ci"` (or CLI + `--profile ci`) for unbounded timeouts. - Prefer structured API results over parsing human CLI output. From 965b1378864a67e9dfb47092216286cf15111615 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:24:54 -0700 Subject: [PATCH 05/16] fix: decamelize string planJson and keep testsWhy path keys JSON.stringify(testsPlan()) must round-trip through comment/graph APIs, analyzeProject document reports need the same conversion, and testsWhy must not camelize changed-file map keys. Co-authored-by: Cursor --- packages/no-mistakes/index.js | 12 ++++++++++-- packages/no-mistakes/planning.js | 30 +++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index aa856aa45..bbfbf7dfc 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -59,8 +59,16 @@ const jsonApis = createJsonApis({ symbols: "symbolsJson", }); -async function analyzeProject(options) { - const result = await jsonApis.analyzeProject(options); +const DOCUMENT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid"]); + +async function analyzeProject(options = {}) { + const request = { ...options }; + if (Array.isArray(request.reports)) { + request.reports = request.reports.map((report) => + DOCUMENT_REPORTS.has(report.type) ? planning.decamelizePlanOptions(report) : report, + ); + } + const result = await jsonApis.analyzeProject(request); for (const report of result.reports || []) { if (report.type === "testsPlan" || report.type === "testsImpact") { report.result = planning.camelizeValue(report.result); diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index 7d7c78c65..0e31f4b06 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -42,14 +42,33 @@ function decamelizeValue(value) { return mapKeys(value, decamelizeKey); } -function decamelizePlanOptions(options) { - const next = { ...(options || {}) }; - if (next.planJson && typeof next.planJson === "object") { - next.planJson = decamelizeValue(next.planJson); +function decamelizePlanOptions(options = {}) { + const next = { ...options }; + if (next.planJson != null) { + let parsed = next.planJson; + if (typeof parsed === "string") { + try { + parsed = JSON.parse(parsed); + } catch { + parsed = next.planJson; + } + } + if (parsed && typeof parsed === "object") { + next.planJson = decamelizeValue(parsed); + } } return next; } +function camelizeWhy(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return camelizeValue(value); + } + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, camelizeValue(nested)]), + ); +} + async function testsComment(options) { const input = Buffer.from(JSON.stringify(decamelizePlanOptions(options))); return String(await native.testsCommentMarkdown(input)); @@ -91,7 +110,7 @@ async function testsTargets(options) { } async function testsWhy(options) { - return camelizeValue(await jsonApis.testsWhy(options)); + return camelizeWhy(await jsonApis.testsWhy(options)); } async function testsGraph(options) { @@ -100,6 +119,7 @@ async function testsGraph(options) { module.exports = { camelizeValue, + decamelizePlanOptions, testsComment, testsGraphMermaid, ...jsonApis, From 09c847cfc7c55253b49e4738cc3e224adb637fcb Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:40:04 -0700 Subject: [PATCH 06/16] fix: key ciTopology memo by workflows and honor profile ci timeouts Different workflow filters must not share a cached topology, and Node profile: \"ci\" must clear supplied command and lock deadlines like the CLI. Co-authored-by: Cursor --- crates/no-mistakes/src/invocation/napi_options.rs | 12 ++++++------ .../src/invocation/tests/napi_options.rs | 4 +++- docs/cli/README.md | 6 +++--- docs/node-api.md | 4 ++-- packages/no-mistakes/index.js | 15 ++++++++++++--- packages/no-mistakes/scripts/api.test.js | 7 +++++++ 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/no-mistakes/src/invocation/napi_options.rs b/crates/no-mistakes/src/invocation/napi_options.rs index 07b487efa..d3d276f51 100644 --- a/crates/no-mistakes/src/invocation/napi_options.rs +++ b/crates/no-mistakes/src/invocation/napi_options.rs @@ -50,20 +50,20 @@ pub fn extract_napi_options_value( } }; let jobs = take_jobs(object)?; - match object.remove("profile") { - None | Some(Value::Null) => {} - Some(Value::String(value)) if value == "ci" => {} + let profile_ci = match object.remove("profile") { + None | Some(Value::Null) => false, + Some(Value::String(value)) if value == "ci" => true, Some(_) => { return Err(anyhow!( "invalid options JSON: profile must be \"ci\" when set" )) } - } + }; Ok(( value, InvocationOptions { - timeout, - lock_timeout, + timeout: if profile_ci { None } else { timeout }, + lock_timeout: if profile_ci { None } else { lock_timeout }, fail_on_lock, jobs, }, diff --git a/crates/no-mistakes/src/invocation/tests/napi_options.rs b/crates/no-mistakes/src/invocation/tests/napi_options.rs index 4b75fb6ec..64b8e1bb2 100644 --- a/crates/no-mistakes/src/invocation/tests/napi_options.rs +++ b/crates/no-mistakes/src/invocation/tests/napi_options.rs @@ -42,7 +42,9 @@ fn napi_missing_controls_disable_timeouts() { #[test] fn napi_profile_ci_is_stripped() { - let (json, options) = extract_napi_options(r#"{"profile":"ci","root":"."}"#).unwrap(); + let (json, options) = + extract_napi_options(r#"{"profile":"ci","timeout":10,"lockTimeout":5,"root":"."}"#) + .unwrap(); assert_eq!(options.timeout, None); assert_eq!(options.lock_timeout, None); assert_eq!( diff --git a/docs/cli/README.md b/docs/cli/README.md index d708b9d36..901b176e2 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -25,9 +25,9 @@ or after the command name: overriding `--lock-timeout`. - `--profile ci` sets `--timeout 0 --lock-timeout 0` for CI jobs that should wait for the machine-wide lock and run without a command deadline. Node - `profile: "ci"` is stripped the same way; omitted Node timeouts are already - unbounded. `ciTopology()` memoizes in-process by resolved root and config - mtime so repeated calls in one process do not re-parse workflows. + `profile: "ci"` clears any supplied `timeout` / `lockTimeout` the same way. + `ciTopology()` memoizes in-process by resolved root, config mtime, and + workflows filter so repeated calls in one process do not re-parse workflows. Command and lock-wait timeouts exit with status `124`. Immediate lock contention and lock setup errors exit with status `2`. Errors are written to diff --git a/docs/node-api.md b/docs/node-api.md index 0c3a40f7e..e5fc04fe6 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -418,6 +418,6 @@ addon avoids UTF-16 string copies at the N-API boundary. so they share the machine-wide lock. `testsPlan()` / `testsImpact()` return camelCase `executionTargets` (optional `name` for Swift path-prefix groups). `includeGlob` is a `testsPlan()` option. `ciTopology()` is memoized - in-process by root and config mtime; pass `profile: "ci"` (or CLI - `--profile ci`) for unbounded timeouts. + in-process by root, config mtime, and workflows filter; pass `profile: "ci"` + (or CLI `--profile ci`) to clear command and lock timeouts. - Prefer structured API results over parsing human CLI output. diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 49820fafc..9530b5fb6 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -90,15 +90,24 @@ async function ciTopology(options) { } catch { mtime = 0; } - const key = `${root}\0${configPath}\0${mtime}`; + const workflows = JSON.stringify( + [...((options && options.workflows) || [])].map(String).sort(), + ); + const identity = `${root}\0${configPath}\0`; + const key = `${identity}${mtime}\0${workflows}`; + for (const memoKey of [...topologyMemo.keys()]) { + if (!memoKey.startsWith(identity)) continue; + const memoMtime = memoKey.slice(identity.length).split("\0")[0]; + if (memoMtime !== String(mtime)) topologyMemo.delete(memoKey); + } const cached = topologyMemo.get(key); - if (cached) return cached; + if (cached) return cached.then((value) => structuredClone(value)); const pending = jsonApis.ciTopology(options).catch((error) => { topologyMemo.delete(key); throw error; }); topologyMemo.set(key, pending); - return pending; + return pending.then((value) => structuredClone(value)); } async function version() { diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index f4ab8dabc..437ad10e2 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -327,6 +327,13 @@ test("programmatic API proxies object options through async native addon calls", "swiftTestTargets", ); assert.equal((await api.ciTopology({ workflows: ["ci.yml"] })).options.workflows[0], "ci.yml"); + assert.equal( + (await api.ciTopology({ workflows: ["deploy.yml"] })).options.workflows[0], + "deploy.yml", + ); + const cached = await api.ciTopology({ workflows: ["ci.yml"] }); + cached.options.workflows[0] = "mutated.yml"; + assert.equal((await api.ciTopology({ workflows: ["ci.yml"] })).options.workflows[0], "ci.yml"); assert.equal(await api.version(), "1.2.3"); } finally { delete require.cache[require.resolve(indexPath)]; From 97f12e5dcec2b42cbe47d29b466956cb6c2be2f5 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:41:48 -0700 Subject: [PATCH 07/16] style: oxfmt ciTopology memo helper Co-authored-by: Cursor --- packages/no-mistakes/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 9530b5fb6..66740eb54 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -90,9 +90,7 @@ async function ciTopology(options) { } catch { mtime = 0; } - const workflows = JSON.stringify( - [...((options && options.workflows) || [])].map(String).sort(), - ); + const workflows = JSON.stringify([...((options && options.workflows) || [])].map(String).sort()); const identity = `${root}\0${configPath}\0`; const key = `${identity}${mtime}\0${workflows}`; for (const memoKey of [...topologyMemo.keys()]) { From 433265b00285340745904d02b42eae92497ca7dd Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:42:38 -0700 Subject: [PATCH 08/16] lint: avoid copying Map keys just to iterate them Co-authored-by: Cursor --- packages/no-mistakes/index.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 66740eb54..36bbea4b0 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -90,14 +90,21 @@ async function ciTopology(options) { } catch { mtime = 0; } - const workflows = JSON.stringify([...((options && options.workflows) || [])].map(String).sort()); + const workflows = JSON.stringify( + [] + .concat((options && options.workflows) || []) + .map(String) + .sort(), + ); const identity = `${root}\0${configPath}\0`; const key = `${identity}${mtime}\0${workflows}`; - for (const memoKey of [...topologyMemo.keys()]) { + const stale = []; + for (const memoKey of topologyMemo.keys()) { if (!memoKey.startsWith(identity)) continue; const memoMtime = memoKey.slice(identity.length).split("\0")[0]; - if (memoMtime !== String(mtime)) topologyMemo.delete(memoKey); + if (memoMtime !== String(mtime)) stale.push(memoKey); } + for (const memoKey of stale) topologyMemo.delete(memoKey); const cached = topologyMemo.get(key); if (cached) return cached.then((value) => structuredClone(value)); const pending = jsonApis.ciTopology(options).catch((error) => { From 099a138144a74e625e4ca7d4c000652bcfea6799 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:47:20 -0700 Subject: [PATCH 09/16] fix: round-trip camelCase TestPlan files and batched reports Saved testsPlan JSON loaded via plan must decamelize like planJson, and analyzeProject must camelize testsTargets, testsGraph, and testsWhy. Co-authored-by: Cursor --- docs/node-api.md | 18 ++++++------ packages/no-mistakes/README.md | 2 +- packages/no-mistakes/index.js | 14 +++++++-- packages/no-mistakes/planning.js | 36 ++++++++++++++++-------- packages/no-mistakes/scripts/api.test.js | 7 ++++- 5 files changed, 52 insertions(+), 25 deletions(-) diff --git a/docs/node-api.md b/docs/node-api.md index 2c537bff2..16e0200a8 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -151,7 +151,7 @@ does not have a one-to-one CLI command: `testsTargets()` and test-plan targets set `workspace: true` when a Vitest workspace/project-array source must be passed with `--workspace`; the emitted -`runner_args` already contain the correct flag. This includes configured and +`runnerArgs` already contain the correct flag. This includes configured and default-discovered `vitest.workspace.*` and `vitest.projects.*` sources, including JSON project arrays, matching the CLI. A default-discovered root workspace/project-array source takes precedence over sibling @@ -186,7 +186,7 @@ tRPC router procedures and client calls through virtual nodes identified as and `procedure`; `FlowNode` uses `kind: "trpc-procedure"`. `all` does not include `trpc`. Empty `projects.*.trpc.routers` lists disable extraction. -`testsPlan(options)` returns `changed_files`, the sorted, deduplicated +`testsPlan(options)` returns `changedFiles`, the sorted, deduplicated changed-file inventory prepared by that same call, relative to the request root. The field is present even when no tests are selected and retains deleted paths plus both sides of detected renames and copies. @@ -202,7 +202,7 @@ Set `directTestOwner: true` with an explicit `framework` to select only changed framework-owned tests and framework-owned tests one reverse canonical graph edge away. This bypasses test-plan environment policy (including groups, limits, samples, fallback, and include/exclude filtering), attaches normal execution -targets, and returns a `direct-test-owner` group with `fallback_triggered: +targets, and returns a `direct-test-owner` group with `fallbackTriggered: false`. `limitPercent`, `limitFiles`, and `globalConfigFallback` conflict with this option. `entrypoints` also conflicts with it: direct-owner selection is bounded to changed files and one reverse canonical graph edge, so use @@ -214,7 +214,7 @@ The TypeScript declaration models this as a discriminated option: direct-owner plans require `framework`, while ordinary plans omit `directTestOwner` or set it to `false`. -`testsPlan(options)` returns `fallback_triggered` and `fallback_reason` when a +`testsPlan(options)` returns `fallbackTriggered` and `fallbackReason` when a `dotnet` or `swift` plan has to fall back from native graph tracing to framework-scoped discovered tests. Vitest plans also use this surface for a dynamic or unresolved `setupFiles`/`globalSetup` declaration: the result is @@ -222,7 +222,7 @@ bounded to its known project owner when possible. Its helper closure follows ordinary static imports/re-exports and literal CommonJS `require(...)` or `require.resolve(...)` dependencies, retaining edits and deletions as owner triggers; computed or non-literal forms are not followed. Resolved setup paths -use `via: ["vitest-setup"]` and may add `via_details`, an optional array aligned +use `via: ["vitest-setup"]` and may add `viaDetails`, an optional array aligned with `via` whose setup edge detail is `{ type: "vitest-setup", field: "setupFiles" | "globalSetup" }`. @@ -247,14 +247,14 @@ see `docs/cli/tests-plan.md`. The API uses the same target-scoped `fullSuiteTriggers.projects` behavior as the CLI. A `{ paths, targets }` match selects only tests owned by those runner projects, emits `configured-trigger` reasons and execution targets, and leaves -`fallback_triggered` false. Semantic `.no-mistakes.yml`/`.yaml` invalidation is +`fallbackTriggered` false. Semantic `.no-mistakes.yml`/`.yaml` invalidation is also identical for revision and inline-diff inputs. `testsPlan`, `testsImpact`, `testsWhy`, and `testsGraph` expose resource-edge provenance without a separate API: plan reasons use optional edge-aligned -`via_details`, why steps use optional `detail`, and graph JSON edges use -optional `detail`. Details are `{ type: "resource", consumer_file, -call_sites: [{ call_kind, line }] }` for literal runtime filesystem edges or +`viaDetails`, why steps use optional `detail`, and graph JSON edges use +optional `detail`. Details are `{ type: "resource", consumerFile, +callSites: [{ callKind, line }] }` for literal runtime filesystem edges or `{ type: "vitest-setup", field: "setupFiles" | "globalSetup" }` for setup edges. diff --git a/packages/no-mistakes/README.md b/packages/no-mistakes/README.md index 27993fd4b..8f1fc44a9 100644 --- a/packages/no-mistakes/README.md +++ b/packages/no-mistakes/README.md @@ -70,7 +70,7 @@ const { changedFiles: ["src/utils.mts"], }); // Complete changed-file inventory, including paths that selected no tests. - console.log(plan.changed_files); + console.log(plan.changedFiles); const targetCommands = await testsTargets({ root: process.cwd(), framework: "vitest", diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index bbfbf7dfc..e95cb8e0d 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -59,18 +59,26 @@ const jsonApis = createJsonApis({ symbols: "symbolsJson", }); -const DOCUMENT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid"]); +const PLAN_INPUT_REPORTS = new Set([ + "testsComment", + "testsGraph", + "testsGraphMermaid", + "testsWhy", +]); +const CAMELIZE_REPORTS = new Set(["testsPlan", "testsImpact", "testsTargets", "testsGraph"]); async function analyzeProject(options = {}) { const request = { ...options }; if (Array.isArray(request.reports)) { request.reports = request.reports.map((report) => - DOCUMENT_REPORTS.has(report.type) ? planning.decamelizePlanOptions(report) : report, + PLAN_INPUT_REPORTS.has(report.type) ? planning.decamelizePlanOptions(report) : report, ); } const result = await jsonApis.analyzeProject(request); for (const report of result.reports || []) { - if (report.type === "testsPlan" || report.type === "testsImpact") { + if (report.type === "testsWhy") { + report.result = planning.camelizeWhy(report.result); + } else if (CAMELIZE_REPORTS.has(report.type)) { report.result = planning.camelizeValue(report.result); } } diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index 0e31f4b06..c7edd058a 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -1,5 +1,6 @@ "use strict"; +const fs = require("node:fs"); const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node"); async function callJson(fn, options) { @@ -42,19 +43,31 @@ function decamelizeValue(value) { return mapKeys(value, decamelizeKey); } +function loadPlanJson(planJson) { + let parsed = planJson; + if (typeof parsed === "string") { + try { + parsed = JSON.parse(parsed); + } catch { + return planJson; + } + } + if (parsed && typeof parsed === "object") { + return decamelizeValue(parsed); + } + return planJson; +} + function decamelizePlanOptions(options = {}) { const next = { ...options }; if (next.planJson != null) { - let parsed = next.planJson; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch { - parsed = next.planJson; - } - } - if (parsed && typeof parsed === "object") { - next.planJson = decamelizeValue(parsed); + next.planJson = loadPlanJson(next.planJson); + } else if (typeof next.plan === "string") { + try { + next.planJson = loadPlanJson(fs.readFileSync(next.plan, "utf8")); + delete next.plan; + } catch { + // Native still loads missing or invalid plan paths. } } return next; @@ -110,7 +123,7 @@ async function testsTargets(options) { } async function testsWhy(options) { - return camelizeWhy(await jsonApis.testsWhy(options)); + return camelizeWhy(await jsonApis.testsWhy(decamelizePlanOptions(options))); } async function testsGraph(options) { @@ -119,6 +132,7 @@ async function testsGraph(options) { module.exports = { camelizeValue, + camelizeWhy, decamelizePlanOptions, testsComment, testsGraphMermaid, diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index f4ab8dabc..540cbf083 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -1,7 +1,8 @@ const assert = require("node:assert/strict"); const test = globalThis.test || require("node:test").test; -const { readFileSync } = require("node:fs"); +const { readFileSync, writeFileSync, mkdtempSync } = require("node:fs"); const { join } = require("node:path"); +const { tmpdir } = require("node:os"); const { pathToFileURL } = require("node:url"); const packageRoot = join(__dirname, ".."); @@ -283,6 +284,10 @@ test("programmatic API proxies object options through async native addon calls", "testsGraph", ); assert.equal(await api.testsGraphMermaid({ planJson: { selected_tests: [] } }), "graph:0"); + const planDir = mkdtempSync(join(tmpdir(), "no-mistakes-plan-")); + const planPath = join(planDir, "plan.json"); + writeFileSync(planPath, JSON.stringify({ selectedTests: [] })); + assert.equal(await api.testsGraphMermaid({ plan: planPath }), "graph:0"); assert.equal((await api.playwrightCheck({ root: "." })).command, "playwrightCheck"); assert.equal((await api.playwrightEdges({ root: "." })).command, "playwrightEdges"); assert.equal( From 0975ed25616e788549ed032eaf00fa348ce54564 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:48:35 -0700 Subject: [PATCH 10/16] style: oxfmt analyzeProject camelize dispatch Co-authored-by: Cursor --- packages/no-mistakes/index.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index e95cb8e0d..329fab752 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -59,12 +59,7 @@ const jsonApis = createJsonApis({ symbols: "symbolsJson", }); -const PLAN_INPUT_REPORTS = new Set([ - "testsComment", - "testsGraph", - "testsGraphMermaid", - "testsWhy", -]); +const PLAN_INPUT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid", "testsWhy"]); const CAMELIZE_REPORTS = new Set(["testsPlan", "testsImpact", "testsTargets", "testsGraph"]); async function analyzeProject(options = {}) { From dfab19c1cf3fae2e7bd64f9b7a7109434306028a Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 12:56:21 -0700 Subject: [PATCH 11/16] fix: keep testsWhy on plan paths instead of planJson Native TestsWhyOptions rejects planJson. Materialize camelCase saved plans to a snake_case temp file and pass that as plan for both standalone and batched why reports. Co-authored-by: Cursor --- packages/no-mistakes/index.js | 9 +++++---- packages/no-mistakes/planning.js | 26 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 329fab752..6e278a594 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -59,15 +59,16 @@ const jsonApis = createJsonApis({ symbols: "symbolsJson", }); -const PLAN_INPUT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid", "testsWhy"]); +const PLAN_INPUT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid"]); const CAMELIZE_REPORTS = new Set(["testsPlan", "testsImpact", "testsTargets", "testsGraph"]); async function analyzeProject(options = {}) { const request = { ...options }; if (Array.isArray(request.reports)) { - request.reports = request.reports.map((report) => - PLAN_INPUT_REPORTS.has(report.type) ? planning.decamelizePlanOptions(report) : report, - ); + request.reports = request.reports.map((report) => { + if (report.type === "testsWhy") return planning.materializeWhyPlan(report); + return PLAN_INPUT_REPORTS.has(report.type) ? planning.decamelizePlanOptions(report) : report; + }); } const result = await jsonApis.analyzeProject(request); for (const report of result.reports || []) { diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index c7edd058a..a0be13509 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -1,6 +1,8 @@ "use strict"; const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node"); async function callJson(fn, options) { @@ -73,6 +75,27 @@ function decamelizePlanOptions(options = {}) { return next; } +function materializeWhyPlan(options = {}) { + const next = { ...options }; + let document = next.planJson; + if (document == null && typeof next.plan === "string") { + try { + document = JSON.parse(fs.readFileSync(next.plan, "utf8")); + } catch { + return next; + } + } + if (document == null) return next; + const tmp = path.join( + os.tmpdir(), + `no-mistakes-why-plan-${process.pid}-${Date.now().toString(36)}.json`, + ); + fs.writeFileSync(tmp, JSON.stringify(loadPlanJson(document))); + next.plan = tmp; + delete next.planJson; + return next; +} + function camelizeWhy(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return camelizeValue(value); @@ -123,7 +146,7 @@ async function testsTargets(options) { } async function testsWhy(options) { - return camelizeWhy(await jsonApis.testsWhy(decamelizePlanOptions(options))); + return camelizeWhy(await jsonApis.testsWhy(materializeWhyPlan(options))); } async function testsGraph(options) { @@ -134,6 +157,7 @@ module.exports = { camelizeValue, camelizeWhy, decamelizePlanOptions, + materializeWhyPlan, testsComment, testsGraphMermaid, ...jsonApis, From ddd7c9eff9d6d42270f3af35012877957c71e8b6 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 17:26:11 -0700 Subject: [PATCH 12/16] fix: give each materialized testsWhy plan its own temp directory Batched analyzeProject why reports can share a PID/timestamp filename; mkdtempSync keeps each saved plan isolated. Co-authored-by: Cursor --- packages/no-mistakes/planning.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index a0be13509..c6dd14a36 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -86,10 +86,7 @@ function materializeWhyPlan(options = {}) { } } if (document == null) return next; - const tmp = path.join( - os.tmpdir(), - `no-mistakes-why-plan-${process.pid}-${Date.now().toString(36)}.json`, - ); + const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "no-mistakes-why-")), "plan.json"); fs.writeFileSync(tmp, JSON.stringify(loadPlanJson(document))); next.plan = tmp; delete next.planJson; From 8ea9180647fe8341186578a05c832fd78e0ab3a5 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 17:43:13 -0700 Subject: [PATCH 13/16] fix: remove generated testsWhy plan directories after native calls Prepare plan documents with fs/promises and always delete mkdtemp dirs after standalone and batched why reports, including native rejections. Co-authored-by: Cursor --- packages/no-mistakes/index.js | 39 +++++++++----- packages/no-mistakes/planning.js | 62 +++++++++++++-------- packages/no-mistakes/scripts/api.test.js | 68 +++++++++++++++++++++++- 3 files changed, 134 insertions(+), 35 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 6e278a594..d9c2cc025 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -64,21 +64,34 @@ const CAMELIZE_REPORTS = new Set(["testsPlan", "testsImpact", "testsTargets", "t async function analyzeProject(options = {}) { const request = { ...options }; - if (Array.isArray(request.reports)) { - request.reports = request.reports.map((report) => { - if (report.type === "testsWhy") return planning.materializeWhyPlan(report); - return PLAN_INPUT_REPORTS.has(report.type) ? planning.decamelizePlanOptions(report) : report; - }); - } - const result = await jsonApis.analyzeProject(request); - for (const report of result.reports || []) { - if (report.type === "testsWhy") { - report.result = planning.camelizeWhy(report.result); - } else if (CAMELIZE_REPORTS.has(report.type)) { - report.result = planning.camelizeValue(report.result); + const generatedDirs = []; + try { + if (Array.isArray(request.reports)) { + request.reports = await Promise.all( + request.reports.map(async (report) => { + if (report.type === "testsWhy") { + const prepared = await planning.prepareWhyPlan(report); + if (prepared.generatedDir) generatedDirs.push(prepared.generatedDir); + return prepared.request; + } + return PLAN_INPUT_REPORTS.has(report.type) + ? await planning.decamelizePlanOptions(report) + : report; + }), + ); + } + const result = await jsonApis.analyzeProject(request); + for (const report of result.reports || []) { + if (report.type === "testsWhy") { + report.result = planning.camelizeWhy(report.result); + } else if (CAMELIZE_REPORTS.has(report.type)) { + report.result = planning.camelizeValue(report.result); + } } + return result; + } finally { + await Promise.all(generatedDirs.map((dir) => planning.removeGeneratedDir(dir))); } - return result; } async function version() { diff --git a/packages/no-mistakes/planning.js b/packages/no-mistakes/planning.js index c6dd14a36..cdd6a9423 100644 --- a/packages/no-mistakes/planning.js +++ b/packages/no-mistakes/planning.js @@ -1,6 +1,6 @@ "use strict"; -const fs = require("node:fs"); +const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node"); @@ -60,37 +60,50 @@ function loadPlanJson(planJson) { return planJson; } -function decamelizePlanOptions(options = {}) { +async function readPlanFile(planPath) { + try { + return JSON.parse(await fs.readFile(planPath, "utf8")); + } catch { + return undefined; + } +} + +async function decamelizePlanOptions(options = {}) { const next = { ...options }; if (next.planJson != null) { next.planJson = loadPlanJson(next.planJson); } else if (typeof next.plan === "string") { - try { - next.planJson = loadPlanJson(fs.readFileSync(next.plan, "utf8")); + const document = await readPlanFile(next.plan); + if (document !== undefined) { + next.planJson = loadPlanJson(document); delete next.plan; - } catch { - // Native still loads missing or invalid plan paths. } } return next; } -function materializeWhyPlan(options = {}) { +async function prepareWhyPlan(options = {}) { const next = { ...options }; let document = next.planJson; if (document == null && typeof next.plan === "string") { - try { - document = JSON.parse(fs.readFileSync(next.plan, "utf8")); - } catch { - return next; - } + document = await readPlanFile(next.plan); + if (document === undefined) return { request: next }; } - if (document == null) return next; - const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "no-mistakes-why-")), "plan.json"); - fs.writeFileSync(tmp, JSON.stringify(loadPlanJson(document))); - next.plan = tmp; + if (document == null) return { request: next }; + const generatedDir = await fs.mkdtemp(path.join(os.tmpdir(), "no-mistakes-why-")); + await fs.writeFile(path.join(generatedDir, "plan.json"), JSON.stringify(loadPlanJson(document))); + next.plan = path.join(generatedDir, "plan.json"); delete next.planJson; - return next; + return { request: next, generatedDir }; +} + +async function materializeWhyPlan(options = {}) { + return (await prepareWhyPlan(options)).request; +} + +async function removeGeneratedDir(generatedDir) { + if (!generatedDir) return; + await fs.rm(generatedDir, { recursive: true, force: true }).catch(() => {}); } function camelizeWhy(value) { @@ -103,12 +116,12 @@ function camelizeWhy(value) { } async function testsComment(options) { - const input = Buffer.from(JSON.stringify(decamelizePlanOptions(options))); + const input = Buffer.from(JSON.stringify(await decamelizePlanOptions(options))); return String(await native.testsCommentMarkdown(input)); } async function testsGraphMermaid(options) { - const input = Buffer.from(JSON.stringify(decamelizePlanOptions(options))); + const input = Buffer.from(JSON.stringify(await decamelizePlanOptions(options))); return String(await native.testsGraphMermaid(input)); } @@ -143,11 +156,16 @@ async function testsTargets(options) { } async function testsWhy(options) { - return camelizeWhy(await jsonApis.testsWhy(materializeWhyPlan(options))); + const { request, generatedDir } = await prepareWhyPlan(options); + try { + return camelizeWhy(await jsonApis.testsWhy(request)); + } finally { + await removeGeneratedDir(generatedDir); + } } async function testsGraph(options) { - return camelizeValue(await jsonApis.testsGraph(decamelizePlanOptions(options))); + return camelizeValue(await jsonApis.testsGraph(await decamelizePlanOptions(options))); } module.exports = { @@ -155,6 +173,8 @@ module.exports = { camelizeWhy, decamelizePlanOptions, materializeWhyPlan, + prepareWhyPlan, + removeGeneratedDir, testsComment, testsGraphMermaid, ...jsonApis, diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 540cbf083..005243914 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -1,6 +1,6 @@ const assert = require("node:assert/strict"); const test = globalThis.test || require("node:test").test; -const { readFileSync, writeFileSync, mkdtempSync } = require("node:fs"); +const { existsSync, readFileSync, writeFileSync, mkdtempSync } = require("node:fs"); const { join } = require("node:path"); const { tmpdir } = require("node:os"); const { pathToFileURL } = require("node:url"); @@ -345,6 +345,72 @@ test("programmatic API proxies object options through async native addon calls", } }); +test("testsWhy and analyzeProject clean generated why-plan directories", async () => { + const previous = require.extensions[".node"]; + const seen = []; + delete require.cache[require.resolve(indexPath)]; + delete require.cache[require.resolve(planningPath)]; + delete require.cache[addonPath]; + require.extensions[".node"] = (module, filename) => { + assert.equal(filename, addonPath); + module.exports = { + testsWhyJson: async (json) => { + const options = JSON.parse(json); + seen.push(options.plan); + if (options.test === "__reject__") throw new Error("why failed"); + return JSON.stringify({ command: "testsWhy", options }); + }, + analyzeProjectJson: async (json) => { + const options = JSON.parse(json); + for (const report of options.reports || []) seen.push(report.plan); + if (options.reports?.some((report) => report.test === "__reject__")) { + throw new Error("why failed"); + } + return JSON.stringify({ command: "analyzeProject", options }); + }, + }; + }; + try { + const api = require(indexPath); + const planDir = mkdtempSync(join(tmpdir(), "no-mistakes-plan-")); + const planPath = join(planDir, "plan.json"); + writeFileSync(planPath, JSON.stringify({ selectedTests: [] })); + await api.testsWhy({ test: "source.test.ts", planJson: { selectedTests: [] } }); + await api.testsWhy({ test: "source.test.ts", plan: planPath }); + await api.analyzeProject({ + reports: [ + { type: "testsWhy", test: "batched.test.ts", planJson: { selectedTests: [] } }, + { type: "testsWhy", test: "file.test.ts", plan: planPath }, + ], + }); + await assert.rejects( + api.testsWhy({ test: "__reject__", planJson: { selectedTests: [] } }), + /why failed/, + ); + await assert.rejects( + api.analyzeProject({ + reports: [{ type: "testsWhy", test: "__reject__", planJson: { selectedTests: [] } }], + }), + /why failed/, + ); + assert.equal(seen.length, 6); + for (const plan of seen) { + assert.match(plan, /no-mistakes-why-/); + assert.equal(existsSync(plan), false); + } + assert.equal(existsSync(planPath), true); + } finally { + delete require.cache[require.resolve(indexPath)]; + delete require.cache[require.resolve(planningPath)]; + delete require.cache[addonPath]; + if (previous) { + require.extensions[".node"] = previous; + } else { + delete require.extensions[".node"]; + } + } +}); + test("native exports, JavaScript exports, and declarations stay in parity", async () => { const previous = require.extensions[".node"]; const nativeExports = nativeExportNames(); From 094ca877186302507c9fa3a02ee31e62f64972fe Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 18:02:06 -0700 Subject: [PATCH 14/16] fix: declare planJson on TestsWhyOptions Runtime already accepts inline saved plans on testsWhy and batched analyzeProject reports; the types need to match. Co-authored-by: Cursor --- packages/no-mistakes/scripts/api.test.js | 4 ++++ packages/no-mistakes/test-types.d.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 005243914..b57d5a8fb 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -646,6 +646,10 @@ test("test plan declarations require current results but accept saved legacy pla assert.match(declarations, /\n changedFiles: string\[\];/); assert.match(declarations, /export type SavedTestPlan = TestPlan;/); assert.match(declarations, /planJson\?: SavedTestPlan \| string;/); + assert.match( + declarations, + /export interface TestsWhyOptions \{[\s\S]*plan\?: string;\n planJson\?: SavedTestPlan \| string;/, + ); assert.match(declarations, /export type TestsPlanOptions =/); assert.match( declarations, diff --git a/packages/no-mistakes/test-types.d.ts b/packages/no-mistakes/test-types.d.ts index cf437c2aa..a52e45d72 100644 --- a/packages/no-mistakes/test-types.d.ts +++ b/packages/no-mistakes/test-types.d.ts @@ -210,6 +210,7 @@ export interface TestsWhyOptions { test: string; changed?: string; plan?: string; + planJson?: SavedTestPlan | string; } export interface WhyStep { From 2f62730183922c81f782d364d861c17bd66af6b9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 18:23:25 -0700 Subject: [PATCH 15/16] fix: keep node-api runtime export table parseable docs_coverage matches the inventory header and `| \`name\` |` rows exactly; the padded merge table failed that parser. Co-authored-by: Cursor --- docs/node-api.md | 114 +++++++++++++++++++++++------------------------ 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/docs/node-api.md b/docs/node-api.md index 8d70c2e95..3d04c8eaf 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -105,63 +105,63 @@ The following inventory is the complete runtime export surface. Keeping this list exhaustive makes a newly added function visible to agents even when it does not have a one-to-one CLI command: -| Runtime export | API | -| ----------------------------- | -------------------------------------------------------------------------------------------------- | -| `createWorkflowTopologyIndex` | `createWorkflowTopologyIndex(topology)` | -| `version` | `version()` | -| `analyzeProject` | `analyzeProject(options)` | -| `callSites` | `callSites(options)` | -| `check` | `check(options)` | -| `ciEnv` | `ciEnv(options)` | -| `ciImpact` | `ciImpact(options)` | -| `ciTopology` | `ciTopology(options)` | -| `dataPw` | `dataPw(options)` | -| `deadExports` | `deadExports(options)` | -| `dependencies` | `dependencies(options)` | -| `dependents` | `dependents(options)` | -| `effects` | `effects(options)` | -| `exportsOf` | `exportsOf(options)` | -| `fetches` | `fetches(options)` | -| `flow` | `flow(options)` | -| `impactedChecks` | `impactedChecks(options)` | -| `importUsages` | `importUsages(options)` | -| `importers` | `importers(options)` | -| `infraOutputs` | `infraOutputs(options)` | -| `infraResourceRefs` | `infraResourceRefs(options)` | -| `infraTestFor` | `infraTestFor(options)` | -| `lockfileDiff` | `lockfileDiff(options)` | -| `validateMermaidMarkdown` | `validateMermaidMarkdown(options)` | -| `playwrightCheck` | `playwrightCheck(options)` | -| `playwrightEdges` | `playwrightEdges(options)` | -| `playwrightRelated` | `playwrightRelated(options)` | -| `playwrightTests` | `playwrightTests(options)` | -| `reactAnalyze` | `reactAnalyze(options)` | -| `reactCheck` | `reactCheck(options)` | -| `reactUsages` | `reactUsages(options)` | -| `registryExtension` | `registryExtension(options)` | -| `related` | `related(options)` | -| `resolveCheck` | `resolveCheck(options)` | -| `resolveConfig` | `resolveConfig(options)` | -| `rscCallers` | `rscCallers(options)` | -| `swiftImporters` | `swiftImporters(options)` | -| `swiftTestTargets` | `swiftTestTargets(options)` | -| `symbols` | `symbols(options)` | -| `testsComment` | `testsComment(options)` | -| `testsGraphMermaid` | `testsGraphMermaid(options)` | -| `queueCheck` | `queueCheck(options)` | -| `queueEdges` | `queueEdges(options)` | -| `queueRelated` | `queueRelated(options)` | -| `queues` | `queues(options)` | -| `serverContracts` | `serverContracts(options)` | -| `serverRouteEdges` | `serverRouteEdges(options)` | -| `serverRouteList` | `serverRouteList(options)` | -| `serverRouteRelated` | `serverRouteRelated(options)` | -| `serverRoutes` | `serverRoutes(options)`; Remix file-based routes appear when a `type: remix` project is configured | -| `testsGraph` | `testsGraph(options)` | -| `testsImpact` | `testsImpact(options)` | -| `testsPlan` | `testsPlan(options)` | -| `testsTargets` | `testsTargets(options)` | -| `testsWhy` | `testsWhy(options)` | +| Runtime export | API | +| --- | --- | +| `createWorkflowTopologyIndex` | `createWorkflowTopologyIndex(topology)` | +| `version` | `version()` | +| `analyzeProject` | `analyzeProject(options)` | +| `callSites` | `callSites(options)` | +| `check` | `check(options)` | +| `ciEnv` | `ciEnv(options)` | +| `ciImpact` | `ciImpact(options)` | +| `ciTopology` | `ciTopology(options)` | +| `dataPw` | `dataPw(options)` | +| `deadExports` | `deadExports(options)` | +| `dependencies` | `dependencies(options)` | +| `dependents` | `dependents(options)` | +| `effects` | `effects(options)` | +| `exportsOf` | `exportsOf(options)` | +| `fetches` | `fetches(options)` | +| `flow` | `flow(options)` | +| `impactedChecks` | `impactedChecks(options)` | +| `importUsages` | `importUsages(options)` | +| `importers` | `importers(options)` | +| `infraOutputs` | `infraOutputs(options)` | +| `infraResourceRefs` | `infraResourceRefs(options)` | +| `infraTestFor` | `infraTestFor(options)` | +| `lockfileDiff` | `lockfileDiff(options)` | +| `validateMermaidMarkdown` | `validateMermaidMarkdown(options)` | +| `playwrightCheck` | `playwrightCheck(options)` | +| `playwrightEdges` | `playwrightEdges(options)` | +| `playwrightRelated` | `playwrightRelated(options)` | +| `playwrightTests` | `playwrightTests(options)` | +| `reactAnalyze` | `reactAnalyze(options)` | +| `reactCheck` | `reactCheck(options)` | +| `reactUsages` | `reactUsages(options)` | +| `registryExtension` | `registryExtension(options)` | +| `related` | `related(options)` | +| `resolveCheck` | `resolveCheck(options)` | +| `resolveConfig` | `resolveConfig(options)` | +| `rscCallers` | `rscCallers(options)` | +| `swiftImporters` | `swiftImporters(options)` | +| `swiftTestTargets` | `swiftTestTargets(options)` | +| `symbols` | `symbols(options)` | +| `testsComment` | `testsComment(options)` | +| `testsGraphMermaid` | `testsGraphMermaid(options)` | +| `queueCheck` | `queueCheck(options)` | +| `queueEdges` | `queueEdges(options)` | +| `queueRelated` | `queueRelated(options)` | +| `queues` | `queues(options)` | +| `serverContracts` | `serverContracts(options)` | +| `serverRouteEdges` | `serverRouteEdges(options)` | +| `serverRouteList` | `serverRouteList(options)` | +| `serverRouteRelated` | `serverRouteRelated(options)` | +| `serverRoutes` | `serverRoutes(options)`; Remix file-based routes appear when a `type: remix` project is configured | +| `testsGraph` | `testsGraph(options)` | +| `testsImpact` | `testsImpact(options)` | +| `testsPlan` | `testsPlan(options)` | +| `testsTargets` | `testsTargets(options)` | +| `testsWhy` | `testsWhy(options)` | `testsTargets()` and test-plan targets set `workspace: true` when a Vitest workspace/project-array source must be passed with `--workspace`; the emitted From 22f1d281f0e3d93fe0100dc717982c3688a81180 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sat, 22 Aug 2026 18:27:01 -0700 Subject: [PATCH 16/16] fix: pin native ciTopology calls to the resolved absolute root Memo keying already uses path.resolve; pass that same root into N-API so a chdir between cache lookup and the native lock cannot serve the wrong tree. Co-authored-by: Cursor --- packages/no-mistakes/index.js | 2 +- packages/no-mistakes/scripts/api.test.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/no-mistakes/index.js b/packages/no-mistakes/index.js index 49b376ece..cf32222bc 100644 --- a/packages/no-mistakes/index.js +++ b/packages/no-mistakes/index.js @@ -124,7 +124,7 @@ async function ciTopology(options) { for (const memoKey of stale) topologyMemo.delete(memoKey); const cached = topologyMemo.get(key); if (cached) return cached.then((value) => structuredClone(value)); - const pending = jsonApis.ciTopology(options).catch((error) => { + const pending = jsonApis.ciTopology({ ...options, root }).catch((error) => { topologyMemo.delete(key); throw error; }); diff --git a/packages/no-mistakes/scripts/api.test.js b/packages/no-mistakes/scripts/api.test.js index 895f80099..cfb50a4ab 100644 --- a/packages/no-mistakes/scripts/api.test.js +++ b/packages/no-mistakes/scripts/api.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const test = globalThis.test || require("node:test").test; const { existsSync, readFileSync, writeFileSync, mkdtempSync } = require("node:fs"); -const { join } = require("node:path"); +const { join, resolve } = require("node:path"); const { tmpdir } = require("node:os"); const { pathToFileURL } = require("node:url"); @@ -332,6 +332,10 @@ test("programmatic API proxies object options through async native addon calls", "swiftTestTargets", ); assert.equal((await api.ciTopology({ workflows: ["ci.yml"] })).options.workflows[0], "ci.yml"); + assert.equal( + (await api.ciTopology({ workflows: ["ci.yml"] })).options.root, + resolve(process.cwd()), + ); assert.equal( (await api.ciTopology({ workflows: ["deploy.yml"] })).options.workflows[0], "deploy.yml",