@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
@@ -1,7 +1,8 @@
1
1
  // Resolve declared cache inputs into the concrete pieces that go into the
2
2
  // cache key:
3
3
  // - files: absolute paths whose contents are hashed
4
- // - envValues: [name, value] pairs from parent process.env
4
+ // - envValues: [name, value] pairs from parent process.env (value
5
+ // undefined for an unset name)
5
6
  //
6
7
  // `cache.inputs.env` is the cache-tracking axis for env vars; it's
7
8
  // independent of `exec.env`, which controls what reaches the child.
@@ -16,12 +17,18 @@
16
17
  // be a git work tree; non-git environments are not supported.
17
18
 
18
19
  import path from 'node:path'
19
- import { lstatSync } from 'node:fs'
20
+ import { lstatSync, readdirSync } from 'node:fs'
20
21
  import { realpath, rm, rmdir } from 'node:fs/promises'
21
22
  import type { CacheInputs } from '../config.js'
22
- import { UserError } from '../util/index.js'
23
+ import {
24
+ asTrees,
25
+ isExecutableMissing,
26
+ isLiteralPattern,
27
+ normalizeGlob,
28
+ taskGlob,
29
+ UserError,
30
+ } from '../util/index.js'
23
31
  import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
24
- import { normalizeGlob } from '../util/index.js'
25
32
 
26
33
  // The git side lives in git-inputs.ts; its whole public surface is
27
34
  // re-exported here so a reader that reaches the resolver for it (the
@@ -35,6 +42,7 @@ export {
35
42
  gitPathspecs,
36
43
  parseCheckAttrOutput,
37
44
  autocrlfConverts,
45
+ attributeFilesOutsideTree,
38
46
  type GitEnumeration,
39
47
  } from './git-inputs.js'
40
48
 
@@ -45,6 +53,17 @@ export {
45
53
  // cache is a leaf module and must not import from workspace.)
46
54
  const ALWAYS_IGNORE = [
47
55
  '**/node_modules/**',
56
+ // Defense in depth, and measured as exactly that (item 497): git never
57
+ // reports a path under `.git`, so dropping this line changes NOTHING on
58
+ // any route into the input set. Probed three ways with the pattern in
59
+ // and out — a tracked `**/*` glob, an UNTRACKED one, and a literal
60
+ // `inputs.files: ['.git/HEAD']` (refused by the git-reports-it check,
61
+ // not by this list) — all identical, while the control in the same
62
+ // fixture showed an untracked ORDINARY file does enter the set. So the
63
+ // enumeration is the live guard and this is the backstop; every other
64
+ // member of this list is load-bearing and has a row that fails without
65
+ // it. Kept: the day an enumeration stops going through git, this is
66
+ // what keeps a repository's object store out of a cache key.
48
67
  '**/.git/**',
49
68
  '**/.vx/**',
50
69
  '**/*.tsbuildinfo',
@@ -61,7 +80,7 @@ const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
61
80
 
62
81
  export interface ResolvedInputs {
63
82
  files: string[]
64
- envValues: Array<[name: string, value: string]>
83
+ envValues: Array<[name: string, value: string | undefined]>
65
84
  runtimeValues: Array<[command: string, output: string]>
66
85
  workspaceRuntimeValues: Array<[command: string, output: string]>
67
86
  }
@@ -109,6 +128,8 @@ export interface ResolveInputsArgs {
109
128
  * was 930 ms of a 3.0 s warm no-op (159 scans, 2026-09-11).
110
129
  */
111
130
  workspaceFilesCache?: WorkspaceFilesCache
131
+ /** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
132
+ projectFilesCache?: ProjectFilesCache
112
133
  }
113
134
 
114
135
  export type WorkspaceFilesCache = Map<
@@ -116,6 +137,25 @@ export type WorkspaceFilesCache = Map<
116
137
  { snapshot: readonly string[]; result: Promise<string[]> }
117
138
  >
118
139
 
140
+ /**
141
+ * Per-run memo of `cache.inputs.files` resolution, keyed by the project and
142
+ * the DECLARATION — tasks of one project that declare the same inputs and
143
+ * the same outputs resolve to the same list, and this repo's own config is
144
+ * the shape that pays for it: twelve shard tasks, each declaring the same
145
+ * whole-tree glob over the same three thousand files (measured 1.2 ms of
146
+ * resolution per task, 2026-09-20).
147
+ *
148
+ * Reuse is gated on the git snapshot being the SAME ARRAY the entry was
149
+ * built from, not on equal contents: a mid-run re-enumeration replaces it
150
+ * (`GitFilesCache.set`), so a task whose inputs a previous task rewrote
151
+ * misses the memo and walks again. Same discipline as WorkspaceFilesCache
152
+ * above.
153
+ */
154
+ export type ProjectFilesCache = Map<
155
+ string,
156
+ { snapshot: readonly string[]; result: readonly string[] }
157
+ >
158
+
119
159
  export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
120
160
  const projectFiles = await resolveFiles({
121
161
  projectDir: args.projectDir,
@@ -124,6 +164,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
124
164
  ownOutputs: args.ownOutputs,
125
165
  nestedProjectDirs: args.nestedProjectDirs,
126
166
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
167
+ ...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
127
168
  })
128
169
  let files = projectFiles
129
170
  const wsDecl = args.inputs?.workspaceFiles
@@ -151,10 +192,17 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
151
192
  resolveRuntimeValues(
152
193
  runtimeDecl,
153
194
  args.projectDir,
195
+ projectBinDirs(args.projectDir, args.workspaceRoot),
154
196
  args.runtimeCache,
155
197
  `${args.projectDir}\0`,
156
198
  ),
157
- resolveRuntimeValues(wsRuntimeDecl, args.workspaceRoot, args.workspaceRuntimeCache, ''),
199
+ resolveRuntimeValues(
200
+ wsRuntimeDecl,
201
+ args.workspaceRoot,
202
+ [path.join(args.workspaceRoot, 'node_modules', '.bin')],
203
+ args.workspaceRuntimeCache,
204
+ '',
205
+ ),
158
206
  ])
159
207
  return {
160
208
  files,
@@ -201,9 +249,12 @@ async function resolveWorkspaceFiles(args: {
201
249
  // any loaded task declares workspaceFiles; a missing/invalidated
202
250
  // partition re-spawns git at the root on demand.
203
251
  let gitFiles = args.gitFilesCache?.snapshotFor(args.workspaceRoot, positiveGlobs)
252
+ let undecodable = args.gitFilesCache?.undecodableNames
204
253
  if (gitFiles === undefined) {
205
- gitFiles = runGitLsFiles(args.workspaceRoot).files
254
+ const ls = runGitLsFiles(args.workspaceRoot)
255
+ gitFiles = ls.files
206
256
  args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
257
+ undecodable = noteUndecodable(args.gitFilesCache, args.workspaceRoot, ls.undecodable)
207
258
  }
208
259
  // The memo is valid for the snapshot it was computed over: a task that
209
260
  // wrote workspace outputs mid-run replaces the partition, and the next
@@ -216,7 +267,14 @@ async function resolveWorkspaceFiles(args: {
216
267
  const hit = args.memo!.get(memoKey)
217
268
  if (hit !== undefined && hit.snapshot === gitFiles) return hit.result
218
269
  }
219
- const result = resolveWorkspaceFilesOver(args, gitFiles, positive, positiveGlobs, excludeGlobs)
270
+ const result = resolveWorkspaceFilesOver(
271
+ args,
272
+ gitFiles,
273
+ positive,
274
+ positiveGlobs,
275
+ excludeGlobs,
276
+ undecodable,
277
+ )
220
278
  if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
221
279
  return result
222
280
  }
@@ -227,13 +285,14 @@ async function resolveWorkspaceFilesOver(
227
285
  positive: readonly string[],
228
286
  positiveGlobs: readonly Bun.Glob[],
229
287
  excludeGlobs: readonly Bun.Glob[],
288
+ undecodable: ReadonlySet<string> | undefined,
230
289
  ): Promise<string[]> {
231
290
  // Second call site of the literal-input guard. `resolveWorkspaceFiles`
232
291
  // carries its own copy of the filter-over-git-set design, so the same
233
292
  // silently-folds-nothing hazard exists here — and a fix applied only to the
234
293
  // project half would pass that half's tests while leaving this one live.
235
294
  const unmatchedLiterals = new Set(
236
- positive.map(normalizeGlob).filter(isLiteralPath).map(stripTrailingSlash),
295
+ positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
237
296
  )
238
297
  const candidates: string[] = []
239
298
  for (const rel of gitFiles) {
@@ -245,12 +304,23 @@ async function resolveWorkspaceFilesOver(
245
304
  if (unmatchedLiterals.size > 0) {
246
305
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
247
306
  }
307
+ refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
248
308
  // Same OID-trust shortcut as project files: a clean-per-status
249
309
  // tracked file necessarily exists on disk.
250
310
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
251
311
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
252
312
  }
253
313
 
314
+ /** Anything at the path — file, directory, symlink to anything or to nothing. */
315
+ function existsOnDisk(abs: string): boolean {
316
+ try {
317
+ lstatSync(abs)
318
+ return true
319
+ } catch {
320
+ return false
321
+ }
322
+ }
323
+
254
324
  /**
255
325
  * What an enumerated path must be to count as an input: a regular file, or
256
326
  * a symlink (to anything, or to nothing — its target STRING is what folds,
@@ -271,11 +341,18 @@ function isInputOnDisk(abs: string): boolean {
271
341
  }
272
342
  }
273
343
 
344
+ /** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
345
+ function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
346
+ const own = path.join(projectDir, 'node_modules', '.bin')
347
+ const root = path.join(workspaceRoot, 'node_modules', '.bin')
348
+ return own === root ? [own] : [own, root]
349
+ }
350
+
274
351
  function resolveEnvValues(
275
352
  names: readonly string[],
276
353
  source: NodeJS.ProcessEnv,
277
- ): Array<[string, string]> {
278
- return [...names].sort().map((name) => [name, source[name] ?? ''] as [string, string])
354
+ ): Array<[string, string | undefined]> {
355
+ return [...names].sort().map((name) => [name, source[name]])
279
356
  }
280
357
 
281
358
  /**
@@ -287,17 +364,36 @@ function resolveEnvValues(
287
364
  * `exec.env.define` / `passThrough`, which describe the command's
288
365
  * environment, not the machine's. That is what lets the run-scoped memo
289
366
  * key on (projectDir, command) alone; see `runtimeCache`.
367
+ *
368
+ * Its PATH does lead with the task's own `node_modules/.bin` directories
369
+ * (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
370
+ * global tsc while the task ran the workspace's, and a changed local tool
371
+ * replayed the old output (item 996).
290
372
  */
291
- async function runRuntimeCommand(command: string, cwd: string): Promise<string> {
373
+ async function runRuntimeCommand(
374
+ command: string,
375
+ cwd: string,
376
+ binDirs: readonly string[],
377
+ ): Promise<string> {
378
+ const ambient = process.env['PATH']
379
+ const prefix = binDirs.join(path.delimiter)
292
380
  let proc
293
381
  try {
294
382
  proc = Bun.spawn(['sh', '-c', command], {
295
383
  cwd,
384
+ env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
296
385
  stdin: 'ignore',
297
386
  stdout: 'pipe',
298
387
  stderr: 'pipe',
299
388
  })
300
- } catch {
389
+ } catch (err) {
390
+ // The probe runs through `sh -c` like a task: a box without sh names
391
+ // the shell, not the command (item 244).
392
+ if (isExecutableMissing(err)) {
393
+ throw new UserError(
394
+ `cache.inputs runtime command could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
395
+ )
396
+ }
301
397
  throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
302
398
  }
303
399
  const [stdout, stderr, exitCode] = await Promise.all([
@@ -327,6 +423,7 @@ async function runRuntimeCommand(command: string, cwd: string): Promise<string>
327
423
  async function resolveRuntimeValues(
328
424
  commands: readonly string[],
329
425
  cwd: string,
426
+ binDirs: readonly string[],
330
427
  memo: Map<string, Promise<string>> | undefined,
331
428
  memoKeyPrefix: string,
332
429
  ): Promise<Array<[string, string]>> {
@@ -337,7 +434,7 @@ async function resolveRuntimeValues(
337
434
  const key = `${memoKeyPrefix}${cmd}`
338
435
  let p = memo?.get(key)
339
436
  if (p === undefined) {
340
- p = runRuntimeCommand(cmd, cwd)
437
+ p = runRuntimeCommand(cmd, cwd, binDirs)
341
438
  memo?.set(key, p)
342
439
  }
343
440
  return [cmd, await p] as [string, string]
@@ -345,6 +442,15 @@ async function resolveRuntimeValues(
345
442
  )
346
443
  }
347
444
 
445
+ /**
446
+ * What no output glob reaches. An output is otherwise taken as written —
447
+ * `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
448
+ * apply here — but these two directories no task produces, and a root
449
+ * project declaring `**` would otherwise wipe its repository and the cache
450
+ * it is restoring from (2026-09-16).
451
+ */
452
+ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
453
+
348
454
  /** Resolve declared output globs (project-relative) to actual produced files. */
349
455
  export async function resolveOutputs(args: {
350
456
  projectDir: string
@@ -352,10 +458,11 @@ export async function resolveOutputs(args: {
352
458
  nestedProjectDirs: string[]
353
459
  }): Promise<string[]> {
354
460
  if (args.outputs.length === 0) return []
355
- const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(globFor)
356
- const scanned = [
357
- ...(await scanUnion(asTrees(args.outputs), excludeGlobs, args.projectDir, 'outputs')),
358
- ]
461
+ const excludeGlobs = [
462
+ ...OUTPUT_NEVER,
463
+ ...boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs),
464
+ ].map(globFor)
465
+ const scanned = [...(await scanUnion(asTrees(args.outputs), excludeGlobs, args.projectDir))]
359
466
  // Containment, enforced HERE and not only at the loader. `cleanOutputs`
360
467
  // DELETES whatever this returns, and `Bun.Glob.scan` happily walks `..` out
361
468
  // of its cwd — so the loader's `..`/absolute rejection alone was a single
@@ -440,13 +547,109 @@ export async function cleanOutputs(args: {
440
547
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
441
548
  // globs overlap and a sibling already deleted a path mid-iteration).
442
549
  // A symlink is unlinked, never followed.
443
- await Promise.all(files.map((f) => rm(f, { force: true })))
550
+ await removeAll(files, args.projectDir)
444
551
  await pruneEmptiedDirs(args.projectDir, files)
445
552
  // Project-relative posix paths of what was removed — the caller
446
553
  // feeds these to GitFilesCache.markOutputsChanged after a restore.
447
554
  return files.map((f) => path.relative(args.projectDir, f).split(path.sep).join('/'))
448
555
  }
449
556
 
557
+ /**
558
+ * Remove exactly these project-relative paths — the recorded rows of an
559
+ * ADDITIVE task's own artifact (item 588), never a glob: the glob would
560
+ * take the upstream's files the task adds beside. Emptied directories are
561
+ * pruned as `cleanOutputs` prunes them, and stop at one the upstream still
562
+ * fills.
563
+ */
564
+ export async function cleanOutputPaths(args: {
565
+ projectDir: string
566
+ rels: readonly string[]
567
+ }): Promise<void> {
568
+ const files = args.rels.map((r) => path.resolve(args.projectDir, r))
569
+ await removeAll(files, args.projectDir)
570
+ await pruneEmptiedDirs(args.projectDir, files)
571
+ }
572
+
573
+ /** A file's identity for the additive diff: what the hit path's fingerprint trusts too. */
574
+ export interface OutputStamp {
575
+ size: number
576
+ mtimeMs: number
577
+ }
578
+
579
+ /**
580
+ * The stamps of every file the declared outputs currently select — taken
581
+ * BEFORE an additive task runs, so what it added or changed can be told
582
+ * from what it found (item 588).
583
+ */
584
+ export async function stampOutputs(args: {
585
+ projectDir: string
586
+ outputs: string[]
587
+ nestedProjectDirs: string[]
588
+ }): Promise<Map<string, OutputStamp>> {
589
+ const files = await resolveOutputs(args)
590
+ const out = new Map<string, OutputStamp>()
591
+ for (const f of files) {
592
+ try {
593
+ const st = lstatSync(f)
594
+ out.set(f, { size: st.size, mtimeMs: st.mtimeMs })
595
+ } catch {
596
+ // Gone between the walk and the stat: not a file the task found.
597
+ }
598
+ }
599
+ return out
600
+ }
601
+
602
+ /**
603
+ * The files an additive task's run ADDED or CHANGED under its declared
604
+ * outputs: every selected file that was not in `before`, or whose size or
605
+ * mtime moved. Size + mtime is the proof the hit path already trusts for
606
+ * a current tree, so no new trust is introduced. A file the run rewrote
607
+ * with identical bytes counts as its own (the mtime moved), which is the
608
+ * rewrite-in-place cost the design note records.
609
+ */
610
+ export async function ownOutputsSince(
611
+ args: { projectDir: string; outputs: string[]; nestedProjectDirs: string[] },
612
+ before: ReadonlyMap<string, OutputStamp>,
613
+ ): Promise<string[]> {
614
+ const after = await resolveOutputs(args)
615
+ const own: string[] = []
616
+ for (const f of after) {
617
+ const was = before.get(f)
618
+ if (was === undefined) {
619
+ own.push(f)
620
+ continue
621
+ }
622
+ try {
623
+ const st = lstatSync(f)
624
+ if (st.size !== was.size || st.mtimeMs !== was.mtimeMs) own.push(f)
625
+ } catch {
626
+ // Vanished since the walk: not an output.
627
+ }
628
+ }
629
+ return own
630
+ }
631
+
632
+ /**
633
+ * A declared output the process cannot remove (a `dist/` another user
634
+ * wrote, a read-only checkout) is the environment's failure, not vx's:
635
+ * the scheduler prints any other error as an "internal error", which
636
+ * sends the reader to file a bug against a permission bit.
637
+ */
638
+ async function removeAll(files: readonly string[], root: string): Promise<void> {
639
+ await Promise.all(
640
+ files.map((f) =>
641
+ rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
642
+ const rel = path.relative(root, f).split(path.sep).join('/')
643
+ throw new UserError(
644
+ `cannot remove declared output ${rel}: ${err.code ?? err.message} — vx clears a task's ` +
645
+ `declared outputs before it runs and before a restore; make the path removable ` +
646
+ `by this user, or stop declaring it as an output`,
647
+ )
648
+ }),
649
+ ),
650
+ )
651
+ }
652
+
450
653
  /**
451
654
  * Remove the directories a clean emptied, bottom-up, never `root` itself.
452
655
  * A restore writes files where the cached entry has them; a directory left
@@ -458,21 +661,30 @@ export async function cleanOutputs(args: {
458
661
  */
459
662
  async function pruneEmptiedDirs(root: string, removed: readonly string[]): Promise<void> {
460
663
  const rootResolved = path.resolve(root)
461
- const tried = new Set<string>()
462
- // Deepest first, so a parent is attempted after every child had its turn.
463
- const dirs = [...new Set(removed.map((f) => path.dirname(f)))].sort(
464
- (a, b) => b.split(path.sep).length - a.split(path.sep).length,
465
- )
466
- for (let dir of dirs) {
467
- while (dir !== rootResolved && dir.startsWith(rootResolved + path.sep) && !tried.has(dir)) {
468
- tried.add(dir)
469
- const gone = await rmdir(dir).then(
470
- () => true,
471
- (err: NodeJS.ErrnoException) => err.code === 'ENOENT',
472
- )
473
- if (!gone) break
474
- dir = path.dirname(dir)
475
- }
664
+ // LEVEL ORDER, not a walk-up per directory. A parent is attempted only
665
+ // once every one of its children has had its turn, which is what makes
666
+ // "the last child empties it" work — and it costs ONE rmdir per
667
+ // directory rather than one per directory PER CHILD.
668
+ //
669
+ // Measured (200 dirs x 20 files, min of 7, three interleaved passes):
670
+ // a walk-up with a `tried` memo is 46 ms but leaves an emptied parent
671
+ // standing when a sibling's turn came first; the same walk-up without
672
+ // the memo is correct and 60 ms, because every child re-attempts the
673
+ // shared parent. This is correct at 44 ms.
674
+ let level = new Set(removed.map((f) => path.dirname(f)))
675
+ while (level.size > 0) {
676
+ const parents = new Set<string>()
677
+ await Promise.all(
678
+ [...level].map(async (dir) => {
679
+ if (dir === rootResolved || !dir.startsWith(rootResolved + path.sep)) return
680
+ const gone = await rmdir(dir).then(
681
+ () => true,
682
+ (err: NodeJS.ErrnoException) => err.code === 'ENOENT',
683
+ )
684
+ if (gone) parents.add(path.dirname(dir))
685
+ }),
686
+ )
687
+ level = parents
476
688
  }
477
689
  }
478
690
 
@@ -488,7 +700,7 @@ export async function resolveWorkspaceOutputs(args: {
488
700
  outputs: string[]
489
701
  }): Promise<string[]> {
490
702
  if (args.outputs.length === 0) return []
491
- const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot, 'outputs'))]
703
+ const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot))]
492
704
  // Same containment as the project twin, anchored one level out. These globs
493
705
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
494
706
  // escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
@@ -509,43 +721,21 @@ export async function cleanWorkspaceOutputs(args: {
509
721
  outputs: string[]
510
722
  }): Promise<string[]> {
511
723
  const files = await resolveWorkspaceOutputs(args)
512
- await Promise.all(files.map((f) => rm(f, { force: true })))
724
+ await removeAll(files, args.workspaceRoot)
513
725
  await pruneEmptiedDirs(args.workspaceRoot, files)
514
726
  return files.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
515
727
  }
516
728
 
517
- function isLiteralPath(glob: string): boolean {
518
- return !/[*?[\]{}]/.test(glob)
519
- }
520
-
521
729
  function stripTrailingSlash(p: string): string {
522
730
  return p.replace(/\/+$/, '')
523
731
  }
524
732
 
525
- /**
526
- * A literal entry names a file OR a directory tree: `src/` and `src` both
527
- * mean everything under `src`, as they do in Turbo and every `.gitignore`.
528
- * A glob matcher sees only the literal path, so `['src/']` folded ZERO
529
- * files — a key that never moves with its source, and the most common
530
- * turbo.json shape (`"outputs": ["dist"]`) captured nothing. Every
531
- * literal therefore compiles to itself plus its subtree; a literal that
532
- * names a file still matches exactly that file, since `x/**` matches
533
- * nothing under a file.
534
- */
535
- export function asTrees(patterns: readonly string[]): string[] {
536
- const out: string[] = []
537
- for (const raw of patterns) {
538
- const p = normalizeGlob(raw)
539
- if (!isLiteralPath(p)) {
540
- out.push(p)
541
- continue
542
- }
543
- const lit = stripTrailingSlash(p)
544
- if (lit.length === 0) continue
545
- out.push(lit, `${lit}/**`)
546
- }
547
- return out
548
- }
733
+ // The literal-is-a-tree rule moved to `util/paths.ts` (item 442): it
734
+ // decides what a clean deletes as well as what a key folds, and the
735
+ // graph's overlapping-output refusal — which may not import `cache` —
736
+ // has to read the same one. Re-exported here so the cache contract is
737
+ // unchanged.
738
+ export { asTrees } from '../util/index.js'
549
739
 
550
740
  /** A literal is answered by the path itself or by anything under it. */
551
741
  function settleLiterals(unmatched: Set<string>, rel: string): void {
@@ -575,6 +765,13 @@ function settleLiterals(unmatched: Set<string>, rel: string): void {
575
765
  * An entry that does NOT exist on disk stays silent: that is an ordinary stale
576
766
  * declaration, and refusing it would break every config that lists an
577
767
  * optional file.
768
+ *
769
+ * "Exists" is lstat, not `Bun.file(p).exists()`: that answers false for a
770
+ * DIRECTORY, so a literal naming a gitignored `gen/` was never judged and
771
+ * folded nothing in silence — the stale hit this refusal exists to stop, one
772
+ * directory above where it looked (item 565, fixed in 576). A literal naming
773
+ * an EMPTY directory is refused by the same rule, and that is right: git
774
+ * lists no file under it, so it folds nothing whether tracked or not.
578
775
  */
579
776
  async function assertNoInvisibleLiteralInputs(
580
777
  literals: ReadonlySet<string>,
@@ -582,7 +779,7 @@ async function assertNoInvisibleLiteralInputs(
582
779
  field: 'files' | 'workspaceFiles' = 'files',
583
780
  ): Promise<void> {
584
781
  for (const rel of literals) {
585
- if (!(await Bun.file(path.resolve(base, rel)).exists())) continue
782
+ if (!existsOnDisk(path.resolve(base, rel))) continue
586
783
  throw new UserError(
587
784
  `cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
588
785
  `so it contributes NOTHING to the cache key — input globs filter the files git ` +
@@ -601,6 +798,45 @@ interface ResolveFilesArgs {
601
798
  ownOutputs: string[]
602
799
  nestedProjectDirs: string[]
603
800
  gitFilesCache?: GitFilesCache
801
+ projectFilesCache?: ProjectFilesCache
802
+ }
803
+
804
+ /**
805
+ * Record the names a fresh `git ls-files` could not decode, in the run's
806
+ * cache when there is one, and return the set to check against.
807
+ */
808
+ function noteUndecodable(
809
+ cache: GitFilesCache | undefined,
810
+ dir: string,
811
+ rels: readonly string[],
812
+ ): ReadonlySet<string> {
813
+ const abs = rels.map((rel) => path.join(dir, rel))
814
+ if (cache === undefined) return new Set(abs)
815
+ cache.markUndecodable(abs)
816
+ return cache.undecodableNames
817
+ }
818
+
819
+ /**
820
+ * An input whose name is not UTF-8 is one vx cannot open (a string cannot
821
+ * spell it), so it could only drop out of the key, where an edit to it is a
822
+ * hit. Named instead, with the way out. One git still lists after it left
823
+ * the disk (a tracked file deleted) drops out as any deleted file does.
824
+ */
825
+ function refuseUndecodable(
826
+ candidates: readonly string[],
827
+ undecodable: ReadonlySet<string> | undefined,
828
+ root: string,
829
+ field: 'files' | 'workspaceFiles',
830
+ ): void {
831
+ if (undecodable === undefined || undecodable.size === 0) return
832
+ const bad = candidates.filter((abs) => undecodable.has(abs) && undecodableOnDisk(abs))
833
+ if (bad.length === 0) return
834
+ const names = bad.map((abs) => JSON.stringify(path.relative(root, abs).split(path.sep).join('/')))
835
+ throw new UserError(
836
+ `cache.inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
837
+ `(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
838
+ `negated glob.`,
839
+ )
604
840
  }
605
841
 
606
842
  async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
@@ -635,16 +871,33 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
635
871
  // same project per run is wasteful; we cache the result for the
636
872
  // duration of one orchestrator run.
637
873
  const positiveGlobs = asTrees(positive).map(globFor)
874
+ // Everything below the snapshot that decides the result: the project, what
875
+ // it declares, what it excludes as its own outputs, and the boundaries.
876
+ const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${boundaryIgnores.join('\u0001')}`
638
877
  let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
878
+ let undecodable = args.gitFilesCache?.undecodableNames
879
+ if (gitFiles !== undefined) {
880
+ const memo = args.projectFilesCache?.get(memoKey)
881
+ // Identity, not equality: a re-enumeration hands back a new array even
882
+ // when the file set is unchanged, and that is exactly when this task's
883
+ // inputs must be walked again.
884
+ if (memo !== undefined && memo.snapshot === gitFiles) return [...memo.result]
885
+ }
639
886
  if (gitFiles === undefined) {
640
- // Mid-run re-enumeration. The OIDs this spawn could yield are NOT
887
+ // Mid-run re-enumeration — or a project the workspace-wide populate
888
+ // left without a partition because the workspace's git did not see its
889
+ // directory (a submodule, an embedded repository); spawned in the
890
+ // project dir, git answers from the nested repository.
891
+ // The OIDs this spawn could yield are NOT
641
892
  // trusted (no fresh `git status` to vouch for them — the project's
642
893
  // tree just changed); set() drops the project's OID slot and these
643
894
  // files fall back to Cache.hashFile, which computes the identical
644
895
  // blob OID from disk.
645
- gitFiles = runGitLsFiles(args.projectDir).files
896
+ const ls = runGitLsFiles(args.projectDir)
897
+ gitFiles = ls.files
646
898
  // set() also clears the project's pending-changed bookkeeping.
647
899
  args.gitFilesCache?.set(args.projectDir, gitFiles)
900
+ undecodable = noteUndecodable(args.gitFilesCache, args.projectDir, ls.undecodable)
648
901
  }
649
902
  // A LITERAL entry — one with no glob metacharacter — names exactly one file,
650
903
  // so "this matched nothing" is unambiguous. For a glob it is not: matching
@@ -658,7 +911,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
658
911
  // claims as an input. See the refusal below for why this is not simply
659
912
  // honoured instead.
660
913
  const unmatchedLiterals = new Set(
661
- positive.map(normalizeGlob).filter(isLiteralPath).map(stripTrailingSlash),
914
+ positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
662
915
  )
663
916
  // First pass: glob-filter to candidate absolute paths (no I/O).
664
917
  const candidates: string[] = []
@@ -678,6 +931,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
678
931
  if (unmatchedLiterals.size > 0) {
679
932
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
680
933
  }
934
+ refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
681
935
  // Second pass: existence check — but ONLY for paths without a
682
936
  // trusted index OID. A clean-per-status tracked file necessarily
683
937
  // exists on disk, so skipping its probe keeps the warm path free of
@@ -685,54 +939,101 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
685
939
  // `git ls-files -s` can surface staged entries whose working-tree
686
940
  // file is gone; the hasher would otherwise throw ENOENT.
687
941
  const oids = args.gitFilesCache?.oidsFor(args.projectDir)
688
- return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
942
+ const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
943
+ // Stored only on the way out: a declaration whose literal named an
944
+ // invisible file threw above, and every task sharing it must throw too.
945
+ args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
946
+ return [...resolved]
947
+ }
948
+
949
+ /**
950
+ * `Bun.Glob` decodes a name that is not UTF-8 lossily, so the path it
951
+ * yields names no file and the output dropped out of the artifact without
952
+ * a word: a hit then restored a tree without it (turborepo#9345's output
953
+ * half). A `\ufffd` that does not stat is that case or a file that left
954
+ * mid-scan; `undecodableOnDisk` tells them apart.
955
+ */
956
+ function refuseUndecodableOutput(abs: string): void {
957
+ if (!undecodableOnDisk(abs)) return
958
+ throw new UserError(
959
+ `cache.outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
960
+ `\ufffd), and vx cannot save a file by it. Rename it, or keep it out of the outputs.`,
961
+ )
689
962
  }
690
963
 
691
964
  /**
692
- * Union of files matching any positive pattern in `cwd`, minus files
693
- * matching any exclude glob (tested by Bun.Glob.match on the relative
965
+ * Is `abs` (a lossy spelling) something on disk whose name, or a
966
+ * directory's above it, is not UTF-8? From the deepest ancestor a string
967
+ * can reach, read the raw names and look for one that decodes lossily to
968
+ * the next segment and fatally not at all. Only a path that failed to
969
+ * stat reaches here, so the reads are off every warm path.
970
+ */
971
+ function undecodableOnDisk(abs: string): boolean {
972
+ let dir = path.dirname(abs)
973
+ let next = path.basename(abs)
974
+ while (!existsOnDisk(dir)) {
975
+ const parent = path.dirname(dir)
976
+ if (parent === dir) return false
977
+ next = path.basename(dir)
978
+ dir = parent
979
+ }
980
+ let raw: Buffer[]
981
+ try {
982
+ raw = readdirSync(dir, { encoding: 'buffer' })
983
+ } catch {
984
+ return false
985
+ }
986
+ const lossy = new TextDecoder()
987
+ for (const name of raw) {
988
+ if (lossy.decode(name) !== next) continue
989
+ try {
990
+ FATAL_UTF8.decode(name)
991
+ } catch {
992
+ return true
993
+ }
994
+ }
995
+ return false
996
+ }
997
+
998
+ const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
999
+
1000
+ /**
1001
+ * Union of the OUTPUT files matching any positive pattern in `cwd`, minus
1002
+ * files matching any exclude glob (tested by Bun.Glob.match on the relative
694
1003
  * path). Bun.Glob takes a single pattern per instance, so we iterate.
695
1004
  *
696
- * `outputs` mode also yields SYMLINKS (never followed, never descended):
697
- * a task that emits `dist/out -> ../src/x` has produced an output, so the
698
- * save must capture it (as its target's bytes, `planArtifact`) and the
699
- * clean must remove it — a link the clean leaves standing blocks the
700
- * directory a later entry restores there. Bun's `onlyFiles` walk drops
701
- * every symlink, so this mode lists everything and sorts by `lstat`.
1005
+ * Yields SYMLINKS (never followed, never descended): a task that emits
1006
+ * `dist/out -> ../src/x` has produced an output, so the save must capture it
1007
+ * (as its target's bytes, `planArtifact`) and the clean must remove it — a
1008
+ * link the clean leaves standing blocks the directory a later entry restores
1009
+ * there. Bun's `onlyFiles` walk drops every symlink, so this lists everything
1010
+ * and sorts by `lstat`.
1011
+ *
1012
+ * Sync scan. The async walk was chosen on 2026-09-02, when a warm HIT globbed
1013
+ * its outputs and the thread pool overlapped a thousand of them; the directory
1014
+ * short-circuit took the glob off the hit path, and what is left runs on the
1015
+ * MISS path twice per task (clean, then resolve), where the walk is CPU-bound
1016
+ * and each async chunk costs a main-thread round trip. Measured 2026-09-09 on
1017
+ * a 1,000-task cold run (see STATUS).
1018
+ *
1019
+ * A second `mode: 'files'` branch lived here until item 566, defaulted to and
1020
+ * reached by NOTHING — both call sites pass `'outputs'`. Its `dot: true` could
1021
+ * be deleted with the whole suite green for the plainest reason there is.
702
1022
  */
703
1023
  async function scanUnion(
704
1024
  positive: readonly string[],
705
1025
  excludeGlobs: readonly Bun.Glob[],
706
1026
  cwd: string,
707
- mode: 'files' | 'outputs' = 'files',
708
1027
  ): Promise<Set<string>> {
709
1028
  const matches = new Set<string>()
710
1029
  for (const pattern of positive) {
711
1030
  const glob = globFor(pattern)
712
- if (mode === 'outputs') {
713
- for (const rel of glob.scanSync({
714
- cwd,
715
- onlyFiles: false,
716
- followSymlinks: false,
717
- dot: true,
718
- })) {
719
- if (excludeGlobs.some((g) => g.match(rel))) continue
720
- const abs = path.resolve(cwd, rel)
721
- const st = lstatSync(abs, { throwIfNoEntry: false })
722
- if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
723
- }
724
- continue
725
- }
726
- // Sync scan. The async walk was chosen on 2026-09-02, when a warm HIT
727
- // globbed its outputs and the thread pool overlapped a thousand of
728
- // them; the directory short-circuit took the glob off the hit path, and
729
- // what is left runs on the MISS path twice per task (clean, then
730
- // resolve), where the walk is CPU-bound and each async chunk costs a
731
- // main-thread round trip. Measured 2026-09-09 on a 1,000-task cold run
732
- // (see STATUS).
733
- for (const rel of glob.scanSync({ cwd, onlyFiles: true, dot: true })) {
1031
+ for (const rel of glob.scanSync({ cwd, onlyFiles: false, followSymlinks: false, dot: true })) {
734
1032
  if (excludeGlobs.some((g) => g.match(rel))) continue
735
- matches.add(path.resolve(cwd, rel))
1033
+ const abs = path.resolve(cwd, rel)
1034
+ const st = lstatSync(abs, { throwIfNoEntry: false })
1035
+ if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
1036
+ else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
736
1037
  }
737
1038
  }
738
1039
  return matches
@@ -744,7 +1045,7 @@ async function scanUnion(
744
1045
  const globMemo = new Map<string, Bun.Glob>()
745
1046
  function globFor(pattern: string): Bun.Glob {
746
1047
  let g = globMemo.get(pattern)
747
- if (g === undefined) globMemo.set(pattern, (g = new Bun.Glob(pattern)))
1048
+ if (g === undefined) globMemo.set(pattern, (g = taskGlob(pattern)))
748
1049
  return g
749
1050
  }
750
1051