Skip to content

Commit 25e600b

Browse files
committed
feat: exit 0 on --filter no-match, add --fail-if-no-match
1 parent c945cc0 commit 25e600b

15 files changed

Lines changed: 272 additions & 61 deletions

File tree

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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,50 @@ 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"]]
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: 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+
```

crates/vite_task_graph/src/query/mod.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,26 @@ pub struct TaskQueryResult {
7979
/// The final execution graph for the selected tasks.
8080
///
8181
/// May be empty if no selected packages have the requested task, or if no
82-
/// packages matched the filters. The caller uses `node_count() == 0` to
83-
/// decide whether to show task-not-found UI.
82+
/// packages matched the filters. The caller distinguishes the two cases
83+
/// with [`Self::selected_package_count`].
8484
pub execution_graph: TaskExecutionGraph,
8585

86-
/// Original `--filter` strings for inclusion selectors that matched no packages.
86+
/// Original `--filter` strings for inclusion filters that contributed no
87+
/// packages to the final selected set — either the core selector matched
88+
/// nothing, or the traversal (e.g. `^...`) collapsed an otherwise-matching
89+
/// seed down to zero.
8790
///
8891
/// Omits synthetic filters (implicit cwd, `-w`) since the user didn't type them.
8992
/// Always empty when `PackageQuery::All` was used.
9093
pub unmatched_selectors: Vec<Str>,
94+
95+
/// Number of packages in the resolved package subgraph (Stage 1 result),
96+
/// before any task mapping.
97+
///
98+
/// `0` means the filter expression(s) selected no packages at all — this
99+
/// is what tells the caller "no packages matched the filter" rather than
100+
/// "packages were selected but none have the requested task".
101+
pub selected_package_count: usize,
91102
}
92103

93104
impl IndexedTaskGraph {
@@ -116,6 +127,7 @@ impl IndexedTaskGraph {
116127

117128
// Stage 1: resolve package selection.
118129
let resolution = self.indexed_package_graph.resolve_query(&query.package_query)?;
130+
let selected_package_count = resolution.package_subgraph.node_count();
119131

120132
// Stage 2: map each selected package to its task node (with reconnection).
121133
self.map_subgraph_to_tasks(
@@ -129,7 +141,11 @@ impl IndexedTaskGraph {
129141
self.add_dependencies(&mut execution_graph, |_| TaskDependencyType::is_explicit());
130142
}
131143

132-
Ok(TaskQueryResult { execution_graph, unmatched_selectors: resolution.unmatched_selectors })
144+
Ok(TaskQueryResult {
145+
execution_graph,
146+
unmatched_selectors: resolution.unmatched_selectors,
147+
selected_package_count,
148+
})
133149
}
134150

135151
/// Map a package subgraph to a task execution graph.

crates/vite_task_plan/src/error.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@ pub enum Error {
154154
#[error("Task \"{0}\" not found")]
155155
NoTasksMatched(Str),
156156

157+
/// One or more `--filter` expressions matched no packages and the user
158+
/// opted into strict mode with `--fail-if-no-match`. The message echoes
159+
/// the warning phrasing ("No packages matched the filter: ...") and joins
160+
/// multiple unmatched sources with `, ` so the chain renderer keeps it on
161+
/// a single line.
162+
#[error(
163+
"No packages matched the filter: {}",
164+
sources.iter().map(vite_str::Str::as_str).collect::<Vec<_>>().join(", ")
165+
)]
166+
NoPackagesMatched { sources: Vec<Str> },
167+
157168
#[error("Invalid value for VP_RUN_CONCURRENCY_LIMIT: {0:?}")]
158169
InvalidConcurrencyLimitEnv(Arc<OsStr>),
159170

0 commit comments

Comments
 (0)