From 1b72410707ea0170395dd6faf39b69d7b0fcf4cb Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 01:19:33 +0800 Subject: [PATCH 01/10] docs: add RFCs for task graph JSON and dry runs Add two RFCs for `vp run` diagnostics: - docs/rfcs/task-graph-json.md: `vp run --json` prints every package and task in the workspace and the `dependsOn` edges between tasks. - docs/rfcs/dry-run.md: `--dry-run` stops before running anything, `--json` prints the plan as JSON, and `-v`/`-vv` make the cache check list every change and everything the cached run depends on. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 520 +++++++++++++++++++++++++++++++++++ docs/rfcs/task-graph-json.md | 215 +++++++++++++++ 2 files changed, 735 insertions(+) create mode 100644 docs/rfcs/dry-run.md create mode 100644 docs/rfcs/task-graph-json.md diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md new file mode 100644 index 000000000..8d8cef006 --- /dev/null +++ b/docs/rfcs/dry-run.md @@ -0,0 +1,520 @@ +# RFC: Dry runs and complete miss reasons (`--dry-run`, `--json`, `-v`) + +## Summary + +Three flags for `vp run `, each doing one thing: + +| Flag | What it does | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--dry-run` | Stops before running any command. Planning and cache checks happen, and print, as in a real run. | +| `--json` | Prints the answer as JSON instead of text. Requires `--dry-run` for now, and conflicts with `--log`. | +| `-v`, `-vv` | Checks every input instead of stopping at the first changed file. `-v` lists every change; `-vv` also lists everything the cached run depends on, and its outputs. | + +```bash +vp run --dry-run -r build # what would run, and the first reason for each miss +vp run --dry-run -v -r build # ... with every reason +vp run --dry-run --json -r build # the same answer as JSON +vp run -v -r build # a real run whose summary lists every reason +``` + +The conventions (task IDs, paths, absent values, output, errors, versioning) are those of the [task-graph RFC](./task-graph-json.md#conventions). + +## Motivation + +- Once the work in [vite-task#929](https://github.com/voidzero-dev/vite-task/issues/929) lands, `vp run` caches tasks that read and rewrite their own files, such as Vitest, TypeScript and Astro, without exclusions in `cache.input` or `cache.output`. Whether such a task hits then depends on rules about which recorded files a hit may rebuild or find missing, not on config the user wrote. People and AI agents will need to see why a task missed, and what it was checked against, more than before. +- "Will this hit the cache?" can't be asked before running ([vite-task#791](https://github.com/voidzero-dev/vite-task/issues/791)). +- "What would this run do?" (which tasks, which commands, in what order) can't be asked either ([vite-plus#2848](https://github.com/voidzero-dev/vite-plus/issues/2848)). +- A miss reports only its first reason, so `'src/a.ts' modified` doesn't say whether reverting it is enough. + +## The example + +The workspace from the [task-graph RFC](./task-graph-json.md#example): `@acme/ui` (`packages/ui`, `build: tsdown`) and `@acme/web` (`apps/web`, depends on `@acme/ui`; `build: vite build` with `cache.env: ['NODE_ENV']`; `deploy: wrangler deploy`, `dependsOn: ['build']`, `cache: false`). + +Yesterday `vp run -r build` ran both builds and cached them. Since then: + +- `packages/ui/src/button.tsx` and `packages/ui/src/theme.css` were edited; +- the shell has `NODE_ENV=production`, where yesterday it had `development`. + +## Each flag on its own + +### `--dry-run` + +`--dry-run` stops before running any command. Everything before that happens as in a real run and prints the same way: each command's line with its cache status, then the summary. There is no task output, and the status doesn't end in "executing" or "replaying". + +``` +$ vp run --dry-run -r build +~/packages/ui$ tsdown ○ cache miss: 'packages/ui/src/button.tsx' modified +~/apps/web$ vite build ○ cache miss: env 'NODE_ENV' changed + +--- +vp run: 0/2 cache hit (0%), nothing was run. +``` + +The real run prints the same lines, with the task output in between: + +``` +$ vp run -r build +~/packages/ui$ tsdown ○ cache miss: 'packages/ui/src/button.tsx' modified, executing +… + +~/apps/web$ vite build ○ cache miss: env 'NODE_ENV' changed, executing +… + +--- +vp run: 0/2 cache hit (0%), 2/2 successful. (Run `vp run --last-details` for full details) +``` + +- Nothing runs and nothing is written: no process, no cache entry, no change to `vp run --last-details`. +- Everything else is a real run's: the plan, the cache check, every run flag and the args after the task name. The remote cache is read exactly when a real run would read it. Outputs are never downloaded or restored, since that would write files. +- Each status is checked against the files as they are now. A command that runs after one that would run may see different files by then; the real run below shows it. +- The first changed file is the first in path order, `cache.input` files before the files the task read. + +### `-v` + +`-v` keeps checking after the first changed file. Its detailed summary, in a dry run as in a real run, lists every change: + +``` +$ vp run --dry-run -v -r build +~/packages/ui$ tsdown ○ cache miss: 'packages/ui/src/button.tsx' modified +~/apps/web$ vite build ○ cache miss: env 'NODE_ENV' changed +… +Task Details: +──────────────────────────────────────────────── + [1] @acme/ui#build: ~/packages/ui$ tsdown + → Cache miss: 'packages/ui/src/button.tsx' modified + 'packages/ui/src/theme.css' modified + ······················································· + [2] @acme/web#build: ~/apps/web$ vite build + → Cache miss: env 'NODE_ENV' changed +``` + +The list is complete: reverting every listed change would make the command a hit. Without `-v`, only the file check stops early; command, config and env changes are always listed in full. + +In a real run: + +``` +$ vp run -v -r build +… +Task Details: +──────────────────────────────────────────────── + [1] @acme/ui#build: ~/packages/ui$ tsdown ✓ + → Cache miss: 'packages/ui/src/button.tsx' modified + 'packages/ui/src/theme.css' modified + ······················································· + [2] @acme/web#build: ~/apps/web$ vite build ✓ + → Cache miss: env 'NODE_ENV' changed + 'packages/ui/dist/index.js' modified +``` + +`packages/ui/dist/index.js` changed because `@acme/ui#build` rebuilt it first. The dry run, checking today's files, couldn't see that. + +### `-vv` + +The detailed summary also lists what the check found unchanged, which shows, for an unexpected hit, whether an edited file is an input at all. + +``` +$ vp run --dry-run -vv --filter @acme/ui build +~/packages/ui$ tsdown ○ cache miss: 'packages/ui/src/button.tsx' modified +… +Task Details: +──────────────────────────────────────────────── + [1] @acme/ui#build: ~/packages/ui$ tsdown + → Cache miss: 'packages/ui/src/button.tsx' modified + 'packages/ui/src/theme.css' modified + Unchanged: 'packages/ui/package.json' + 'packages/ui/src' (listing) + 'packages/ui/src/index.ts' + 'packages/ui/tsdown.config.ts' + 'packages/ui/.browserslistrc' (missing) + Outputs: 'packages/ui/dist/index.d.ts' + 'packages/ui/dist/index.js' +``` + +### `--json` + +`--json` prints the same answer as a document. With `--dry-run`, that is the run plan: + +```bash +vp run --dry-run --json -r build +``` + +```json +{ + "version": 1, + "workspaceRoot": "/home/me/acme", + "plan": { + "apps/web#build": { + "name": "build", + "package": "apps/web", + "dependsOn": ["packages/ui#build"], + "steps": [ + { + "program": "apps/web/node_modules/.bin/vite", + "args": ["build"], + "cwd": "apps/web", + "cache": { + "status": "changed", + "changes": [{ "kind": "env", "name": "NODE_ENV", "change": "changed" }], + "baseline": { "durationMs": 9120 } + } + } + ] + }, + "packages/ui#build": { + "name": "build", + "package": "packages/ui", + "steps": [ + { + "program": "packages/ui/node_modules/.bin/tsdown", + "cwd": "packages/ui", + "cache": { + "status": "changed", + "partial": true, + "baseline": { "durationMs": 1830 }, + "changes": [ + { + "kind": "file", + "path": "packages/ui/src/button.tsx", + "change": "changed" + } + ] + } + } + ] + } + } +} +``` + +(As printed in a terminal; captured output is the same on one line.) + +- `"partial": true` marks a check that stopped at the first changed file; with `-v` it is gone. +- `@acme/web#build` has no `partial`: its check found no changed file, so it checked them all. + +## Flags together + +`-v` and `-vv` change the same parts of the JSON as of the text. For `packages/ui#build`: + +```jsonc +// --dry-run --json +"cache": { + "status": "changed", "partial": true, + "changes": [ + { "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" } + ], + "baseline": { "durationMs": 1830 } +} + +// --dry-run --json -v: complete, so no "partial" +"cache": { + "status": "changed", + "changes": [ + { "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" }, + { "kind": "file", "path": "packages/ui/src/theme.css", "change": "changed" } + ], + "baseline": { "durationMs": 1830 } +} + +// --dry-run --json -vv: also everything the cached run depends on, changed or not, and its outputs +"cache": { + "status": "changed", + "changes": [ … as with -v … ], + "baseline": { + "durationMs": 1830, + "fingerprint": { + "paths": { + "packages/ui/.browserslistrc": { "type": "missing" }, + "packages/ui/package.json": { "type": "file", "hash": "8f2c41d09a7be356" }, + "packages/ui/src": { "type": "dir", "entries": ["button.tsx", "index.ts", "theme.css"] }, + "packages/ui/src/button.tsx": { "type": "file", "hash": "1b9e07c4f26da813" }, + "packages/ui/src/index.ts": { "type": "file", "hash": "c3a5d8e2017f94b6" }, + "packages/ui/src/theme.css": { "type": "file", "hash": "5e0f9b72ad41c638" }, + "packages/ui/tsdown.config.ts": { "type": "file", "hash": "e7d24a6b9c05f183" } + } + }, + "outputs": ["packages/ui/dist/index.d.ts", "packages/ui/dist/index.js"] + } +} +``` + +Every combination: + +| Command | default | `-v` | `-vv` | +| ------------------------------- | -------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------ | +| `vp run ` | runs; one-line summary; each miss shows its first reason | runs; detailed summary with every change | … and what it depends on | +| `vp run --dry-run ` | each command's cache status; one-line summary | detailed summary with every change | … and what it depends on | +| `vp run --dry-run --json ` | the plan as JSON; `partial: true` where it stopped | every change | … and `baseline.fingerprint`, `baseline.outputs` | +| `vp run --json ` | error: requires `--dry-run` (JSON lines later) | the same | the same | + +Two more combinations: + +- **Structure only.** `vp run --dry-run --json --no-cache -r build` is what `vp run --no-cache` would do: every command is `{ "status": "disabled" }`, and no file is hashed and no remote request made. +- **Edges and disabled caching.** `vp run --dry-run @acme/web#deploy` shows `deploy` after `build`, through its `dependsOn`: + +```json +"apps/web#deploy": { + "name": "deploy", + "package": "apps/web", + "dependsOn": ["apps/web#build"], + "steps": [ + { "program": "apps/web/node_modules/.bin/wrangler", "args": ["deploy"], "cwd": "apps/web/infra", "cache": { "status": "disabled" } } + ] +} +``` + +### Rules + +1. **A flag means the same thing in every combination.** `--dry-run` only stops execution, `--json` only changes the format, `-v`/`-vv` only change how much is checked and listed. +2. **Adding `--dry-run` to a command line that works never breaks it.** Flags that only shape execution (`--log`, `--concurrency-limit`) are accepted and do nothing. +3. **A combination that isn't supported yet fails and says so**, like `--json` on a real run. +4. **vp's flags go before the task name.** Everything after it is passed to the task. + +## The run plan + +```ts +type RunPlan = { + /** Format version. It changes only when the format changes incompatibly. */ + version: 1; + + /** + * Absolute path of the workspace root. Other paths are relative to it, except paths outside + * the workspace, which are absolute. + */ + workspaceRoot: string; + + /** + * The tasks this run would run, keyed by task ID. The IDs are the same as in `vp run --json`, + * which has each task's package name and dependencies. + */ + plan: { + [id: string]: { + /** The task name, such as `build`. */ + name: string; + + /** The path of the task's package, such as `packages/ui`. */ + package: string; + + /** + * The tasks that must finish before this one starts, by ID: the task's own `dependsOn`, + * plus, with `-r`, `-t` or `--filter`, the same task in the packages this package depends + * on. A package without that task is skipped over. Absent when there are none. + * + * Each ID is a key of the same `plan` object as this task. A nested `plan` has its own + * dependency graph: its tasks never depend on tasks of an enclosing or sibling plan, and + * the same ID can appear in more than one plan. + */ + dependsOn?: string[]; + + /** + * What the task runs, in order. Each part of the command between `&&`, each entry of a + * command array, and each `pre`/`post` script is one step. Each step is cached separately. + */ + steps: ( + | { + /** + * The executable that would be started, as `vp run` finds it: a path relative to + * the workspace root if it is inside it (`apps/web/node_modules/.bin/vite`), + * otherwise an absolute path (`/usr/local/bin/node`). A command with shell syntax, + * such as a pipe, runs through the shell: `/bin/sh` with `["-c", "tsc | tee log"]`, + * or `C:\Windows\System32\cmd.exe` with `/d /s /c` on Windows. For a built-in such + * as `echo`, which runs without a new process, this is just its name. + */ + program: string; + + /** The arguments, including any given after the task name. Absent when there are none. */ + args?: string[]; + + /** + * The directory it runs in: the package directory, unless the task's `cwd` setting or + * an earlier `cd` in the command changes it. + */ + cwd: string; + + /** Set when the step comes from a `pre` or `post` script, such as `prebuild`. */ + hook?: string; + + /** + * Whether the cache would be used. Absent for a built-in such as `echo`. + * + * The check looks at the files as they are now. A task that runs earlier, listed in + * `dependsOn`, may still change them before this step starts. + */ + cache?: + | (( + | { + /** Matches `baseline`, so it would replay `baseline` instead of running. */ + status: 'hit'; + } + | { + /** + * Differs from `baseline`, so it would run. `changes` lists the differences. + */ + status: 'changed'; + + /** + * Everything that differs from `baseline`, sorted by kind, then by name or + * path. Undoing all of them would make the step a hit. + */ + changes: ( + | { + /** + * The program as the cache records it: a path inside the workspace, or + * just the file name outside it, so `node` from another install is not + * a change. + */ + kind: 'program'; + from: string; + to: string; + } + | { kind: 'args'; from: string[]; to: string[] } + | { kind: 'cwd'; from: string; to: string } + | { + /** + * The setting changed. A changed `input` is one change: the files it + * now matches aren't listed. + */ + kind: 'config'; + setting: 'input' | 'output' | 'untrackedEnv'; + } + | { + kind: 'env'; + name: string; + change: 'added' | 'removed' | 'changed'; + } + | { + kind: 'file'; + path: string; + /** `added` also covers a new file in a directory the task listed. */ + change: 'added' | 'removed' | 'changed'; + } + )[]; + + /** + * Only the first changed file is listed: without `-v`, the check stops there. + * Absent with `-v`, which lists every change. + */ + partial?: true; + } + ) & { + /** + * The cached run the step is compared with: the one with the same command, + * config and env, or, if there is none, the step's most recent cached run. + */ + baseline: { + /** How long the cached run took, in milliseconds. On a hit, the time a replay saves. */ + durationMs: number; + + /** + * With `-vv`: everything the cached run depends on, changed or not: the env + * names and paths the cache records for this step. + */ + fingerprint?: { + /** Env names. */ + env?: string[]; + + /** Each path the cached run depends on, with what is checked. */ + paths?: { + [path: string]: + | { + /** A file, matched by `cache.input` or read by the task. Its content is checked. */ + type: 'file'; + + /** + * A hash of the content the cached run recorded. Use it only to compare with other + * hashes from the same version of Vite+: the algorithm isn't part of the format and + * can change, so don't compute hashes yourself to compare with it. + */ + hash: string; + + /** + * The task rewrites this file, so it may be missing, as on a fresh checkout. When it + * is there, its content is still checked. + */ + mayBeMissing?: true; + } + | { + /** A directory the task opened. */ + type: 'dir'; + + /** + * The names of the directory's entries, when the task listed it. Adding or removing an + * entry is a change. An empty array means the directory was empty. Absent when the + * task opened the directory without listing it: then only that it exists is checked. + */ + entries?: string[]; + } + | { + /** A path the task looked for and didn't find. Creating it is a change. */ + type: 'missing'; + }; + }; + }; + + /** + * With `-vv`: the files the cached run archived as its outputs, which a hit restores. + * Only files are listed: `packages/ui/dist/index.js` appears, but its directory + * `packages/ui/dist` doesn't get an entry of its own. + */ + outputs?: string[]; + }; + }) + | { + /** + * Would run: there is no cached run to compare with, as on a first run or after + * `vp cache clean`. Arguments given after the task name count, so a new + * combination of arguments is also `noBaseline`. + */ + status: 'noBaseline'; + } + | { + /** + * Would run: the cache couldn't be read. For example, the remote cache failed + * and nothing is cached locally. + */ + status: 'error'; + + /** What went wrong. */ + message: string; + } + | { + /** Would run without caching, because caching is off for this step. */ + status: 'disabled'; + }; + } + | { + /** + * The `vp run …` command this step stands for, as written, with any arguments given + * after the task name: `vp run -r test`. + */ + expandedFrom: string; + + /** + * The plan of that nested `vp run`, in the same shape, so the plan is a tree. Its tasks + * start when this step does, after the task's earlier steps, and their `dependsOn` + * refers only to tasks in this nested plan. A nested run isn't started as a separate + * process, so the step has no `program`, `cwd` or `cache`. + */ + plan: RunPlan['plan']; + } + )[]; + }; + }; +}; +``` + +## Other behaviour + +- **Placement.** Like every vp flag, `--dry-run` goes before the task name. `vp run deploy --dry-run` passes `--dry-run` to `deploy` and runs it. +- **Nested dry runs.** A task whose command runs `vp run --dry-run …` isn't inlined like other nested `vp run` commands: that command is started as a process, and it prints its own dry run. +- **Exit codes.** A dry run exits 0 when planning succeeds, whatever it predicts. When planning fails, the error goes to stderr and the exit code is non-zero. +- **`--last-details`.** After a run with `-v`, it shows every change. + +## Changes to existing behaviour + +- `-v` / `--verbose` becomes a counted flag: `-v`, `-vv` (or `--verbose --verbose`). +- `vp run -v` checks every input of a command that misses, and its detailed summary lists every change instead of the first. + +## Future + +- `--json` without `--dry-run` can print JSON lines as a real run progresses, one event per line. +- A step that runs after one that would run is checked against today's files, which the earlier step may change. Such steps could get a status of their own, such as `unknown`. If cache entries recorded each output's path and hash, a dry run could instead check them against the files as earlier hits will restore them. diff --git a/docs/rfcs/task-graph-json.md b/docs/rfcs/task-graph-json.md new file mode 100644 index 000000000..cba40f80f --- /dev/null +++ b/docs/rfcs/task-graph-json.md @@ -0,0 +1,215 @@ +# RFC: Task graph as JSON (`vp run --json`) + +## Summary + +`vp run --json` with no task prints every package and task in the workspace, and how the tasks depend on each other, as one JSON document: + +```bash +vp run --json +``` + +It is the JSON form of the task list `vp run` prints without a task. It runs nothing, and gives the same output from any directory in the workspace. + +The companion RFC, [dry runs and complete miss reasons](./dry-run.md), covers `vp run --dry-run --json `: what a particular run would do. It uses the conventions defined here. + +## Motivation + +- Shell completion, editor task panels, CI scripts and coding agents need the task list without parsing text or loading every `vite.config.*` ([vite-task#320](https://github.com/voidzero-dev/vite-task/pull/320)). +- Wiring checks need the `dependsOn` edges, which a flat list lacks ([vite-plus#2848](https://github.com/voidzero-dev/vite-plus/issues/2848)). +- "Which packages does `vp run` see?" needs the package list ([vite-plus#1182](https://github.com/voidzero-dev/vite-plus/issues/1182)). + +## Example + +A workspace with one library and one app that depends on it: + +``` +acme/ +├── package.json { "name": "acme", "private": true } +├── pnpm-workspace.yaml +├── packages/ui/ +│ ├── package.json { "name": "@acme/ui" } +│ └── vite.config.ts +└── apps/web/ + ├── package.json { "name": "@acme/web", "dependencies": { "@acme/ui": "workspace:*" } } + └── vite.config.ts +``` + +```ts +// packages/ui/vite.config.ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { command: 'tsdown' }, + }, + }, +}); +``` + +```ts +// apps/web/vite.config.ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { command: 'vite build', cache: { env: ['NODE_ENV'] } }, + deploy: { command: 'wrangler deploy', cwd: 'infra', dependsOn: ['build'], cache: false }, + }, + }, +}); +``` + +`vp run --json` prints, in a terminal (captured output is the same on one line): + +```json +{ + "version": 1, + "workspaceRoot": "/home/me/acme", + "packages": { + ".": { "name": "acme" }, + "apps/web": { "name": "@acme/web", "dependencies": ["packages/ui"] }, + "packages/ui": { "name": "@acme/ui" } + }, + "tasks": { + "apps/web#build": { "package": "apps/web", "name": "build", "command": "vite build" }, + "apps/web#deploy": { + "package": "apps/web", + "name": "deploy", + "cwd": "apps/web/infra", + "command": "wrangler deploy", + "dependsOn": ["apps/web#build"] + }, + "packages/ui#build": { "package": "packages/ui", "name": "build", "command": "tsdown" } + } +} +``` + +Things to notice: + +- A task's `package` is a key into `packages`, so its package name is `.packages[.package].name`. +- Only `deploy` has a `cwd`: its `cwd` setting moves it out of the package directory. +- `apps/web#build` has no `dependsOn`. That `-r` runs it after `packages/ui#build` comes from the package dependency, and the [run plan](./dry-run.md) shows it. + +Typical uses: + +```bash +# All task names, for completion +vp run --json | jq -r '.tasks[].name' | sort -u + +# Does @acme/web have a deploy task? +vp run --json | jq -e '.tasks["apps/web#deploy"]' > /dev/null + +# Every task as `name#task`, the form `vp run` accepts +vp run --json | jq -r '.packages as $p | .tasks[] | "\($p[.package].name // "")#\(.name)"' +``` + +## The document + +```ts +type TaskGraph = { + /** Format version. It changes only when the format changes incompatibly; see Versioning below. */ + version: 1; + + /** Absolute path of the workspace root. Other paths are relative to it. */ + workspaceRoot: string; + + /** Every package in the workspace, keyed by path (`"."` for the root), even ones without tasks. */ + packages: { + [path: string]: { + /** The name in `package.json`. Absent when the package has none. */ + name?: string; + + /** + * The other workspace packages listed in each `package.json` field, by path. Packages from + * outside the workspace aren't listed. A package listed in two fields appears in both. + * Absent when there are none. + */ + dependencies?: string[]; + devDependencies?: string[]; + peerDependencies?: string[]; + }; + }; + + /** Every task, keyed by task ID (see Task IDs below). */ + tasks: { + [id: string]: { + /** The path of the task's package, which is also its key in `packages`. */ + package: string; + + /** The task name, from `vite.config.*` or a `package.json` script. */ + name: string; + + /** The directory the command runs in, when the task's `cwd` setting changes it. */ + cwd?: string; + + /** + * The command as the task defines it: a string, or an array of strings that run one after + * another. A `package.json` script is always a string. + */ + command: string | string[]; + + /** + * The tasks in the task's own `dependsOn`, by ID. An entry such as + * `{ task: 'build', from: 'dependencies' }` is replaced by the tasks it selects. Absent when + * there are none. + */ + dependsOn?: string[]; + }; + }; +}; +``` + +## Conventions + +These apply to the run plan as well. + +### Task IDs + +An ID is `#`, e.g. `packages/ui#build`. A task of the root package is just its name, e.g. `ci`. + +- In each part, `%` is written `%25` and `#` is written `%23`, so an ID has one literal `#`, or none for a root task, and two tasks never share an ID. + - Without encoding `#`, task `c` in the package at `a#b` and task `b#c` in the package at `a` would both be `a#b#c`. Encoded, they are `a%23b#c` and `a#b%23c`. + - Without encoding `%`, root tasks named `a#b` and `a%23b` would both be `a%23b`. Encoded, they are `a%23b` and `a%2523b`. +- Package names aren't part of IDs, since they can be absent or shared by two packages. +- Compare IDs; don't decode them. Every task also has `package` and `name`. + +### Paths + +A path inside the workspace is relative to `workspaceRoot`, with `/` on every platform; the workspace root itself is `"."`. A path outside it, such as a program the run plan starts, is absolute. + +### Absent means empty or default + +Empty lists and maps, and fields at their default value, are left out, so the document has no `null`, `[]` or `{}`. This saves tokens for AI agents, the main readers of the document. The tables above say what each absence means. + +### Output + +Indented when stdout is a terminal, one line otherwise, with keys sorted. Nothing else goes to stdout: warnings go to stderr, and config files that print while loading must not reach stdout. + +### Errors + +When the workspace can't be loaded (a config error, a cycle), the error goes to stderr as text, nothing is printed to stdout, and the exit code is non-zero. + +### Versioning + +`version` changes only for a breaking change: a rename, a removal, or a changed meaning. New fields and values don't change it; consumers ignore fields they don't know. + +### Field names + +camelCase, as in `vite.config.*`. Values that mirror `package.json` use its spelling (`devDependencies`). + +## Flags + +| Command | Behaviour | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `vp run --json` | The task graph. Implies non-interactive: no selector, even in a terminal. | +| `vp run --json -r` (or `-t`, `-w`, `--filter`) | Error. The graph always covers the whole workspace; the [run plan](./dry-run.md) shows what a selection runs. | +| `vp run --json --dry-run` | Error: there is no task to preview. | +| `vp run --json -v` | Error: there are no cache checks. | +| `vp run --json --log=…` | Error: `--json` and `--log` conflict. | + +## Not included + +- **Cache settings.** The [run plan](./dry-run.md) reports, per command, whether it is cached and what changed. +- **Package selection** (`--filter` and friends). The [run plan](./dry-run.md) shows what a selection runs. From a16b7746153153f321143b5a3ffd18242f510a9d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 01:25:57 +0800 Subject: [PATCH 02/10] docs(rfc): sort keys in the JSON examples The RFCs say output keys are sorted, but the examples listed them in another order. Sort every example's keys so they match. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 82 ++++++++++++++++++------------------ docs/rfcs/task-graph-json.md | 20 ++++----- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index 8d8cef006..b48b1dee3 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -140,23 +140,21 @@ vp run --dry-run --json -r build ```json { - "version": 1, - "workspaceRoot": "/home/me/acme", "plan": { "apps/web#build": { + "dependsOn": ["packages/ui#build"], "name": "build", "package": "apps/web", - "dependsOn": ["packages/ui#build"], "steps": [ { - "program": "apps/web/node_modules/.bin/vite", "args": ["build"], - "cwd": "apps/web", "cache": { - "status": "changed", - "changes": [{ "kind": "env", "name": "NODE_ENV", "change": "changed" }], - "baseline": { "durationMs": 9120 } - } + "baseline": { "durationMs": 9120 }, + "changes": [{ "change": "changed", "kind": "env", "name": "NODE_ENV" }], + "status": "changed" + }, + "cwd": "apps/web", + "program": "apps/web/node_modules/.bin/vite" } ] }, @@ -165,24 +163,22 @@ vp run --dry-run --json -r build "package": "packages/ui", "steps": [ { - "program": "packages/ui/node_modules/.bin/tsdown", - "cwd": "packages/ui", "cache": { - "status": "changed", - "partial": true, "baseline": { "durationMs": 1830 }, "changes": [ - { - "kind": "file", - "path": "packages/ui/src/button.tsx", - "change": "changed" - } - ] - } + { "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" } + ], + "partial": true, + "status": "changed" + }, + "cwd": "packages/ui", + "program": "packages/ui/node_modules/.bin/tsdown" } ] } - } + }, + "version": 1, + "workspaceRoot": "/home/me/acme" } ``` @@ -198,42 +194,41 @@ vp run --dry-run --json -r build ```jsonc // --dry-run --json "cache": { - "status": "changed", "partial": true, - "changes": [ - { "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" } - ], - "baseline": { "durationMs": 1830 } + "baseline": { "durationMs": 1830 }, + "changes": [{ "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" }], + "partial": true, + "status": "changed" } // --dry-run --json -v: complete, so no "partial" "cache": { - "status": "changed", + "baseline": { "durationMs": 1830 }, "changes": [ - { "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" }, - { "kind": "file", "path": "packages/ui/src/theme.css", "change": "changed" } + { "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" }, + { "change": "changed", "kind": "file", "path": "packages/ui/src/theme.css" } ], - "baseline": { "durationMs": 1830 } + "status": "changed" } // --dry-run --json -vv: also everything the cached run depends on, changed or not, and its outputs "cache": { - "status": "changed", - "changes": [ … as with -v … ], "baseline": { "durationMs": 1830, "fingerprint": { "paths": { "packages/ui/.browserslistrc": { "type": "missing" }, - "packages/ui/package.json": { "type": "file", "hash": "8f2c41d09a7be356" }, - "packages/ui/src": { "type": "dir", "entries": ["button.tsx", "index.ts", "theme.css"] }, - "packages/ui/src/button.tsx": { "type": "file", "hash": "1b9e07c4f26da813" }, - "packages/ui/src/index.ts": { "type": "file", "hash": "c3a5d8e2017f94b6" }, - "packages/ui/src/theme.css": { "type": "file", "hash": "5e0f9b72ad41c638" }, - "packages/ui/tsdown.config.ts": { "type": "file", "hash": "e7d24a6b9c05f183" } + "packages/ui/package.json": { "hash": "8f2c41d09a7be356", "type": "file" }, + "packages/ui/src": { "entries": ["button.tsx", "index.ts", "theme.css"], "type": "dir" }, + "packages/ui/src/button.tsx": { "hash": "1b9e07c4f26da813", "type": "file" }, + "packages/ui/src/index.ts": { "hash": "c3a5d8e2017f94b6", "type": "file" }, + "packages/ui/src/theme.css": { "hash": "5e0f9b72ad41c638", "type": "file" }, + "packages/ui/tsdown.config.ts": { "hash": "e7d24a6b9c05f183", "type": "file" } } }, "outputs": ["packages/ui/dist/index.d.ts", "packages/ui/dist/index.js"] - } + }, + "changes": [ … as with -v … ], + "status": "changed" } ``` @@ -253,11 +248,16 @@ Two more combinations: ```json "apps/web#deploy": { + "dependsOn": ["apps/web#build"], "name": "deploy", "package": "apps/web", - "dependsOn": ["apps/web#build"], "steps": [ - { "program": "apps/web/node_modules/.bin/wrangler", "args": ["deploy"], "cwd": "apps/web/infra", "cache": { "status": "disabled" } } + { + "args": ["deploy"], + "cache": { "status": "disabled" }, + "cwd": "apps/web/infra", + "program": "apps/web/node_modules/.bin/wrangler" + } ] } ``` diff --git a/docs/rfcs/task-graph-json.md b/docs/rfcs/task-graph-json.md index cba40f80f..84aa28365 100644 --- a/docs/rfcs/task-graph-json.md +++ b/docs/rfcs/task-graph-json.md @@ -65,24 +65,24 @@ export default defineConfig({ ```json { - "version": 1, - "workspaceRoot": "/home/me/acme", "packages": { ".": { "name": "acme" }, - "apps/web": { "name": "@acme/web", "dependencies": ["packages/ui"] }, + "apps/web": { "dependencies": ["packages/ui"], "name": "@acme/web" }, "packages/ui": { "name": "@acme/ui" } }, "tasks": { - "apps/web#build": { "package": "apps/web", "name": "build", "command": "vite build" }, + "apps/web#build": { "command": "vite build", "name": "build", "package": "apps/web" }, "apps/web#deploy": { - "package": "apps/web", - "name": "deploy", - "cwd": "apps/web/infra", "command": "wrangler deploy", - "dependsOn": ["apps/web#build"] + "cwd": "apps/web/infra", + "dependsOn": ["apps/web#build"], + "name": "deploy", + "package": "apps/web" }, - "packages/ui#build": { "package": "packages/ui", "name": "build", "command": "tsdown" } - } + "packages/ui#build": { "command": "tsdown", "name": "build", "package": "packages/ui" } + }, + "version": 1, + "workspaceRoot": "/home/me/acme" } ``` From 434fba0c29001a67f269ccbc8a2364b29a8e344d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 09:41:36 +0800 Subject: [PATCH 03/10] docs(rfc): mark steps after files-writing steps as unknown On a fresh checkout, a step that reads another step's outputs looked changed in a dry run, although the real run hits once those outputs are restored. A step that would be hit or changed is now unknown when a step it waits on would write files first, by running or by restoring outputs. It keeps today's changes, and restore-aware checking stays in Future. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 109 ++++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index b48b1dee3..d8addb846 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -66,7 +66,7 @@ vp run: 0/2 cache hit (0%), 2/2 successful. (Run `vp run --last-details` for ful - Nothing runs and nothing is written: no process, no cache entry, no change to `vp run --last-details`. - Everything else is a real run's: the plan, the cache check, every run flag and the args after the task name. The remote cache is read exactly when a real run would read it. Outputs are never downloaded or restored, since that would write files. -- Each status is checked against the files as they are now. A command that runs after one that would run may see different files by then; the real run below shows it. +- Each step is checked against the files as they are now. In the JSON, a step that waits on one that would write files first, by running or by restoring outputs on a hit, is `unknown`: its files may change before it starts. The text shows today's check for it, and the real run below shows what can change. - The first changed file is the first in path order, `cache.input` files before the files the task read. ### `-v` @@ -106,7 +106,7 @@ Task Details: 'packages/ui/dist/index.js' modified ``` -`packages/ui/dist/index.js` changed because `@acme/ui#build` rebuilt it first. The dry run, checking today's files, couldn't see that. +`packages/ui/dist/index.js` changed because `@acme/ui#build` rebuilt it first. The dry run, checking today's files, couldn't see that, which is why its JSON marks `@acme/web#build` as `unknown`. ### `-vv` @@ -151,7 +151,7 @@ vp run --dry-run --json -r build "cache": { "baseline": { "durationMs": 9120 }, "changes": [{ "change": "changed", "kind": "env", "name": "NODE_ENV" }], - "status": "changed" + "status": "unknown" }, "cwd": "apps/web", "program": "apps/web/node_modules/.bin/vite" @@ -185,7 +185,7 @@ vp run --dry-run --json -r build (As printed in a terminal; captured output is the same on one line.) - `"partial": true` marks a check that stopped at the first changed file; with `-v` it is gone. -- `@acme/web#build` has no `partial`: its check found no changed file, so it checked them all. +- `@acme/web#build` is `unknown`: it waits on `@acme/ui#build`, which would run. Its `changes` are still today's check, and it has no `partial` because that check found no changed file, so it checked them all. ## Flags together @@ -336,8 +336,8 @@ type RunPlan = { /** * Whether the cache would be used. Absent for a built-in such as `echo`. * - * The check looks at the files as they are now. A task that runs earlier, listed in - * `dependsOn`, may still change them before this step starts. + * The check looks at the files as they are now. `unknown` marks a step whose files an + * earlier step may change before it starts. */ cache?: | (( @@ -350,50 +350,16 @@ type RunPlan = { * Differs from `baseline`, so it would run. `changes` lists the differences. */ status: 'changed'; - - /** - * Everything that differs from `baseline`, sorted by kind, then by name or - * path. Undoing all of them would make the step a hit. - */ - changes: ( - | { - /** - * The program as the cache records it: a path inside the workspace, or - * just the file name outside it, so `node` from another install is not - * a change. - */ - kind: 'program'; - from: string; - to: string; - } - | { kind: 'args'; from: string[]; to: string[] } - | { kind: 'cwd'; from: string; to: string } - | { - /** - * The setting changed. A changed `input` is one change: the files it - * now matches aren't listed. - */ - kind: 'config'; - setting: 'input' | 'output' | 'untrackedEnv'; - } - | { - kind: 'env'; - name: string; - change: 'added' | 'removed' | 'changed'; - } - | { - kind: 'file'; - path: string; - /** `added` also covers a new file in a directory the task listed. */ - change: 'added' | 'removed' | 'changed'; - } - )[]; - + } + | { /** - * Only the first changed file is listed: without `-v`, the check stops there. - * Absent with `-v`, which lists every change. + * Can't be predicted yet: a step it waits on would write files before it starts, by + * running or by being a hit that restores outputs. A step waits on the earlier steps of + * its task, on the tasks in its `dependsOn`, and, in a nested plan, on the steps before + * the enclosing step. Only a step that would otherwise be `hit` or `changed` can be + * `unknown`, and `changes` and `partial` still show today's check. */ - partial?: true; + status: 'unknown'; } ) & { /** @@ -457,6 +423,51 @@ type RunPlan = { */ outputs?: string[]; }; + + /** + * Everything that differs from `baseline`, sorted by kind, then by name or path. + * Undoing all of them would make the step a hit. Present for `changed`, and for + * `unknown` when today's check found differences. Absent for `hit`. + */ + changes?: ( + | { + /** + * The program as the cache records it: a path inside the workspace, or + * just the file name outside it, so `node` from another install is not + * a change. + */ + kind: 'program'; + from: string; + to: string; + } + | { kind: 'args'; from: string[]; to: string[] } + | { kind: 'cwd'; from: string; to: string } + | { + /** + * The setting changed. A changed `input` is one change: the files it + * now matches aren't listed. + */ + kind: 'config'; + setting: 'input' | 'output' | 'untrackedEnv'; + } + | { + kind: 'env'; + name: string; + change: 'added' | 'removed' | 'changed'; + } + | { + kind: 'file'; + path: string; + /** `added` also covers a new file in a directory the task listed. */ + change: 'added' | 'removed' | 'changed'; + } + )[]; + + /** + * Only the first changed file is listed: without `-v`, the check stops there. + * Absent with `-v`, which lists every change. + */ + partial?: true; }) | { /** @@ -517,4 +528,4 @@ type RunPlan = { ## Future - `--json` without `--dry-run` can print JSON lines as a real run progresses, one event per line. -- A step that runs after one that would run is checked against today's files, which the earlier step may change. Such steps could get a status of their own, such as `unknown`. If cache entries recorded each output's path and hash, a dry run could instead check them against the files as earlier hits will restore them. +- A hit that restores outputs makes every step that waits on it `unknown`, even when the restore rewrites identical files. If cache entries recorded each output's path and hash, a dry run could check those steps against the files the hit will restore, and only a restore that changes files a step reads would make it `unknown`. From 5433535bbbb1dcdb8a09ca0c2526caee1c3c474e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 09:45:07 +0800 Subject: [PATCH 04/10] docs(rfc): merge the hit, changed and unknown cache shapes They share the same fields, so declare one object with a status union instead of three members joined by an intersection. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index d8addb846..f004074e9 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -340,28 +340,18 @@ type RunPlan = { * earlier step may change before it starts. */ cache?: - | (( - | { - /** Matches `baseline`, so it would replay `baseline` instead of running. */ - status: 'hit'; - } - | { - /** - * Differs from `baseline`, so it would run. `changes` lists the differences. - */ - status: 'changed'; - } - | { - /** - * Can't be predicted yet: a step it waits on would write files before it starts, by - * running or by being a hit that restores outputs. A step waits on the earlier steps of - * its task, on the tasks in its `dependsOn`, and, in a nested plan, on the steps before - * the enclosing step. Only a step that would otherwise be `hit` or `changed` can be - * `unknown`, and `changes` and `partial` still show today's check. - */ - status: 'unknown'; - } - ) & { + | { + /** + * - `hit`: matches `baseline`, so it would replay `baseline` instead of running. + * - `changed`: differs from `baseline`, so it would run. `changes` lists the differences. + * - `unknown`: can't be predicted yet, because a step it waits on would write files before + * it starts, by running or by being a hit that restores outputs. A step waits on the + * earlier steps of its task, on the tasks in its `dependsOn`, and, in a nested plan, on + * the steps before the enclosing step. Only a step that would otherwise be `hit` or + * `changed` can be `unknown`, and `changes` and `partial` still show today's check. + */ + status: 'hit' | 'changed' | 'unknown'; + /** * The cached run the step is compared with: the one with the same command, * config and env, or, if there is none, the step's most recent cached run. @@ -468,7 +458,7 @@ type RunPlan = { * Absent with `-v`, which lists every change. */ partial?: true; - }) + } | { /** * Would run: there is no cached run to compare with, as on a first run or after From c4d2edf25dccaf55f45b7ca6549bb4ff60330936 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 11:02:23 +0800 Subject: [PATCH 05/10] docs(rfc): rename partial to changesPartial Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index f004074e9..d1f415ed5 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -168,7 +168,7 @@ vp run --dry-run --json -r build "changes": [ { "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" } ], - "partial": true, + "changesPartial": true, "status": "changed" }, "cwd": "packages/ui", @@ -184,8 +184,8 @@ vp run --dry-run --json -r build (As printed in a terminal; captured output is the same on one line.) -- `"partial": true` marks a check that stopped at the first changed file; with `-v` it is gone. -- `@acme/web#build` is `unknown`: it waits on `@acme/ui#build`, which would run. Its `changes` are still today's check, and it has no `partial` because that check found no changed file, so it checked them all. +- `"changesPartial": true` marks a check that stopped at the first changed file; with `-v` it is gone. +- `@acme/web#build` is `unknown`: it waits on `@acme/ui#build`, which would run. Its `changes` are still today's check, and it has no `changesPartial` because that check found no changed file, so it checked them all. ## Flags together @@ -196,11 +196,11 @@ vp run --dry-run --json -r build "cache": { "baseline": { "durationMs": 1830 }, "changes": [{ "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" }], - "partial": true, + "changesPartial": true, "status": "changed" } -// --dry-run --json -v: complete, so no "partial" +// --dry-run --json -v: complete, so no "changesPartial" "cache": { "baseline": { "durationMs": 1830 }, "changes": [ @@ -234,12 +234,12 @@ vp run --dry-run --json -r build Every combination: -| Command | default | `-v` | `-vv` | -| ------------------------------- | -------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------ | -| `vp run ` | runs; one-line summary; each miss shows its first reason | runs; detailed summary with every change | … and what it depends on | -| `vp run --dry-run ` | each command's cache status; one-line summary | detailed summary with every change | … and what it depends on | -| `vp run --dry-run --json ` | the plan as JSON; `partial: true` where it stopped | every change | … and `baseline.fingerprint`, `baseline.outputs` | -| `vp run --json ` | error: requires `--dry-run` (JSON lines later) | the same | the same | +| Command | default | `-v` | `-vv` | +| ------------------------------- | --------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------ | +| `vp run ` | runs; one-line summary; each miss shows its first reason | runs; detailed summary with every change | … and what it depends on | +| `vp run --dry-run ` | each command's cache status; one-line summary | detailed summary with every change | … and what it depends on | +| `vp run --dry-run --json ` | the plan as JSON; `changesPartial: true` where it stopped | every change | … and `baseline.fingerprint`, `baseline.outputs` | +| `vp run --json ` | error: requires `--dry-run` (JSON lines later) | the same | the same | Two more combinations: @@ -348,7 +348,7 @@ type RunPlan = { * it starts, by running or by being a hit that restores outputs. A step waits on the * earlier steps of its task, on the tasks in its `dependsOn`, and, in a nested plan, on * the steps before the enclosing step. Only a step that would otherwise be `hit` or - * `changed` can be `unknown`, and `changes` and `partial` still show today's check. + * `changed` can be `unknown`, and `changes` and `changesPartial` still show today's check. */ status: 'hit' | 'changed' | 'unknown'; @@ -454,10 +454,11 @@ type RunPlan = { )[]; /** - * Only the first changed file is listed: without `-v`, the check stops there. - * Absent with `-v`, which lists every change. + * Other files may have changed too: without `-v`, the file check stops at the + * first changed file. Command, config and env changes are always listed in + * full. Absent with `-v`. */ - partial?: true; + changesPartial?: true; } | { /** From 1ecded244416adb6157195b0ff6091ea5068daec Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 11:04:44 +0800 Subject: [PATCH 06/10] docs(rfc): print fields in declared order, not sorted Fields follow the order the types declare them, so program and args come before cache. Map entries keyed by path or ID stay sorted by key. Plan tasks now declare package before name, as task graph tasks do. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 81 ++++++++++++++++++------------------ docs/rfcs/task-graph-json.md | 22 +++++----- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index d1f415ed5..07d89b4d6 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -140,45 +140,45 @@ vp run --dry-run --json -r build ```json { + "version": 1, + "workspaceRoot": "/home/me/acme", "plan": { "apps/web#build": { - "dependsOn": ["packages/ui#build"], - "name": "build", "package": "apps/web", + "name": "build", + "dependsOn": ["packages/ui#build"], "steps": [ { + "program": "apps/web/node_modules/.bin/vite", "args": ["build"], + "cwd": "apps/web", "cache": { + "status": "unknown", "baseline": { "durationMs": 9120 }, - "changes": [{ "change": "changed", "kind": "env", "name": "NODE_ENV" }], - "status": "unknown" - }, - "cwd": "apps/web", - "program": "apps/web/node_modules/.bin/vite" + "changes": [{ "kind": "env", "name": "NODE_ENV", "change": "changed" }] + } } ] }, "packages/ui#build": { - "name": "build", "package": "packages/ui", + "name": "build", "steps": [ { + "program": "packages/ui/node_modules/.bin/tsdown", + "cwd": "packages/ui", "cache": { + "status": "changed", "baseline": { "durationMs": 1830 }, "changes": [ - { "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" } + { "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" } ], - "changesPartial": true, - "status": "changed" - }, - "cwd": "packages/ui", - "program": "packages/ui/node_modules/.bin/tsdown" + "changesPartial": true + } } ] } - }, - "version": 1, - "workspaceRoot": "/home/me/acme" + } } ``` @@ -194,41 +194,41 @@ vp run --dry-run --json -r build ```jsonc // --dry-run --json "cache": { + "status": "changed", "baseline": { "durationMs": 1830 }, - "changes": [{ "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" }], - "changesPartial": true, - "status": "changed" + "changes": [{ "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" }], + "changesPartial": true } // --dry-run --json -v: complete, so no "changesPartial" "cache": { + "status": "changed", "baseline": { "durationMs": 1830 }, "changes": [ - { "change": "changed", "kind": "file", "path": "packages/ui/src/button.tsx" }, - { "change": "changed", "kind": "file", "path": "packages/ui/src/theme.css" } - ], - "status": "changed" + { "kind": "file", "path": "packages/ui/src/button.tsx", "change": "changed" }, + { "kind": "file", "path": "packages/ui/src/theme.css", "change": "changed" } + ] } // --dry-run --json -vv: also everything the cached run depends on, changed or not, and its outputs "cache": { + "status": "changed", "baseline": { "durationMs": 1830, "fingerprint": { "paths": { "packages/ui/.browserslistrc": { "type": "missing" }, - "packages/ui/package.json": { "hash": "8f2c41d09a7be356", "type": "file" }, - "packages/ui/src": { "entries": ["button.tsx", "index.ts", "theme.css"], "type": "dir" }, - "packages/ui/src/button.tsx": { "hash": "1b9e07c4f26da813", "type": "file" }, - "packages/ui/src/index.ts": { "hash": "c3a5d8e2017f94b6", "type": "file" }, - "packages/ui/src/theme.css": { "hash": "5e0f9b72ad41c638", "type": "file" }, - "packages/ui/tsdown.config.ts": { "hash": "e7d24a6b9c05f183", "type": "file" } + "packages/ui/package.json": { "type": "file", "hash": "8f2c41d09a7be356" }, + "packages/ui/src": { "type": "dir", "entries": ["button.tsx", "index.ts", "theme.css"] }, + "packages/ui/src/button.tsx": { "type": "file", "hash": "1b9e07c4f26da813" }, + "packages/ui/src/index.ts": { "type": "file", "hash": "c3a5d8e2017f94b6" }, + "packages/ui/src/theme.css": { "type": "file", "hash": "5e0f9b72ad41c638" }, + "packages/ui/tsdown.config.ts": { "type": "file", "hash": "e7d24a6b9c05f183" } } }, "outputs": ["packages/ui/dist/index.d.ts", "packages/ui/dist/index.js"] }, - "changes": [ … as with -v … ], - "status": "changed" + "changes": [ … as with -v … ] } ``` @@ -248,15 +248,15 @@ Two more combinations: ```json "apps/web#deploy": { - "dependsOn": ["apps/web#build"], - "name": "deploy", "package": "apps/web", + "name": "deploy", + "dependsOn": ["apps/web#build"], "steps": [ { + "program": "apps/web/node_modules/.bin/wrangler", "args": ["deploy"], - "cache": { "status": "disabled" }, "cwd": "apps/web/infra", - "program": "apps/web/node_modules/.bin/wrangler" + "cache": { "status": "disabled" } } ] } @@ -288,12 +288,12 @@ type RunPlan = { */ plan: { [id: string]: { - /** The task name, such as `build`. */ - name: string; - /** The path of the task's package, such as `packages/ui`. */ package: string; + /** The task name, such as `build`. */ + name: string; + /** * The tasks that must finish before this one starts, by ID: the task's own `dependsOn`, * plus, with `-r`, `-t` or `--filter`, the same task in the packages this package depends @@ -415,7 +415,8 @@ type RunPlan = { }; /** - * Everything that differs from `baseline`, sorted by kind, then by name or path. + * Everything that differs from `baseline`, in the order the kinds are listed + * below, then by name or path. * Undoing all of them would make the step a hit. Present for `changed`, and for * `unknown` when today's check found differences. Absent for `hit`. */ diff --git a/docs/rfcs/task-graph-json.md b/docs/rfcs/task-graph-json.md index 84aa28365..c27700a29 100644 --- a/docs/rfcs/task-graph-json.md +++ b/docs/rfcs/task-graph-json.md @@ -65,24 +65,24 @@ export default defineConfig({ ```json { + "version": 1, + "workspaceRoot": "/home/me/acme", "packages": { ".": { "name": "acme" }, - "apps/web": { "dependencies": ["packages/ui"], "name": "@acme/web" }, + "apps/web": { "name": "@acme/web", "dependencies": ["packages/ui"] }, "packages/ui": { "name": "@acme/ui" } }, "tasks": { - "apps/web#build": { "command": "vite build", "name": "build", "package": "apps/web" }, + "apps/web#build": { "package": "apps/web", "name": "build", "command": "vite build" }, "apps/web#deploy": { - "command": "wrangler deploy", - "cwd": "apps/web/infra", - "dependsOn": ["apps/web#build"], + "package": "apps/web", "name": "deploy", - "package": "apps/web" + "cwd": "apps/web/infra", + "command": "wrangler deploy", + "dependsOn": ["apps/web#build"] }, - "packages/ui#build": { "command": "tsdown", "name": "build", "package": "packages/ui" } - }, - "version": 1, - "workspaceRoot": "/home/me/acme" + "packages/ui#build": { "package": "packages/ui", "name": "build", "command": "tsdown" } + } } ``` @@ -185,7 +185,7 @@ Empty lists and maps, and fields at their default value, are left out, so the do ### Output -Indented when stdout is a terminal, one line otherwise, with keys sorted. Nothing else goes to stdout: warnings go to stderr, and config files that print while loading must not reach stdout. +Indented when stdout is a terminal, one line otherwise. Fields appear in the order the types declare them; entries of a map keyed by path or ID, such as `packages` and `tasks`, are sorted by key. Nothing else goes to stdout: warnings go to stderr, and config files that print while loading must not reach stdout. ### Errors From b200e272004b6824cae5b56560ce5593dda29ac7 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 11:07:14 +0800 Subject: [PATCH 07/10] docs(rfc): omit a step's cwd when it is the package directory Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index 07d89b4d6..5d08c7424 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -151,7 +151,6 @@ vp run --dry-run --json -r build { "program": "apps/web/node_modules/.bin/vite", "args": ["build"], - "cwd": "apps/web", "cache": { "status": "unknown", "baseline": { "durationMs": 9120 }, @@ -166,7 +165,6 @@ vp run --dry-run --json -r build "steps": [ { "program": "packages/ui/node_modules/.bin/tsdown", - "cwd": "packages/ui", "cache": { "status": "changed", "baseline": { "durationMs": 1830 }, @@ -325,10 +323,10 @@ type RunPlan = { args?: string[]; /** - * The directory it runs in: the package directory, unless the task's `cwd` setting or - * an earlier `cd` in the command changes it. + * The directory it runs in, when the task's `cwd` setting or an earlier `cd` in the + * command changes it. Absent when it runs in the package directory. */ - cwd: string; + cwd?: string; /** Set when the step comes from a `pre` or `post` script, such as `prebuild`. */ hook?: string; From 5544c6856fce2f88bdb85620c087f40575d6d4db Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 11:11:13 +0800 Subject: [PATCH 08/10] docs(rfc): scope the --dry-run rule to command lines with a task vp run --json without a task prints the task graph and rejects --dry-run, so the rule that adding --dry-run never breaks a working command line only holds when there is a task. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index 5d08c7424..4a07b4915 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -4,11 +4,11 @@ Three flags for `vp run `, each doing one thing: -| Flag | What it does | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--dry-run` | Stops before running any command. Planning and cache checks happen, and print, as in a real run. | -| `--json` | Prints the answer as JSON instead of text. Requires `--dry-run` for now, and conflicts with `--log`. | -| `-v`, `-vv` | Checks every input instead of stopping at the first changed file. `-v` lists every change; `-vv` also lists everything the cached run depends on, and its outputs. | +| Flag | What it does | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--dry-run` | Stops before running any command. Planning and cache checks happen, and print, as in a real run. | +| `--json` | Prints the answer as JSON instead of text. With a task, it requires `--dry-run` for now; without one, it prints the [task graph](./task-graph-json.md). Conflicts with `--log`. | +| `-v`, `-vv` | Checks every input instead of stopping at the first changed file. `-v` lists every change; `-vv` also lists everything the cached run depends on, and its outputs. | ```bash vp run --dry-run -r build # what would run, and the first reason for each miss @@ -263,7 +263,7 @@ Two more combinations: ### Rules 1. **A flag means the same thing in every combination.** `--dry-run` only stops execution, `--json` only changes the format, `-v`/`-vv` only change how much is checked and listed. -2. **Adding `--dry-run` to a command line that works never breaks it.** Flags that only shape execution (`--log`, `--concurrency-limit`) are accepted and do nothing. +2. **Adding `--dry-run` to a working command line with a task never breaks it.** Flags that only shape execution (`--log`, `--concurrency-limit`) are accepted and do nothing. `vp run --json` without a task runs nothing, so it rejects `--dry-run`: there is no task to preview. 3. **A combination that isn't supported yet fails and says so**, like `--json` on a real run. 4. **vp's flags go before the task name.** Everything after it is passed to the task. From 7e0f3f70de1a5addbe56997c2dfa5532ba7c3155 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 11:57:18 +0800 Subject: [PATCH 09/10] docs(rfc): add $schema to the task graph and run plan Points to a published JSON Schema with each field's description, so an agent can look up what a field means from the output alone. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 7 +++++++ docs/rfcs/task-graph-json.md | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index 4a07b4915..e06b9be26 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -140,6 +140,7 @@ vp run --dry-run --json -r build ```json { + "$schema": "https://viteplus.dev/schemas/run-plan.v1.json", "version": 1, "workspaceRoot": "/home/me/acme", "plan": { @@ -271,6 +272,12 @@ Two more combinations: ```ts type RunPlan = { + /** + * The JSON Schema of this document, with the same descriptions as the comments here, so a + * reader can look up what each field means. Its URL changes with `version`. + */ + $schema: 'https://viteplus.dev/schemas/run-plan.v1.json'; + /** Format version. It changes only when the format changes incompatibly. */ version: 1; diff --git a/docs/rfcs/task-graph-json.md b/docs/rfcs/task-graph-json.md index c27700a29..5e519fe6c 100644 --- a/docs/rfcs/task-graph-json.md +++ b/docs/rfcs/task-graph-json.md @@ -65,6 +65,7 @@ export default defineConfig({ ```json { + "$schema": "https://viteplus.dev/schemas/task-graph.v1.json", "version": 1, "workspaceRoot": "/home/me/acme", "packages": { @@ -109,6 +110,12 @@ vp run --json | jq -r '.packages as $p | .tasks[] | "\($p[.package].name // "")# ```ts type TaskGraph = { + /** + * The JSON Schema of this document, with the same descriptions as the comments here, so a + * reader can look up what each field means. Its URL changes with `version`. + */ + $schema: 'https://viteplus.dev/schemas/task-graph.v1.json'; + /** Format version. It changes only when the format changes incompatibly; see Versioning below. */ version: 1; @@ -197,7 +204,7 @@ When the workspace can't be loaded (a config error, a cycle), the error goes to ### Field names -camelCase, as in `vite.config.*`. Values that mirror `package.json` use its spelling (`devDependencies`). +camelCase, as in `vite.config.*`. Values that mirror `package.json` use its spelling (`devDependencies`), and `$schema` uses JSON Schema's. ## Flags From 408b97bc283889f38027bae05f07e7a8a84d7cd4 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 5 Nov 2026 12:41:51 +0800 Subject: [PATCH 10/10] docs(rfc): point $schema at the schema shipped with vite-plus The schema ships in the vite-plus package that printed the document, so it matches the output exactly and works offline. The value is a file:// URL that is not percent-encoded and has a / before a Windows drive, the form both VS Code and JetBrains IDEs read; neither reads a plain Windows path. Co-Authored-By: Claude Opus 5.5 --- docs/rfcs/dry-run.md | 8 ++++---- docs/rfcs/task-graph-json.md | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index e06b9be26..a222b39b6 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -140,7 +140,7 @@ vp run --dry-run --json -r build ```json { - "$schema": "https://viteplus.dev/schemas/run-plan.v1.json", + "$schema": "file:///home/me/acme/node_modules/vite-plus/schemas/run-plan.json", "version": 1, "workspaceRoot": "/home/me/acme", "plan": { @@ -273,10 +273,10 @@ Two more combinations: ```ts type RunPlan = { /** - * The JSON Schema of this document, with the same descriptions as the comments here, so a - * reader can look up what each field means. Its URL changes with `version`. + * `file://` URL of the JSON Schema shipped with the vp that printed this document, written as + * in `vp run --json`. */ - $schema: 'https://viteplus.dev/schemas/run-plan.v1.json'; + $schema: string; /** Format version. It changes only when the format changes incompatibly. */ version: 1; diff --git a/docs/rfcs/task-graph-json.md b/docs/rfcs/task-graph-json.md index 5e519fe6c..2564b6ed3 100644 --- a/docs/rfcs/task-graph-json.md +++ b/docs/rfcs/task-graph-json.md @@ -65,7 +65,7 @@ export default defineConfig({ ```json { - "$schema": "https://viteplus.dev/schemas/task-graph.v1.json", + "$schema": "file:///home/me/acme/node_modules/vite-plus/schemas/task-graph.json", "version": 1, "workspaceRoot": "/home/me/acme", "packages": { @@ -111,10 +111,10 @@ vp run --json | jq -r '.packages as $p | .tasks[] | "\($p[.package].name // "")# ```ts type TaskGraph = { /** - * The JSON Schema of this document, with the same descriptions as the comments here, so a - * reader can look up what each field means. Its URL changes with `version`. + * `file://` URL of the JSON Schema shipped with the vp that printed this document, so it + * describes this output exactly. Its descriptions are the comments here. See `$schema` below. */ - $schema: 'https://viteplus.dev/schemas/task-graph.v1.json'; + $schema: string; /** Format version. It changes only when the format changes incompatibly; see Versioning below. */ version: 1; @@ -184,7 +184,16 @@ An ID is `#`, e.g. `packages/ui#build`. A task of the r ### Paths -A path inside the workspace is relative to `workspaceRoot`, with `/` on every platform; the workspace root itself is `"."`. A path outside it, such as a program the run plan starts, is absolute. +A path inside the workspace is relative to `workspaceRoot`, with `/` on every platform; the workspace root itself is `"."`. A path outside it, such as a program the run plan starts, is absolute. `$schema` is a URL instead (below). + +### `$schema` + +`$schema` points to a JSON Schema (draft-07) in the vite-plus package that printed the document, so an agent can read what each field means, and an editor shows the descriptions and checks the document when it is opened as JSON. Its value uses a form that VS Code and JetBrains IDEs both read on every platform: + +- `file://`, then the absolute path with `/` separators, with a `/` before a Windows drive: `file:///home/me/acme/node_modules/vite-plus/schemas/task-graph.json`, `file:///C:/Users/me/acme/node_modules/vite-plus/schemas/task-graph.json`. +- Not percent-encoded. JetBrains IDEs look the path up as written, so they wouldn't find a space written as `%20`; VS Code reads both. + +A plain path wouldn't work on Windows: neither editor reads `C:\Users\…` as a schema location. ### Absent means empty or default