Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/vite_task/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ impl ResolvedRunCommand {
let include_explicit_deps = !self.flags.ignore_depends_on;
let concurrency_limit = self.flags.concurrency_limit.map(|n| n.max(1));
let parallel = self.flags.parallel;
// Read before `into_package_query` consumes the args.
let fail_if_no_match = self.flags.package_query.fail_if_no_match;

let (package_query, is_cwd_only) =
self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?;
Expand All @@ -233,6 +235,7 @@ impl ResolvedRunCommand {
cache_override,
concurrency_limit,
parallel,
fail_if_no_match,
},
},
is_cwd_only,
Expand Down
41 changes: 27 additions & 14 deletions crates/vite_task/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,22 @@ impl<'a> Session<'a> {
let graph = if let Some(ref task_specifier) = run_command.task_specifier {
// Task specifier provided — plan it.
let cwd = Arc::clone(&self.cwd);
let (graph, is_cwd_only) =
let (plan_result, is_cwd_only) =
self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?;

if graph.graph.node_count() == 0 {
// No tasks matched. Show the interactive selector only when
// the command has no scope flags and no execution flags
// (concurrency-limit, parallel) — otherwise the user intended
// a specific execution mode and a typo should be an error.
if plan_result.graph.graph.node_count() == 0 {
// Three empty-graph outcomes, in order of precedence:
// 1. `--filter` selected zero packages — the planner has
// already warned per filter; exit 0 silently. This is the
// pnpm-compatible default; `--fail-if-no-match` opts in
// to strict behaviour and is raised inside the planner.
// 2. Bare `vp run` (cwd-only, no execution flags) — fall
// through to the interactive task selector.
// 3. Otherwise (e.g. typoed task, `-r` with no matching
// package) — surface NoTasksMatched.
if plan_result.no_packages_matched {
return Ok(());
}
let has_execution_flags = run_command.flags.concurrency_limit.is_some()
|| run_command.flags.parallel;
if is_cwd_only && !has_execution_flags {
Expand All @@ -294,7 +302,7 @@ impl<'a> Session<'a> {
.into());
}
} else {
graph
plan_result.graph
}
} else {
// No task specifier (e.g. `vp run` or `vp run --verbose`).
Expand Down Expand Up @@ -563,6 +571,9 @@ impl<'a> Session<'a> {
cache_override: run_command.flags.cache_override(),
concurrency_limit: None,
parallel: false,
// The selector path runs whatever the user picked interactively;
// there is no `--filter` in play, so strict-mode does not apply.
fail_if_no_match: false,
},
})
}
Expand Down Expand Up @@ -741,8 +752,9 @@ impl<'a> Session<'a> {
cwd: Arc<AbsolutePath>,
command: RunCommand,
) -> Result<ExecutionGraph, vite_task_plan::Error> {
let (graph, _) = self.plan_from_cli_run_resolved(cwd, command.into_resolved()).await?;
Ok(graph)
let (plan_result, _) =
self.plan_from_cli_run_resolved(cwd, command.into_resolved()).await?;
Ok(plan_result.graph)
}

/// Internal: plans execution from a resolved run command.
Expand All @@ -751,7 +763,7 @@ impl<'a> Session<'a> {
&mut self,
cwd: Arc<AbsolutePath>,
command: crate::cli::ResolvedRunCommand,
) -> Result<(ExecutionGraph, bool), vite_task_plan::Error> {
) -> Result<(vite_task_plan::PlanResult, bool), vite_task_plan::Error> {
let (query_plan_request, is_cwd_only) = match command.into_query_plan_request(&cwd) {
Ok(result) => result,
Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => {
Expand All @@ -766,7 +778,7 @@ impl<'a> Session<'a> {
});
}
};
let graph = vite_task_plan::plan_query(
let plan_result = vite_task_plan::plan_query(
query_plan_request,
&self.workspace_path,
&cwd,
Expand All @@ -775,7 +787,7 @@ impl<'a> Session<'a> {
&mut self.lazy_task_graph,
)
.await?;
Ok((graph, is_cwd_only))
Ok((plan_result, is_cwd_only))
}

/// Plan execution from a pre-built [`QueryPlanRequest`].
Expand All @@ -787,15 +799,16 @@ impl<'a> Session<'a> {
request: QueryPlanRequest,
) -> Result<ExecutionGraph, vite_task_plan::Error> {
let cwd = Arc::clone(&self.cwd);
vite_task_plan::plan_query(
let plan_result = vite_task_plan::plan_query(
request,
&self.workspace_path,
&cwd,
&self.envs,
&mut self.plan_request_parser,
&mut self.lazy_task_graph,
)
.await
.await?;
Ok(plan_result.graph)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,50 @@ comment = """
A directory-style `--filter` (`./packages/...`) that points nowhere should warn just like an unmatched name filter.
"""
steps = [["vt", "run", "--filter", "@test/app", "--filter", "./packages/nope", "build"]]

[[e2e]]
name = "filter_with_zero_results_exits_zero_by_default"
comment = """
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`.
"""
steps = [["vt", "run", "--filter", "nonexistent", "build"]]

[[e2e]]
name = "traversal_collapsed_to_empty_exits_zero_by_default"
comment = """
`{.}^...` 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.
"""
cwd = "packages/lib"
steps = [["vt", "run", "--filter", "{.}^...", "build"]]

[[e2e]]
name = "fail_if_no_match_errors_on_unmatched_filter"
comment = """
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`.
"""
steps = [["vt", "run", "--filter", "nonexistent", "--fail-if-no-match", "build"]]

[[e2e]]
name = "fail_if_no_match_errors_even_when_other_filters_match"
comment = """
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.
"""
steps = [
[
"vt",
"run",
"--filter",
"@test/app",
"--filter",
"nonexistent",
"--fail-if-no-match",
"build",
],
]

[[e2e]]
name = "fail_if_no_match_succeeds_when_all_filters_match"
comment = """
With `--fail-if-no-match` and only matching filters, the run proceeds normally — strict mode does not change the success path.
"""
steps = [["vt", "run", "--filter", "@test/app", "--fail-if-no-match", "build"]]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# fail_if_no_match_errors_even_when_other_filters_match

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.

## `vt run --filter @test/app --filter nonexistent --fail-if-no-match build`

**Exit code:** 1

```
error: No packages matched the filter: nonexistent
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# fail_if_no_match_errors_on_unmatched_filter

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`.

## `vt run --filter nonexistent --fail-if-no-match build`

**Exit code:** 1

```
error: No packages matched the filter: nonexistent
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# fail_if_no_match_succeeds_when_all_filters_match

With `--fail-if-no-match` and only matching filters, the run proceeds normally — strict mode does not change the success path.

## `vt run --filter @test/app --fail-if-no-match build`

```
~/packages/app$ vtt print built-app
built-app
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# filter_with_zero_results_exits_zero_by_default

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`.

## `vt run --filter nonexistent build`

```
No packages matched the filter: nonexistent
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# traversal_collapsed_to_empty_exits_zero_by_default

`{.}^...` 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.

## `vt run --filter {.}^... build`

```
No packages matched the filter: {.}^...
```
24 changes: 20 additions & 4 deletions crates/vite_task_graph/src/query/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,26 @@ pub struct TaskQueryResult {
/// The final execution graph for the selected tasks.
///
/// May be empty if no selected packages have the requested task, or if no
/// packages matched the filters. The caller uses `node_count() == 0` to
/// decide whether to show task-not-found UI.
/// packages matched the filters. The caller distinguishes the two cases
/// with [`Self::selected_package_count`].
pub execution_graph: TaskExecutionGraph,

/// Original `--filter` strings for inclusion selectors that matched no packages.
/// Original `--filter` strings for inclusion filters that contributed no
/// packages to the final selected set — either the core selector matched
/// nothing, or the traversal (e.g. `^...`) collapsed an otherwise-matching
/// seed down to zero.
///
/// Omits synthetic filters (implicit cwd, `-w`) since the user didn't type them.
/// Always empty when `PackageQuery::All` was used.
pub unmatched_selectors: Vec<Str>,

/// Number of packages in the resolved package subgraph (Stage 1 result),
/// before any task mapping.
///
/// `0` means the filter expression(s) selected no packages at all — this
/// is what tells the caller "no packages matched the filter" rather than
/// "packages were selected but none have the requested task".
pub selected_package_count: usize,
}

impl IndexedTaskGraph {
Expand Down Expand Up @@ -116,6 +127,7 @@ impl IndexedTaskGraph {

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

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

Ok(TaskQueryResult { execution_graph, unmatched_selectors: resolution.unmatched_selectors })
Ok(TaskQueryResult {
execution_graph,
unmatched_selectors: resolution.unmatched_selectors,
selected_package_count,
})
}

/// Map a package subgraph to a task execution graph.
Expand Down
11 changes: 11 additions & 0 deletions crates/vite_task_plan/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ pub enum Error {
#[error("Task \"{0}\" not found")]
NoTasksMatched(Str),

/// One or more `--filter` expressions matched no packages and the user
/// opted into strict mode with `--fail-if-no-match`. The message echoes
/// the warning phrasing ("No packages matched the filter: ...") and joins
/// multiple unmatched sources with `, ` so the chain renderer keeps it on
/// a single line.
#[error(
"No packages matched the filter: {}",
sources.iter().map(vite_str::Str::as_str).collect::<Vec<_>>().join(", ")
)]
NoPackagesMatched { sources: Vec<Str> },

#[error("Invalid value for VP_RUN_CONCURRENCY_LIMIT: {0:?}")]
InvalidConcurrencyLimitEnv(Arc<OsStr>),

Expand Down
16 changes: 15 additions & 1 deletion crates/vite_task_plan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,20 @@ pub trait TaskGraphLoader {
) -> Result<&vite_task_graph::IndexedTaskGraph, TaskGraphLoadError>;
}

/// Output of [`plan_query`] (and the internal `plan_query_request`).
///
/// Wraps the [`ExecutionGraph`] together with a diagnostic flag that lets the
/// CLI distinguish a Stage-1 zero-package result (a `--filter` that selected
/// nothing — succeed silently) from a Stage-2 zero-task result (packages were
/// selected but none have the requested task — surface `NoTasksMatched`).
#[derive(Debug)]
pub struct PlanResult {
pub graph: ExecutionGraph,
/// `true` when the package filter selected zero packages (Stage 1 was
/// empty). The warnings printed inside the planner already explain why.
pub no_packages_matched: bool,
}

/// Plan a query execution: load the task graph, query it, and build the execution graph.
///
/// # Errors
Expand All @@ -193,7 +207,7 @@ pub async fn plan_query(
envs: &FxHashMap<Arc<OsStr>, Arc<OsStr>>,
plan_request_parser: &mut (dyn PlanRequestParser + '_),
task_graph_loader: &mut (dyn TaskGraphLoader + '_),
) -> Result<ExecutionGraph, Error> {
) -> Result<PlanResult, Error> {
let indexed_task_graph = task_graph_loader.load_task_graph().await?;

let resolved_global_cache = resolve_cache_with_override(
Expand Down
Loading