@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -6,6 +6,7 @@
6
6
  // <pattern>... pattern + its transitive workspace dependencies
7
7
  // ...<pattern> pattern + its transitive workspace dependents
8
8
  // <pattern>^... only the transitive deps of pattern (excluding the matched package)
9
+ // ...^<pattern> only the transitive dependents of pattern (excluding the matched package)
9
10
  // !<pattern> exclude packages matching pattern from the selection
10
11
  // [<since>] projects affected since the given git ref
11
12
  // (Turbo-style; resolved upstream of applyFilters via
@@ -16,6 +17,7 @@
16
17
  // given, the base set is "all projects" and excluded packages are removed.
17
18
 
18
19
  import path from 'node:path'
20
+ import { BUN_GLOB_WILDCARDS } from '../util/index.js'
19
21
  import type { PackageGraph } from './package-graph.js'
20
22
  import type { ProjectMeta } from './workspace.js'
21
23
 
@@ -25,6 +27,8 @@ export interface ParsedFilter {
25
27
  withDeps: boolean
26
28
  withDependents: boolean
27
29
  onlyDeps: boolean
30
+ /** `...^<pattern>`: the dependents alone, the matched package left out. */
31
+ onlyDependents: boolean
28
32
  isPath: boolean
29
33
  /** Glob pattern (for name match) or absolute path (for path match). */
30
34
  matcher: string
@@ -34,6 +38,10 @@ export interface ParsedFilter {
34
38
  * to a concrete set of project names before calling applyFilters.
35
39
  */
36
40
  gitSince?: string
41
+ /** A path form carrying a glob (`./packages/*`): matched over the root-relative project dir. */
42
+ pathGlob?: Bun.Glob
43
+ /** The workspace root `pathGlob` is relative to. */
44
+ pathRoot?: string
37
45
  }
38
46
 
39
47
  export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
@@ -43,6 +51,10 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
43
51
 
44
52
  const withDependents = s.startsWith('...')
45
53
  if (withDependents) s = s.slice(3)
54
+ // `...^a`: cli.md listed it, and the `^` stayed in the name glob, so it
55
+ // matched no package and the run refused (item 890).
56
+ const onlyDependents = withDependents && s.startsWith('^')
57
+ if (onlyDependents) s = s.slice(1)
46
58
 
47
59
  let onlyDeps = false
48
60
  let withDeps = false
@@ -63,6 +75,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
63
75
  withDeps,
64
76
  withDependents,
65
77
  onlyDeps,
78
+ onlyDependents,
66
79
  isPath: false,
67
80
  matcher: '',
68
81
  gitSince: s.slice(1, -1),
@@ -71,15 +84,37 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
71
84
 
72
85
  let isPath = false
73
86
  let matcher = s
74
- if (s.startsWith('./') || s === '.') {
87
+ let pathGlob: Bun.Glob | undefined
88
+ const pathForm =
89
+ s.startsWith('./') || s === '.'
90
+ ? s
91
+ : s.startsWith('{') && s.endsWith('}')
92
+ ? s.slice(1, -1)
93
+ : undefined
94
+ if (pathForm !== undefined) {
75
95
  isPath = true
76
- matcher = path.resolve(workspaceRoot, s)
77
- } else if (s.startsWith('{') && s.endsWith('}')) {
78
- isPath = true
79
- matcher = path.resolve(workspaceRoot, s.slice(1, -1))
96
+ matcher = path.resolve(workspaceRoot, pathForm)
97
+ // `./packages/*` — pnpm's and Turbo's spelling for "every package under
98
+ // packages": a glob over the root-relative project dir. Resolved
99
+ // against the workspace root like the literal form.
100
+ // A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
101
+ if (BUN_GLOB_WILDCARDS.test(pathForm)) {
102
+ const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
103
+ pathGlob = new Bun.Glob(rel.replace(/\/+$/, ''))
104
+ }
80
105
  }
81
106
 
82
- return { raw, negate, withDeps, withDependents, onlyDeps, isPath, matcher }
107
+ return {
108
+ raw,
109
+ negate,
110
+ withDeps,
111
+ withDependents,
112
+ onlyDeps,
113
+ onlyDependents,
114
+ isPath,
115
+ matcher,
116
+ ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
117
+ }
83
118
  }
84
119
 
85
120
  function matchProjects(
@@ -99,14 +134,28 @@ function matchProjects(
99
134
  for (const p of projects) {
100
135
  if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
101
136
  }
102
- return out
103
- }
104
- if (!filter.matcher.includes('*')) {
105
- for (const p of projects) {
106
- if (p.name === filter.matcher) out.push(p.name)
137
+ // A path that names a project directory literally means that directory,
138
+ // as git reads a pathspec, even when it holds glob characters
139
+ // (`./packages/[abc]`); only a path that selects nothing literally is
140
+ // read as a glob.
141
+ if (out.length > 0) return out
142
+ if (filter.pathGlob !== undefined) {
143
+ // The glob is matched against the project's own dir, as pnpm and
144
+ // Turbo do: `./packages/*` is the packages directly under `packages`,
145
+ // `./packages/**` reaches the nested ones too.
146
+ for (const p of projects) {
147
+ const rel = path
148
+ .relative(filter.pathRoot ?? '', p.dir)
149
+ .split(path.sep)
150
+ .join('/')
151
+ if (filter.pathGlob.match(rel)) out.push(p.name)
152
+ }
107
153
  }
108
154
  return out
109
155
  }
156
+ // A `*`-free name compiles to an exact anchored match, so a bare name
157
+ // needs no branch of its own: the one this had was deleted with the
158
+ // whole core suite green (item 649).
110
159
  const re = compileNameGlob(filter.matcher)
111
160
  for (const p of projects) {
112
161
  if (re.test(p.name)) out.push(p.name)
@@ -149,12 +198,17 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
149
198
  const hasInclude = opts.filters.some((f) => !f.negate)
150
199
  const selected = new Set<string>(hasInclude ? [] : allNames)
151
200
 
201
+ // Every include before any exclude, as pnpm does: applied in argv order,
202
+ // `--filter '!b' --filter 'a...'` let the later include add back what the
203
+ // exclude had removed, and b ran (item 979). The expansions are still
204
+ // taken in argv order, so `onNoMatch` names the filters as typed.
205
+ const excludes: Set<string>[] = []
152
206
  for (const f of opts.filters) {
153
207
  const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
154
208
  if (matched.length === 0) opts.onNoMatch?.(f)
155
209
  const expanded = new Set<string>()
156
210
  for (const name of matched) {
157
- if (!f.onlyDeps) expanded.add(name)
211
+ if (!f.onlyDeps && !f.onlyDependents) expanded.add(name)
158
212
  if (f.withDeps || f.onlyDeps) {
159
213
  for (const d of opts.graph.transitiveDeps(name)) expanded.add(d)
160
214
  }
@@ -162,12 +216,10 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
162
216
  for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
163
217
  }
164
218
  }
165
- if (f.negate) {
166
- for (const name of expanded) selected.delete(name)
167
- } else {
168
- for (const name of expanded) selected.add(name)
169
- }
219
+ if (f.negate) excludes.push(expanded)
220
+ else for (const name of expanded) selected.add(name)
170
221
  }
222
+ for (const expanded of excludes) for (const name of expanded) selected.delete(name)
171
223
 
172
224
  return selected
173
225
  }
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { xxh3 } from '../util/index.js'
3
+ import { type LoadReads, readOnce } from './load-reads.js'
3
4
 
4
5
  // Every package-manager lockfile we know about, plus the workspace
5
6
  // definition files. Whichever ones exist get folded into the
@@ -23,6 +24,12 @@ export const WORKSPACE_FINGERPRINT_FILES = [
23
24
  // Workspace definition (pnpm); package.json is hashed per-project
24
25
  // via projectPackageJsonHash, not here.
25
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',
26
33
  ]
27
34
 
28
35
  // DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
@@ -43,14 +50,59 @@ export const WORKSPACE_FINGERPRINT_FILES = [
43
50
  * `npm install`, `bun install`, …) or a workspace-shape change
44
51
  * invalidates every cached entry. Coarse but correct.
45
52
  */
46
- export async function computeWorkspaceFingerprint(workspaceRoot: string): Promise<string> {
47
- let h = 0n
53
+ export async function computeWorkspaceFingerprint(
54
+ workspaceRoot: string,
55
+ reads?: LoadReads,
56
+ ): Promise<string> {
57
+ return (await computeWorkspaceFingerprints(workspaceRoot, NONE, reads)).all
58
+ }
59
+
60
+ const NONE: ReadonlySet<string> = new Set()
61
+
62
+ export interface WorkspaceFingerprints {
63
+ /** Every file folded — what the config-evaluation cache keys on. */
64
+ readonly all: string
65
+ /**
66
+ * The same fold minus the files a plugin claims (`VxPlugin.fingerprint`)
67
+ * — what every task key folds. Identical to `all` with nothing claimed.
68
+ */
69
+ readonly unclaimed: string
70
+ /**
71
+ * The bytes folded, by file name — what a mid-run check compares against
72
+ * when a task may have rewritten one (orchestrator/fingerprint-watch.ts).
73
+ * Already in memory for the fold; kept, not copied.
74
+ */
75
+ readonly files: ReadonlyMap<string, Uint8Array>
76
+ }
77
+
78
+ /**
79
+ * Both digests from one read of each file — none at all for the
80
+ * `pnpm-workspace.yaml` discovery already read into the load's `reads`. A
81
+ * claimed file leaves the key digest entirely (its name too): the claimant
82
+ * folds what it means per project, and a fold of "present" would still
83
+ * re-key the workspace when the file appeared.
84
+ */
85
+ export async function computeWorkspaceFingerprints(
86
+ workspaceRoot: string,
87
+ claimed: ReadonlySet<string>,
88
+ reads?: LoadReads,
89
+ ): Promise<WorkspaceFingerprints> {
90
+ let all = 0n
91
+ let unclaimed = 0n
92
+ const files = new Map<string, Uint8Array>()
48
93
  for (const f of WORKSPACE_FINGERPRINT_FILES) {
49
- const full = path.join(workspaceRoot, f)
50
- const file = Bun.file(full)
51
- if (!(await file.exists())) continue
52
- h = xxh3(`${f}\0`, h)
53
- h = xxh3(await file.bytes(), h)
94
+ const bytes = await readOnce(reads, path.join(workspaceRoot, f))
95
+ if (bytes === null) continue
96
+ files.set(f, bytes)
97
+ all = xxh3(`${f}\0`, all)
98
+ all = xxh3(bytes, all)
99
+ if (claimed.has(f)) continue
100
+ unclaimed = xxh3(`${f}\0`, unclaimed)
101
+ unclaimed = xxh3(bytes, unclaimed)
54
102
  }
103
+ return { all: hex(all), unclaimed: hex(unclaimed), files }
104
+ }
105
+
106
+ function hex(h: bigint): string {
55
107
  return h.toString(16).padStart(16, '0')
56
108
  }
@@ -1,9 +1,20 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { affectedProjects, defaultAffectedBase, workspaceGlobsMatch } from './affected.js'
4
+ export {
5
+ affectedProjects,
6
+ defaultAffectedBase,
7
+ type FingerprintClaims,
8
+ refIsHead,
9
+ workspaceGlobsMatch,
10
+ } from './affected.js'
5
11
  export { applyFilters, parseFilter } from './filter.js'
6
- export { computeWorkspaceFingerprint, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
12
+ export {
13
+ computeWorkspaceFingerprint,
14
+ computeWorkspaceFingerprints,
15
+ WORKSPACE_FINGERPRINT_FILES,
16
+ type WorkspaceFingerprints,
17
+ } from './fingerprint.js'
7
18
  export { computeNestedProjectDirs } from './nested-dirs.js'
8
19
  export { buildPackageGraph, type PackageGraph } from './package-graph.js'
9
20
  export {
@@ -11,6 +22,7 @@ export {
11
22
  LOCKFILE_NAME,
12
23
  LOCKFILE_VERSION,
13
24
  lockfilePath,
25
+ FROZEN_WITHOUT_LOCK,
14
26
  readLockfile,
15
27
  writeLockfile,
16
28
  type Lockfile,
@@ -20,20 +32,42 @@ export {
20
32
  loadProjectConfig,
21
33
  loadProjectConfigs,
22
34
  loadWorkspaceConfig,
23
- validateProjectConfig,
35
+ WORKSPACE_CONFIG_FILENAMES,
24
36
  } from './project-loader.js'
37
+ export { validateProjectConfig } from './config-schema.js'
25
38
  export type { LoadProjectConfigOptions } from './project-loader.js'
26
39
  export {
27
40
  blobOidOf,
28
41
  configEvalKey,
29
42
  configEvalKeyFromClosure,
43
+ configImports,
30
44
  type ConfigEvalStore,
31
45
  } from './config-cache.js'
32
46
  export {
33
47
  findWorkspaceRoot,
34
48
  listProjects,
35
49
  loadWorkspace,
50
+ unreachedHint,
51
+ unreachedPackages,
52
+ memberBaseDirs,
53
+ PROJECT_CONFIG_FILENAMES,
36
54
  resolveCacheDir,
37
55
  type ProjectEntry,
38
56
  type ProjectMeta,
39
57
  } from './workspace.js'
58
+ export type { LoadReads } from './load-reads.js'
59
+ // The migration seam (see migration.ts) and core's own mapper, the scripts one.
60
+ export {
61
+ applyMigration,
62
+ foldScriptHooks,
63
+ PERSISTENT_TASK_NAMES,
64
+ PERSISTENT_TODO,
65
+ quoteTsLiteral,
66
+ type ApplyMigrationArgs,
67
+ type MigrationFormat,
68
+ type GeneratedProject,
69
+ type GeneratedTask,
70
+ type MigrationPlan,
71
+ type RawExpr,
72
+ } from './migration.js'
73
+ export { delegatedScript, migrateScripts } from './migrate-scripts.js'
@@ -0,0 +1,90 @@
1
+ // A project config is JSON data (item 701).
2
+ //
3
+ // The cache key folds `JSON.stringify` of a task's config, `vx lock`
4
+ // stores the same JSON, and a repeat load (`vx watch`) crosses back from
5
+ // its worker as JSON. A value JSON cannot carry faithfully was therefore
6
+ // refused by one path and dropped by another — `description: () => 'x'`
7
+ // failed `vx run`'s first load ("must be a string") and passed the
8
+ // worker's, which never saw it — or, where the validator does not type
9
+ // the value, it was silently outside the key on both. So every path
10
+ // refuses it, before schema validation, by this one rule: the first load
11
+ // on the live object (`validateProjectConfig`), the worker before its
12
+ // `JSON.stringify` (config-eval.ts), and the site's playground in its own
13
+ // Worker. The two workers embed `nonJsonPaths` by its source text, so
14
+ // the function must stay self-contained: no import, no module-scope name.
15
+ //
16
+ // An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
17
+ // it as absent, and configs write it with conditional spreads. Inside an
18
+ // array it is refused, because JSON writes it as `null`.
19
+
20
+ /** A value JSON cannot carry, at the path the validator would name. */
21
+ export interface NonJsonValue {
22
+ path: string
23
+ /** What it is, as the message reads: `a function`, `NaN`, `an instance of Map`. */
24
+ is: string
25
+ }
26
+
27
+ /** Every value in `value` that `JSON.stringify` would drop, rewrite or throw on. */
28
+ export function nonJsonPaths(value: unknown): NonJsonValue[] {
29
+ const out: NonJsonValue[] = []
30
+ // The key or index at each level, rendered only for a finding: the walk
31
+ // runs on every first load, and a path string built per node cost three
32
+ // times the schema check itself.
33
+ const keys: Array<string | number> = []
34
+ // The objects on the path to the one being walked: a cycle throws in
35
+ // `JSON.stringify` and would recurse here forever. A DAG is data.
36
+ const ancestors: object[] = []
37
+ const found = (is: string): void => {
38
+ let path = ''
39
+ for (const k of keys) path += typeof k === 'number' ? `[${k}]` : path === '' ? k : `.${k}`
40
+ out.push({ path: path === '' ? 'the default export' : path, is })
41
+ }
42
+ const walk = (v: unknown, inArray: boolean): void => {
43
+ if (typeof v === 'function') found('a function')
44
+ else if (typeof v === 'symbol') found('a symbol')
45
+ else if (typeof v === 'bigint') found('a bigint')
46
+ else if (typeof v === 'number' && !Number.isFinite(v)) found(String(v))
47
+ else if (v === undefined && inArray) found('undefined in an array, which JSON writes as null')
48
+ else if (typeof v === 'object' && v !== null) {
49
+ if (ancestors.includes(v)) {
50
+ found('a cyclic reference')
51
+ return
52
+ }
53
+ if (Array.isArray(v)) {
54
+ ancestors.push(v)
55
+ // By index, not `forEach`: a hole is `undefined` to JSON too.
56
+ for (let i = 0; i < v.length; i++) {
57
+ keys.push(i)
58
+ walk(v[i], true)
59
+ keys.pop()
60
+ }
61
+ ancestors.pop()
62
+ return
63
+ }
64
+ const proto: unknown = Object.getPrototypeOf(v)
65
+ if (proto !== Object.prototype && proto !== null) {
66
+ const name = (proto as { constructor?: { name?: unknown } }).constructor?.name
67
+ found(
68
+ typeof name === 'string' && name !== ''
69
+ ? `an instance of ${name}`
70
+ : 'an object whose prototype is not Object.prototype',
71
+ )
72
+ return
73
+ }
74
+ ancestors.push(v)
75
+ for (const key of Object.keys(v)) {
76
+ keys.push(key)
77
+ walk((v as Record<string, unknown>)[key], false)
78
+ keys.pop()
79
+ }
80
+ ancestors.pop()
81
+ }
82
+ }
83
+ walk(value, false)
84
+ return out
85
+ }
86
+
87
+ /** The refusal every path gives, the CLI's and the playground's. */
88
+ export function nonJsonMessage(configPath: string, found: NonJsonValue): string {
89
+ return `${configPath}: ${found.path} is ${found.is} — a config must be JSON data, because the cache key folds its JSON`
90
+ }
@@ -0,0 +1,32 @@
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
4
+ // a file costs one probe and one read per load however many stages ask for
5
+ // it. The map lives exactly as long as the load: a `vx watch` cycle is a
6
+ // new load and reads fresh bytes.
7
+
8
+ /** The files one load has read, by absolute path: the bytes, or null when no file is there. */
9
+ export type LoadReads = Map<string, Promise<Uint8Array | null>>
10
+
11
+ /**
12
+ * `file`'s bytes, read at most once per `reads`; null when there is no file
13
+ * there (`Bun.file(<dir>).exists()` is false, so a directory is none).
14
+ *
15
+ * The probe stays ahead of the read: most names asked about are absent (six
16
+ * of the seven fingerprint files, three of the four workspace config names),
17
+ * and a failed read costs 90–200 µs building its error where `exists()`
18
+ * answers in 15–40 µs (measured 2026-09-24) — so one stat per present file
19
+ * is cheaper than letting the open answer both questions.
20
+ */
21
+ export function readOnce(reads: LoadReads | undefined, file: string): Promise<Uint8Array | null> {
22
+ const held = reads?.get(file)
23
+ if (held !== undefined) return held
24
+ const pending = readIfFile(file)
25
+ reads?.set(file, pending)
26
+ return pending
27
+ }
28
+
29
+ async function readIfFile(file: string): Promise<Uint8Array | null> {
30
+ const f = Bun.file(file)
31
+ return (await f.exists()) ? await f.bytes() : null
32
+ }
@@ -2,19 +2,22 @@
2
2
  //
3
3
  // `vx lock` evaluates every project's vx.config.* in the CURRENT
4
4
  // environment and freezes the resolved objects (plus a content hash of
5
- // each config file) into `vx-lock.json` at the workspace root. While the
6
- // lock exists, runs load configs from it instead of evaluating —
7
- // frozen-env semantics. See docs/design/config-lock-2026-06.md.
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.
8
9
  //
9
10
  // Verification asymmetry (deliberate):
10
- // - runs TRUST the lock: hash-only staleness check, eval-free.
11
- // - `vx lock --check` AUDITS it: full re-evaluation + deep equality,
12
- // which catches eval-time env drift that file hashes cannot see.
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.
13
15
 
16
+ import { rename, rm } from 'node:fs/promises'
14
17
  import path from 'node:path'
15
18
  import type { ProjectConfig } from '../config.js'
16
19
  import { relPosix, UserError } from '../util/index.js'
17
- import { validateProjectConfig } from './project-loader.js'
20
+ import { validateProjectConfig } from './config-schema.js'
18
21
 
19
22
  export const LOCKFILE_NAME = 'vx-lock.json'
20
23
  export const LOCKFILE_VERSION = 1
@@ -37,6 +40,10 @@ export function lockfilePath(root: string): string {
37
40
  return path.join(root, LOCKFILE_NAME)
38
41
  }
39
42
 
43
+ /** What a `--frozen` verb says when there is no lock to read. */
44
+ export const FROZEN_WITHOUT_LOCK =
45
+ "--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
46
+
40
47
  /**
41
48
  * Read and shape-validate `vx-lock.json`. Returns `null` when no lock
42
49
  * exists (the common case — the feature is opt-in). The lock is a
@@ -92,16 +99,36 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
92
99
  }
93
100
 
94
101
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
95
- await Bun.write(lockfilePath(root), `${JSON.stringify(lock, null, 2)}\n`)
102
+ // Written beside its name and renamed over it: `Bun.write` truncates in
103
+ // place, so a `--frozen` run, or `vx watch --frozen` woken by the write
104
+ // itself (item 971), could read an empty or half-written lock and fail
105
+ // on "not valid JSON" (item 972).
106
+ const file = lockfilePath(root)
107
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
108
+ try {
109
+ await Bun.write(tmp, `${JSON.stringify(lock, null, 2)}\n`)
110
+ await rename(tmp, file)
111
+ } catch (err) {
112
+ // Best effort: where the write was refused the temp cannot be named
113
+ // either (ENOTDIR), and that must not replace the refusal itself.
114
+ await rm(tmp, { force: true }).catch(() => {})
115
+ // The refusal names the file the user knows: a read-only checkout read
116
+ // `open '…/vx-lock.json.tmp-…'` where it had read the lock's own name.
117
+ const e = err as NodeJS.ErrnoException
118
+ if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
119
+ if (e.path === tmp) e.path = file
120
+ throw err
121
+ }
96
122
  }
97
123
 
98
124
  /**
99
- * Run-time config load from the lock: verify the config file's content
100
- * hash against the lock entry, then return the FROZEN resolved config.
101
- * No evaluation happens — this is the fast, eval-free trust path. A
102
- * changed file or an unlocked project is a hard error: the lock's
103
- * contract is "what runs is what was locked", and silently falling
104
- * back to evaluation would break frozen-env semantics.
125
+ * Run-time config load from the lock: return the FROZEN resolved config
126
+ * for a project the lock has an entry for. No evaluation happens — this
127
+ * is the fast, eval-free trust path — and no staleness check either (see
128
+ * below): the lock's contract is "what runs is what was locked", so a
129
+ * config file edited since `vx lock` still runs as locked until
130
+ * `vx lock --check` says otherwise. An unlocked project is a hard error;
131
+ * silently falling back to evaluation would break frozen-env semantics.
105
132
  */
106
133
  export async function frozenProjectConfig(
107
134
  lock: Lockfile,