Skip to content
Merged
1 change: 1 addition & 0 deletions crates/no-mistakes/src/impacted_checks/generate/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub(crate) fn plan_args_for(
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/napi_api/cli_parity_builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub(crate) fn build_plan_args(options: TestsPlanOptions) -> AnyhowResult<crate::
format: Some(crate::tests::PlanFormat::Json),
json: true,
include_comment: options.include_comment,
include_glob: options.include_glob,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/napi_api/options_flow_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub(crate) struct TestsPlanOptions {
pub(crate) global_config_fallback: Option<bool>,
pub(crate) direct_test_owner: bool,
pub(crate) include_comment: bool,
pub(crate) include_glob: Vec<String>,
}

#[derive(Debug, Default, Deserialize)]
Expand Down
4 changes: 4 additions & 0 deletions crates/no-mistakes/src/tests/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Args, Debug, Clone)]
Expand Down
3 changes: 3 additions & 0 deletions crates/no-mistakes/src/tests/configured_plan/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn vitest_setup_args(root: PathBuf, changed_file: Vec<PathBuf>) -> PlanArgs {
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/no-mistakes/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ pub struct GroupedExecutionTarget {
pub config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
/// Path-prefix display name, such as a Swift package root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub base_command: Vec<String>,
/// Runner flags without test file paths.
pub runner_args: Vec<String>,
Expand Down
17 changes: 16 additions & 1 deletion crates/no-mistakes/src/tests/plan/changed_inventory.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -7,6 +8,20 @@ pub(crate) fn generate_plan_with_prepared(
) -> Result<TestPlan> {
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));
Comment thread
jonathanong marked this conversation as resolved.
Comment thread
jonathanong marked this conversation as resolved.
}
Ok(())
}
34 changes: 30 additions & 4 deletions crates/no-mistakes/src/tests/plan_finish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,46 @@ 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<String>, Option<String>, Vec<String>);
type ExecutionGroupKey = (
String,
Option<String>,
Option<String>,
Vec<String>,
Option<String>,
);

fn grouped_execution_targets(selected: &[super::SelectedTest]) -> Vec<GroupedExecutionTarget> {
fn grouped_execution_targets(
selected: &[super::SelectedTest],
prefixes: &[String],
) -> Vec<GroupedExecutionTarget> {
let mut groups: BTreeMap<ExecutionGroupKey, GroupedExecutionTarget> = BTreeMap::new();
for test in selected {
for target in &test.targets {
let name = if target.runner == "swift" {
prefix_name(&test.test_file, prefixes)
} else {
None
};
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(),
Expand All @@ -39,6 +55,16 @@ fn grouped_execution_targets(selected: &[super::SelectedTest]) -> Vec<GroupedExe
groups.into_values().collect()
}

fn prefix_name(file: &str, prefixes: &[String]) -> Option<String> {
prefixes
.iter()
.map(|prefix| prefix.trim_end_matches('/'))
.filter(|prefix| !prefix.is_empty())
.filter(|prefix| file == *prefix || file.starts_with(&format!("{prefix}/")))
Comment thread
jonathanong marked this conversation as resolved.
.max_by_key(|prefix| prefix.len())
.map(str::to_string)
}

fn runner_args_without_file(target: &TestExecutionTarget, test_file: &str) -> Vec<String> {
let mut args = target.runner_args.clone();
if args.len() >= 2 && args[args.len() - 2] == "--test" {
Expand Down
69 changes: 57 additions & 12 deletions crates/no-mistakes/src/tests/plan_finish/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ fn selected(file: &str, targets: Vec<TestExecutionTarget>) -> 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();
Expand All @@ -63,12 +66,54 @@ 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!(
groups[0].test_files,
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())
]
);
}

#[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);
}
1 change: 1 addition & 0 deletions crates/no-mistakes/src/tests/plan_resources_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ fn resource_plan_args(root: &Path, changed: PathBuf) -> PlanArgs {
format: None,
json: true,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/tests/prepared_plan/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fn framework_args(root: &Path, framework: TestFramework) -> PlanArgs {
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/tests/why.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
11 changes: 9 additions & 2 deletions docs/cli/tests-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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
selected and retains deleted paths plus both sides of detected renames and
Expand All @@ -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
Expand Down
22 changes: 13 additions & 9 deletions docs/node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -214,15 +214,15 @@ 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
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" }`.

Expand All @@ -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.

Expand Down Expand Up @@ -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 Swift path-prefix
groups). `includeGlob` is a `testsPlan()` option.
Comment thread
jonathanong marked this conversation as resolved.
- Prefer structured API results over parsing human CLI output.
2 changes: 1 addition & 1 deletion packages/no-mistakes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading