@vzn/vx 0.0.636 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -0,0 +1,57 @@
1
+ import type { DependencyRule } from '../config.js'
2
+ import { UserError } from '../util/index.js'
3
+ import { compileNameGlob } from './filter.js'
4
+ import type { PackageGraph } from './package-graph.js'
5
+
6
+ /**
7
+ * `run.rules.dependencies`, held to each project's direct workspace
8
+ * dependencies (`PackageGraph.directDeps`: the manifest's and the
9
+ * cross-project `dependsOn` edges). A rule constrains the projects its
10
+ * `from` selects: each dependency must match an `allow` selector when
11
+ * `allow` is given, and no `deny` one. Every rule that selects a project
12
+ * applies, so a deny beats any allow. Checked by `vx lock` only (owner,
13
+ * 2026-10-13); every broken edge is named in one refusal.
14
+ */
15
+ export function checkDependencyRules(
16
+ rules: readonly DependencyRule[],
17
+ projects: readonly string[],
18
+ tags: ReadonlyMap<string, readonly string[]>,
19
+ graph: PackageGraph,
20
+ ): void {
21
+ if (rules.length === 0) return
22
+ const compiled = rules.map((r) => ({
23
+ from: selector(r.from),
24
+ allow: r.allow?.map(selector),
25
+ deny: r.deny?.map(selector),
26
+ }))
27
+ const broken: string[] = []
28
+ for (const name of projects) {
29
+ const own = tags.get(name) ?? []
30
+ for (const [i, rule] of compiled.entries()) {
31
+ if (!rule.from(name, own)) continue
32
+ for (const dep of graph.directDeps(name)) {
33
+ const theirs = tags.get(dep) ?? []
34
+ const denied = rule.deny?.some((s) => s(dep, theirs)) === true
35
+ if (denied || (rule.allow !== undefined && !rule.allow.some((s) => s(dep, theirs))))
36
+ broken.push(
37
+ `${name} depends on ${dep}, which run.rules.dependencies[${i}] (from '${rules[i]!.from}') ${denied ? 'denies' : 'does not allow'}`,
38
+ )
39
+ }
40
+ }
41
+ }
42
+ if (broken.length === 0) return
43
+ throw new UserError(
44
+ `${broken.length === 1 ? '1 dependency breaks' : `${broken.length} dependencies break`} run.rules.dependencies:\n${broken.map((b) => ` ${b}`).join('\n')}\nDrop the dependency, or change the project's tags or the rule.`,
45
+ 'VX_E_DEPENDENCY_RULE',
46
+ )
47
+ }
48
+
49
+ /** `tag:<glob>` matches a project carrying a matching tag; anything else, its name. */
50
+ function selector(s: string): (name: string, tags: readonly string[]) => boolean {
51
+ if (s.startsWith('tag:')) {
52
+ const re = compileNameGlob(s.slice(4))
53
+ return (_, tags) => tags.some((t) => re.test(t))
54
+ }
55
+ const re = compileNameGlob(s)
56
+ return (name) => re.test(name)
57
+ }
@@ -131,8 +131,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
131
131
  if (s === '') throw new UserError(`filter "${raw}" names no project`)
132
132
 
133
133
  // `//` is Turbo's name for the root package (`--filter=//`, `//...`,
134
- // `!//`, probed on 2.8.17). vx names the root project by its
135
- // package.json name, so `//` is its directory, and only a project
134
+ // `!//`, probed on 2.8.17), and vx's for the root project unless
135
+ // something names it, so `//` is its directory, and only a project
136
136
  // there: read as `.`, it would select every project when the root is
137
137
  // none.
138
138
  if (s === '//') {
@@ -1,129 +1,23 @@
1
1
  import path from 'node:path'
2
- import { xxh3 } from '../util/index.js'
3
2
  import { type LoadReads, readOnce } from './load-reads.js'
4
3
 
5
- // Every package-manager lockfile we know about, plus the workspace
6
- // definition files. Whichever ones exist get folded into the
7
- // fingerprint; missing ones are skipped. Hashed in declaration order
8
- // for determinism — adding a new entry here changes the fingerprint
9
- // for projects that have that file present, which is fine: that's the
10
- // whole point of bumping CACHE_VERSION when the fingerprint surface
11
- // expands.
12
- // Exported so `--affected` can consult the SAME list. A change to any of
13
- // these re-keys every task in the workspace, so selection has to widen to
14
- // match — two copies of this list would silently drift into a run that
15
- // rebuilds everything while selecting nothing.
16
- export const WORKSPACE_FINGERPRINT_FILES = [
17
- // Lockfiles
18
- 'pnpm-lock.yaml',
19
- 'package-lock.json',
20
- 'npm-shrinkwrap.json',
21
- 'yarn.lock',
22
- 'bun.lock',
23
- 'bun.lockb',
24
- // Workspace definition (pnpm); package.json is hashed per-project
25
- // via projectPackageJsonHash, not here.
26
- 'pnpm-workspace.yaml',
27
- // Yarn 4 catalogs: yarn.lock records a `catalog:` dependency as that
28
- // literal, so a catalog flip to a range the file already resolves
29
- // leaves it byte-identical while the workspace's install moves
30
- // (turborepo#12635). pnpm's catalogs live in pnpm-workspace.yaml, and
31
- // its lockfile records each importer's resolved version besides.
32
- '.yarnrc.yml',
33
- // Install settings no lockfile records: bun's `linker = "isolated"` and
34
- // pnpm's `node-linker` / hoisting lay `node_modules` out anew under a
35
- // byte-identical lockfile, and a build that imported an undeclared
36
- // hoisted package kept its green hit where it now fails (D-7).
37
- '.npmrc',
38
- 'bunfig.toml',
39
- ]
40
-
41
- // DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs,cts,cjs}`. Everything it can
42
- // declare — `concurrency`, `cacheDir`, `timeout`, and the
43
- // plugin list — is placement, storage or observability, never what a
44
- // command produces; core's three seams may change WHERE a task runs, not
45
- // WHAT it runs (architecture principle #3). Folding it in would also do
46
- // active harm: a laptop declaring only the local plugins and a CI runner
47
- // declaring `reapi()` would compute different fingerprints and share not
48
- // one cache entry — the same split the rejected `NODE_OPTIONS` non-goal
49
- // describes. A per-task input that genuinely varies belongs in
50
- // `cache.inputs`, which is the surface built for it.
51
-
52
- /**
53
- * One hash for the workspace as a whole, derived from whichever
54
- * lockfile + workspace-definition files exist at the root. Folded
55
- * into every task's cache key so any lockfile bump (`pnpm update`,
56
- * `npm install`, `bun install`, …) or a workspace-shape change
57
- * invalidates every cached entry. Coarse but correct.
58
- */
59
- export async function computeWorkspaceFingerprint(
60
- workspaceRoot: string,
61
- reads?: LoadReads,
62
- ): Promise<string> {
63
- return (await computeWorkspaceFingerprints(workspaceRoot, NONE, reads)).all
64
- }
65
-
66
- const NONE: ReadonlySet<string> = new Set()
67
-
68
- export interface WorkspaceFingerprints {
69
- /** Every file folded — what the config-evaluation cache keys on. */
70
- readonly all: string
71
- /**
72
- * The same fold minus the files a plugin claims (`VxPlugin.fingerprint`)
73
- * — what every task key folds. Identical to `all` with nothing claimed.
74
- */
75
- readonly unclaimed: string
76
- /**
77
- * The bytes folded, by file name — what a mid-run check compares against
78
- * when a task may have rewritten one (orchestrator/fingerprint-watch.ts).
79
- * Already in memory for the fold; kept, not copied.
80
- */
81
- readonly files: ReadonlyMap<string, Uint8Array>
82
- }
83
-
84
4
  /**
85
- * Both digests from one read of each file — none at all for the
86
- * `pnpm-workspace.yaml` discovery already read into the load's `reads`. A
87
- * claimed file leaves the key digest entirely (its name too): the claimant
88
- * folds what it means per project, and a fold of "present" would still
89
- * re-key the workspace when the file appeared.
5
+ * The bytes of each named root file that exists: the files the plugins
6
+ * claim (`VxPlugin.fingerprint`), read once at run start so a task that
7
+ * rewrites one is caught (orchestrator/fingerprint-watch.ts). Core folds
8
+ * none of them into a key; the claimant does.
90
9
  */
91
- export async function computeWorkspaceFingerprints(
10
+ export async function readClaimedFiles(
92
11
  workspaceRoot: string,
93
- claimed: ReadonlySet<string>,
12
+ names: Iterable<string>,
94
13
  reads?: LoadReads,
95
- ): Promise<WorkspaceFingerprints> {
96
- let all = 0n
97
- let unclaimed = 0n
14
+ ): Promise<ReadonlyMap<string, Uint8Array>> {
98
15
  const files = new Map<string, Uint8Array>()
99
- for (const f of WORKSPACE_FINGERPRINT_FILES) {
16
+ for (const f of names) {
100
17
  const bytes = await readOnce(reads, path.join(workspaceRoot, f))
101
- if (bytes === null) continue
102
- files.set(f, bytes)
103
- all = xxh3(`${f}\0`, all)
104
- all = xxh3(bytes, all)
105
- if (claimed.has(f)) continue
106
- unclaimed = xxh3(`${f}\0`, unclaimed)
107
- unclaimed = xxh3(bytes, unclaimed)
18
+ if (bytes !== null) files.set(f, bytes)
108
19
  }
109
- // bun.lock names a patch by PATH, never by its content: an edited patch
110
- // left the lockfile byte-identical and every key unmoved while the
111
- // install applied the new one (item 1014, where only `bun()`'s claim
112
- // learned it). Each file it names folds here; a claimed bun.lock's
113
- // plugin folds them itself.
114
- const bunLock = files.get('bun.lock')
115
- if (bunLock !== undefined) {
116
- for (const rel of bunPatchFiles(bunLock)) {
117
- const bytes = await readOnce(reads, path.join(workspaceRoot, rel))
118
- const mark = `patch\0${rel}\0${bytes === null ? 'gone' : 'present'}`
119
- all = xxh3(mark, all)
120
- if (bytes !== null) all = xxh3(bytes, all)
121
- if (claimed.has('bun.lock')) continue
122
- unclaimed = xxh3(mark, unclaimed)
123
- if (bytes !== null) unclaimed = xxh3(bytes, unclaimed)
124
- }
125
- }
126
- return { all: hex(all), unclaimed: hex(unclaimed), files }
20
+ return files
127
21
  }
128
22
 
129
23
  const PATCHED = new TextEncoder().encode('"patchedDependencies"')
@@ -132,7 +26,7 @@ const PATCHED = new TextEncoder().encode('"patchedDependencies"')
132
26
  * The workspace-relative patch files a `bun.lock` names, sorted; none
133
27
  * outside the workspace. A lockfile without the key is not parsed (the
134
28
  * common case costs one byte search), and one that will not parse names
135
- * none: its bytes still fold whole.
29
+ * none.
136
30
  */
137
31
  export function bunPatchFiles(bytes: Uint8Array): string[] {
138
32
  if (Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).indexOf(PATCHED) < 0) return []
@@ -153,7 +47,3 @@ export function bunPatchFiles(bytes: Uint8Array): string[] {
153
47
  }
154
48
  return [...out].sort()
155
49
  }
156
-
157
- function hex(h: bigint): string {
158
- return h.toString(16).padStart(16, '0')
159
- }
@@ -10,24 +10,23 @@ export {
10
10
  refIsHead,
11
11
  } from './affected.js'
12
12
  export { applyFilters, parseFilter } from './filter.js'
13
- export {
14
- computeWorkspaceFingerprint,
15
- computeWorkspaceFingerprints,
16
- WORKSPACE_FINGERPRINT_FILES,
17
- type WorkspaceFingerprints,
18
- } from './fingerprint.js'
13
+ export { readClaimedFiles } from './fingerprint.js'
14
+ export { checkDependencyRules } from './dependency-rules.js'
19
15
  export { computeNestedProjectDirs } from './nested-dirs.js'
20
16
  export { buildPackageGraph, type PackageGraph } from './package-graph.js'
21
17
  export {
22
18
  frozenProjectConfigs,
23
19
  LOCKFILE_NAME,
24
20
  LOCKFILE_VERSION,
21
+ LOCK_INVALID,
22
+ lockEntryBroken,
25
23
  lockfilePath,
26
- FROZEN_WITHOUT_LOCK,
24
+ lockKey,
25
+ NO_LOCK,
27
26
  readLockfile,
28
27
  writeLockfile,
29
28
  type Lockfile,
30
- type LockfileEntry,
29
+ type LockedProject,
31
30
  } from './lockfile.js'
32
31
  export {
33
32
  loadProjectConfig,
@@ -40,47 +39,41 @@ export {
40
39
  cacheScopeError,
41
40
  inputStabilityEnv,
42
41
  isCacheScope,
42
+ taskNameProblem,
43
43
  validateProjectConfig,
44
44
  validateWorkspace,
45
45
  } from './config-schema.js'
46
- export type { LoadProjectConfigOptions } from './project-loader.js'
47
- export {
48
- blobOidOf,
49
- configEvalKey,
50
- configEvalKeyFromClosure,
51
- configImports,
52
- type ConfigEvalStore,
53
- } from './config-cache.js'
46
+ export { configImports } from './config-imports.js'
54
47
  export {
48
+ checkProjectNames,
55
49
  findWorkspaceRoot,
56
- discoverProjects,
57
- listProjects,
58
- namedProject,
59
- loadWorkspace,
60
- unreachedHint,
61
- unreachedPackages,
62
- memberBaseDirs,
50
+ findProjectConfigs,
51
+ discoveredProject,
52
+ linkDiscoveredEdges,
53
+ lockedProjects,
54
+ pathName,
63
55
  PROJECT_CONFIG_FILENAMES,
64
56
  resolveCacheDir,
65
57
  resolveStoreRoot,
58
+ ROOT_NAME,
59
+ type ConfigDir,
66
60
  type ProjectEntry,
67
61
  type ProjectMeta,
68
- type Workspace,
69
62
  } from './workspace.js'
70
63
  export type { LoadReads } from './load-reads.js'
71
- // The migration seam (see migration.ts) and core's own mapper, the scripts one.
64
+ // The migration seam (see migration.ts).
72
65
  export {
73
66
  applyMigration,
74
67
  foldScriptHooks,
75
- PERSISTENT_TASK_NAMES,
76
68
  PERSISTENT_TODO,
77
69
  pruneOrphanPersistentNotes,
78
70
  quoteTsLiteral,
79
71
  type ApplyMigrationArgs,
80
72
  type MigrationFormat,
73
+ type MigrationMember,
81
74
  type GeneratedProject,
82
75
  type GeneratedTask,
83
76
  type MigrationPlan,
84
77
  type RawExpr,
78
+ vxInvocation,
85
79
  } from './migration.js'
86
- export { delegatedScript, migrateScripts } from './migrate-scripts.js'
@@ -1,6 +1,6 @@
1
1
  // A load reads each root file once. Finding the root reads its manifest,
2
- // listing the package globs read it again and the workspace fingerprint a
3
- // third time. Every such read goes through a `LoadReads` the load owns, so
2
+ // listing the package globs read it again, and the claimed root files are
3
+ // read for the run (`readClaimedFiles`). Every such read goes through a `LoadReads` the load owns, so
4
4
  // a file costs one probe and one read per load however many stages ask for
5
5
  // it. The map lives exactly as long as the load: a `vx watch` cycle is a
6
6
  // new load and reads fresh bytes.
@@ -14,8 +14,8 @@ export type LoadReads = Map<string, Promise<Uint8Array | null>>
14
14
  * `file`'s bytes, read at most once per `reads`; null when there is no file
15
15
  * there (`Bun.file(<dir>).exists()` is false, so a directory is none).
16
16
  *
17
- * The probe stays ahead of the read: most names asked about are absent (six
18
- * of the seven fingerprint files, three of the four workspace config names),
17
+ * The probe stays ahead of the read: most names asked about are absent
18
+ * (three of the four workspace config names, a manager's settings files),
19
19
  * and a failed read costs 90–200 µs building its error where `exists()`
20
20
  * answers in 15–40 µs (measured 2026-09-24) — so one stat per present file
21
21
  * is cheaper than letting the open answer both questions.
@@ -1,41 +1,43 @@
1
- // vx-lock.json — frozen resolved-config lockfile.
1
+ // .vx/projects.lock.json — frozen resolved-config lockfile.
2
2
  //
3
- // `vx lock` evaluates every project's vx.config.* in the CURRENT
4
- // environment and freezes the resolved objects (plus a content hash of
5
- // each config file) into `vx-lock.json` at the workspace root. A run
6
- // under `--frozen` loads configs from it instead of evaluating —
7
- // frozen-env semantics; a plain run always evaluates live. See
8
- // docs/design/config-lock-2026-06.md.
3
+ // `vx lock` finds every project's vx.config.*, evaluates each in the
4
+ // CURRENT environment and freezes the resolved objects, by directory, into
5
+ // `.vx/projects.lock.json` at the workspace root. Every run loads configs from
6
+ // it and never evaluates one (owner, 2026-10-14); only `vx lock` writes
7
+ // it. See docs/design/config-lock-2026-06.md.
9
8
  //
10
9
  // Verification asymmetry (deliberate):
11
- // - `--frozen` runs TRUST the lock: no staleness check, eval-free.
12
- // - `vx lock --check` AUDITS it: the file hashes, then full
13
- // re-evaluation + deep equality, which catches eval-time env drift
14
- // that file hashes cannot see.
10
+ // - runs TRUST the lock: no staleness check, no schema validation,
11
+ // eval-free.
12
+ // - `vx lock --check` AUDITS it: full re-evaluation + deep equality.
15
13
 
14
+ import { readFileSync } from 'node:fs'
16
15
  import { rename, rm } from 'node:fs/promises'
17
16
  import path from 'node:path'
18
17
  import type { ProjectConfig } from '../config.js'
19
18
  import { relPosix, UserError, xxh3 } from '../util/index.js'
20
19
  import { VERSION } from '../version.js'
21
- import { CONFIG_EVAL_VERSION, type ConfigEvalStore } from './config-cache.js'
22
20
  import { validateProjectConfig } from './config-schema.js'
21
+ import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
23
22
 
24
- export const LOCKFILE_NAME = 'vx-lock.json'
25
- export const LOCKFILE_VERSION = 1
23
+ export const LOCKFILE_NAME = '.vx/projects.lock.json'
24
+ export const LOCKFILE_VERSION = 3
26
25
 
27
- export interface LockfileEntry {
28
- /** Workspace-root-relative POSIX path to the project's config file. */
29
- configPath: string
30
- /** xxh3 hex of the config file bytes at lock time. */
31
- configHash: string
32
- /** The resolved (post-evaluation) config object, JSON-normalized. */
26
+ /** One project as the lock holds it. */
27
+ export interface LockedProject {
28
+ /** The config file's name in the directory (`vx.config.ts`). */
29
+ file: string
30
+ /** Its resolved (post-evaluation) config, JSON-normalized. */
33
31
  config: ProjectConfig
34
32
  }
35
33
 
36
34
  export interface Lockfile {
37
35
  version: number
38
- projects: Record<string, LockfileEntry>
36
+ /**
37
+ * Root-relative POSIX directory (`.` for the root) → its project. Keyed
38
+ * by directory, so a name a plugin gives never invalidates the lock.
39
+ */
40
+ projects: Record<string, LockedProject>
39
41
  /** Written by `writeLockfile` from the bytes; never read off the object. */
40
42
  validated?: string
41
43
  }
@@ -44,12 +46,38 @@ export function lockfilePath(root: string): string {
44
46
  return path.join(root, LOCKFILE_NAME)
45
47
  }
46
48
 
47
- /** What a `--frozen` verb says when there is no lock to read. */
48
- export const FROZEN_WITHOUT_LOCK =
49
- "--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
49
+ /** What a run says when a crash came from a hand-broken lock entry (M, 2026-10-14). */
50
+ export const LOCK_INVALID = `${LOCKFILE_NAME} is invalid, run \`vx lock\``
50
51
 
51
52
  /**
52
- * Read and shape-validate `vx-lock.json`. Returns `null` when no lock
53
+ * Whether the lock holds an entry `vx lock` could not have written. Runs
54
+ * validate no entry, so a hand-broken one crashed deep in a run with a
55
+ * raw TypeError; asked only on that crash's path, so a run pays nothing.
56
+ */
57
+ export function lockEntryBroken(root: string): boolean {
58
+ let projects: unknown
59
+ try {
60
+ projects = (JSON.parse(readFileSync(lockfilePath(root), 'utf8')) as { projects?: unknown })
61
+ .projects
62
+ } catch {
63
+ return false
64
+ }
65
+ if (typeof projects !== 'object' || projects === null) return false
66
+ for (const [dir, entry] of Object.entries(projects)) {
67
+ try {
68
+ validateProjectConfig((entry as LockedProject).config, `${LOCKFILE_NAME}: "${dir}"`, null)
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+ return false
74
+ }
75
+
76
+ /** What a run says when there is no lock to read. */
77
+ export const NO_LOCK = 'no lock: run `vx lock`'
78
+
79
+ /**
80
+ * Read and shape-validate `.vx/projects.lock.json`. Returns `null` when no lock
53
81
  * exists (the common case — the feature is opt-in). The lock is a
54
82
  * user-editable file, so this is a system boundary: malformed content
55
83
  * throws a `UserError` instead of crashing deeper in the run.
@@ -84,68 +112,57 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
84
112
  }
85
113
  // Same trap, and this one is worse than a bad message: an ARRAY of entries is
86
114
  // ACCEPTED, because `Object.entries` happily walks it — yielding a project
87
- // literally named "0". `--frozen` then looks up real project names against a
88
- // lock that declares none of them.
115
+ // at directory "0".
89
116
  if (typeof lock.projects !== 'object' || lock.projects === null || Array.isArray(lock.projects)) {
90
117
  throw new UserError(`${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``)
91
118
  }
92
- for (const [name, entry] of Object.entries(lock.projects)) {
93
- const e = entry as Partial<LockfileEntry> | null
119
+ for (const [dir, entry] of Object.entries(lock.projects)) {
120
+ const { file, config } = (entry ?? {}) as { file?: unknown; config?: unknown }
94
121
  if (
95
- !e ||
96
- typeof e.configPath !== 'string' ||
97
- typeof e.configHash !== 'string' ||
98
- typeof e.config !== 'object' ||
99
- e.config === null
122
+ typeof entry !== 'object' ||
123
+ entry === null ||
124
+ Array.isArray(entry) ||
125
+ typeof file !== 'string' ||
126
+ !PROJECT_CONFIG_FILENAMES.includes(file) ||
127
+ typeof config !== 'object' ||
128
+ config === null ||
129
+ Array.isArray(config)
100
130
  ) {
101
- throw new UserError(`${LOCKFILE_NAME}: entry for "${name}" is malformed — re-run \`vx lock\``)
131
+ throw new UserError(`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``)
102
132
  }
103
133
  }
104
- lockDigests.set(parsed, xxh3(text).toString(16))
105
- const stamped = VALIDATED_LINE.exec(text)
106
- if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
107
- selfValidated.add(parsed)
108
- }
109
134
  return parsed as Lockfile
110
135
  }
111
136
 
112
- /** The digest of the bytes each lock `readLockfile` returned was parsed from. */
113
- const lockDigests = new WeakMap<object, string>()
114
-
115
137
  /**
116
- * The lock's own record that this vx validated its entries: `vx lock`
117
- * evaluates and validates every config, then writes a digest of the bytes
118
- * it wrote, under vx's version (validation's rules are vx's alone, so Bun's
119
- * is left out and does not churn a committed lock), as the last field. A lock
120
- * whose digest matches is validated already, and a cold `--frozen` run
121
- * skips the ~30 ms of validating 1,090 entries before its first task. An
122
- * edit, a merge, or another vx version leaves it unmatched, and the
123
- * entries are validated as before.
138
+ * `vx lock` evaluates and validates every config, then writes a digest of
139
+ * the bytes it wrote, under vx's version, as the last field: the lock's
140
+ * record of which vx validated it. No reader consults it.
124
141
  */
125
- const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
126
- const selfValidated = new WeakSet<object>()
127
-
128
142
  function validatedDigest(body: string): string {
129
- return xxh3(`vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${body}`).toString(16)
143
+ return xxh3(`vx-lock-valid-v3\0${VERSION}\0${body}`).toString(16)
130
144
  }
131
145
 
132
146
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
133
147
  // Written beside its name and renamed over it: `Bun.write` truncates in
134
- // place, so a `--frozen` run, or `vx watch --frozen` woken by the write
148
+ // place, so a run, or `vx watch` woken by the write
135
149
  // itself (item 971), could read an empty or half-written lock and fail
136
150
  // on "not valid JSON" (item 972).
137
151
  const file = lockfilePath(root)
138
152
  const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
139
153
  try {
140
- const body = JSON.stringify({ version: lock.version, projects: lock.projects }, null, 2)
141
- await Bun.write(tmp, `${body.slice(0, -2)},\n "validated": "${validatedDigest(body)}"\n}\n`)
154
+ // Minified: the file only vx reads, and it loads faster than pretty
155
+ // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
156
+ // beside each config is the copy for people.
157
+ const body = JSON.stringify({ version: lock.version, projects: lock.projects })
158
+ await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
142
159
  await rename(tmp, file)
143
160
  } catch (err) {
144
161
  // Best effort: where the write was refused the temp cannot be named
145
162
  // either (ENOTDIR), and that must not replace the refusal itself.
146
163
  await rm(tmp, { force: true }).catch(() => {})
147
164
  // The refusal names the file the user knows: a read-only checkout read
148
- // `open '…/vx-lock.json.tmp-…'` where it had read the lock's own name.
165
+ // `open '…/.vx/projects.lock.json.tmp-…'` where it had read the lock's own name.
149
166
  const e = err as NodeJS.ErrnoException
150
167
  if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
151
168
  if (e.path === tmp) e.path = file
@@ -155,93 +172,32 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
155
172
 
156
173
  /**
157
174
  * Run-time config load from the lock: return the FROZEN resolved config
158
- * for each project, in order. No evaluation happens — this is the fast,
159
- * eval-free trust path — and no staleness check either (see below): the
160
- * lock's contract is "what runs is what was locked", so a config file
161
- * edited since `vx lock` still runs as locked until `vx lock --check`
162
- * says otherwise. An unlocked project is a hard error; silently falling
163
- * back to evaluation would break frozen-env semantics.
164
- *
165
- * `store` remembers which entries of which lock bytes this validator
166
- * accepted, beside the config evaluations and keyed like them (vx's and
167
- * Bun's versions): validating 1,000 entries was ~30 ms of every warm
168
- * `--frozen` run, for a verdict that cannot change until the bytes or vx
169
- * do.
175
+ * for each project, in order. No evaluation happens, and no staleness
176
+ * check or schema validation either: the lock's contract is "what runs is
177
+ * what was locked", `vx lock` validated every entry it wrote, and
178
+ * `vx lock --check` re-evaluates them all. An unlocked project is a hard
179
+ * error; a run never evaluates.
170
180
  */
171
- export async function frozenProjectConfigs(
181
+ export function frozenProjectConfigs(
172
182
  lock: Lockfile,
173
- metas: ReadonlyArray<{ name: string; configPath: string }>,
183
+ metas: ReadonlyArray<{ name: string; dir: string; configPath: string }>,
174
184
  root: string,
175
- store?: ConfigEvalStore,
176
- ): Promise<ProjectConfig[]> {
177
- const entries = metas.map((meta) => {
178
- const rel = relPosix(root, meta.configPath)
185
+ ): ProjectConfig[] {
186
+ return metas.map((meta) => {
187
+ const dir = lockKey(root, meta.dir)
179
188
  // Own entries only: `constructor` read Object's, "locked" at undefined (D-152).
180
- const entry = Object.hasOwn(lock.projects, meta.name) ? lock.projects[meta.name] : undefined
181
- if (entry !== undefined && entry.configPath !== rel) {
182
- // A renamed or moved config: "has no entry for a" named an entry the
183
- // lock holds, under its old path (D-88).
184
- throw new UserError(
185
- `${LOCKFILE_NAME} locks "${meta.name}" at ${entry.configPath}, but its config is ${rel} now — ` +
186
- `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
187
- )
188
- }
189
- if (!entry) {
189
+ const entry = Object.hasOwn(lock.projects, dir) ? lock.projects[dir] : undefined
190
+ if (entry === undefined) {
190
191
  throw new UserError(
191
- `${LOCKFILE_NAME} has no entry for "${meta.name}" (${rel}) — ` +
192
- `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
192
+ `${LOCKFILE_NAME} has no entry for "${meta.name}" (${relPosix(root, meta.configPath)}) — ` +
193
+ 'run `vx lock`',
193
194
  )
194
195
  }
195
- return entry
196
+ return entry.config
196
197
  })
197
- // No staleness checks here, deliberately: --frozen runs after
198
- // `vx lock --check` in any sane pipeline, and that audit re-
199
- // evaluates everything — a per-file byte-hash re-check would be
200
- // redundant work and a weaker guarantee pretending to add safety
201
- // (it can't see import closures or env anyway). configHash stays
202
- // in the file solely for --check's fast file-changed reporting.
203
- // The lock is hand-editable; the stored config crosses the same
204
- // boundary a freshly evaluated one does.
205
- const digest = store === undefined ? undefined : lockDigests.get(lock)
206
- const prefix =
207
- digest === undefined
208
- ? undefined
209
- : `vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${digest}`
210
- // One row for a lock whose every entry was accepted: a warm `--all` run
211
- // asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
212
- // config load, where the eval-cache path it replaces is cheaper (X-181).
213
- // A scoped run still validates, and remembers, only what it loads.
214
- if (
215
- selfValidated.has(lock) ||
216
- (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
217
- ) {
218
- return entries.map((e) => e.config)
219
- }
220
- const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
221
- const known =
222
- keys === undefined || store === undefined
223
- ? new Map<string, string>()
224
- : store.getConfigEvals !== undefined
225
- ? store.getConfigEvals(keys)
226
- : new Map(keys.flatMap((k) => (store.getConfigEval(k) === null ? [] : [[k, '1'] as const])))
227
- const accepted: Array<readonly [string, string]> = []
228
- for (let i = 0; i < entries.length; i++) {
229
- const key = keys?.[i]
230
- if (key !== undefined && known.has(key)) continue
231
- validateProjectConfig(entries[i]!.config, `${LOCKFILE_NAME} (${metas[i]!.name})`)
232
- if (key !== undefined) accepted.push([key, '1'])
233
- }
234
- // Every meta found its entry above, so as many distinct names as the
235
- // lock holds is every entry of it.
236
- if (
237
- prefix !== undefined &&
238
- new Set(metas.map((m) => m.name)).size === Object.keys(lock.projects).length
239
- ) {
240
- accepted.push([prefix, '1'])
241
- }
242
- if (store !== undefined && accepted.length > 0) {
243
- if (store.putConfigEvals !== undefined) store.putConfigEvals(accepted)
244
- else for (const [k, v] of accepted) store.putConfigEval(k, v)
245
- }
246
- return entries.map((e) => e.config)
198
+ }
199
+
200
+ /** A project directory as the lock keys it: root-relative POSIX, `.` for the root. */
201
+ export function lockKey(root: string, dir: string): string {
202
+ return relPosix(root, dir) || '.'
247
203
  }