Skip to content
vxvx
GitHubBlueskydev.toRSS

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. What vx shares with them is in patterns.md.

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

  • 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 it has deprecated its own daemon for turbo run (2.8.11) and --parallel (by 2.9.18); 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), affected semantics, 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 from package.json scripts. 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 one extension (resolved-config hash captures TS imports); package files key a task only through a plugin (@vzn/vx-lockfile). 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 → discover → project → graph → key → fingerprint → measures → 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.

turbo run / nx run-many / vp run / vx run:

Capability Turbo Nx vite-task vx
pnpm-style filter DSL --filter --projects, --exclude --filter --filter
select by project tag — -p tag:<name> — --filter tag:<name> (config tags; -p tag: aliases it)
recursive (every project) implicit implicit (no -p); --all deprecated -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-history cpus reservation above the worker count runs alone; or --concurrency 1
forward args -- --args="..." trailing args --
skip cache reads+writes --cache-policy=local:,remote: (--force skips reads, --no-cache writes) --skipNxCache, --skipRemoteCache --no-cache --no-cache (all off) / --force (reads off, writes on) / --cache-policy=<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 — — (--dry=json lists each task’s deps)
continue past failure --continue=never|dependencies-successful|always --nxBail — --continue[=never|deps-ok|always] (deps-ok default)
per-run JSON summary --summarize, --json — --last-details replay vx last --format json
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.8.11; ignored) (Nx daemon, always on) — (no daemon)
retries / timeouts — — — --retries <n>, --timeout <duration> (also per task in config)
remote placement / outputs — (remote cache only) Nx Cloud agents — --cache-download=all|toplevel|none, exec.remote (executor plugin, e.g. @vzn/vx-reapi)
run report --summarize — — vx last replays any recorded run; @vzn/vx-ci writes the job summary
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 --parallel exists because users often over-declare dependsOn and want an escape hatch. In vx, dependsOn is opt-in and explicit — if you wrote dependsOn: ['^build'] you meant it. The legitimate “I want to fan out without waiting” cases are already covered by (a) not declaring dependsOn in the first place, and (b) --exclude-dependencies, which keeps dependencies from running, entirely or selectively.

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 (run, cache, plugins)
Per-task dependsOn: same project bare name lint bare name bare name 'lint'
Per-task dependsOn: workspace deps ^lint ^lint ({projects:"dependencies"} deprecated) 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} inputs.files: string[]
Auto-input inference — — yes (fspy, see §3) — out of scope (see §3)
Root-anchored inputs/outputs $TURBO_ROOT$/… {workspaceRoot}/… (none) inputs/outputs.workspaceFiles
Runtime-command inputs (tool versions, probes) — (vercel/turborepo#4124) runtime input (none) inputs.runtime / workspaceRuntime
Frozen / locked resolved configs — — — vx lock (every run reads it)
Migration generator from other runners — — — bunx @vzn/vx-migrate (Turbo, Nx → 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 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} outputs.files: string[]
Output cleaning before exec / restore (no — additive) (no — additive) (via materialized artifacts) yes — strict
Implicit-dependency hash (project package.json) yes — package.json is a default input externalDependencies (via lockfile) via @vzn/vx-lockfile — an input of every cached task
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, recorded by vx lock and held to the lockfile’s 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, exec.interactive
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 yes — the package manager runs them (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 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 a store every checkout of the repo shares (~/.vx/<id>/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 vx cache clean --older-than / --max-size; no run evicts
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 vx last --format json
Flaky task detection — Nx Cloud (paid) — local — same key failed after it passed: the task’s row, 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
Capability Turbo Nx vite-task vx
pnpm / npm / yarn / bun workspaces yes yes yes yes (@vzn/vx-lockfile: pnpm, npm, yarn, bun)
Non-JS projects (Rust, .NET, Gradle, …) experimental (native Go, Cargo, uv) yes (plugins) no yes (any directory with a vx.config; edges declared)
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.8.11 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, --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 starts, temporarily, 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 inputs.files: ['.env*', '.env.local', '.env.production.local', '.env.development.local']: .env* keys the tracked files, and each gitignored *.local one is named outright, since a glob skips a gitignored file (vx lock names one it skips). 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 Removed 2026-10-18: a run never evicts (housekeeping at every run end was a cost); vx cache clean --older-than / --max-size from a schedule or CI bounds the cache.
Graph UI, TUI, dashboards both Rejected for core; --dry=json 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 (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.

Ranked by leverage. Cited file paths are inside the respective upstream repos.

  1. Remote cache is PLUGIN-DRIVEN (owner directive 2026-07-10). Core carries zero HTTP cache code: it keeps the seams (LayeredCache, the RemoteCacheLayer interface, the cache plugin capability, RunOptions.remoteCache), and a plugin ships the wire — @vzn/vx-reapi speaks Bazel’s ActionCache + CAS, so NativeLink, BuildBuddy, Buildbarn and bazel-remote all work. Turbo /v8/artifacts compatibility was DROPPED from core and came back as a plugin: turboCache() in @vzn/vx-migrate speaks the Turbo wire (self-hosted or Vercel) and nxCache() the Nx self-hosted one, both against the same cache seam — the recipe for any other wire lives in the plugins guide.

  2. --continue=<mode> — shipped. --continue[=never|deps-ok|always] controls failure propagation: never fail-fast (stop dispatch on the first failure), deps-ok (default) skip only a failure’s dependents while independent siblings continue, always run everything. Bare --continue = always. Enforced in the scheduler; see cli.md § Failure propagation.

  3. 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 (inputs.tasks), and 'pkg#pattern' is rejected. See schema.md § dependsOn.

    • Nx 19.5+ build-* parity.
  4. 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-migrate Turbo path already emits a generated vx-preset.ts for exactly this. Not a gap; will not be added.

    • Turbo globalEnv, globalPassThroughEnv.
  5. --cache-dir <path> CLI flag — shipped. Overrides the defineWorkspace({ cache: { dir } }) field + the .vx/cache default, resolved relative to cwd. A per-run knob; never folded into a cache key.

  6. 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_unotify kernel 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 by crates/fspy*.

Maybe-worth-adding (heavier lift, narrower payoff)

Section titled “Maybe-worth-adding (heavier lift, narrower payoff)”
  1. --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, schema outputLogs.
  2. Configurations (named option sets per target). build:prod vs build:dev as 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.
  3. Pre/post script lifecycle — shipped in vx init (2026-09-03). pre<x> / post<x> scripts fold into x’s command in npm order (tasks of their own where the manager runs no hooks) 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 over other. Not applied at run time: a task’s command is exactly what its config says.

    • vite-task: enablePrePostScripts (default true).
  4. vx prune — shipped (2026-08-25), @vzn/vx-prune from 2026-09-10, removed 2026-09-11, back 2026-10-03 as a verb of @vzn/vx-lockfile, removed 2026-10-14. Workspace subset for Docker builds: targets + transitive workspace deps, workspace lists rewritten, root manifests and vx configs, each lockfile (bun, pnpm, npm, yarn) PRUNED to what the subset installs so it installs frozen, --docker json/full split for layer caching. The 2026-09 verb copied the lockfile unpruned.

    • Turbo: turbo prune.
  5. Cache TTL / size caps in config — removed 2026-10-18. cache.retention (item 658) evicted at the end of every run; a run now never evicts, and vx cache clean --older-than / --max-size is the one cleaner.

    • Turbo: cacheMaxAge, cacheMaxSize (verified in the 2.10 configuration reference).
    • Nx: maxCacheSize.
  6. 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, --list for recent run ids, --format json for scripting. Read-only over metrics.ts.

  • vx watch <task> — debounced re-run loop.
  • --output-logs full|errors-only|hash-only|none.
  • vx info.
  • 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-reapi re-hashes every CAS blob against its digest, and turboCache() verifies Turbo’s HMAC tag when given a signatureKey.
  • OTel run telemetry — the otel() plugin in @vzn/vx-otel (declare it in .vx/workspace.ts + set OTEL_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 (runs read only the lock), vx init / @vzn/vx-migrate, vx show.
  • The plugin pipeline (2026-09-02). One VxPlugin hooks every stage — config, discover, project, graph, key, fingerprint, measures, schedule, admit — beside the executor / cache / telemetry / output capabilities, commands (new verbs) and the setup / teardown pair. Core applies no plugin by default; a workspace declares all of them.
  • vx init (scripts → configs), vx why, vx last, --cache-download, remote execution through @vzn/vx-reapi, @vzn/vx-ci (job summary), the vx-history plugin (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.

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 every config read from the lock); 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 executor capability 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 last replays 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. A Rust / .NET / Gradle project is a directory with a vx.config whose tasks call its own tool; vx ships no per-language plugin.
  • Native Windows. vx spawns POSIX shell. Binaries are built for linux/darwin × x64/arm64, and Windows runs the Linux one under WSL2; there is no native Windows build, by decision (2026-10-09).

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, and vx why shows 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 --summarize and 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.json as a plugin’s input. Core keys no package file (2026-10-14); @vzn/vx-lockfile adds each project’s package.json to its cached tasks’ inputs, so narrow inputs.files like ['src/**'] doesn’t miss dep / version-bump invalidation.
  • Lockfile-aware invalidation as a plugin. @vzn/vx-lockfile claims the lockfile (pnpm, bun, npm, yarn) and keys each project on its own resolved closure (name, version, peers, integrity, patches, link: reach), so pnpm update foo re-keys only the projects that reach foo and --affected selects their tasks (and the tasks behind them). Nx does this inside its daemon’s project graph; Turbo hashes the lockfile changes that affect each package. vx lock records the digests and a run keys from that record, holding the lockfile to its hash, so no run parses for its key: a warm run pays a read and a hash.
  • Strict output ownership. Declared outputs.files are 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 (just dependsOn) 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. inputs.runtime / workspaceRuntime fold a probe command’s live output into the key (tool versions, OS info) — Nx has runtime; 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. Configs are programs; the lock freezes the resolved objects and every run reads only it, with a full re-evaluation audit (vx lock --check). No analog in Turbo/Nx.
  • bunx @vzn/vx-migrate. One command generates per-package vx.config.ts from Turbo or Nx, 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 (@vzn/vx-ci), or a custom sink — observe-only by construction, zero cost when unused.
  • Bun-native everything, and none of it the user’s concern. Bun.spawn for child rusage capture, bun:sqlite, Bun.YAML, Bun.Glob, Bun.hash.xxHash3, Bun.zstdCompress, native await 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 --compile produces 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’s runs table is queryable with sqlite3 directly; --profile exports 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’s persistent is more elaborate (separate interruptible / with flavors); vx’s surface is smaller. exec.interactive is Turbo’s interactive, outside its TUI: on a TTY the task gets the terminal and runs alone.
  • Explicit cache + persistent rejection. The project loader throws — no silent surprise.

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.

  • Git blob OIDs for input files, like Turbo (v20). Tracked clean files contribute their index OID (harvested from the same bulk git ls-files -s spawn 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 .gitattributes CRLF normalization in the fallback. Turbo’s manual-hash fallback replicates git’s CRLF conversion so it matches git hash-object under text=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 .gitattributes binary detection. Same root: raw bytes; no text-vs-binary distinction needed at hash time.
  • forwardArgs does NOT inherit into dependsOn deps. Nx forwards args/options into dependents via options: 'forward'. vx scopes forwardArgs to user-requested nodes only — passing vx run build -- --foo does NOT pollute upstream tasks’ cache keys. Explicit > magical (architecture principle #1).
  • 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 core to @acme/core; vx’s --filter core selects only a package named exactly core. Name the scope (@acme/core) or lead with * (*core) (tests/filter.test.ts > applyFilters > scoped names).
  • Project removal does NOT invalidate every project’s cache. Nx invalidates everything when a project is removed. A dependent’s key folds its upstreams’ keys and, under @vzn/vx-lockfile, its own package.json, which catches “project gone” at finer granularity — only tasks that actually consumed the gone project are busted.
  • git diff --no-renames for affected. vx flips rename detection OFF so cross-project git mv flags BOTH source and destination projects. Turbo’s default rename-on would surface only the destination, silently missing the source’s affected status.
  • 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/, a .vx-meta.json mode/mtime sidecar and a .vx-sum checksum (only successful runs are cached and their stderr is near-always empty); stdout ALSO lives in the SQLite entries row 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 invocations row instead, and the per-component entry_inputs rows answer “what inputs produced this artifact” at finer granularity.
  • 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 cache seam and nothing else. The wire, its auth and its deadlines belong to the plugin — @vzn/vx-reapi speaks 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.
  • A symlink is a link, not its target. Turbo distinguishes shallow-wildcard vs doublestar follow-link behavior explicitly. vx scans with followSymlinks: false and 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.
  • Standard globs. As in Turbo and Nx, [id] in a task glob is a character class and \[id\] names a route directory, so a turbo.json or Nx input glob means the same files under turbo() and nx().
  • 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.hashFile mtime+size fast path; complexity not yet justified.
  • 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.
  • No .env auto-loading. Nx auto-loads .env files. vx requires explicit inputs.env declarations. “Explicit over magical” (architecture principle #1).
  • No wildcards in inputs.env. Turbo supports VERCEL_* 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 inputs.env).
  • 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.
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.