Skip to content
GitHubRSS

Config lock — vx lock / vx-lock.json (2026-06)

Status: shipped.

vx configs are programs (vx.config.ts is evaluated, not parsed). That is the source of vx’s “resolved-config hashing” power — imports and computed values participate in cache keys — but it has two costs:

  1. Eval time. Evaluating ~1000 configs costs ~200 ms, the dominant fixed cost of small runs (scoped loading already trims this to the dep closure; the lock removes it entirely for locked projects).
  2. Eval-time nondeterminism. A config that reads process.env.X resolves to different objects in different environments. File hashes are blind to this: the bytes on disk never changed.

vx lock makes the resolved configs explicit, reviewable state: a single JSON file freezing “what this workspace’s tasks ARE”.

A resolved-config eval cache (transparently cache pure-literal configs on content hash) was designed and REJECTED in June 2026: its static purity gate was correctness-critical heuristic machinery. The lock is the sound dependency story that rejection asked for:

  • Explicit, user-invoked. Nothing is cached behind the user’s back; vx lock is a deliberate act, like pnpm install writing a lockfile. No purity heuristics — the user asserts “this resolution is the truth” and owns when to refresh it.
  • Hash-pinned + hard-fail. A changed config file is a loud error, never a silent stale replay.
  • Auditable. vx lock --check re-derives the truth from scratch and diffs it.

vx-lock.json at the workspace root:

{
"version": 1,
"projects": {
"<package-name>": {
"configPath": "packages/app/vx.config.ts",
"configHash": "<xxh3 hex of file bytes>",
"config": { "tasks": { ... } }
}
}
}
  • config is the resolved (post-evaluation) ProjectConfig, JSON-normalized (a JSON.stringify round-trip drops undefined fields, so the stored form equals what a later read produces).
  • Entries are sorted by project name (stable diffs).
  • Only project configs are covered. vx.workspace.ts is not locked: it holds concurrency / cacheDir only, neither of which participates in cache keys or task semantics.

Implementation: src/workspace/lockfile.ts (format, read/write, run-time verification), src/cli/lock.ts (subcommand), one hook in src/orchestrator/prepare.ts (run-time load path).

Discovers the workspace, freshly evaluates every config-bearing project’s config in the current environment (a per-invocation cache bust bypasses Bun’s module cache, which would otherwise replay an evaluation made under earlier env values in the same process), and writes the lock. Exit 0.

Runs under --frozen (vx run, vx watch) — TRUST

Section titled “Runs under --frozen (vx run, vx watch) — TRUST”

As revised on 2026-06-13 (the two sections at the end; item 973 brought this one in line with them): a plain run always evaluates live, and only --frozen reads the lock. Under it, prepareRun loads each in-scope project’s config from the lock, with no staleness check of any kind. No evaluation happens — frozen-env semantics: a config that read process.env.X at lock time keeps the locked value no matter what X is at run time, and a config edited since keeps its locked form until vx lock.

Hard failures (UserError, exit 1):

conditionoutcome
no vx-lock.json--frozen refuses and names vx lock
project has a config but no lock entry (or moved)vx-lock.json has no entry for "<project>"

There is deliberately no silent fallback to evaluation: the lock’s contract is “what runs is what was locked”. Falling back would reintroduce exactly the env-dependence the user locked against. The frozen config is still shape-validated on load — vx-lock.json is a hand-editable file, i.e. a system boundary.

Cache-key interaction: keys hash the resolved config object (principle “resolved-config hashing”), so frozen configs simply pin the hashed object. Key derivation is untouched — no CACHE_VERSION bump.

--check is the only verification there is. Per config-bearing project:

  1. The file hash check (file bytes vs configHash, which only --check reads); a mismatch reports config file changed since lock (<project>).

  2. Full re-evaluation in the current environment (fresh, module cache bypassed), JSON-normalized, then Bun.deepEquals(fresh, stored, /* strict */ true). A mismatch reports:

    lock differs from fresh evaluation in this environment (<project>) —
    a file the config imports changed, or the config reads the environment; run 'vx lock' here

Plus set-level drift: projects missing from the lock, and locked projects that no longer exist in the workspace. Any failure → every mismatched project is listed on stderr, exit 1. Clean → exit 0.

Runs trust the lock; --check audits it.

  • A --frozen run verifies nothing — fast and eval-free. That is the entire point of the lock on the hot path: zero config evaluation per run, and frozen-env semantics — the run’s behavior cannot drift with the environment because the environment is never consulted. Re-evaluating on every run to “verify” would (a) pay the eval cost the lock exists to remove, and (b) be self-defeating: in an environment where evaluation resolves differently, the frozen value is the intended one, not an error.
  • --check re-evaluates and deep-compares. It answers a different question — not only “is the lock internally consistent with the files?” (its hash step answers that) but “would locking here, now produce the same truth?” Only evaluation can answer it, because eval-time env-var drift leaves file bytes — and therefore every hash — unchanged.

Intended workflow: run vx lock --check where environments are supposed to agree (CI gate, post-checkout hook). A failure means the lock was produced under assumptions this environment violates — either re-lock here on purpose, or remove the env read from the config (move it to exec.env / cache.inputs.env, which are run-time surfaces the cache key tracks properly).

  • vx watch --frozen: a config edited mid-watch starts a cycle that runs the locked config, unchanged, until vx lock; the loop hears the re-lock and runs the new one (item 971).
  • The lock does not cover vx.workspace.ts (see Format).
  • --check’s re-evaluation runs config code; like any vx invocation it assumes configs are trusted code in the repo.

tests/lock.test.ts (e2e, real CLI subprocesses — env drift is cross-invocation by nature):

  1. The mandated drift scenario: config reads process.env.X; lock under X=a; --check under X=a → 0; --check under X=b → 1 naming the project; vx run under X=b → 0 with the frozen flavor-a output.
  2. Changed file: edit config after lock → --check exits 1 (file-changed), a live vx run uses the edit, vx run --frozen keeps the freeze until a re-lock.
  3. --check with no lock present → exit 1, points at vx lock.

Why isn’t package.json in the lock? Configs are frozen because they’re programs — evaluation can differ per machine (env, imports). Manifests are committed data: local and CI parse the same bytes from git, so they’re reproducible without freezing, and edits flow live (dep edges reshape the graph immediately; the whole-file byte hash already shifts every cache key via the v12 implicit- dependency rule). Freezing them would mean relocking on every version bump for zero reproducibility gain.

Do we need to parse exports / files like Nx? No. Nx parses them to infer dependencies from source imports and compute production file sets; vx’s graph is declared, not inferred. Those fields still participate in cache identity byte-wise through the manifest hash — changing them invalidates correctly without vx understanding them.

Semantics revision (2026-06-13, owner decision)

Section titled “Semantics revision (2026-06-13, owner decision)”

Byte-hashing a config file cannot see its IMPORT CLOSURE: editing a shared preset changes the resolved config while the config file’s bytes — and therefore the staleness tripwire — stay unchanged. Consuming the lock on every local run therefore gave false confidence. Revised contract:

  • vx run — ALWAYS live evaluation. Local truth has no asterisks; costs the eval time (~120 ms / 1000 pkgs).
  • vx run --frozen — CI mode: configs from the lock (a hash tripwire was kept here for direct config edits, then dropped by the revision below; import-closure/env drift is —check’s job). Errors if no lock exists.
  • vx lock / vx lock --check — unchanged: the only full-graph operations. pnpm-style auto-relock on plain runs was considered and rejected: scoped runs evaluate only a dep closure and cannot correctly rewrite a whole-workspace lock.

Revision (owner, 2026-06-13): --frozen performs no staleness checks at all — in the canonical pipeline vx lock --check runs first and re-evaluates everything, so a per-file byte-hash re-check inside the run is redundant work wearing a safety costume. configHash remains in the lock purely for --check’s fast “file changed” reporting.