From 7ec16e69ba1c9602f73e3cdea75539e2a41cc59c Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 7 Apr 2026 01:07:43 +0800 Subject: [PATCH 1/6] docs: add RFC for enhanced `dependsOn` syntax Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/depends-on.md | 129 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/depends-on.md diff --git a/docs/depends-on.md b/docs/depends-on.md new file mode 100644 index 00000000..27c9c135 --- /dev/null +++ b/docs/depends-on.md @@ -0,0 +1,129 @@ +# RFC: Enhanced `dependsOn` Syntax + +## Background + +Today, `dependsOn` entries can only refer to a single task by name (`"build"`) or by package-qualified name (`"pkg#build"`). A common pattern in monorepo task runners is "run `build` in all transitive dependencies first" — tools like Nx (`^build`) and Turborepo (`^build`) support this, but each introduces its own symbol with its own meaning. + +The CLI already supports package selection through flags like `--recursive`, `--transitive`, and `--filter`. Rather than invent yet another DSL with new symbols, we reuse the exact same mental model and syntax from `vp run`. + +### Design principle + +**No new mental models.** If you know how to write `vp run`, you know how to write a `dependsOn` entry. The flag names, filter syntax, and task specifier format are identical. + +## Current Syntax + +```jsonc +{ + "tasks": { + "test": { + "dependsOn": [ + "build", // same-package task + "utils#build", // task in a specific package + ], + }, + }, +} +``` + +These simple forms remain valid and unchanged under both proposed styles. + +## Proposed Syntax + +### Style 1: CLI string syntax + +Each `dependsOn` element is a string (or array of strings) written exactly as you would type CLI arguments to `vp run`: + +```jsonc +{ + "tasks": { + "test": { + "dependsOn": [ + // Existing syntax — still works + "build", + "utils#build", + + // Run `build` across all workspace packages + "--recursive build", + + // Run `build` in current package and its transitive dependencies + "--transitive build", + + // Run `build` in packages matching a filter + "--filter @myorg/core build", + "--filter @myorg/core... build", // @myorg/core and its deps + + // Array form — each element is one CLI token + ["--filter", "@myorg/core", "build"], + ["--transitive", "build"], + ], + }, + }, +} +``` + +The parser splits a string element on whitespace (like a shell would) and interprets the tokens as `vp run` arguments. The array form avoids splitting entirely — useful when a filter value contains whitespace or for explicitness. + +**Supported flags:** + +| Flag | Short | Meaning | +| -------------------- | -------------- | ------------------------------------------------------------------ | +| `--recursive` | `-r` | All workspace packages | +| `--transitive` | `-t` | Current package + its transitive dependencies | +| `--filter ` | `-F ` | Packages matching a [filter expression](https://pnpm.io/filtering) | +| `--workspace-root` | `-w` | The workspace root package | + +Everything after the flags is the task specifier (e.g. `build`, `pkg#task`). + +### Style 2: Object syntax + +Each `dependsOn` element can be an object whose keys mirror the CLI flag names: + +```jsonc +{ + "tasks": { + "test": { + "dependsOn": [ + // Existing syntax — still works as plain strings + "build", + "utils#build", + + // Run `build` across all workspace packages + { "recursive": true, "task": "build" }, + + // Run `build` in current package and its transitive dependencies + { "transitive": true, "task": "build" }, + + // Run `build` in packages matching a filter + { "filter": "@myorg/core", "task": "build" }, + { "filter": "@myorg/core...", "task": "build" }, + + // Multiple filters + { "filter": ["@myorg/core", "@myorg/utils"], "task": "build" }, + + // Workspace root + { "workspaceRoot": true, "task": "build" }, + ], + }, + }, +} +``` + +**Object fields:** + +| Field | Type | Meaning | +| --------------- | -------------------- | ---------------------------------------------------------- | +| `task` | `string` | **Required.** Task specifier (`"build"` or `"pkg#build"`). | +| `recursive` | `boolean` | Select all workspace packages. | +| `transitive` | `boolean` | Select current package + transitive dependencies. | +| `filter` | `string \| string[]` | Select packages by filter expression(s). | +| `workspaceRoot` | `boolean` | Select the workspace root package. | + +The same validation rules from the CLI apply: + +- `recursive` and `transitive` are mutually exclusive. +- `filter` cannot be combined with `recursive` or `transitive`. +- When `task` contains a `#` (e.g. `"pkg#build"`), it cannot be combined with `recursive` or `filter`. + +## Context: "Current Package" + +When `--transitive` or a filter with traversal suffixes (e.g. `@myorg/core...`) resolves packages, "current package" means the package that owns the task containing this `dependsOn` entry — the same package that would be inferred from an unqualified `"build"` dependency today. From 1113d51fd4cd281159dc5520edda3b55b552360e Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 7 Apr 2026 09:14:32 +0800 Subject: [PATCH 2/6] docs: use shorthand object syntax in dependsOn RFC Task name is passed as the value of the flag key (e.g. `{ "transitive": "build" }`) instead of a separate `task` field with a boolean flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/depends-on.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/depends-on.md b/docs/depends-on.md index 27c9c135..2b308b22 100644 --- a/docs/depends-on.md +++ b/docs/depends-on.md @@ -29,6 +29,8 @@ These simple forms remain valid and unchanged under both proposed styles. ## Proposed Syntax +Two equivalent styles are proposed. + ### Style 1: CLI string syntax Each `dependsOn` element is a string (or array of strings) written exactly as you would type CLI arguments to `vp run`: @@ -88,10 +90,10 @@ Each `dependsOn` element can be an object whose keys mirror the CLI flag names: "utils#build", // Run `build` across all workspace packages - { "recursive": true, "task": "build" }, + { "recursive": "build" }, // Run `build` in current package and its transitive dependencies - { "transitive": true, "task": "build" }, + { "transitive": "build" }, // Run `build` in packages matching a filter { "filter": "@myorg/core", "task": "build" }, @@ -101,28 +103,27 @@ Each `dependsOn` element can be an object whose keys mirror the CLI flag names: { "filter": ["@myorg/core", "@myorg/utils"], "task": "build" }, // Workspace root - { "workspaceRoot": true, "task": "build" }, + { "workspaceRoot": "build" }, ], }, }, } ``` -**Object fields:** +**Object forms:** -| Field | Type | Meaning | -| --------------- | -------------------- | ---------------------------------------------------------- | -| `task` | `string` | **Required.** Task specifier (`"build"` or `"pkg#build"`). | -| `recursive` | `boolean` | Select all workspace packages. | -| `transitive` | `boolean` | Select current package + transitive dependencies. | -| `filter` | `string \| string[]` | Select packages by filter expression(s). | -| `workspaceRoot` | `boolean` | Select the workspace root package. | +| Form | Meaning | +| -------------------------------------------------- | ---------------------------------------------------------------- | +| `{ "recursive": "" }` | Run `` across all workspace packages. | +| `{ "transitive": "" }` | Run `` in current package and its transitive dependencies. | +| `{ "filter": "", "task": "" }` | Run `` in packages matching a filter expression. | +| `{ "filter": ["", ""], "task": "" }` | Run `` in packages matching multiple filters. | +| `{ "workspaceRoot": "" }` | Run `` in the workspace root package. | The same validation rules from the CLI apply: -- `recursive` and `transitive` are mutually exclusive. -- `filter` cannot be combined with `recursive` or `transitive`. -- When `task` contains a `#` (e.g. `"pkg#build"`), it cannot be combined with `recursive` or `filter`. +- `recursive`, `transitive`, `filter`, and `workspaceRoot` are mutually exclusive. +- When using `filter`, the task name goes in a separate `task` field (since `filter` takes a pattern as its value). ## Context: "Current Package" From 2ec0c761c5e13ed0101aafadf0f35115dd8a7d39 Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 7 Apr 2026 09:20:51 +0800 Subject: [PATCH 3/6] docs: use string arrays for style 1, add comparison table Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/depends-on.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/depends-on.md b/docs/depends-on.md index 2b308b22..eb6a82b4 100644 --- a/docs/depends-on.md +++ b/docs/depends-on.md @@ -33,7 +33,7 @@ Two equivalent styles are proposed. ### Style 1: CLI string syntax -Each `dependsOn` element is a string (or array of strings) written exactly as you would type CLI arguments to `vp run`: +Each `dependsOn` element is a string (existing syntax) or a string array written exactly as you would type CLI arguments to `vp run`: ```jsonc { @@ -45,25 +45,21 @@ Each `dependsOn` element is a string (or array of strings) written exactly as yo "utils#build", // Run `build` across all workspace packages - "--recursive build", + ["--recursive", "build"], // Run `build` in current package and its transitive dependencies - "--transitive build", + ["--transitive", "build"], // Run `build` in packages matching a filter - "--filter @myorg/core build", - "--filter @myorg/core... build", // @myorg/core and its deps - - // Array form — each element is one CLI token ["--filter", "@myorg/core", "build"], - ["--transitive", "build"], + ["--filter", "@myorg/core...", "build"], // @myorg/core and its deps ], }, }, } ``` -The parser splits a string element on whitespace (like a shell would) and interprets the tokens as `vp run` arguments. The array form avoids splitting entirely — useful when a filter value contains whitespace or for explicitness. +Each element in the array is one CLI token, exactly as you would pass to `vp run`. **Supported flags:** @@ -128,3 +124,11 @@ The same validation rules from the CLI apply: ## Context: "Current Package" When `--transitive` or a filter with traversal suffixes (e.g. `@myorg/core...`) resolves packages, "current package" means the package that owns the task containing this `dependsOn` entry — the same package that would be inferred from an unqualified `"build"` dependency today. + +## Comparison + +| | Style 1 (CLI string) | Style 2 (Object) | +| ------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------ | +| Learning curve | None if you already know `vp run` — identical syntax | Minimal — same flag names, written as JSON keys | +| IDE autocompletion | Yes — TypeScript tuple types can constrain each array position | Yes — TypeScript object types can validate keys and suggest fields | +| Config consistency | Unusual — CLI syntax embedded in config arrays | Consistent — matches the object style used elsewhere in the config | From 9084b68ce39200a0190609a2e3b25004ddf00389 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 12 Apr 2026 10:16:00 +0800 Subject: [PATCH 4/6] docs: remove CLI string syntax (Style 1) from dependsOn RFC Keep only the object syntax, removing the alternative CLI string array style and the comparison table. Co-Authored-By: Claude Opus 4.6 --- docs/depends-on.md | 55 ++-------------------------------------------- 1 file changed, 2 insertions(+), 53 deletions(-) diff --git a/docs/depends-on.md b/docs/depends-on.md index eb6a82b4..de2c7b77 100644 --- a/docs/depends-on.md +++ b/docs/depends-on.md @@ -25,54 +25,11 @@ The CLI already supports package selection through flags like `--recursive`, `-- } ``` -These simple forms remain valid and unchanged under both proposed styles. +These simple forms remain valid and unchanged. ## Proposed Syntax -Two equivalent styles are proposed. - -### Style 1: CLI string syntax - -Each `dependsOn` element is a string (existing syntax) or a string array written exactly as you would type CLI arguments to `vp run`: - -```jsonc -{ - "tasks": { - "test": { - "dependsOn": [ - // Existing syntax — still works - "build", - "utils#build", - - // Run `build` across all workspace packages - ["--recursive", "build"], - - // Run `build` in current package and its transitive dependencies - ["--transitive", "build"], - - // Run `build` in packages matching a filter - ["--filter", "@myorg/core", "build"], - ["--filter", "@myorg/core...", "build"], // @myorg/core and its deps - ], - }, - }, -} -``` - -Each element in the array is one CLI token, exactly as you would pass to `vp run`. - -**Supported flags:** - -| Flag | Short | Meaning | -| -------------------- | -------------- | ------------------------------------------------------------------ | -| `--recursive` | `-r` | All workspace packages | -| `--transitive` | `-t` | Current package + its transitive dependencies | -| `--filter ` | `-F ` | Packages matching a [filter expression](https://pnpm.io/filtering) | -| `--workspace-root` | `-w` | The workspace root package | - -Everything after the flags is the task specifier (e.g. `build`, `pkg#task`). - -### Style 2: Object syntax +### Object syntax Each `dependsOn` element can be an object whose keys mirror the CLI flag names: @@ -124,11 +81,3 @@ The same validation rules from the CLI apply: ## Context: "Current Package" When `--transitive` or a filter with traversal suffixes (e.g. `@myorg/core...`) resolves packages, "current package" means the package that owns the task containing this `dependsOn` entry — the same package that would be inferred from an unqualified `"build"` dependency today. - -## Comparison - -| | Style 1 (CLI string) | Style 2 (Object) | -| ------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------ | -| Learning curve | None if you already know `vp run` — identical syntax | Minimal — same flag names, written as JSON keys | -| IDE autocompletion | Yes — TypeScript tuple types can constrain each array position | Yes — TypeScript object types can validate keys and suggest fields | -| Config consistency | Unusual — CLI syntax embedded in config arrays | Consistent — matches the object style used elsewhere in the config | From 07ad55c22a941e0d5837e676c73c7f1e24b26f87 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 12 Apr 2026 11:15:20 +0800 Subject: [PATCH 5/6] feat: add `--direct` / `-d` CLI flag for selecting direct dependencies Adds a new package selection flag that selects only the direct (one-hop) dependencies of the current package, excluding the package itself. This is the equivalent of Turborepo/Nx's `^` prefix in `dependsOn`. Unlike `--transitive` which walks the full transitive closure, `--direct` stops after one hop. All dependency types (dependencies, devDependencies, peerDependencies) are traversed. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/direct-dependencies/package.json | 4 + .../packages/app/package.json | 13 ++ .../packages/core/package.json | 7 + .../packages/utils/package.json | 10 ++ .../direct-dependencies/pnpm-workspace.yaml | 2 + .../direct-dependencies/snapshots.toml | 61 +++++++++ .../query - dependencies from app.snap | 17 +++ ...uery - dependencies from leaf package.snap | 12 ++ .../query - dependencies from utils.snap | 14 ++ ...- dependencies with package specifier.snap | 16 +++ ...ry - dependencies with workspace root.snap | 12 ++ .../query - direct with filter conflict.snap | 13 ++ ...uery - direct with recursive conflict.snap | 12 ++ ...ery - direct with transitive conflict.snap | 12 ++ ... - transitive from app for comparison.snap | 21 +++ .../snapshots/task graph.snap | 109 +++++++++++++++ .../snapshots.toml | 15 +++ ...- direct from middle finds direct dep.snap | 14 ++ ... direct from top stops at direct deps.snap | 12 ++ crates/vite_workspace/src/package_filter.rs | 125 ++++++++++++++---- crates/vite_workspace/src/package_graph.rs | 78 ++++++++--- 21 files changed, 536 insertions(+), 43 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json new file mode 100644 index 00000000..b65cafad --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace", + "private": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json new file mode 100644 index 00000000..3632bbf6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json @@ -0,0 +1,13 @@ +{ + "name": "@test/app", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/app'" + }, + "dependencies": { + "@test/utils": "workspace:*" + }, + "devDependencies": { + "@test/core": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json new file mode 100644 index 00000000..5fa64938 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/core", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/core'" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json new file mode 100644 index 00000000..95e18952 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/utils", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/utils'" + }, + "dependencies": { + "@test/core": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml new file mode 100644 index 00000000..a52a797a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml @@ -0,0 +1,61 @@ +# Tests --direct / -d flag: select direct dependencies only (one hop). +# Topology: app → utils (dependency), app → core (devDependency), utils → core +# Both dependency types are traversed. + +# -d from app: should select utils (dep) and core (devDep), NOT app itself. +[[plan]] +compact = true +name = "dependencies from app" +args = ["run", "-d", "build"] +cwd = "packages/app" + +# -d from utils: should select core only (direct dep), NOT utils itself. +[[plan]] +compact = true +name = "dependencies from utils" +args = ["run", "-d", "build"] +cwd = "packages/utils" + +# -d from core: core has no deps, so no packages selected. +[[plan]] +compact = true +name = "dependencies from leaf package" +args = ["run", "-d", "build"] +cwd = "packages/core" + +# Contrast with -t from app: should select app, utils, AND core (full transitive). +[[plan]] +compact = true +name = "transitive from app for comparison" +args = ["run", "-t", "build"] +cwd = "packages/app" + +# -d with package specifier: direct deps of @test/app. +[[plan]] +compact = true +name = "dependencies with package specifier" +args = ["run", "-d", "@test/app#build"] + +# -d with --workspace-root: direct deps of the workspace root package. +[[plan]] +compact = true +name = "dependencies with workspace root" +args = ["run", "-d", "-w", "build"] + +# Error: -d and -r conflict. +[[plan]] +compact = true +name = "direct with recursive conflict" +args = ["run", "-d", "-r", "build"] + +# Error: -d and -t conflict. +[[plan]] +compact = true +name = "direct with transitive conflict" +args = ["run", "-d", "-t", "build"] + +# Error: -d and --filter conflict. +[[plan]] +compact = true +name = "direct with filter conflict" +args = ["run", "-d", "--filter", "@test/app", "build"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap new file mode 100644 index 00000000..59b4f44e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/app +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/core#build": [], + "packages/utils#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap new file mode 100644 index 00000000..6662ac66 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/core +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap new file mode 100644 index 00000000..7ceb02c5 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/utils +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/core#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap new file mode 100644 index 00000000..8fb198b9 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - "@test/app#build" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/core#build": [], + "packages/utils#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap new file mode 100644 index 00000000..fa253f2c --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - "-w" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap new file mode 100644 index 00000000..73ecd23a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "-d" + - "--filter" + - "@test/app" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +Invalid vite task command: vt with args [] under cwd "/": --direct and --filter cannot be used together diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap new file mode 100644 index 00000000..367e528f --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "-d" + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +Invalid vite task command: vt with args [] under cwd "/": --direct and --recursive cannot be used together diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap new file mode 100644 index 00000000..2fe098ed --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "-d" + - "-t" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +Invalid vite task command: vt with args [] under cwd "/": --direct and --transitive cannot be used together diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap new file mode 100644 index 00000000..08fd0429 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap @@ -0,0 +1,21 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-t" + - build + cwd: packages/app +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/app#build": [ + "packages/core#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/utils#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap new file mode 100644 index 00000000..8ce12eef --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap @@ -0,0 +1,109 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +[ + { + "key": [ + "/packages/app", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/app", + "task_name": "build", + "package_path": "/packages/app" + }, + "resolved_config": { + "command": "echo 'Building @test/app'", + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/core", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/core", + "task_name": "build", + "package_path": "/packages/core" + }, + "resolved_config": { + "command": "echo 'Building @test/core'", + "resolved_options": { + "cwd": "/packages/core", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/utils", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/utils", + "task_name": "build", + "package_path": "/packages/utils" + }, + "resolved_config": { + "command": "echo 'Building @test/utils'", + "resolved_options": { + "cwd": "/packages/utils", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml index c5f5f8cb..c8bd97a2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml @@ -40,3 +40,18 @@ cwd = "packages/middle" compact = true name = "transitive with package specifier lacking task" args = ["run", "-t", "@test/middle#build"] + +# -d from top: direct deps are {middle}. middle lacks build → empty. +# Unlike -t which walks transitively through middle to find bottom. +[[plan]] +compact = true +name = "direct from top stops at direct deps" +args = ["run", "-d", "build"] +cwd = "packages/top" + +# -d from middle: direct dep is {bottom}. bottom has build → bottom#build. +[[plan]] +compact = true +name = "direct from middle finds direct dep" +args = ["run", "-d", "build"] +cwd = "packages/middle" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap new file mode 100644 index 00000000..1fc8b795 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/middle +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap new file mode 100644 index 00000000..27e90860 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/top +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{} diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 07fc7fde..94d579c3 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -170,6 +170,12 @@ pub(crate) struct GraphTraversal { /// Produced by `^` in `foo^...` (keep dependencies, drop foo) /// or `...^foo` (keep dependents, drop foo). pub(crate) exclude_self: bool, + + /// When `true`, only traverse one hop (direct dependencies/dependents). + /// When `false`, traverse the full transitive closure. + /// + /// Produced by `--dependencies` / `-d`. + pub(crate) direct_only: bool, } /// A single package filter, corresponding to one `--filter` argument. @@ -224,6 +230,15 @@ pub enum PackageQueryError { #[error("--filter and --recursive cannot be used together")] FilterWithRecursive, + #[error("--direct and --recursive cannot be used together")] + DirectWithRecursive, + + #[error("--direct and --transitive cannot be used together")] + DirectWithTransitive, + + #[error("--direct and --filter cannot be used together")] + DirectWithFilter, + #[error("cannot specify package name with --recursive")] PackageNameWithRecursive { package_name: Str }, @@ -245,6 +260,7 @@ pub enum PackageQueryError { /// Use `#[clap(flatten)]` to embed these in a parent clap struct. /// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. #[derive(Debug, Clone, PartialEq, Eq, clap::Args)] +#[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] pub struct PackageQueryArgs { /// Select all packages in the workspace. #[clap(default_value = "false", short, long)] @@ -254,6 +270,10 @@ pub struct PackageQueryArgs { #[clap(default_value = "false", short, long)] transitive: bool, + /// Select the direct dependencies of the current package. + #[clap(default_value = "false", short, long)] + direct: bool, + /// Select the workspace root package. #[clap(default_value = "false", short = 'w', long = "workspace-root")] workspace_root: bool, @@ -297,7 +317,7 @@ impl PackageQueryArgs { package_name: Option, cwd: &Arc, ) -> Result<(PackageQuery, bool), PackageQueryError> { - let Self { recursive, transitive, workspace_root, filters } = self; + let Self { recursive, transitive, direct, workspace_root, filters } = self; // Collect filter tokens from all `--filter` arguments, splitting on whitespace. let mut filter_tokens = Vec::::with_capacity(filters.len()); @@ -318,34 +338,40 @@ impl PackageQueryArgs { // Error arms only match the conflicting fields (wildcards for the rest). // Success arms explicitly match every field — no wildcards. - match (recursive, transitive, workspace_root, filter_tokens, package_name) { + match (recursive, transitive, direct, workspace_root, filter_tokens, package_name) { // ------------------------- error cases -------------------------------- // --recursive --transitive - (true, true, _, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), + (true, true, _, _, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), + // --recursive --direct + (true, _, true, _, _, _) => Err(PackageQueryError::DirectWithRecursive), + // --transitive --direct + (_, true, true, _, _, _) => Err(PackageQueryError::DirectWithTransitive), // --recursive --filter - (true, _, _, Some(_), _) => Err(PackageQueryError::FilterWithRecursive), + (true, _, _, _, Some(_), _) => Err(PackageQueryError::FilterWithRecursive), + // --direct --filter + (_, _, true, _, Some(_), _) => Err(PackageQueryError::DirectWithFilter), // --recursive # - (true, false, _, _, Some(package_name)) => { + (true, false, false, _, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithRecursive { package_name }) } // --transitive --filter - (false, true, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), + (false, true, false, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), // --filter # - (_, _, _, Some(_), Some(package_name)) => { + (_, _, _, _, Some(_), Some(package_name)) => { Err(PackageQueryError::PackageNameWithFilter { package_name }) } // --workspace-root # - (_, _, true, _, Some(package_name)) => { + (_, _, _, true, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }) } // ------------------------ success cases ------------------------------- // --recursive (--workspace-root is redundant) - (true, false, true | false, None, None) => Ok((PackageQuery::all(), false)), + (true, false, false, true | false, None, None) => Ok((PackageQuery::all(), false)), // --filter [--workspace-root] - (false, false, workspace_root, Some(filter_tokens), None) => { + (false, false, false, workspace_root, Some(filter_tokens), None) => { let mut parsed: Vec1 = filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; if workspace_root { @@ -358,12 +384,13 @@ impl PackageQueryArgs { } Ok((PackageQuery::filters(parsed), false)) } - // --workspace-root [--transitive] - (false, transitive, true, None, None) => { - let traversal = if transitive { + // --workspace-root [--transitive|--direct] + (false, transitive, direct, true, None, None) => { + let traversal = if transitive || direct { Some(GraphTraversal { direction: TraversalDirection::Dependencies, - exclude_self: false, + exclude_self: direct, + direct_only: direct, }) } else { None @@ -378,12 +405,13 @@ impl PackageQueryArgs { false, )) } - // [--transitive] # - (false, transitive, false, None, Some(name)) => { - let traversal = if transitive { + // [--transitive|--direct] # + (false, transitive, direct, false, None, Some(name)) => { + let traversal = if transitive || direct { Some(GraphTraversal { direction: TraversalDirection::Dependencies, - exclude_self: false, + exclude_self: direct, + direct_only: direct, }) } else { None @@ -402,20 +430,35 @@ impl PackageQueryArgs { )) } // --transitive - (false, true, false, None, None) => Ok(( + (false, true, false, false, None, None) => Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), traversal: Some(GraphTraversal { direction: TraversalDirection::Dependencies, exclude_self: false, + direct_only: false, + }), + source: None, + })), + false, + )), + // --direct + (false, false, true, false, None, None) => Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), + traversal: Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: true, + direct_only: true, }), source: None, })), false, )), // (no flags, implicit cwd) - (false, false, false, None, None) => Ok(( + (false, false, false, false, None, None) => Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), @@ -476,15 +519,24 @@ pub(crate) fn parse_filter( let exclude_self = deps_exclude_self || dependents_exclude_self; // Step 4–5: build the traversal descriptor. + // Filter-based traversals are always transitive (not direct_only). let traversal = match (include_dependencies, include_dependents) { (false, false) => None, - (true, false) => { - Some(GraphTraversal { direction: TraversalDirection::Dependencies, exclude_self }) - } - (false, true) => { - Some(GraphTraversal { direction: TraversalDirection::Dependents, exclude_self }) - } - (true, true) => Some(GraphTraversal { direction: TraversalDirection::Both, exclude_self }), + (true, false) => Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self, + direct_only: false, + }), + (false, true) => Some(GraphTraversal { + direction: TraversalDirection::Dependents, + exclude_self, + direct_only: false, + }), + (true, true) => Some(GraphTraversal { + direction: TraversalDirection::Both, + exclude_self, + direct_only: false, + }), }; // Step 6–9: parse the remaining core selector. @@ -1116,6 +1168,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1142,6 +1195,7 @@ mod tests { let args = PackageQueryArgs { recursive: true, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1160,6 +1214,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: true, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1185,6 +1240,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: vec![Str::from("foo")], }; @@ -1209,6 +1265,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1233,6 +1290,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("a b")], }; @@ -1253,6 +1311,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: vec![Str::from("foo")], }; @@ -1273,6 +1332,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1294,6 +1354,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("")], }; @@ -1306,6 +1367,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from(" ")], }; @@ -1318,6 +1380,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("foo"), Str::from("")], }; @@ -1330,6 +1393,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from(""), Str::from("foo")], }; @@ -1342,6 +1406,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("foo"), Str::from(" \t ")], }; @@ -1356,6 +1421,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1369,6 +1435,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1382,6 +1449,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: true, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1395,6 +1463,7 @@ mod tests { let args = PackageQueryArgs { recursive: true, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1408,6 +1477,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("foo")], }; @@ -1421,6 +1491,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 69908d62..0bdb7617 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -431,23 +431,39 @@ impl IndexedPackageGraph { let mut reachable = FxHashSet::default(); - match traversal.direction { - TraversalDirection::Dependencies => { - self.bfs_outgoing(&seeds, &mut reachable); - } - TraversalDirection::Dependents => { - self.bfs_incoming(&seeds, &mut reachable); + if traversal.direct_only { + // One-hop traversal: only direct neighbors, no recursive expansion. + match traversal.direction { + TraversalDirection::Dependencies => { + self.direct_outgoing(&seeds, &mut reachable); + } + TraversalDirection::Dependents => { + self.direct_incoming(&seeds, &mut reachable); + } + TraversalDirection::Both => { + self.direct_outgoing(&seeds, &mut reachable); + self.direct_incoming(&seeds, &mut reachable); + } } - TraversalDirection::Both => { - // Walk dependents first, then walk dependencies of ALL dependents found - // (including the original seeds). - // pnpm ref: - let mut dependents = FxHashSet::default(); - self.bfs_incoming(&seeds, &mut dependents); - let all_dep_seeds: FxHashSet<_> = - seeds.iter().chain(dependents.iter()).copied().collect(); - self.bfs_outgoing(&all_dep_seeds, &mut reachable); - reachable.extend(dependents); + } else { + match traversal.direction { + TraversalDirection::Dependencies => { + self.bfs_outgoing(&seeds, &mut reachable); + } + TraversalDirection::Dependents => { + self.bfs_incoming(&seeds, &mut reachable); + } + TraversalDirection::Both => { + // Walk dependents first, then walk dependencies of ALL dependents found + // (including the original seeds). + // pnpm ref: + let mut dependents = FxHashSet::default(); + self.bfs_incoming(&seeds, &mut dependents); + let all_dep_seeds: FxHashSet<_> = + seeds.iter().chain(dependents.iter()).copied().collect(); + self.bfs_outgoing(&all_dep_seeds, &mut reachable); + reachable.extend(dependents); + } } } @@ -462,6 +478,36 @@ impl IndexedPackageGraph { reachable } + /// Collect direct (one-hop) outgoing neighbors of `seeds`. + /// + /// Seeds are NOT added to `out`. + fn direct_outgoing( + &self, + seeds: &FxHashSet, + out: &mut FxHashSet, + ) { + for &node in seeds { + for edge in self.graph.edges(node) { + out.insert(edge.target()); + } + } + } + + /// Collect direct (one-hop) incoming neighbors of `seeds`. + /// + /// Seeds are NOT added to `out`. + fn direct_incoming( + &self, + seeds: &FxHashSet, + out: &mut FxHashSet, + ) { + for &node in seeds { + for edge in self.graph.edges_directed(node, Direction::Incoming) { + out.insert(edge.source()); + } + } + } + /// BFS along outgoing (dependency) edges from `seeds`, collecting all reachable nodes. /// /// Seeds are NOT added to `out`; the caller decides inclusion based on `exclude_self`. From 1a750bf18b262766dc7e460a2e6e9f3304ab6ce5 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 12 Apr 2026 11:24:14 +0800 Subject: [PATCH 6/6] refactor: extract `TraversalMode` enum from boolean flags Replace the three mutually exclusive booleans (`recursive`, `transitive`, `direct`) with a `TraversalMode` enum resolved via `from_flags()`. Clap's `group = "traversal"` enforces mutual exclusivity at parse time; the enum makes it explicit in the type system. Co-Authored-By: Claude Opus 4.6 --- .../query - direct with filter conflict.snap | 2 +- ...uery - direct with recursive conflict.snap | 8 +- ...ery - direct with transitive conflict.snap | 8 +- crates/vite_workspace/src/package_filter.rs | 177 +++++++++++------- 4 files changed, 118 insertions(+), 77 deletions(-) diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap index 73ecd23a..acc0ec88 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap @@ -10,4 +10,4 @@ info: - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies --- -Invalid vite task command: vt with args [] under cwd "/": --direct and --filter cannot be used together +Invalid vite task command: vt with args [] under cwd "/": --filter and --direct cannot be used together diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap index 367e528f..c63778bb 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap @@ -1,6 +1,6 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs -expression: err_str.as_ref() +expression: err info: args: - run @@ -9,4 +9,8 @@ info: - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies --- -Invalid vite task command: vt with args [] under cwd "/": --direct and --recursive cannot be used together +error: the argument '--direct' cannot be used with '--recursive' + +Usage: vt run --direct ... + +For more information, try '--help'. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap index 2fe098ed..3a5df9f0 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap @@ -1,6 +1,6 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs -expression: err_str.as_ref() +expression: err info: args: - run @@ -9,4 +9,8 @@ info: - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies --- -Invalid vite task command: vt with args [] under cwd "/": --direct and --transitive cannot be used together +error: the argument '--direct' cannot be used with '--transitive' + +Usage: vt run --direct ... + +For more information, try '--help'. diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 94d579c3..4da4aa57 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -221,8 +221,8 @@ pub enum PackageFilterParseError { /// Errors that can occur when converting [`PackageQueryArgs`] into a [`PackageQuery`]. #[derive(Debug, thiserror::Error)] pub enum PackageQueryError { - #[error("--recursive and --transitive cannot be used together")] - RecursiveTransitiveConflict, + #[error("--recursive, --transitive, and --direct are mutually exclusive")] + ConflictingTraversalModes, #[error("--filter and --transitive cannot be used together")] FilterWithTransitive, @@ -230,13 +230,7 @@ pub enum PackageQueryError { #[error("--filter and --recursive cannot be used together")] FilterWithRecursive, - #[error("--direct and --recursive cannot be used together")] - DirectWithRecursive, - - #[error("--direct and --transitive cannot be used together")] - DirectWithTransitive, - - #[error("--direct and --filter cannot be used together")] + #[error("--filter and --direct cannot be used together")] DirectWithFilter, #[error("cannot specify package name with --recursive")] @@ -255,23 +249,58 @@ pub enum PackageQueryError { InvalidFilter(#[from] PackageFilterParseError), } +/// How to traverse the package dependency graph when selecting packages. +/// +/// These modes are mutually exclusive at the CLI level (`-r`, `-t`, `-d`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TraversalMode { + /// `--recursive` / `-r`: select all packages in the workspace. + Recursive, + /// `--transitive` / `-t`: select the current package and its full transitive dependencies. + Transitive, + /// `--direct` / `-d`: select only the direct dependencies of the current package (one hop, excluding self). + Direct, +} + +impl TraversalMode { + /// Convert to the internal graph traversal specification. + pub(crate) fn to_graph_traversal(self) -> GraphTraversal { + match self { + Self::Recursive => GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + direct_only: false, + }, + Self::Transitive => GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + direct_only: false, + }, + Self::Direct => GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: true, + direct_only: true, + }, + } + } +} + /// CLI arguments for selecting which packages a command applies to. /// /// Use `#[clap(flatten)]` to embed these in a parent clap struct. /// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. #[derive(Debug, Clone, PartialEq, Eq, clap::Args)] -#[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] pub struct PackageQueryArgs { /// Select all packages in the workspace. - #[clap(default_value = "false", short, long)] + #[clap(default_value = "false", short, long, group = "traversal")] recursive: bool, /// Select the current package and its transitive dependencies. - #[clap(default_value = "false", short, long)] + #[clap(default_value = "false", short, long, group = "traversal")] transitive: bool, /// Select the direct dependencies of the current package. - #[clap(default_value = "false", short, long)] + #[clap(default_value = "false", short, long, group = "traversal")] direct: bool, /// Select the workspace root package. @@ -297,6 +326,27 @@ Match packages by name, directory, or glob pattern. filters: Vec, } +impl TraversalMode { + /// Resolve three mutually exclusive traversal flags into an `Option`. + /// + /// Clap's `group = "traversal"` enforces mutual exclusivity at parse time. + /// This function also handles direct struct construction (e.g. in tests). + fn from_flags( + recursive: bool, + transitive: bool, + direct: bool, + ) -> Result, PackageQueryError> { + match (recursive, transitive, direct) { + (false, false, false) => Ok(None), + (true, false, false) => Ok(Some(Self::Recursive)), + (false, true, false) => Ok(Some(Self::Transitive)), + (false, false, true) => Ok(Some(Self::Direct)), + // Clap group prevents this from CLI; guard for direct construction. + _ => Err(PackageQueryError::ConflictingTraversalModes), + } + } +} + impl PackageQueryArgs { /// Convert CLI arguments into an opaque [`PackageQuery`]. /// @@ -304,20 +354,20 @@ impl PackageQueryArgs { /// `cwd` is the working directory (used as fallback when no package name or filter is given). /// /// Returns `(query, is_cwd_only)` where `is_cwd_only` is `true` when the query - /// falls through to the implicit-cwd path (no `-r`, `-t`, `-w`, `--filter`, + /// falls through to the implicit-cwd path (no `-r`, `-t`, `-d`, `-w`, `--filter`, /// or explicit package name). /// /// # Errors /// /// Returns [`PackageQueryError`] if conflicting flags are set, a package name /// is specified with `--recursive` or `--filter`, or a filter expression is invalid. - #[expect(clippy::too_many_lines, reason = "single exhaustive match")] pub fn into_package_query( self, package_name: Option, cwd: &Arc, ) -> Result<(PackageQuery, bool), PackageQueryError> { let Self { recursive, transitive, direct, workspace_root, filters } = self; + let traversal_mode = TraversalMode::from_flags(recursive, transitive, direct)?; // Collect filter tokens from all `--filter` arguments, splitting on whitespace. let mut filter_tokens = Vec::::with_capacity(filters.len()); @@ -338,40 +388,42 @@ impl PackageQueryArgs { // Error arms only match the conflicting fields (wildcards for the rest). // Success arms explicitly match every field — no wildcards. - match (recursive, transitive, direct, workspace_root, filter_tokens, package_name) { + match (traversal_mode, workspace_root, filter_tokens, package_name) { // ------------------------- error cases -------------------------------- - // --recursive --transitive - (true, true, _, _, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), - // --recursive --direct - (true, _, true, _, _, _) => Err(PackageQueryError::DirectWithRecursive), - // --transitive --direct - (_, true, true, _, _, _) => Err(PackageQueryError::DirectWithTransitive), // --recursive --filter - (true, _, _, _, Some(_), _) => Err(PackageQueryError::FilterWithRecursive), - // --direct --filter - (_, _, true, _, Some(_), _) => Err(PackageQueryError::DirectWithFilter), + (Some(TraversalMode::Recursive), _, Some(_), _) => { + Err(PackageQueryError::FilterWithRecursive) + } // --recursive # - (true, false, false, _, _, Some(package_name)) => { + (Some(TraversalMode::Recursive), _, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithRecursive { package_name }) } // --transitive --filter - (false, true, false, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), + (Some(TraversalMode::Transitive), _, Some(_), _) => { + Err(PackageQueryError::FilterWithTransitive) + } + // --direct --filter + (Some(TraversalMode::Direct), _, Some(_), _) => { + Err(PackageQueryError::DirectWithFilter) + } // --filter # - (_, _, _, _, Some(_), Some(package_name)) => { + (_, _, Some(_), Some(package_name)) => { Err(PackageQueryError::PackageNameWithFilter { package_name }) } // --workspace-root # - (_, _, _, true, _, Some(package_name)) => { + (_, true, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }) } // ------------------------ success cases ------------------------------- // --recursive (--workspace-root is redundant) - (true, false, false, true | false, None, None) => Ok((PackageQuery::all(), false)), + (Some(TraversalMode::Recursive), true | false, None, None) => { + Ok((PackageQuery::all(), false)) + } // --filter [--workspace-root] - (false, false, false, workspace_root, Some(filter_tokens), None) => { + (None, workspace_root, Some(filter_tokens), None) => { let mut parsed: Vec1 = filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; if workspace_root { @@ -385,16 +437,13 @@ impl PackageQueryArgs { Ok((PackageQuery::filters(parsed), false)) } // --workspace-root [--transitive|--direct] - (false, transitive, direct, true, None, None) => { - let traversal = if transitive || direct { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: direct, - direct_only: direct, - }) - } else { - None - }; + ( + mode @ (None | Some(TraversalMode::Transitive | TraversalMode::Direct)), + true, + None, + None, + ) => { + let traversal = mode.map(TraversalMode::to_graph_traversal); Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, @@ -406,16 +455,13 @@ impl PackageQueryArgs { )) } // [--transitive|--direct] # - (false, transitive, direct, false, None, Some(name)) => { - let traversal = if transitive || direct { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: direct, - direct_only: direct, - }) - } else { - None - }; + ( + mode @ (None | Some(TraversalMode::Transitive | TraversalMode::Direct)), + false, + None, + Some(name), + ) => { + let traversal = mode.map(TraversalMode::to_graph_traversal); Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, @@ -429,36 +475,23 @@ impl PackageQueryArgs { false, )) } - // --transitive - (false, true, false, false, None, None) => Ok(( - PackageQuery::filters(Vec1::new(PackageFilter { - exclude: false, - selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), - traversal: Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - direct_only: false, - }), - source: None, - })), + // --transitive | --direct (from cwd) + ( + Some(mode @ (TraversalMode::Transitive | TraversalMode::Direct)), false, - )), - // --direct - (false, false, true, false, None, None) => Ok(( + None, + None, + ) => Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), - traversal: Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: true, - direct_only: true, - }), + traversal: Some(mode.to_graph_traversal()), source: None, })), false, )), // (no flags, implicit cwd) - (false, false, false, false, None, None) => Ok(( + (None, false, None, None) => Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector: PackageSelector::ContainingPackage(Arc::clone(cwd)),