Compared to Turborepo, Nx, vite-task
A side-by-side reference for what each of the four tools does, plus an
explicit list of gaps @vzn/vx has against the other three.
This is a living document; every claim cites a source file or reference
page in the upstream repo so future revisions can be diffed against
reality. Last verified 2026-09-03 against turbo@2.10.12, nx@23.2.0
and voidzero-dev/vite-task main (now the engine behind Vite+‘s
vp run). Five Turbo and Nx rows (the lockfile, non-JS projects, the
daemon, the plugin surface, Nx’s language plugins) were corrected on
2026-09-24 against the tools’ docs sources, for the choosing page (item
689).
Positioning in one paragraph each
Section titled “Positioning in one paragraph each”- Turborepo — production-grade. Per-package
turbo.json, remote cache, OTLP observability, watch, prune, query, boundaries, a TUI. Maximally featureful; many features are flagged “experimental”, and 2.10 deprecates its own daemon,--parallel,--no-cacheand--remote-only; the flag surface is the largest of the four. Reference repo:vercel/turborepo. - Nx — production-grade and pluggable. Per-package
project.json, plugins that infer tasks and add generators and executors (first-party Gradle, Maven and .NET; community Rust, Go and Python),affectedsemantics, Terminal UI, named inputs / target defaults, distributed task execution via Nx Cloud agents. Heaviest schema. Reference repo:nrwl/nx. - vite-task — Rust-fast, smallest schema, novel filesystem-spy
auto-input inference (default
{auto: true}). Pre/post script lifecycle frompackage.jsonscripts. SQLite + tar.zst local cache with make-style validate-at-lookup (traced reads re-fingerprinted on every hit — gives early cutoff, but the key isn’t derivable before execution, which is why it has no remote cache). Reference repo:voidzero-dev/vite-task. @vzn/vx— one thing, built to be built on. TypeScript-native config, opt-in caching, Turbo-shape cache key with two extensions (project package.json folded in; resolved-config hash captures TS imports). One self-contained binary per platform (Linux and macOS, x64 and arm64; Windows under WSL) — no Node or Bun to run it. Smallest CLI surface; deliberately no daemon, no cloud, no account and no JS-function tasks. The core is a Vite-style pipeline: plugins hook each stage (config→project→graph→key→fingerprint→schedule→admit), supply the executor (WHERE a command runs, never what it is), the cache layers and the telemetry sinks, and add verbs — core applies none of them by default and names none. Strict output ownership. What Nx would be if it were not a product.
Every row a Turbo or Nx user would look for, spelled in vx and pinned by
a test that runs the real CLI, is parity.md; this page
is the wider matrix and the reasoning.
Quick CLI flag map
Section titled “Quick CLI flag map”turbo run / nx run-many / vp run / vx run:
| Capability | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| pnpm-style filter DSL | --filter | --projects, --exclude | --filter | --filter |
| recursive (every project) | implicit | --all | -r | --all |
| transitive deps | pkg... | --with-deps (legacy) | -t | pkg... (via DSL) |
pkg#task addressing | yes | nx run pkg:target | yes | yes |
| concurrency cap | --concurrency | --parallel | --concurrency-limit | --concurrency |
| serialize / drop dep order | --parallel (deprecated) | (always topo) | --parallel | --concurrency 1 to serialize; no --parallel (see note) |
| skip dependsOn | --only | --excludeTaskDependencies | --ignore-depends-on | --exclude-dependencies[=<names>] |
pkg#task + bare task | cross-product: web#lint build also runs web#build | — | — | ≠ bare names take the filter scope only; an anchored task never widens it |
| one-at-a-time task | — | parallelism: false | — | a @vzn/vx-schedule-history reservation at or above the worker count runs alone; or --concurrency 1 |
| forward args | -- | --args="..." | trailing args | -- |
| skip cache reads+writes | --cache=<spec>, --force (--no-cache deprecated) | --skipNxCache, --skipRemoteCache | --no-cache | --no-cache (all off) / --force (reads off, writes on) / --cache=<spec> per-layer |
| dry-run (print plan) | --dry, --dry=json | --graph renders | — | --dry, --dry=json |
| affected (git-relative) | --affected | full affected subcommand | — | --affected[=<base>] + [<since>] filter form |
| graph render | --graph file.{dot,html} | --graph | — | --graph[=<path>] (DOT) |
| continue past failure | --continue=never|dependencies-successful|always | --nxBail | — | --continue[=never|deps-ok|always] (deps-ok default) |
| per-run JSON summary | --summarize, --json | --outputStyle | --last-details replay | --summarize[=<path>] |
| output log mode | --output-logs=full|hash-only|new-only|errors-only|none | --outputStyle=tui|dynamic|static|stream|… | --log=interleaved/labeled/grouped | --output-logs full|hash-only|errors-only|none (+ flow-derived default) |
| profile / Chrome trace | --profile | (via Nx Cloud) | — | --profile[=<path>] |
| daemon on/off | (deprecated in 2.10; ignored) | (Nx daemon, always on) | — | (no daemon) |
| retries / timeouts | — | — | — | --retry <n>, --timeout <ms> (also per task in config) |
| remote placement / outputs | — (remote cache only) | Nx Cloud agents | — | --download=all|toplevel|none, exec.remote (executor plugin, e.g. @vzn/vx-reapi) |
| run report | --summarize | — | — | --report=markdown, --report-file; vx last replays any recorded run |
| watch mode | turbo watch | nx watch | — | vx watch <task> |
| version / help | --version / --help | --version / --help | --version / --help | --version, --help / -h |
Sources: Turbo /docs/reference/run (turborepo.dev, 2.10.12); Nx
/reference/core-api/nx/documents/run-many (nx.dev, 23.2.0);
vite-task /crates/vite_task/src/cli/mod.rs; vx src/cli/run.ts.
Why no
--parallel? Turbo’s--parallelexists because users often over-declaredependsOnand want an escape hatch. In vx,dependsOnis opt-in and explicit — if you wrotedependsOn: ['^build']you meant it. The legitimate “I want to fan out without waiting” cases are already covered by (a) not declaringdependsOnin the first place, and (b)--exclude-dependencies, which keeps dependencies from running, entirely or selectively.
Config schema comparison
Section titled “Config schema comparison”| Schema feature | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| Config language | JSON (turbo.json) | JSON (project.json, nx.json) | Vite config (run key) | TypeScript (vx.config.ts) |
| Per-package config | yes | yes | yes | yes |
| Workspace-level config | turbo.json at root + extends | nx.json | root vite.config.* | vx.workspace.ts (concurrency, cacheDir, timeout, cacheRetention, plugins) |
Per-task dependsOn: same project | bare name lint | bare name | bare name | 'lint' |
Per-task dependsOn: workspace deps | ^lint | ^lint or {projects:"dependencies"} | pkg#task | '^lint' |
Per-task dependsOn: arbitrary other package’s task | pkg#task | {projects:["pkg"],target:"task"} | pkg#task | 'pkg#task' |
Wildcards in dependsOn | — | v19.5+: build-*, ^build-* | — | 'build.*', '^build.*' (task-name patterns; bare * stays filter-only) |
| Group / umbrella tasks | tasks with dependsOn only | (achieved via target groups) | (none) | yes — tasks with no exec |
| Input declarations | inputs: [...] + $TURBO_DEFAULT$ etc. | inputs: [...] w/ rich types | input: glob or {auto:true} | cache.inputs.files: string[] |
| Auto-input inference | — | — | yes (fspy, see §3) | — out of scope (see §3) |
| Root-anchored inputs/outputs | $TURBO_ROOT$/… | {workspaceRoot}/… | (none) | cache.inputs/outputs.workspaceFiles |
| Runtime-command inputs (tool versions, probes) | — (vercel/turborepo#4124) | runtime input | (none) | cache.inputs.runtime / workspaceRuntime |
| Frozen / locked resolved configs | — | — | — | vx lock + vx run --frozen |
| Migration generator from other runners | — | — | — | bunx @vzn/vx-migrate (Turbo, Nx, moon, wireit, lage, scripts → vx.config.ts) |
| Named / reusable input sets | (none) | namedInputs at workspace + project level | (none) | rejected by design — TS arrays/imports compose |
| Per-task env inputs | env: ["NODE_ENV"] | inputs: [{env: "NODE_ENV"}] | env: [...] + untrackedEnv | cache.inputs.env: string[] |
| Pass-through env | passThroughEnv | (always pass through) | untrackedEnv (passed, no hash) | exec.env.passThrough |
| Define / literal env | (no; rely on globalEnv) | (via executor options) | (no; in script) | exec.env.define |
| Workspace-level env inputs | globalEnv, globalPassThroughEnv | workspace namedInputs + inputs | (no) | rejected by design — TS presets compose |
| Output declarations | outputs: [...] | outputs: [...] | output: glob or {pattern,base} | cache.outputs.files: string[] |
| Output cleaning before exec / restore | (no — additive) | (no — additive) | (via materialized artifacts) | yes — strict |
Implicit-dependency hash (project package.json) | (via lockfile) | externalDependencies | (via lockfile) | yes — folded directly (v12) |
| Lockfile-aware invalidation (only the projects a dependency change reaches) | per package: the lockfile changes that affect it | yes — pruned lockfile per project, in the daemon’s graph | (whole lockfile) | @vzn/vx-lockfile — per-project closure digest, memoised by lockfile hash; --affected follows it |
| Resolved-config hash (captures TS imports) | — | — | — | yes — node.config JSON hashed |
| Persistent / long-running tasks (dev servers) | persistent, interruptible, interactive, with | continuous | (handled outside graph) | exec.persistent.readyWhen |
| Configurations (named option sets) | — | configurations + -c | — | — gap |
Per-target metadata (description) | description | metadata.description | — | description: string |
| Target defaults / inheritance | extends, task extends | targetDefaults (priority-resolved) | (no) | rejected by design — presets are TS imports |
| Pre/post script lifecycle | (no) | (executor-defined) | enablePrePostScripts: true | vx init folds them into the command |
| Boundaries / package-tag visibility | boundaries.tags | @nx/enforce-module-boundaries | (no) | — gap |
Cache feature comparison
Section titled “Cache feature comparison”| Cache feature | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| Local cache | tarball-per-hash in .turbo/cache | .nx/cache SQLite-ish | materialized-artifact crates | SQLite index + one <hash>.tar.zst per entry in .vx/cache |
| Remote cache wire | Vercel /v8/artifacts/ (HMAC, pre-signed) | Nx Cloud or plugin | — | plugin-driven: @vzn/vx-reapi (Bazel AC/CAS), @vzn/vx-migrate’s turboCache() (/v8/artifacts, self-hosted or Vercel), @vzn/vx-migrate’s nxCache() (/v1/cache) |
| Log replay on hit | yes | yes | yes | yes |
| Output restore on hit | yes | yes | yes | yes |
| Output cleaning | (no — additive) | (no) | (materialized) | yes — wipe before exec AND before restore |
| Cache pruning | cacheMaxAge, cacheMaxSize in config | maxCacheSize | vp run cache clean | cacheRetention: { olderThan, maxSize } in vx.workspace.ts (run end), vx cache prune --older-than / --max-size |
| Stats / run history | --summarize JSON files | Nx Cloud dashboard | --last-details | runs + invocations tables in cache.db (direct SQL); vx info; vx last |
| Per-run JSON summary | --summarize | --outputStyle | --last-details | --summarize[=<path>] |
| Flaky task detection | — | Nx Cloud (paid) | — | local — same key, both outcomes on record: the run’s footer, --summarize (flaky), vx info |
| Chrome-trace profile | --profile | (Nx Cloud) | — | --profile[=<path>] |
| Async remote prefetch | — | — | — | yes — stable-key GETs overlap execution |
| Restore-ahead scheduling | — | — | — | yes — two-tier scheduler restores warm hits ahead of their deps |
| Artifact integrity | HMAC x-artifact-tag | (transport-level) | — | core refuses a bomb, a non-vx archive, or one recording another key; @vzn/vx-reapi re-hashes every blob |
| Pre-signed URL auth | yes | yes | — | plugin’s business — core ships the cache seam, not a transport |
Workspace integration
Section titled “Workspace integration”| Capability | Turbo | Nx | vite-task | vx |
|---|---|---|---|---|
| pnpm / npm / yarn / bun workspaces | yes | yes | yes | yes (pnpm-workspace.yaml, package.json workspaces, bare pkg.json) |
| Non-JS projects (Rust, .NET, Gradle, …) | experimental (native Go, Cargo, uv) | yes (plugins) | no | no |
| Filter DSL | pnpm-style + [<since>] (git-relative) | yes via --projects/--exclude (no DSL) | pnpm-style | pnpm-style + [<since>] |
| Affected / git-relative | --filter '[since...]', --affected | full affected subcommand | — | --affected[=<base>] + [<since>] |
| Daemon / persistent project-graph process | not for turbo run since 2.9 | yes (on by default locally) | — | — out of scope |
| Watch mode | turbo watch | nx watch | — | vx watch <task> |
| Prune workspace (Docker subset) | turbo prune | — | — | — gap |
Gap audit 2026-09-04 — what a developer would miss, core or plugin
Section titled “Gap audit 2026-09-04 — what a developer would miss, core or plugin”The owner’s question: which Nx 23 / Turbo 2.10 features are a MUST or
a game changer for developers, and of those, which belong in core.
Rule applied: if a plugin can do it through an existing seam, it is
not core. Every row below was checked against docs/cli.md,
docs/schema.md and the source, not remembered.
Verified present in core (parity or ahead): the task graph with
^task / wildcards / nearest-holder frontier; --filter as a superset
of Turbo’s DSL (..., ^..., !, ./dir, [git-ref]) and
--affected; caching with declared inputs, outputs, inputs.env,
workspace files; strict env isolation (Turbo’s
--env-mode=strict is vx’s only mode); persistent tasks with
readiness gating (ahead of Turbo’s persistent and Nx’s
continuous); the interactive picker; watch, --dry, --graph,
--summarize, --profile, --continue modes, retries, timeouts,
--output-logs modes; init (scripts to configs) and, its own
package, bunx @vzn/vx-migrate (Turbo, Nx); the cwd-scoped default;
last, why, show, info; remote cache and execution through the
seams (@vzn/vx-reapi).
Missing, and where it belongs:
| Feature | Nx / Turbo | Verdict |
|---|---|---|
Zero-config adoption: package.json scripts are tasks, no per-package file | both, in core | SHIPPED 2026-09-09 as @vzn/vx-turbo, turbo() in @vzn/vx-migrate since 2026-09-11: a project-stage plugin over the same mapper vx-migrate renders from, so a turbo.json repo runs with only a vx.workspace.ts; the seam widening core needed (visit a config-less package when a plugin declares project) landed with it. Nx followed 2026-09-22 (nx(), item 590): the same stage over Nx’s resolved graph, executor targets run as themselves through nx-exec. |
Inferred tasks from tool configs (vite.config ⇒ build/serve/test) | Nx plugins | The project stage is the seam (a plugin visits every package, a config-less one as { tasks: {} }, and fills what the package did not declare); a first-party family was built and RETIRED the same day (2026-09-10, owner): technology plugins are the community’s, core names no tool. The recipe is in the plugins guide. |
.env files loaded into the task env | Nx | Tasks read their own .env (Vite, Next do); the cache side is cache.inputs.files: ['.env*']. A config-stage plugin can inject. Not core; a docs footnote. |
Configurations (build:prod as one task, two modes) | Nx | The language: a TS function returning the task per mode. Not core. |
| Cache size / age caps applied during runs | both | SHIPPED 2026-09-23 (item 658) in core as cacheRetention: the local cache is core’s floor, not a plugin, so its caps are a workspace field. |
| Graph UI, TUI, dashboards | both | Rejected for core; --graph emits the data for a plugin or a site. |
Versioning and publishing (nx release) | Nx | commands seam; changesets already exists. Not core. |
| Test splitting (Nx atomizer) | Nx | graph-stage plugin. Not core. |
Import boundaries (turbo boundaries) | Turbo | A lint; out of scope. |
| Shell completions | both | SHIPPED 2026-09-10: vx completions bash|zsh|fish over the verb table and each verb’s help cut. |
| Windows | both | Under WSL (owner, 2026-09-10): POSIX shell is the API, and WSL is where it is on Windows. A native port is not proposed. |
Net: core is at parity or ahead on the must-haves; the gap that costs adoption is the trial with no generated files, and its core half is a small seam. Everything else is a plugin or the language.
Gaps for @vzn/vx (the running list)
Section titled “Gaps for @vzn/vx (the running list)”Ranked by leverage. Cited file paths are inside the respective upstream repos.
Likely-worth-adding
Section titled “Likely-worth-adding”-
Remote cache is PLUGIN-DRIVEN (owner directive 2026-07-10). Core carries zero HTTP cache code: it keeps the seams (
LayeredCache, theRemoteCacheLayerinterface, thecacheplugin capability,RunOptions.remoteCache), and a plugin ships the wire —@vzn/vx-reapispeaks Bazel’s ActionCache + CAS, so NativeLink, BuildBuddy, Buildbarn and bazel-remote all work. Turbo/v8/artifactscompatibility was DROPPED from core and came back as a plugin:turboCache()in@vzn/vx-migratespeaks the Turbo wire (self-hosted or Vercel) andnxCache()the Nx self-hosted one, both against the samecacheseam — the recipe for any other wire lives in the plugins guide. -
--continue=<mode>— shipped.--continue[=never|deps-ok|always]controls failure propagation:neverfail-fast (stop dispatch on the first failure),deps-ok(default) skip only a failure’s dependents while independent siblings continue,alwaysrun everything. Bare--continue=always. Enforced in the scheduler; seecli.md§ Failure propagation. -
Wildcards in
dependsOn— shipped.'build.*'expands to every other same-project task matching the pattern (zero matches legal);'^build.*'walks the nearest-holder frontier where a holder is a dep declaring ≥1 match and receives edges to ALL of them.*is the sole metacharacter; bare'*'/'^*'stay filter-only (cache.inputs.tasks), and'pkg#pattern'is rejected. Seeschema.md§ dependsOn.- Nx 19.5+
build-*parity.
- Nx 19.5+
-
Workspace-level
globalInputs/globalEnv/globalPassThrough— owner-REJECTED (2026-07-05, “no global”). TypeScript configs compose: a shared preset imported and spread into each config IS the global-inputs/global-env mechanism (same rationale as the rejected named-inputs machinery — a schema field would duplicate the language). The@vzn/vx-migrateTurbo path already emits a generatedvx-preset.tsfor exactly this. Not a gap; will not be added.- Turbo
globalEnv,globalPassThroughEnv.
- Turbo
-
--cache-dir <path>CLI flag — shipped. Overrides thedefineWorkspace({ cacheDir })field + the.vx/cachedefault, resolved relative to cwd. A per-run knob; never folded into a cache key. -
Auto-input inference via filesystem tracing — owner-REJECTED (reconfirmed 2026-07-05, “no auto input”). Re-classified out of scope for vx (2026-06) after studying vite-task’s implementation. Doing this soundly is a multi-platform native systems project — vite-task ships ~9 Rust crates for it: LD_PRELOAD / DYLD_INSERT_LIBRARIES interposition for glibc/macOS, a
seccomp_unotifykernel supervisor for static binaries (esbuild, Go tools) that bypass libc, Microsoft Detours on Windows, a 4 GiB shared-memory IPC channel, and — because macOS SIP strips DYLD injection from Apple-signed binaries — their own shipped shell + coreutils to run commands under. Traced reads are re-validated at every cache lookup (not key-folded). vx cannot ship per-OS native helper binaries without abandoning its no-build-step distribution; explicit inputs stay the contract.- vite-task:
{auto: true}is the default; backed bycrates/fspy*.
- vite-task:
Maybe-worth-adding (heavier lift, narrower payoff)
Section titled “Maybe-worth-adding (heavier lift, narrower payoff)”-
--output-logs hash-only— shipped (2026-08-25). One line per task — outcome word, task id, cache key — with no log output; the run’s audit trail of which key each task resolved to. All four Turbo modes now covered.- Turbo:
--output-logs, schemaoutputLogs.
- Turbo:
-
Configurations (named option sets per target).
build:prodvsbuild:devas one task with two configurations rather than two tasks. vx’s answer today is the language: a TS function returning the task for a mode, spread into two tasks, plus a group over both.- Nx:
configurations+-c.
- Nx:
-
Pre/post script lifecycle — shipped in
vx init(2026-09-03).pre<x>/post<x>scripts fold intox’s command in npm order when the config is generated, so the ordering survives as a plain command rather than as runtime magic; a script that only delegates (npm run other) becomes a group overother. Not applied at run time: a task’s command is exactly what its config says.- vite-task:
enablePrePostScripts(default true).
- vite-task:
-
vx prune— shipped (2026-08-25),@vzn/vx-prunefrom 2026-09-10, REMOVED 2026-09-11 (owner). Was: workspace subset for Docker builds: target + transitive workspace deps, rewrittenpnpm-workspace.yaml, root manifests, unpruned lockfile (deliberate — per-format lockfile pruning is out of phase 1),--dockerjson/full split for layer caching.- Turbo:
turbo prune.
- Turbo:
-
Cache TTL / size caps in config — shipped (2026-09-23, item 658) as
cacheRetention.defineWorkspace({ cacheRetention: { olderThan: '30d', maxSize: '10G' } })applies thevx cache prunepolicy at the end of every run, only when something is due.- Turbo:
cacheMaxAge,cacheMaxSize(verified in the 2.10 configuration reference). - Nx:
maxCacheSize.
- Turbo:
-
Last-run replay — shipped (2026-08-25) as
vx last. Replays the most recent (or any recorded) run’s summary from the local history: header + per-task table,--listfor recent run ids,--format jsonfor scripting. Read-only overmetrics.ts.
Shipped since this list was first drawn
Section titled “Shipped since this list was first drawn”vx watch <task>— debounced re-run loop.--output-logs full|errors-only|hash-only|none.vx info(absorbedvx stats, since removed).- Artifact integrity at the ingest boundary — core refuses a remote
artifact that decompresses past its ceiling, is not a vx archive, or
records a key other than the one it was fetched under;
@vzn/vx-reapire-hashes every CAS blob against its digest, andturboCache()verifies Turbo’s HMAC tag when given asignatureKey. - OTel run telemetry — the
otel()plugin in@vzn/vx-otel(declare it invx.workspace.ts+ setOTEL_EXPORTER_OTLP_ENDPOINT). - Per-task OS sandboxing (
exec.sandbox, SRT-backed, fail-on-violation). - Root-anchored inputs/outputs (
workspaceFiles) and runtime-command inputs (runtime/workspaceRuntime). vx lock/vx run --frozen,vx init/@vzn/vx-migrate,vx show.- The plugin pipeline (2026-09-02). One
VxPluginhooks every stage —config,project,graph,key,fingerprint,schedule,admit— beside theexecutor/cache/telemetrycapabilities,commands(new verbs) and thesetup/teardownpair. Core applies no plugin by default; a workspace declares all of them. - A config evaluation cache for provably pure configs
(
src/workspace/config-cache.ts): a lexer-backed purity GATE, not a heuristic — with string literals and comments stripped, any BACKSLASH in code position (an identifier escape:\u0070rocessISprocess, the one spelling a deny-list cannot see), any non-@vzn/vxbare import, or a closure past 32 files opts a config out. Warm 1000-project run ~400 → 237 ms with the rest of the perf waves. vx init(scripts → configs),vx why,vx last,--download, remote execution through@vzn/vx-reapi,@vzn/vx-github(job summary + check run),@vzn/vx-mcp(an MCP server as a plugin verb), theschedule-historyplugin (critical-path priorities from recorded durations).- npm distribution as per-platform binary packages plus a launcher, signed on macOS.
Explicitly rejected (owner decisions — do not re-propose)
Section titled “Explicitly rejected (owner decisions — do not re-propose)”- Named / reusable input sets (
namedInputs) and target defaults / inheritance. TypeScript configs compose — shared presets via import ARE vx’s named inputs and defaults; schema machinery would duplicate the language.
Explicitly out of scope (today)
Section titled “Explicitly out of scope (today)”These don’t appear on the roadmap and won’t be added without a deliberate design pass.
- Daemon / persistent project-graph process. Re-discovery is fast
enough on Bun (a warm 1000-project run is ~170 ms end to end, with
config loading scoped and pure configs served from the eval cache);
the operational cost of a daemon doesn’t pay for itself. Turbo
reached the same conclusion: from 2.9 its docs say the daemon is no
longer used for
turbo run. - JS-function tasks. “Shell is the API” is a deliberate
constraint: a task is a command string. The
executorcapability decides WHERE that command runs (a worker, a sandbox, this machine) and the pipeline hooks shape what the graph contains and how it is keyed and ordered — none of them can replace the command with code. - Generators / scaffolding. Not a task-runner concern.
- TUI / interactive panes. Streamed framed blocks + the worker
status region are the terminal format; no Nx-style Terminal UI (an
attempt was built and dropped).
vx lastreplays a recorded run from the local history; anything browsable is a telemetry-plugin story now that the self-hosted dashboard is gone (2026-08-23). - Boundaries / package-tag visibility. Module-level constraint
rules belong in lint (
oxlint,eslint-plugin-import), not the task runner. - Non-JS executor plugins. Rust / .NET / Gradle projects use their own runners. vx is a JS-monorepo runner.
- Native Windows. vx spawns POSIX shell. Binaries are built for linux/darwin × x64/arm64, and Windows runs the Linux one under WSL; a native port is not on the matrix.
Where vx is ahead
Section titled “Where vx is ahead”Things @vzn/vx does that the others don’t:
- A Vite-style pipeline instead of a feature list. Every stage
has a hook and every capability is a plugin, in declaration order,
with core re-validating what a hook returns. A plugin can fold a
value into every cache key (
key, andvx whyshows it by plugin name), rewrite a project’s tasks, add an edge, reorder the schedule, or add a verb — so “vx doesn’t do X” is answered with a plugin, not a fork. Turbo documents no plugin surface; Nx’s plugins infer tasks and add graph data, generators, migrations and executors. vx why— cache-key explainability. Per-component input fingerprints are recorded on every miss, so a re-run is explained by diffing two keys: which file, env var, upstream key or plugin part moved. Turbo’s--summarizeand Nx’s cache view show hashes, not the diff.- TypeScript config with full type inference — no string typos,
IDE autocomplete, presets as plain imports. The closest thing in
Turbo/Nx is
extends; in vite-task it’s tied to Vite’s config loader. - Resolved-config hash. Imports and computed values get folded into the cache key because the post-evaluation object is what we serialize. Turbo and Nx hash the static JSON file and miss anything computed at config-load time.
- Project
package.jsonhash folded in automatically. Turbo and Nx get this transitively via the lockfile; vx folds the per-project bytes directly, so narrowinputs.fileslike['src/**']doesn’t miss dep / version-bump invalidation. - Lockfile-aware invalidation as a plugin.
@vzn/vx-lockfileclaims the lockfile (pnpm, bun, npm, yarn) and keys each project on its own resolved closure (name, version, peers, integrity, patches,link:reach), sopnpm update foore-keys only the projects that reachfooand--affectedselects them (and their dependents). Nx does this inside its daemon’s project graph; Turbo hashes the lockfile changes that affect each package. vx parses once per lockfile content and memoises the digests, so a warm run pays a read and a hash, not a parse. - Strict output ownership. Declared
cache.outputs.filesare wiped before exec AND before cache restore. Both Turbo and Nx restore additively; stale files from a prior build survive a cache hit there. - Group tasks as first-class. A task with no
exec(justdependsOn) is a pure aggregator; doesn’t appear in the run output, isn’t counted in the summary, isn’t recorded in analytics. - Runtime-command inputs.
cache.inputs.runtime/workspaceRuntimefold a probe command’s live output into the key (tool versions, OS info) — Nx hasruntime; Turbo has no equivalent (vercel/turborepo#4124). - Restore-ahead two-tier scheduling. A stable-key warm hit restores immediately, before its deps finish running, as worker backfill — misses still own the pool. Remote runs get the same idea as async prefetch (remote GETs overlap execution, at most one per key).
vx lock/--frozen. Configs are programs; the lock freezes the resolved objects for CI reproducibility, with a full re-evaluation audit (vx lock --check). No analog in Turbo/Nx.bunx @vzn/vx-migrate. One command generates per-packagevx.config.tsfrom Turbo, Nx, moon, wireit or lage, or root scripts, with TODO comments for everything unmappable.- A versioned telemetry contract + plugin seam.
TelemetryRecord/RunSummaryRecord(TELEMETRY_SCHEMA_VERSION) is one neutral export shape every sink reads — OTel (@vzn/vx-otel), the GitHub job summary and check run (@vzn/vx-github), or a custom sink — observe-only by construction, zero cost when unused. - Bun-native everything, and none of it the user’s concern.
Bun.spawnfor child rusage capture,bun:sqlite,Bun.YAML,Bun.Glob,Bun.hash.xxHash3,Bun.zstdCompress, nativeawait import()with a content-hash query string for config cache-busting. The binary embeds the runtime: no native-binary build step on install, no Node or Bun on the machine. - One-binary distribution.
bun build --compileproduces a single self-contained executable per platform target, published as per-platform npm packages behind a tiny launcher (the esbuild model) and as GitHub release assets — no Bun or Node needed at runtime. - Wallclock-ns analytics out of the box. Every task records
hrtime spans relative to the run’s t=0;
cache.db’srunstable is queryable withsqlite3directly;--profileexports Chrome-trace JSON without any additional setup. - Persistent tasks with regex-readiness.
readyWhen: 'Local:'for a dev server is a one-liner; downstream tasks unblock on ready, not on exit. Turbo’spersistentis more elaborate (separateinterruptible/interactive/withflavors); vx’s surface is smaller. - Explicit
cache + persistentrejection. The project loader throws — no silent surprise.
Deliberate divergences from Turbo / Nx
Section titled “Deliberate divergences from Turbo / Nx”These are places where Turbo or Nx pin a specific behavior in their
test suites and vx deliberately does something else. Listed here so
the choices don’t drift accidentally — if any of these change, the
rationale below needs revisiting. Sourced from the full gap analysis
in design/turbo-nx-test-gaps.md.
Hashing pipeline
Section titled “Hashing pipeline”- Git blob OIDs for input files, like Turbo (v20). Tracked clean
files contribute their index OID (harvested from the same bulk
git ls-files -sspawn that enumerates files — zero reads on a clean tree); dirty/untracked files fall back to an in-process blob-OID computation that is byte-identical for identical content. The KEY composition on top of those per-file OIDs is xxHash3 (16-hex), not SHA — Turbo composes with xxh64; the widths match. - No
.gitattributesCRLF normalization in the fallback. Turbo’s manual-hash fallback replicates git’s CRLF conversion so it matchesgit hash-objectundertext=auto/autocrlf. vx’s in-process fallback hashes raw bytes (blob <len>\0+ content), and a file under a clean filter (text/eol/ident/filter/autocrlf) drops its index OID for that worktree hash, so the key follows the bytes the task reads (dropFilteredOids,cache/git-inputs.ts). - No
.gitattributesbinary detection. Same root: raw bytes; no text-vs-binary distinction needed at hash time.
Task graph
Section titled “Task graph”forwardArgsdoes NOT inherit intodependsOndeps. Nx forwards args/options into dependents viaoptions: 'forward'. vx scopesforwardArgsto user-requested nodes only — passingvx run build -- --foodoes NOT pollute upstream tasks’ cache keys. Explicit > magical (architecture principle #1).- No tag-based selectors (
tag:foo,!tag:bar). Nx has project tags as a generator/devkit concept. vx project identity is workspace path + package.json name only.
Filter DSL
Section titled “Filter DSL”- Stacked
--filter name --filter [ref]is UNION, not intersection. Turbo’s discussion #9096 argues for intersection (“only packages that are both affected AND match the name”). vx unions (tests/filter.test.ts > applyFilters > stacked: —filter ui —filter [main] unions name + affected sets). Mental model: each filter ADDS to the selection; never narrows another filter’s set. - A bare name is an exact match; it never reaches into a scope.
Nx resolves
coreto@acme/core; vx’s--filter coreselects only a package named exactlycore. Name the scope (@acme/core) or lead with*(*core) (tests/filter.test.ts > applyFilters > scoped names).
Affected detection
Section titled “Affected detection”- Project removal does NOT invalidate every project’s cache. Nx
invalidates everything when a project is removed. vx already folds
each project’s
package.jsonbytes into every task’s cache key (PR #42, CACHE_VERSION → v12), which catches “project gone” at finer granularity — only tasks that actually consumed the gone project’s bytes are busted. git diff --no-renamesfor affected. vx flips rename detection OFF so cross-projectgit mvflags BOTH source and destination projects. Turbo’s default rename-on would surface only the destination, silently missing the source’s affected status.
Cache storage
Section titled “Cache storage”- stderr is not cached; stdout is stored twice on purpose. Turbo
embeds the run’s full logs inside the cache archive. vx’s artifact
is
stdout,outputs/,workspace-outputs/and a.vx-meta.jsonmode/mtime sidecar (only successful runs are cached and their stderr is near-always empty); stdout ALSO lives in the SQLiteentriesrow so a local hit replays it with pure SQL — never decompressing the artifact. - Per-run SCM metadata, not per-entry. Turbo writes the git sha +
dirty-hash into each cache entry’s metadata. vx records git
commit/branch/dirty on the per-run
invocationsrow instead, and the per-componententry_inputsrows answer “what inputs produced this artifact” at finer granularity.
Remote cache
Section titled “Remote cache”- No remote wire in core. Turbo ships its HTTP cache client
(bearer token, refresh on 403, preflight, timeout) inside the
binary; vx core ships the
cacheseam and nothing else. The wire, its auth and its deadlines belong to the plugin —@vzn/vx-reapispeaks Bazel’s ActionCache + CAS over gRPC with a per-call deadline on every cache-path RPC, and a remote error always degrades to a MISS rather than failing the run.
Glob walking
Section titled “Glob walking”- A symlink is a link, not its target. Turbo distinguishes
shallow-wildcard vs doublestar follow-link behavior explicitly. vx
scans with
followSymlinks: falseand folds a link’s target STRING, as git does (cache/inputs.ts); since Bun 1.4.0 a scan can still descend a linked directory, so an output path whose directory resolves outside the project is refused before any delete. The symlink-cycle test (tests/inputs.test.ts) pins that the resolver doesn’t hang. - A bracket is literal; no character classes (item 667). Turbo and
Nx read
[id]in a glob as a class, soapp/[id]/**matchesapp/i/…and never the route directory. Bracket route directories are everywhere in JS monorepos (Next.js, SvelteKit, Astro), and a class there silently keys nothing — a stale hit — while an output clean deletes the class’s namesakes. vx reads[and]literally in every task glob; the escaped\[id\]Turbo users write means the same path. Member globs and--filterpath globs keep the package manager’s grammar.
Engine / scheduling
Section titled “Engine / scheduling”- No executor batching. Nx batches same-executor tasks into a single child process. vx’s executor is a PLACEMENT seam — it decides where one command runs, never merges commands — so every task is its own process. Tradeoff: more spawn overhead; far simpler model.
- No incremental watcher state. Turbo’s watcher maintains rich
incremental change-accumulator + rediscover state. vx re-runs the
orchestrator from scratch on each cycle. Cheap because of
gitFilesCache+Cache.hashFilemtime+size fast path; complexity not yet justified.
Config schema
Section titled “Config schema”- No
$WORKSPACE_ROOT$/$TURBO_ROOT$token substitution. Turbo + Nx use template tokens in path strings; vx uses real paths from the project-dir context. The path resolution context is unambiguous because every glob is scoped per-project.
Env handling
Section titled “Env handling”- No
.envauto-loading. Nx auto-loads.envfiles. vx requires explicitcache.inputs.envdeclarations. “Explicit over magical” (architecture principle #1). - No wildcards in
cache.inputs.env. Turbo supportsVERCEL_*expansion. vx rejects wildcards at load time so a typo doesn’t silently contribute an empty value to the cache key (pinned by tests/project-loader.test.ts > rejects wildcards in cache.inputs.env).
Concurrency model
Section titled “Concurrency model”- Single-event-loop JS, no shared mutex. Turbo uses
RwLock<TaskHashTracker>and tests concurrent reads + read/write. vx is single-threaded JS by construction — no shared mutable state across “threads” to race over.