Skip to content

Commit 884df0c

Browse files
kazuponwan9chiclaude
authored
feat: exit 0 on --filter no-match, add --fail-if-no-match (#393)
## Summary - Match pnpm: `vp run --filter <expr>` now exits **0** with a warning when the expression matches no packages, instead of failing with `Task "X" not found`. - This also covers `--filter {.}^...` on a leaf package: the seed matches but the traversal collapses to zero, a legitimate no-op rather than a typo. - Add `--fail-if-no-match` for callers (CI scripts) that want the previous strict behaviour. In strict mode, any unmatched `--filter` aborts the run, even when other filters did match. ## Implementation notes - `unmatched_selectors` now tracks filters whose expanded set is empty (was: whose core selector matched nothing), so traversal collapses are reported too. - `TaskQueryResult` exposes `selected_package_count` and `plan_query` returns a `PlanResult { graph, no_packages_matched }` so the CLI can distinguish "no packages matched filter" (succeed) from "packages matched but task missing" (still errors as `NoTasksMatched`). - Nested `vp run --filter X build` inside a task script gets the same default-success treatment. Closes #380 --------- Co-authored-by: branchseer <dk4rest@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 636351d commit 884df0c

18 files changed

Lines changed: 315 additions & 55 deletions

crates/vite_task/src/cli/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ impl ResolvedRunCommand {
217217
let include_explicit_deps = !self.flags.ignore_depends_on;
218218
let concurrency_limit = self.flags.concurrency_limit.map(|n| n.max(1));
219219
let parallel = self.flags.parallel;
220+
// Read before `into_package_query` consumes the args.
221+
let fail_if_no_match = self.flags.package_query.fail_if_no_match;
220222

221223
let (package_query, is_cwd_only) =
222224
self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?;
@@ -233,6 +235,7 @@ impl ResolvedRunCommand {
233235
cache_override,
234236
concurrency_limit,
235237
parallel,
238+
fail_if_no_match,
236239
},
237240
},
238241
is_cwd_only,

crates/vite_task/src/session/mod.rs

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,22 @@ impl<'a> Session<'a> {
274274
let graph = if let Some(ref task_specifier) = run_command.task_specifier {
275275
// Task specifier provided — plan it.
276276
let cwd = Arc::clone(&self.cwd);
277-
let (graph, is_cwd_only) =
277+
let (plan_result, is_cwd_only) =
278278
self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?;
279279

280-
if graph.graph.node_count() == 0 {
281-
// No tasks matched. Show the interactive selector only when
282-
// the command has no scope flags and no execution flags
283-
// (concurrency-limit, parallel) — otherwise the user intended
284-
// a specific execution mode and a typo should be an error.
280+
if plan_result.graph.graph.node_count() == 0 {
281+
// Three empty-graph outcomes, in order of precedence:
282+
// 1. `--filter` selected zero packages — the planner has
283+
// already warned per filter; exit 0 silently. This is the
284+
// pnpm-compatible default; `--fail-if-no-match` opts in
285+
// to strict behaviour and is raised inside the planner.
286+
// 2. Bare `vp run` (cwd-only, no execution flags) — fall
287+
// through to the interactive task selector.
288+
// 3. Otherwise (e.g. typoed task, `-r` with no matching
289+
// package) — surface NoTasksMatched.
290+
if plan_result.no_packages_matched {
291+
return Ok(());
292+
}
285293
let has_execution_flags = run_command.flags.concurrency_limit.is_some()
286294
|| run_command.flags.parallel;
287295
if is_cwd_only && !has_execution_flags {
@@ -294,7 +302,7 @@ impl<'a> Session<'a> {
294302
.into());
295303
}
296304
} else {
297-
graph
305+
plan_result.graph
298306
}
299307
} else {
300308
// No task specifier (e.g. `vp run` or `vp run --verbose`).
@@ -563,6 +571,9 @@ impl<'a> Session<'a> {
563571
cache_override: run_command.flags.cache_override(),
564572
concurrency_limit: None,
565573
parallel: false,
574+
// The selector path runs whatever the user picked interactively;
575+
// there is no `--filter` in play, so strict-mode does not apply.
576+
fail_if_no_match: false,
566577
},
567578
})
568579
}
@@ -741,8 +752,9 @@ impl<'a> Session<'a> {
741752
cwd: Arc<AbsolutePath>,
742753
command: RunCommand,
743754
) -> Result<ExecutionGraph, vite_task_plan::Error> {
744-
let (graph, _) = self.plan_from_cli_run_resolved(cwd, command.into_resolved()).await?;
745-
Ok(graph)
755+
let (plan_result, _) =
756+
self.plan_from_cli_run_resolved(cwd, command.into_resolved()).await?;
757+
Ok(plan_result.graph)
746758
}
747759

748760
/// Internal: plans execution from a resolved run command.
@@ -751,7 +763,7 @@ impl<'a> Session<'a> {
751763
&mut self,
752764
cwd: Arc<AbsolutePath>,
753765
command: crate::cli::ResolvedRunCommand,
754-
) -> Result<(ExecutionGraph, bool), vite_task_plan::Error> {
766+
) -> Result<(vite_task_plan::PlanResult, bool), vite_task_plan::Error> {
755767
let (query_plan_request, is_cwd_only) = match command.into_query_plan_request(&cwd) {
756768
Ok(result) => result,
757769
Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => {
@@ -766,7 +778,7 @@ impl<'a> Session<'a> {
766778
});
767779
}
768780
};
769-
let graph = vite_task_plan::plan_query(
781+
let plan_result = vite_task_plan::plan_query(
770782
query_plan_request,
771783
&self.workspace_path,
772784
&cwd,
@@ -775,7 +787,7 @@ impl<'a> Session<'a> {
775787
&mut self.lazy_task_graph,
776788
)
777789
.await?;
778-
Ok((graph, is_cwd_only))
790+
Ok((plan_result, is_cwd_only))
779791
}
780792

781793
/// Plan execution from a pre-built [`QueryPlanRequest`].
@@ -787,15 +799,16 @@ impl<'a> Session<'a> {
787799
request: QueryPlanRequest,
788800
) -> Result<ExecutionGraph, vite_task_plan::Error> {
789801
let cwd = Arc::clone(&self.cwd);
790-
vite_task_plan::plan_query(
802+
let plan_result = vite_task_plan::plan_query(
791803
request,
792804
&self.workspace_path,
793805
&cwd,
794806
&self.envs,
795807
&mut self.plan_request_parser,
796808
&mut self.lazy_task_graph,
797809
)
798-
.await
810+
.await?;
811+
Ok(plan_result.graph)
799812
}
800813
}
801814

crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter_unmatched/snapshots.toml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,64 @@ comment = """
3939
A directory-style `--filter` (`./packages/...`) that points nowhere should warn just like an unmatched name filter.
4040
"""
4141
steps = [["vt", "run", "--filter", "@test/app", "--filter", "./packages/nope", "build"]]
42+
43+
[[e2e]]
44+
name = "filter_with_zero_results_exits_zero_by_default"
45+
comment = """
46+
A `--filter` whose entire result is empty (typo, glob with no match, …) prints the warning and exits 0 — pnpm's default. Previously this errored with `Task "build" not found`.
47+
"""
48+
steps = [["vt", "run", "--filter", "nonexistent", "build"]]
49+
50+
[[e2e]]
51+
name = "traversal_collapsed_to_empty_exits_zero_by_default"
52+
comment = """
53+
`{.}^...` selects the dependencies of the current package, excluding itself. On a leaf with no workspace deps the expression collapses to zero matches — a legitimate no-op rather than a typo — so the run warns and exits 0.
54+
"""
55+
cwd = "packages/lib"
56+
steps = [["vt", "run", "--filter", "{.}^...", "build"]]
57+
58+
[[e2e]]
59+
name = "fail_if_no_match_errors_on_unmatched_filter"
60+
comment = """
61+
With `--fail-if-no-match`, an unmatched `--filter` aborts the run with a non-zero exit code instead of warning. Mirrors pnpm's `--fail-if-no-match`.
62+
"""
63+
steps = [["vt", "run", "--filter", "nonexistent", "--fail-if-no-match", "build"]]
64+
65+
[[e2e]]
66+
name = "fail_if_no_match_errors_even_when_other_filters_match"
67+
comment = """
68+
Strict mode errors on **any** unmatched filter, even when other filters did match packages — this catches typos in CI scripts that combine an exact name with a glob.
69+
"""
70+
steps = [
71+
[
72+
"vt",
73+
"run",
74+
"--filter",
75+
"@test/app",
76+
"--filter",
77+
"nonexistent",
78+
"--fail-if-no-match",
79+
"build",
80+
],
81+
]
82+
83+
[[e2e]]
84+
name = "fail_if_no_match_succeeds_when_all_filters_match"
85+
comment = """
86+
With `--fail-if-no-match` and only matching filters, the run proceeds normally — strict mode does not change the success path.
87+
"""
88+
steps = [["vt", "run", "--filter", "@test/app", "--fail-if-no-match", "build"]]
89+
90+
[[e2e]]
91+
name = "nested_filter_no_match_succeeds_by_default"
92+
comment = """
93+
The default-success rule must apply to nested `vt run --filter ...` invocations too: a task whose command is `vt run --filter nonexistent build` should exit 0 with the warning, not fail the outer task. Guards the script wrapping that pnpm users typically write.
94+
"""
95+
steps = [["vt", "run", "filter-nonexistent"]]
96+
97+
[[e2e]]
98+
name = "nested_filter_no_match_with_fail_if_no_match_errors"
99+
comment = """
100+
Strict mode also propagates through nesting: a task command that adds `--fail-if-no-match` aborts the outer task when the nested filter selects nothing. The error chain identifies both the nested command and the unmatched filter source.
101+
"""
102+
steps = [["vt", "run", "filter-nonexistent-strict"]]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# fail_if_no_match_errors_even_when_other_filters_match
2+
3+
Strict mode errors on **any** unmatched filter, even when other filters did match packages — this catches typos in CI scripts that combine an exact name with a glob.
4+
5+
## `vt run --filter @test/app --filter nonexistent --fail-if-no-match build`
6+
7+
**Exit code:** 1
8+
9+
```
10+
error: No packages matched the filter: nonexistent
11+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# fail_if_no_match_errors_on_unmatched_filter
2+
3+
With `--fail-if-no-match`, an unmatched `--filter` aborts the run with a non-zero exit code instead of warning. Mirrors pnpm's `--fail-if-no-match`.
4+
5+
## `vt run --filter nonexistent --fail-if-no-match build`
6+
7+
**Exit code:** 1
8+
9+
```
10+
error: No packages matched the filter: nonexistent
11+
```
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# fail_if_no_match_succeeds_when_all_filters_match
2+
3+
With `--fail-if-no-match` and only matching filters, the run proceeds normally — strict mode does not change the success path.
4+
5+
## `vt run --filter @test/app --fail-if-no-match build`
6+
7+
```
8+
~/packages/app$ vtt print built-app
9+
built-app
10+
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# filter_with_zero_results_exits_zero_by_default
2+
3+
A `--filter` whose entire result is empty (typo, glob with no match, …) prints the warning and exits 0 — pnpm's default. Previously this errored with `Task "build" not found`.
4+
5+
## `vt run --filter nonexistent build`
6+
7+
```
8+
No packages matched the filter: nonexistent
9+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# nested_filter_no_match_succeeds_by_default
2+
3+
The default-success rule must apply to nested `vt run --filter ...` invocations too: a task whose command is `vt run --filter nonexistent build` should exit 0 with the warning, not fail the outer task. Guards the script wrapping that pnpm users typically write.
4+
5+
## `vt run filter-nonexistent`
6+
7+
```
8+
No packages matched the filter: nonexistent
9+
---
10+
vt run: 0/0 cache hit (0%). (Run `vt run --last-details` for full details)
11+
```
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# nested_filter_no_match_with_fail_if_no_match_errors
2+
3+
Strict mode also propagates through nesting: a task command that adds `--fail-if-no-match` aborts the outer task when the nested filter selects nothing. The error chain identifies both the nested command and the unmatched filter source.
4+
5+
## `vt run filter-nonexistent-strict`
6+
7+
**Exit code:** 1
8+
9+
```
10+
error: Failed to plan tasks from `vt run --filter nonexistent --fail-if-no-match build` in task filter-unmatched-test#filter-nonexistent-strict
11+
* No packages matched the filter: nonexistent
12+
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# traversal_collapsed_to_empty_exits_zero_by_default
2+
3+
`{.}^...` selects the dependencies of the current package, excluding itself. On a leaf with no workspace deps the expression collapses to zero matches — a legitimate no-op rather than a typo — so the run warns and exits 0.
4+
5+
## `vt run --filter {.}^... build`
6+
7+
```
8+
No packages matched the filter: {.}^...
9+
```

0 commit comments

Comments
 (0)