Config schema
Complete reference for every field accepted by vx.config.{ts,mts,js,mjs,cts,cjs}
and the workspace-level .vx/workspace.{ts,mts,js,mjs,cts,cjs}, which is
required: it marks the workspace root (cli.md § The workspace root). The
authoritative TypeScript definitions live in src/config.ts and are
re-exported from @vzn/vx.
Fields
Section titled “Fields”| Page | Covers |
|---|---|
| exec | exec |
| exec.sandbox | exec.sandbox |
| dependsOn | dependsOn |
| inputs | inputs |
| outputs | outputs |
| cache | cache |
| .vx/workspace.ts | Workspace config |
File layout
Section titled “File layout”<workspaceRoot>/├── vx.config.ts (optional: the root project, named `//`)├── .vx/│ ├── workspace.ts (required: marks the root; workspace-wide settings)│ ├── projects.lock.json (`vx lock`, minified; committed)│ ├── .gitattributes (`projects.lock.json linguist-generated=true`; written when missing)│ ├── .gitignore (`/cache`; written by `vx init` and the cache when missing)│ └── cache/ (machine-local state; the only part git ignores)└── <any>/<dir>/ ├── vx.config.ts (this directory is a project; its tasks) └── vx.lock.json (`vx lock`: its resolved config, pretty, for review)A project is a directory holding a vx.config.*, in any language;
core reads no package.json and no package-manager member list. vx lock
finds them (git’s tracked and unignored files; a config at or under a
nested .vx/workspace.*, ignored or not, is that workspace’s) and records each by
directory, and each nested workspace root too: every outer project’s outputs
stop there as at a nested project (its inputs do not). A run takes its projects from the lock, so a config added since
runs once vx lock records it. A discover plugin may add directories
with no config (@vzn/vx-lockfile adds the package manager’s members);
one whose config git ignores is refused, naming the plugin and the file.
The hook runs under vx lock only: the lock records the projects, names
and edges it returned, and a run uses that record.
A project’s name is its config’s name, else the name a discover plugin
gives (@vzn/vx-lockfile gives package.json#name), else its
root-relative path (apps/web); the root is //. Two projects of one
name are refused, naming both directories.
Project config
Section titled “Project config”import { defineProject } from '@vzn/vx/config'
export default defineProject({ name: 'web', tags: ['scope:web', 'type:app'], tasks: { <taskName>: TaskConfig, ... },})interface ProjectConfig { name?: string // what `<name>#<task>`, filters and `dependsOn` address; no "#" tags?: readonly string[] // labels for `--filter tag:<name>` tasks?: Record<string, TaskConfig> // keyed by task name}defineProject is an identity function — it exists purely so
TypeScript narrows literal types in your config (clean autocomplete,
strict validation against the schema). It has zero runtime effect.
The same key checking without a runtime import is
import type { ProjectConfig } from '@vzn/vx/config' plus
export default { … } satisfies ProjectConfig (it does not check
dependsOn names against the task keys) — the form vx init
and @vzn/vx-migrate generate, because Bun erases the type import and the
file then loads in a workspace that runs the vx binary without the
package installed. Either form is fine; the object is what vx reads.
@vzn/vx/config is the schema alone (src/config.ts, which imports
nothing): your own tsc checks that one file, where @vzn/vx would
walk core’s Bun-only sources and fail without @types/bun.
The object is JSON data. The cache key folds JSON.stringify of each
task’s config, vx lock stores the same JSON, and vx watch re-reads a
config through a worker that hands it back as JSON, so a value JSON
cannot carry would be dropped, rewritten or refused depending on the
path. vx refuses it on every path, before the schema: a function, a
symbol, a bigint, NaN or ±Infinity, undefined inside an array
(JSON writes null), a cycle, a getter or setter (code that each read
may answer differently; vx never calls it), a symbol key or a
non-enumerable property (JSON drops
each), and any object that is not a
plain object or an array (a Date, Map, Set, RegExp or class
instance), and nesting deeper than 256 levels. An
undefined property is fine: JSON drops it and the schema reads it as
absent, which is what a preset’s optional field
(description: opts.description) relies on. A config is evaluated once,
by vx lock, so a branch on process.env (CI) is fixed by whoever
locked it, and vx lock --check under the other value fails. The workspace config is not
held to this: its plugins are objects of functions.
vx lock evaluates a project config with the workspace root as
process.cwd() and PWD, from whichever directory it runs, so the lock does not
depend on where you ran it; import.meta.dir is the config’s own
directory, an absolute path: in a command it locks this checkout’s path,
so another checkout fails vx lock --check and keys apart (vx lock
warns). A command runs in its project’s directory: name a script by a
path relative to it, or $VX_RUN_WORKSPACE/scripts/gen.ts from the
workspace root. What it prints goes to stderr, a child it spawns
included: a verb’s stdout is its output.
tasks is a Record<string, TaskConfig>. Task names are strings
referenced by dependsOn, by inputs.tasks, and by the CLI
(vx run <taskName> or vx run <pkg>#<taskName>), so a name those
could not spell is refused at load: an empty one, one with surrounding
whitespace, one holding # (the project separator) or * (a pattern),
and one starting with ^ (dependencies’ tasks), ! (a negation) or
- (a CLI flag).
A task or project name holding a control character (ESC, \r) is
refused too: vx prints names raw, and one could retitle the terminal.
name is refused at load when dependsOn or --filter could not
spell it: surrounding whitespace, a #, a * (a pattern), a leading
^ (dependencies’ tasks) or ! (an exclusion), a leading tag:, a
leading or trailing ... (a dependency walk), a trailing [<ref>] (a
git range), or a directory form (./x, {x}, x{dir}). A name a plugin gives
and a project’s path name are held to the same rule.
tags label the project for selection: --filter tag:<name> selects
the projects carrying one (cli.md § Filter DSL), as Nx’s tag: does.
Each is a non-empty string a tag filter can name, or it is refused at
load: no surrounding whitespace, no * (a pattern), no control
character (vx show prints tags raw), and no ending in
... (a dependency walk) or [<ref>] (a git range). A
project plugin may set or edit them (nx() gives each project its Nx
tags unless the vx.config has its own). A tag is in no cache key: it
changes no task’s behaviour, so editing one re-runs nothing.
TaskConfig
Section titled “TaskConfig”interface TaskConfig { description?: string // one-line blurb for the picker / --dry view exec?: ExecConfig // omit to declare a group task dependsOn?: readonly string[] // Turbo/Nx micro-syntax inputs?: InputsConfig // what the task reads: its key, cached or not outputs?: TaskOutputs // what it writes, cached or not cache?: boolean // caching is opt-in; needs `exec`, `inputs` and `outputs`}A task either has an exec (it does work) or omits exec and declares
dependsOn or inputs (it’s a group task: an aggregator, or a key).
The loader rejects a task that has none of them — a no-op standalone
task is almost always a config mistake. dependsOn: [] is the deliberate form: an
explicit empty group, for a package that wants a task by that name to
exist and do nothing.
A project whose config and plugins declare no build gets one
(2026-10-04): a group with dependsOn: ['^build'] keyed on every file of
the project (inputs.files: ['**'], no outputs). It runs nothing,
but a dependant behind ^build folds its key, so a package consumed as
source moves its dependants’ keys and reaches them under --affected.
A config can declare a keyed group too (Group tasks, below). A
bare vx run build does not select it: run where no selected project
declares build, it is refused as any undeclared name is, while
lib#build names it and a dependant’s ^build reaches it. vx show
and vx info count it; vx show build and the vx build hint, which
name declared tasks, do not. Its ^build needs an edge source as any
^ does: with no plugin returning deps, running it is refused.
description (optional)
Section titled “description (optional)”description?: stringA short one-line blurb describing what the task does. No effect on
scheduling or execution, but it does participate in the cache key —
the key hashes the whole resolved task config (see caching.md, step 5),
and carving exceptions out of that object is what invites stale hits.
Editing a description therefore costs one re-run. Surfaced in three
places:
- The interactive task picker (
vx runwith no positional in a TTY) — printed to the right of eachpkg#taskid. - The
--drytext preview — printed on a second indented line under the cache-status row. - The
--dry=jsonoutput —descriptionlives on each task entry.
test: { description: 'bun test against the tests/ tree', exec: { command: 'bun test' }, inputs: { files: ['src/**', 'tests/**'] }, outputs: { files: [] }, cache: true,}Group tasks (no exec)
Section titled “Group tasks (no exec)”A task with no exec and a dependsOn is a group task — a pure
aggregator. Running a group is equivalent to running its dependencies;
nothing else happens (no spawn, no I/O, no cache read/write). An empty
dependsOn: [] is an explicit no-op group: it exists to be named — by a
dependant’s ^build, by vx run build --all — and runs nothing. A
project with no build gets a keyed one (above), which a bare name
does not select.
A group with inputs is a keyed group: its inputs are a key
and nothing runs. A task that depends on it folds that key, so an edit
to those inputs re-runs the dependant, and --affected reaches it. It
takes no cache (a group writes nothing), and it needs no dependsOn. It spawns nothing, writes no cache entry, and the
run summary does not count it. @vzn/vx-migrate writes one for each
Nx ^production-style input and each Turbo transit node.
// `test` re-runs when a file under fixtures/ changes; nothing spawns for itfixtures: { inputs: { files: ['fixtures/**'] } },test: { exec: { command: 'bun test' }, dependsOn: ['fixtures'], cache: true /* … */ },Bare --exclude-dependencies keeps a group’s edges for the same reason:
vx run ci --exclude-dependencies runs ci’s members without their own
dependencies. A name list (--exclude-dependencies=lint.oxfmt) drops a
member it names, like any edge.
// `vx run install --all` → fans out to `build` in every workspace depinstall: { description: 'build everything in workspace dependency order', dependsOn: ['^build'],}
// `vx run ci` → runs format-check + lint + test in the cwd projectci: { description: 'format-check + lint + test (CI gate)', dependsOn: ['format-check', 'lint', 'test'],}Group tasks:
- Are not counted in the run summary. “3 tasks, 2 cached” reflects real executable tasks — including a group in the count is confusing (“3 of 4 cached” when one was a group that ran nothing).
- Are not recorded in the
runstable. Analytics queries that aggregate runtime won’t see them. - Render no framed block in the live output.
- DO contribute a stable hash to downstream tasks that filter
inputs.tasksto include the group. The hash is rolled up from the group’s upstream outcomes (sorted, joined, hashed). So any change beneath the group cascades correctly.
The loader rejects:
- A task with no
exec, nodependsOnand noinputs(literal no-op). - A group with
cache(a group writes nothing).
Helpers
Section titled “Helpers”import { defineProject, defineWorkspace } from '@vzn/vx/config'
// Identity functions; their purpose is type inference.defineProject<const T extends ProjectConfig>(config: T): TdefineWorkspace<T extends WorkspaceConfig>(config: T): TUse them so TypeScript narrows literal types in your config —
autocomplete for task names in dependsOn against your declared
tasks, strict validation against the schema, errors at edit time
rather than at vx run time.
They cost one runtime import of @vzn/vx/config per config file:
src/config.ts, which imports nothing, so it is cheap, but it must
resolve from the config’s directory. The type-only form checks the
same keys with no runtime import, but not dependsOn names (a typo is a
plain string), and is what vx init / @vzn/vx-migrate write:
import type { ProjectConfig, WorkspaceConfig } from '@vzn/vx/config'export default { tasks: { … } } satisfies ProjectConfigexport default { plugins: [] } satisfies WorkspaceConfigYou can skip the helpers and write
export default { tasks: { … } }, but the IDE experience is
strictly worse.
Full example
Section titled “Full example”import { defineProject } from '@vzn/vx/config'
export default defineProject({ tasks: { // Real cached task with file inputs, env tracking, and outputs. build: { description: 'compile TypeScript to dist/', // NODE_ENV is a cache input below, so it must reach the child // too — the exec env is isolated, not inherited. exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } }, dependsOn: ['^build'], inputs: { files: ['src/**', '!**/*.test.ts', 'tsconfig.json'], env: ['NODE_ENV'], }, outputs: { files: ['dist/**', '*.tsbuildinfo'] }, cache: true, },
// Cached, depends on upstream build; CI is in passThrough only // (CI=1 shouldn't bust the cache — it's true on every CI run). test: { description: 'run unit tests', exec: { command: 'bun test', env: { passThrough: ['CI'] } }, dependsOn: ['build'], inputs: { files: ['src/**', 'tests/**'] }, outputs: { files: [] }, cache: true, },
// Cross-project edges. The pkg.tgz from one project's build is // cached, and depends on builds in every workspace dep. package: { description: 'pack the npm tarball', exec: { command: 'rm -rf pkg && npm pack --pack-destination ./pkg' }, dependsOn: ['build', 'test', '^build'], inputs: { files: ['package.json'], tasks: ['build', '^build'], // hash these upstream too }, outputs: { files: ['pkg/*.tgz'] }, cache: true, },
// Persistent dev server. Downstream tasks unblock on the // "Local:" line, not on exit (which never comes). dev: { description: 'vite dev server', exec: { command: 'vite', timeout: '30s', persistent: { readyWhen: 'Local:' }, env: { passThrough: ['VITE_API_URL'] }, }, },
// E2E test gated on the dev server. Together: vx run e2e runs // dev → e2e; e2e exits → orchestrator SIGTERMs dev → run ends. e2e: { description: 'end-to-end tests against dev server', exec: { command: 'playwright test' }, dependsOn: ['dev'], },
// Two cached checks with no outputs: the verdict is the exit code. // A name with a hyphen is quoted, as any object key is. lint: { description: 'oxlint', exec: { command: 'oxlint .' }, inputs: { files: ['src/**', '.oxlintrc.json'] }, outputs: { files: [] }, cache: true, }, 'format-check': { description: 'oxfmt --check', exec: { command: 'oxfmt --check .' }, inputs: { files: ['src/**', '.oxfmtrc.json'] }, outputs: { files: [] }, cache: true, },
// Pure group task — `vx run ci` fans out, the group itself is // silent in the run output. `dependsOn` is typed against the task // names above: a name declared nowhere is a type error, not a // silent no-op. ci: { description: 'format-check + lint + test', dependsOn: ['format-check', 'lint', 'test'], }, },})The rest of the task fields, in one config:
import { defineProject } from '@vzn/vx/config'
export default defineProject({ tasks: { // Prompts on the terminal, so it runs alone and is never cached. 'db:push': { exec: { command: 'drizzle-kit push', interactive: true } }, // Talks to this machine's Docker daemon: never sent to a remote executor. 'docker:build': { exec: { command: 'docker build -t app .', remote: false } }, e2e: { exec: { // Playwright removes its output directory: one inside the grant. command: 'playwright test --output test-results/run', env: { define: { BASE_URL: 'http://localhost:3000' }, passThrough: ['E2E_TOKEN'], secret: ['E2E_TOKEN'], }, sandbox: { allow: { read: ['.'], write: ['test-results/'], localBinding: [3000], unixSockets: true, pty: true, gitConfig: false, systemInfo: ['hw.memsize'], machLookup: ['com.apple.SecurityServer'], }, weakerNetworkIsolation: false, weakerWhenNested: false, }, }, inputs: { // The app playwright's webServer starts: a page edit makes e2e run again. files: ['e2e/**', 'src/**', 'playwright.config.ts'], runtime: ['playwright --version'], workspaceRuntime: ['node -v'], }, outputs: { files: ['test-results/**'] }, cache: true, }, },})Common patterns
Section titled “Common patterns”Sharing inputs across tasks
Section titled “Sharing inputs across tasks”TypeScript composition is the mechanism (named-input schema machinery was deliberately rejected — the language already does this). Plain TS arrays:
import { defineProject } from '@vzn/vx/config'
const srcInputs = ['src/**', 'tsconfig.json']
export default defineProject({ tasks: { build: { exec: { command: 'tsc' }, inputs: { files: srcInputs }, outputs: { files: ['dist/**'] }, cache: true, }, test: { exec: { command: 'bun test' }, inputs: { files: [...srcInputs, 'tests/**'] }, outputs: { files: [] }, cache: true, }, },})Presets
Section titled “Presets”A preset is a TypeScript function that returns a TaskConfig.
defineProject checks dependsOn names written inline; a preset’s
dependsOn is string[], so its names are not checked:
import type { TaskConfig } from '@vzn/vx/config'
export function tsBuild(opts?: { tsconfig?: string }): TaskConfig { return { description: 'compile TypeScript', exec: { command: `tsc -b ${opts?.tsconfig ?? ''}`.trim() }, inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] }, outputs: { files: ['dist/**', '*.tsbuildinfo'] }, cache: true, }}import { defineProject } from '@vzn/vx/config'import { tsBuild } from '../../presets/ts-build.ts'
export default defineProject({ tasks: { build: tsBuild(), test: { exec: { command: 'bun test' }, dependsOn: ['build'], inputs: { files: ['src/**'] }, outputs: { files: [] }, cache: true, }, },})When the preset is consumed at config-load time, it should resolve
to .ts source — see
architecture.md § Config-time imports
for the bootstrap-cycle tradeoff.
When to use inputs.tasks: []
Section titled “When to use inputs.tasks: []”When a dependsOn exists for ordering only, and the upstream’s
output doesn’t influence this task’s output. Common case: an
integration test that needs a database container up first, whose
image the test’s own inputs already pin.
'test:integration': { dependsOn: ['db:up'], // docker compose up -d postgres exec: { command: 'bun test integration/' }, inputs: { files: ['src/**', 'integration/**', 'compose.yaml'], tasks: [], // db:up only orders; compose.yaml pins what it starts }, cache: true,}Not for a dev server: it serves the app’s source, so e2e must fold
its key (the persistent example above), or a broken page replays a
green e2e.
When to use --no-cache instead of removing cache
Section titled “When to use --no-cache instead of removing cache”--no-cache is a runtime flag, not a config change. Reach for it
when:
- You suspect cache corruption and want a clean run.
- You’re benchmarking and need fresh timings.
- You’re forwarding one-off args and don’t want to pollute the cache with a one-shot key (though note: forwarded args are already in the key, so they form their own entry anyway).
It applies to the whole run; per-task opt-out belongs in config (omit
cache: true).
Schema validation errors
Section titled “Schema validation errors”The loader (src/workspace/project-loader.ts) validates at load time
and surfaces UserError (clean output, no stack). Every field’s
accepted values and exact refusal text are recorded in
tests/contract/config-schema.json, which a test regenerates from the
validator and compares, so a change to either is deliberate
(design/versioning-1.0.md § How the contract is held).
When the field a refusal names is in the file, the CLI prints the file
at its line and column, relative to the current directory (a terminal
opens it on click), and the lines up to it with a caret under the field
(util/config-frame.ts):
✗ vx lock lock not written
packages/b/vx.config.ts:4:15 tasks.build.exec has unknown field "comand" allowed: …
2 | tasks: { 3 | build: { > 4 | exec: { comand: 'mkdir -p dist' }, | ^
fix use commandA value the config imports (test: testTask, ...lib()) is followed to
the file that declares it, a relative import or a workspace package’s,
and that file is framed, with the configs it reaches as used by …:
a typo in a shared preset prints once, not once per config. An import
of a name its module does not export is framed at that import. A field
no file holds (a required one missing, or one a plugin set) prints at
its file, without the frame. Every config that does not load is
named in one run: the first in full, then up to four more as ✗ vx lock also refused, then a count of the rest. The table below lists the messages
a user meets most:
| Symptom | Cause |
|---|---|
did not export a default object |
Forgot export default (said so, framed at a named export), or exported a non-object; a function (Vite’s defineConfig(() => …) shape) adds that it is one and to export what it returns (D-110). |
tasks must be an object keyed by task name |
tasks is not an object — an ARRAY included. |
<path> is <what> — a config must be JSON data, because the cache key is built from its JSON |
A value JSON cannot carry, anywhere in the config: a function, a symbol, a bigint, NaN / ±Infinity, undefined in an array, a cycle, a getter, a symbol key, a non-enumerable property, an object that is not plain (Date, Map, RegExp, a class instance), or nesting deeper than 256 levels. |
<level> has unknown field "<key>" |
Typo’d / unsupported key (see below). |
<level> must be an object (fields: <fields>), not an array |
An array where an object goes — outputs: ['dist/**'] (Turbo’s spelling) is outputs: { files: ['dist/**'] }, and the message says so. |
cannot find '<name>' — no node_modules above the config provides it; install the workspace's dependencies first |
A bare import nothing installed serves — a fresh clone before its install, or a typo. Refused before the config is evaluated, so Bun never auto-installs it from the registry (it would, when no node_modules exists above). |
<file> imports '<name>', which no node_modules above it provides; install the workspace's dependencies first |
The same, in a file the config imports by relative path (a preset), looked up from that file. |
cannot find '<name>' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set <remedy> |
The same, with a .pnp.cjs at or above the config: the install is there and Bun cannot read it, so the remedy is nodeLinker: node-modules in .yarnrc.yml and yarn install (D-109). |
tasks.<name> must be an object |
The task value is null / a string / etc.; a string (package.json’s name: 'command') adds the { exec: { command } } it goes in. |
exec must be an object with a command string |
exec is malformed. |
exec.command must be a non-empty string |
Forgot command, or an empty or whitespace-only string. |
exec.command holds a NUL, which no command line can carry |
A \0 in the command (a template slip); the spawn refused it as exit 127, “not on this task’s PATH”, with the NUL printed as a space. |
exec.persistent must be an object (or omitted) |
Wrong shape. |
exec.persistent.readyWhen must be a string regex |
Non-string readyWhen. |
exec.persistent.readyWhen is not a valid regex (<error>) |
A readyWhen the runner could not compile ((); it failed the task as an internal error at run time. |
cache is not allowed on a persistent task |
persistent + cache combined. |
exec.interactive must be a boolean (or omitted) |
Wrong shape. |
cache is not allowed on an interactive task |
interactive + cache combined: what it does depends on what is typed. |
sandbox is not allowed on an interactive task |
interactive + sandbox combined. |
persistent.readyWhen is not allowed on an interactive task |
vx reads none of an interactive task’s output. |
a task with no exec must declare dependsOn |
Group task with no edges. |
cache needs exec — a task with no command produces nothing to cache |
cache on a group: a task with no command writes nothing to cache. |
dependsOn must be an array of strings |
Wrong shape. |
cache needs inputs — the key is the task's inputs |
Forgot inputs on a cached task. |
inputs must be an object |
Present but not an object: a string (outputs: 'dist'). An array is the row above. |
inputs.files must be an array |
Wrong shape. |
inputs.runtime must be an array of non-empty shell command strings with no NUL |
Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
inputs.workspaceRuntime must be an array of non-empty shell command strings with no NUL |
Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
inputs.tasks must be an array of non-empty strings |
Non-string / empty entry, or a bare string. |
inputs.tasks: "<name>" names no task in <task>.dependsOn |
An exact entry no dependsOn entry of its form names. |
inputs.tasks: every entry is a negation, which selects NOTHING |
Only ! entries: a ! entry only takes back what a positive entry selected. |
invalid inputs.tasks "<entry>": <reason> |
A malformed entry (^, a#, #x, !, ^a#b), refused at load with the parser’s reason. |
cache must be true or false |
cache: {...} or 'yes'; caching is a switch. |
cache needs outputs — what the task produces, outputs: { files: [...] } ([] for none) |
Forgot outputs on a cached task. |
outputs needs exec — a task with no command writes nothing |
outputs on a group task. |
inputs.stability needs exec — a task with no command runs nothing that could write its inputs |
inputs.stability on a group task. |
inputs.stability must be an object |
A bare string (stability: 'mutable'). |
inputs.stability.strategy must be 'immutable', 'mutable' or 'unchecked' |
Any other value ('content', false). |
inputs.stability.severity must be 'warn' or 'error' |
Any other value ('fail', true). |
outputs must be an object |
Present but not an object: a string (outputs: 'dist'). An array is the row above. |
outputs.files must be an array |
Wrong shape. |
inputs.files: every entry is a negation, which selects NOTHING |
Only ! globs — nothing to subtract from. |
inputs.files: "<file>" is taken back by "!<glob>" |
A literal input a ! entry covers: the negation subtracts wherever it sits, so the file never entered the key. |
outputs.files: every entry is a negation, which selects NOTHING |
Only ! globs: a ! entry only takes back what a positive glob selected (A-44). |
outputs.files: "<file>" is taken back by "!<glob>" |
A literal output a ! entry covers: it is never saved, so a hit left it missing and said up-to-date. |
outputs.files: "<glob>" covers the project's own <file> |
An output glob that matches the project’s own vx.config.* (**, *.ts): the clean before a run would delete it. A ! entry that takes the file back (!vx.config.ts) lets it load. |
outputs.files: "<glob>" is under workspace-outputs/ |
A project output named into the artifact’s namespace for outputs.workspaceFiles: every hit read it back as a workspace output. |
inputs.files: '!!' is not a double negation |
!!x inverts the set — it folds only x. |
exec.timeout of <value> exceeds the maximum timer delay |
Past 2^31-1 ms a timer fires at once, not never. |
description must be a string |
Non-string description. |
tag "<tag>" <why> — --filter tag:<name> could not name it |
A tag with surrounding whitespace or a *, or one ending in ... or [<ref>], which a filter reads as a pattern, a dependency walk or a git range. |
Unknown fields are rejected, not ignored, at every object level —
the project’s top level (tasks), the task itself, exec, exec.env,
exec.persistent, exec.sandbox and its allow /
deny / ignore blocks, cache, inputs, and outputs
(tests/schema-unknown-keys.test.ts walks every one) — and at the top
of .vx/workspace.ts,
where plugin: (singular) would otherwise declare no plugins and run
the workspace bare. A silently-dropped
workspaceFile (singular) or timeoutMs would make the task hash as
though the field had never been written, so vx would replay an artifact
built from different inputs; a silently-dropped env: { set: … } would
run the task without the variables it was written to have. The error
names the offending key, lists what that level accepts, and adds the
nearest accepted spelling when one is within two edits:
tasks.build.exec.env has unknown field "passthrough", then
allowed: define, passThrough, secret and fix use passThrough. A field another runner spells
on the task names vx’s home for it instead (D-37): Turbo’s outputs,
inputs, env, passThroughEnv, persistent, outputLogs, interactive
(exec.interactive) and with, Nx’s target executor, options, continuous (D-49), cwd,
parallelism and configurations (D-89), and a command
(cmd, script) on the task or cmd on exec, and on exec Nx
run-commands’ cwd, args, commands, parallel and shell
(D-100), and in cache: true wireit’s files / output,
env, dependencies, enabled, and globs / include / patterns /
exclude / ignore under inputs or outputs (D-118). So outputs on a task
ends — vx spells it outputs.files in code quotes. A field of
exec, inputs or cache written on the task itself (retries,
timeout, files) names the level it belongs to (exec.retries). A cache that
is no object (Turbo’s cache: false) and a persistent that is none
(true) name the shape to write.
Project-discovery errors (src/workspace/workspace.ts, src/orchestrator/projects.ts):
| Symptom | Cause |
|---|---|
two projects are named "<name>": <dir> and <dir> — give one another \name` in its vx.config` |
Two directories got one name: a config’s name, a plugin’s, or the path. |
<dir>: a project is named by its path unless named, and this one holds "#" … |
A path no task spec can address: set name in its config. |
<dir>: the directory's name is not valid UTF-8, which no project path may hold — rename it |
A project directory’s name has bytes UTF-8 cannot decode, shown as \xNN. |
<dir>: "//" names the workspace root — set another \name`` |
A config away from the root says name: '//'. |
.vx/projects.lock.json locks <dir>, but its <file> is gone — run \vx lock`` |
A locked project’s config was deleted or renamed (X-144). |
.vx/projects.lock.json locks <dir>, but its <file> is not readable by this user (<code>) |
This user may not search the project’s directory (D-132). |
.vx/projects.lock.json has unsupported version <n> (this vx expects 6) — re-run \vx lock`` |
A lock an earlier vx wrote. |
.vx/projects.lock.json was written by a newer vx (version <n>; this vx reads 6) — upgrade vx |
A lock a later vx wrote: re-locking would write the older version. |
<dir> is outside this sparse checkout, so vx lock cannot read its <file> — run \git sparse-checkout add <dir>`` |
vx lock or --check where a sparse checkout left a config out. |
.vx/projects.lock.json: no <pool> <n> in its pool — re-run \vx lock`` |
A hand-edited lock names a pooled value it does not hold. |
plugin '<name>' adds project <p>, but git ignores <file>, so \vx lock` cannot read it — unignore it or delete it` |
A discover plugin’s directory holds a config git ignores: vx lock, --check and a run naming its task. |
git ls-files failed (exit <n>): <why> |
vx lock could not list the configs (a torn index). |
Core reads no package.json or pnpm-workspace.yaml; the errors reading them are @vzn/vx-migrate’s (its README, “Errors reading the members”).
Workspace-config errors:
| Symptom | Cause |
|---|---|
<setting> must be <what its kind takes> (got <value>) |
A setting of the wrong type or out of range: run.concurrency: 0, cache.dir: 42, run.schedule: 'fastest'. The settings table says what each takes; a count is a number and a switch a boolean, never a string. |
<setting> takes a duration with its unit, like '<n>ms' or '<n>s' (got <n>) |
A bare number for a duration: run.timeout: 30000. Durations carry a unit, so 30000 cannot be read as seconds by mistake. |
<setting> takes a size with its unit, like '<n>KiB' or '<n>B' (got <n>) |
A bare number for a size: cache.store.embeddings: '32'. Sizes carry a unit, so 32 cannot turn the store off as 32 bytes; 0 is refused naming false. |
<setting> of <value> exceeds the maximum timer delay |
A timer past 2^31-1 ms fires at once, not never. Omit run.timeout for no limit. |
<group> must be an object (fields: <fields>) |
run, watch, run.rules or cache.retention that is not an object. |
cache must be an object like { dir, policy }; a task caches through its own cache in its project's vx.config |
Not an object: Turbo’s and Nx’s workspace cache: true is a task’s own cache in vx. |
cache.dir holds a NUL, which no path can carry |
No path can hold a NUL; the cache directory’s error blamed the workspace’s permissions. |
baseBranch must be a branch name like 'main' |
Not a string, empty, holding whitespace, a NUL, .. or one of ~^:?*[\, or starting with -, which git would read as an option. |
plugins must be an array of plugin objects |
Wrong shape. |
plugins[<i>] must be an object |
A non-object entry in plugins; a string (Nx’s '@nx/vite/plugin') adds that a plugin is what its package’s function returns, not a module name (D-49). |
plugins[<i>] must come from definePlugin(import.meta, { … }) |
A plain object where a plugin was expected: a plugin’s name is its package name, and only definePlugin sets it. |
plugins[<i>] is a Promise — await the factory that returns the plugin |
An async plugin factory called without await (plugins: [later()]); the refusal said the plugin did not come from definePlugin (D-159). |
plugins[<i>] is plugins[<j>] again (plugin '<name>') — declare a plugin once |
One plugin instance listed twice (two presets sharing it): its setup, its handlers and its telemetry sinks ran twice. Two instances of one package stay two plugins. |
plugins[<i>].name overrides the package name |
A name set over definePlugin’s result; drop the field. |
plugins[<i>].<capability> of plugin '<name>' must be a function |
A capability key holding something that is not callable. |
plugins[<i>] (plugin '<name>') must contribute at least one of config/discover/project/graph/key/fingerprint/measures/schedule/admit/executor/cache/telemetry/output/setup/commands/teardown |
A plugin object with no capability. |
plugin '<name>' declares '<key>', which is no plugin hook |
A key on a plugin that names no hook (excutor, setUp): it was never called. The nearest hook is hinted. |
<file> has unknown field "<key>" |
Typo’d / unsupported top-level key (plugin); the hint names the nearest spelling, or where vx keeps a Turbo or Nx key (pipeline, remoteCache, parallel, cacheDirectory, defaultBase, tasksRunnerOptions, globalPassThroughEnv; D-38, D-49; pnpm-workspace.yaml’s, Lerna’s and package.json’s packages, workspaces, catalog, npmClient, and ignore, cache, remote, env, D-98; in a project’s vx.config, targets, extends, implicitDependencies and name, D-56; scripts, pipeline, namedInputs, root, sourceRoot, projectType, and a task’s dependsOn, cache, exec, inputs, outputs, env one level too high, D-99). |
plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed |
A plugin verb the dispatcher matches first; it could never run. |
plugin '<name>' declares command '<verb>', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-' |
A verb like --version, '' or a b: core reads a flag first, an empty word is no verb, and vx completions writes verbs unquoted into a shell script. |
plugins '<a>' and '<b>' both declare command '<verb>' — a verb has one owner |
Two packages on one verb; the first would win and hide the second. Plugins of one package are one owner, and the first declared runs. |
plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function, extraFiles?: function, digests?: function } |
A fingerprint claim without its file list or its affected answer, or with an extraFiles or digests that is not a function. |
plugin '<name>' claims fingerprint file "<file>", which is not a file name at the workspace root |
A claim names a path, or no name: a claim is a bare file at the root (vx watch’s root arm is not recursive). |
plugins '<a>' and '<b>' both claim fingerprint file '<file>' — a file has one claimant |
Two plugins keying the same lockfile; the key would fold both and --affected could ask only one. |
plugin '<name>' claims fingerprint file '<file>' twice (plugins[<i>] and plugins[<j>]) — a file has one claimant; declare the plugin once |
One plugin package declared twice (plugins: [bun(), bun()]); the message named it as two plugins (D-158). |