@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -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
@@ -47,6 +51,10 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
47
51
 
48
52
  const withDependents = s.startsWith('...')
49
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)
50
58
 
51
59
  let onlyDeps = false
52
60
  let withDeps = false
@@ -67,6 +75,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
67
75
  withDeps,
68
76
  withDependents,
69
77
  onlyDeps,
78
+ onlyDependents,
70
79
  isPath: false,
71
80
  matcher: '',
72
81
  gitSince: s.slice(1, -1),
@@ -88,7 +97,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
88
97
  // `./packages/*` — pnpm's and Turbo's spelling for "every package under
89
98
  // packages": a glob over the root-relative project dir. Resolved
90
99
  // against the workspace root like the literal form.
91
- if (/[*?[\]{}]/.test(pathForm)) {
100
+ // A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
101
+ if (BUN_GLOB_WILDCARDS.test(pathForm)) {
92
102
  const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
93
103
  pathGlob = new Bun.Glob(rel.replace(/\/+$/, ''))
94
104
  }
@@ -100,6 +110,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
100
110
  withDeps,
101
111
  withDependents,
102
112
  onlyDeps,
113
+ onlyDependents,
103
114
  isPath,
104
115
  matcher,
105
116
  ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
@@ -119,6 +130,15 @@ function matchProjects(
119
130
  }
120
131
  const out: string[] = []
121
132
  if (filter.isPath) {
133
+ const prefix = filter.matcher + path.sep
134
+ for (const p of projects) {
135
+ if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
136
+ }
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
122
142
  if (filter.pathGlob !== undefined) {
123
143
  // The glob is matched against the project's own dir, as pnpm and
124
144
  // Turbo do: `./packages/*` is the packages directly under `packages`,
@@ -130,20 +150,12 @@ function matchProjects(
130
150
  .join('/')
131
151
  if (filter.pathGlob.match(rel)) out.push(p.name)
132
152
  }
133
- return out
134
- }
135
- const prefix = filter.matcher + path.sep
136
- for (const p of projects) {
137
- if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
138
- }
139
- return out
140
- }
141
- if (!filter.matcher.includes('*')) {
142
- for (const p of projects) {
143
- if (p.name === filter.matcher) out.push(p.name)
144
153
  }
145
154
  return out
146
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).
147
159
  const re = compileNameGlob(filter.matcher)
148
160
  for (const p of projects) {
149
161
  if (re.test(p.name)) out.push(p.name)
@@ -186,12 +198,17 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
186
198
  const hasInclude = opts.filters.some((f) => !f.negate)
187
199
  const selected = new Set<string>(hasInclude ? [] : allNames)
188
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>[] = []
189
206
  for (const f of opts.filters) {
190
207
  const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
191
208
  if (matched.length === 0) opts.onNoMatch?.(f)
192
209
  const expanded = new Set<string>()
193
210
  for (const name of matched) {
194
- if (!f.onlyDeps) expanded.add(name)
211
+ if (!f.onlyDeps && !f.onlyDependents) expanded.add(name)
195
212
  if (f.withDeps || f.onlyDeps) {
196
213
  for (const d of opts.graph.transitiveDeps(name)) expanded.add(d)
197
214
  }
@@ -199,12 +216,10 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
199
216
  for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
200
217
  }
201
218
  }
202
- if (f.negate) {
203
- for (const name of expanded) selected.delete(name)
204
- } else {
205
- for (const name of expanded) selected.add(name)
206
- }
219
+ if (f.negate) excludes.push(expanded)
220
+ else for (const name of expanded) selected.add(name)
207
221
  }
222
+ for (const expanded of excludes) for (const name of expanded) selected.delete(name)
208
223
 
209
224
  return selected
210
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,8 +50,11 @@ 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
- return (await computeWorkspaceFingerprints(workspaceRoot, NONE)).all
53
+ export async function computeWorkspaceFingerprint(
54
+ workspaceRoot: string,
55
+ reads?: LoadReads,
56
+ ): Promise<string> {
57
+ return (await computeWorkspaceFingerprints(workspaceRoot, NONE, reads)).all
48
58
  }
49
59
 
50
60
  const NONE: ReadonlySet<string> = new Set()
@@ -57,32 +67,40 @@ export interface WorkspaceFingerprints {
57
67
  * — what every task key folds. Identical to `all` with nothing claimed.
58
68
  */
59
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>
60
76
  }
61
77
 
62
78
  /**
63
- * Both digests from one read of each file. A claimed file leaves the key
64
- * digest entirely (its name too): the claimant folds what it means per
65
- * project, and a fold of "present" would still re-key the workspace when
66
- * the file appeared.
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.
67
84
  */
68
85
  export async function computeWorkspaceFingerprints(
69
86
  workspaceRoot: string,
70
87
  claimed: ReadonlySet<string>,
88
+ reads?: LoadReads,
71
89
  ): Promise<WorkspaceFingerprints> {
72
90
  let all = 0n
73
91
  let unclaimed = 0n
92
+ const files = new Map<string, Uint8Array>()
74
93
  for (const f of WORKSPACE_FINGERPRINT_FILES) {
75
- const full = path.join(workspaceRoot, f)
76
- const file = Bun.file(full)
77
- if (!(await file.exists())) continue
78
- const bytes = await file.bytes()
94
+ const bytes = await readOnce(reads, path.join(workspaceRoot, f))
95
+ if (bytes === null) continue
96
+ files.set(f, bytes)
79
97
  all = xxh3(`${f}\0`, all)
80
98
  all = xxh3(bytes, all)
81
99
  if (claimed.has(f)) continue
82
100
  unclaimed = xxh3(`${f}\0`, unclaimed)
83
101
  unclaimed = xxh3(bytes, unclaimed)
84
102
  }
85
- return { all: hex(all), unclaimed: hex(unclaimed) }
103
+ return { all: hex(all), unclaimed: hex(unclaimed), files }
86
104
  }
87
105
 
88
106
  function hex(h: bigint): string {
@@ -5,6 +5,7 @@ export {
5
5
  affectedProjects,
6
6
  defaultAffectedBase,
7
7
  type FingerprintClaims,
8
+ refIsHead,
8
9
  workspaceGlobsMatch,
9
10
  } from './affected.js'
10
11
  export { applyFilters, parseFilter } from './filter.js'
@@ -39,20 +40,26 @@ export {
39
40
  blobOidOf,
40
41
  configEvalKey,
41
42
  configEvalKeyFromClosure,
43
+ configImports,
42
44
  type ConfigEvalStore,
43
45
  } from './config-cache.js'
44
46
  export {
45
47
  findWorkspaceRoot,
46
48
  listProjects,
47
49
  loadWorkspace,
50
+ unreachedHint,
51
+ unreachedPackages,
48
52
  memberBaseDirs,
53
+ PROJECT_CONFIG_FILENAMES,
49
54
  resolveCacheDir,
50
55
  type ProjectEntry,
51
56
  type ProjectMeta,
52
57
  } from './workspace.js'
58
+ export type { LoadReads } from './load-reads.js'
53
59
  // The migration seam (see migration.ts) and core's own mapper, the scripts one.
54
60
  export {
55
61
  applyMigration,
62
+ foldScriptHooks,
56
63
  PERSISTENT_TASK_NAMES,
57
64
  PERSISTENT_TODO,
58
65
  quoteTsLiteral,
@@ -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,15 +2,18 @@
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'
@@ -37,16 +40,16 @@ 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
43
50
  * user-editable file, so this is a system boundary: malformed content
44
51
  * throws a `UserError` instead of crashing deeper in the run.
45
52
  */
46
- /** What a `--frozen` verb says when there is no lock to read. */
47
- export const FROZEN_WITHOUT_LOCK =
48
- "--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
49
-
50
53
  export async function readLockfile(root: string): Promise<Lockfile | null> {
51
54
  const file = Bun.file(lockfilePath(root))
52
55
  if (!(await file.exists())) return null
@@ -96,7 +99,26 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
96
99
  }
97
100
 
98
101
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
99
- 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
+ }
100
122
  }
101
123
 
102
124
  /**
@@ -26,6 +26,7 @@
26
26
 
27
27
  import type { ProjectMeta } from './workspace.js'
28
28
  import {
29
+ foldScriptHooks,
29
30
  type GeneratedProject,
30
31
  type GeneratedTask,
31
32
  type MigrationPlan,
@@ -46,10 +47,38 @@ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepubli
46
47
  */
47
48
  export function delegatedScript(command: string): string | null {
48
49
  const m =
49
- /^(?:(?:npm run|pnpm(?: run)?|yarn(?: run)?|bun(?: run)?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
50
+ /^(?:(?:npm run|(pnpm|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
50
51
  command.trim(),
51
52
  )
52
- return m === null ? null : (m[1] ?? m[2])!
53
+ if (m === null) return null
54
+ if (m[4] !== undefined) return m[4]
55
+ const [, manager, run, name] = m
56
+ // Bare, the manager's own command wins over a script of that name:
57
+ // `bun test` is Bun's test runner and `bun build` its bundler, never the
58
+ // `test` / `build` script, and a group over the script ran the wrong
59
+ // thing (item 908). `pnpm test` and `yarn test` do run the script.
60
+ if (manager !== undefined && run === undefined && OWN_COMMANDS[manager]!.has(name!)) return null
61
+ return name!
62
+ }
63
+
64
+ /** What `<manager> <name>`, with no `run`, runs as the manager's own command. */
65
+ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
66
+ // `bun --help`, Bun 1.4.2, with the short aliases.
67
+ bun: new Set(
68
+ 'run test x repl exec install i add a remove rm update audit outdated link unlink publish patch pm info why build init create c upgrade feedback lint'.split(
69
+ ' ',
70
+ ),
71
+ ),
72
+ pnpm: new Set(
73
+ 'add install i update up remove rm link unlink import rebuild prune fetch patch audit list ls outdated why exec dlx create init publish pack store env setup config root bin licenses deploy doctor'.split(
74
+ ' ',
75
+ ),
76
+ ),
77
+ yarn: new Set(
78
+ 'add install remove upgrade up info init link unlink pack publish why workspace workspaces config cache dlx exec bin node plugin set version constraints npm patch explain dedupe'.split(
79
+ ' ',
80
+ ),
81
+ ),
53
82
  }
54
83
 
55
84
  function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
@@ -60,6 +89,29 @@ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
60
89
  return raw as Record<string, unknown>
61
90
  }
62
91
 
92
+ /**
93
+ * A `build` that only delegates (`build: pnpm run compile`) is a group over
94
+ * its target, and the `^build` edge every `build` carries belongs on the
95
+ * task that does the work: the group ran `compile` with no edge at all, so
96
+ * a package compiled before the ones it imports had built (item 907). On
97
+ * the group itself it would not hold — the group waits on both, `compile`
98
+ * on neither. Followed through a chain of groups to the first command.
99
+ */
100
+ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
101
+ const byName = new Map(tasks.map((t) => [t.name, t]))
102
+ const seen = new Set<string>()
103
+ let at = byName.get('build')
104
+ while (at?.task !== undefined && at.task !== null && !('exec' in at.task)) {
105
+ if (seen.has(at.name)) return
106
+ seen.add(at.name)
107
+ const deps = at.task['dependsOn']
108
+ at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
109
+ }
110
+ if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
111
+ const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
112
+ if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
113
+ }
114
+
63
115
  export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
64
116
  const projects: GeneratedProject[] = []
65
117
  for (const meta of metas) {
@@ -96,11 +148,11 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
96
148
  continue
97
149
  }
98
150
 
99
- const command = [
100
- ...(hook(`pre${name}`) ? [scripts[`pre${name}`] as string] : []),
151
+ const command = foldScriptHooks(
152
+ hook(`pre${name}`) ? (scripts[`pre${name}`] as string) : undefined,
101
153
  own,
102
- ...(hook(`post${name}`) ? [scripts[`post${name}`] as string] : []),
103
- ].join(' && ')
154
+ hook(`post${name}`) ? (scripts[`post${name}`] as string) : undefined,
155
+ )
104
156
  if (hooks.length > 0) {
105
157
  todos.push(
106
158
  `npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
@@ -114,13 +166,14 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
114
166
  if (name === 'build') {
115
167
  task['dependsOn'] = ['^build']
116
168
  todos.push(
117
- "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs; a block with EMPTY outputs would be a cached no-op, not an uncached task",
169
+ "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task",
118
170
  )
119
171
  } else if (AFTER_BUILD.has(name) && hasBuild) {
120
172
  task['dependsOn'] = ['build']
121
173
  }
122
174
  tasks.push({ name, todos, task })
123
175
  }
176
+ upstreamBuildOnWorker(tasks)
124
177
  if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
125
178
  }
126
179
  return {
@@ -8,6 +8,7 @@
8
8
  import path from 'node:path'
9
9
  import { relPosix, UserError } from '../util/index.js'
10
10
  import type { ProjectMeta } from './workspace.js'
11
+ import { loadWorkspace, unreachedHint, unreachedPackages } from './workspace.js'
11
12
 
12
13
  /**
13
14
  * Escape an arbitrary string into a single-quoted TS literal. Escapes
@@ -38,6 +39,32 @@ export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
38
39
  'preview',
39
40
  ])
40
41
 
42
+ /**
43
+ * A package.json script with the `pre<name>` / `post<name>` hooks npm runs
44
+ * around it, as ONE sh command. Each part runs in its own subshell, so a
45
+ * `;` or an `exit` in one ends that part alone, and the chain stops at the
46
+ * first that fails, as npm stops. The parts sit in a function the
47
+ * forwarded `--` args are appended to, and only the body takes them, as
48
+ * npm appends them to the script and never to its hooks. A plain ` && `
49
+ * join handed them to the post hook, and `test -f x && echo A; echo B` ran
50
+ * `echo B` after a failed pre hook and went green (item 905). Each part
51
+ * ends on its own line, so a trailing `# comment` cannot swallow the
52
+ * paren. A script with no hooks is its body, verbatim.
53
+ */
54
+ export function foldScriptHooks(
55
+ pre: string | undefined,
56
+ body: string,
57
+ post: string | undefined,
58
+ ): string {
59
+ if (pre === undefined && post === undefined) return body
60
+ const parts = [
61
+ ...(pre === undefined ? [] : [`(${pre}\n)`]),
62
+ `(${body} "$@"\n)`,
63
+ ...(post === undefined ? [] : [`(${post}\n)`]),
64
+ ]
65
+ return `vx_script() {\n${parts.join(' && ')}\n}\nvx_script`
66
+ }
67
+
41
68
  /** The one wording every mapper emits for a task it made persistent. */
42
69
  export const PERSISTENT_TODO =
43
70
  'persistent task — set persistent.readyWhen (regex matched against output) so ' +
@@ -191,7 +218,19 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
191
218
  }
192
219
  }
193
220
  const report: string[] = []
194
- if (empty) {
221
+ // Single-project mode with packages the root's missing `workspaces` never
222
+ // reaches: the scripts exist, the globs do not (item 248).
223
+ const unreached = empty ? await unreachedPackages(await loadWorkspace(root)) : []
224
+ if (empty && unreached.length > 0) {
225
+ report.push(
226
+ `${verb}: ${unreachedHint(unreached)}`,
227
+ hasWorkspaceFile
228
+ ? `${workspaceName} already exists.`
229
+ : dry
230
+ ? `would write ${workspaceName} (dry run, nothing written).`
231
+ : `wrote ${workspaceName}.`,
232
+ )
233
+ } else if (empty) {
195
234
  report.push(
196
235
  `${verb}: no package.json scripts to turn into tasks.`,
197
236
  hasWorkspaceFile