@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -6,22 +6,37 @@
6
6
  // `cache.inputs.env` is the cache-tracking axis for env vars; it's
7
7
  // independent of `exec.env`, which controls what reaches the child.
8
8
  //
9
- // File enumeration defers to git — same as Turbo and Nx. We ask git for
10
- // the file set via `git ls-files --cached --others --exclude-standard`,
11
- // which gives us:
12
- // - all tracked files,
13
- // - plus untracked-but-not-ignored files,
14
- // - with nested .gitignore + .git/info/exclude + global excludes
15
- // correctly applied (because git already does the cascade).
16
- // The user's `inputs.files` globs are then matched as a *filter* on
17
- // top of that file set. vx requires git to be installed and the
18
- // workspace to be a git work tree; non-git environments are not
19
- // supported.
9
+ // File enumeration defers to git — same as Turbo and Nx. git-inputs.ts
10
+ // asks for the tracked set with `git ls-files -s -v` (index OIDs and the
11
+ // skip-worktree flag in the same spawn) and for the dirty and untracked
12
+ // paths with `git status -uall`, with nested .gitignore, .git/info/exclude
13
+ // and the global excludes applied because git already does the cascade.
14
+ // The user's `inputs.files` globs are then matched as a *filter* on top
15
+ // of that file set. vx requires git to be installed and the workspace to
16
+ // be a git work tree; non-git environments are not supported.
20
17
 
21
18
  import path from 'node:path'
22
- import { rm } from 'node:fs/promises'
19
+ import { lstatSync } from 'node:fs'
20
+ import { realpath, rm, rmdir } from 'node:fs/promises'
23
21
  import type { CacheInputs } from '../config.js'
24
22
  import { UserError } from '../util/index.js'
23
+ import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
24
+ import { normalizeGlob } from '../util/index.js'
25
+
26
+ // The git side lives in git-inputs.ts; its whole public surface is
27
+ // re-exported here so a reader that reaches the resolver for it (the
28
+ // tests do, by deep import) keeps working.
29
+ export {
30
+ GitFilesCache,
31
+ populateGitFilesCache,
32
+ runGitLsFiles,
33
+ startGitEnumeration,
34
+ applyGitEnumeration,
35
+ gitPathspecs,
36
+ parseCheckAttrOutput,
37
+ autocrlfConverts,
38
+ type GitEnumeration,
39
+ } from './git-inputs.js'
25
40
 
26
41
  // vx-lock.json is committed (so git enumerates it) but it's vx's own
27
42
  // frozen-config metadata — never a task input. Excluded globally so a
@@ -74,7 +89,11 @@ export interface ResolveInputsArgs {
74
89
  * Run-scoped memo for `cache.inputs.runtime` command execution, keyed
75
90
  * by `projectDir + '\0' + command`. Shared across a run's tasks so a
76
91
  * project's command runs once even across build/test/lint and across
77
- * the hash + sandbox-baseline resolveInputs calls.
92
+ * the hash + sandbox-baseline resolveInputs calls. The key is sound
93
+ * ONLY because the probe runs in vx's own ambient environment, never a
94
+ * task's `exec.env` (`runRuntimeCommand`): two tasks with different
95
+ * `define`s share one value. Threading the task env in would have to
96
+ * widen this key too, or task A's probe becomes task B's key component.
78
97
  */
79
98
  runtimeCache?: Map<string, Promise<string>>
80
99
  /**
@@ -82,8 +101,21 @@ export interface ResolveInputsArgs {
82
101
  * only — global dedup so a root-level probe spawns once per run.
83
102
  */
84
103
  workspaceRuntimeCache?: Map<string, Promise<string>>
104
+ /**
105
+ * Run-scoped memo for `cache.inputs.workspaceFiles`, keyed by the
106
+ * declaration and valid for one enumeration snapshot. A Turbo-mapped
107
+ * workspace gives every task the same `globalDependencies`, and
108
+ * resolving one literal against medusa's 24k-file enumeration per task
109
+ * was 930 ms of a 3.0 s warm no-op (159 scans, 2026-09-11).
110
+ */
111
+ workspaceFilesCache?: WorkspaceFilesCache
85
112
  }
86
113
 
114
+ export type WorkspaceFilesCache = Map<
115
+ string,
116
+ { snapshot: readonly string[]; result: Promise<string[]> }
117
+ >
118
+
87
119
  export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
88
120
  const projectFiles = await resolveFiles({
89
121
  projectDir: args.projectDir,
@@ -101,26 +133,29 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
101
133
  workspaceFiles: wsDecl,
102
134
  ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
103
135
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
136
+ ...(args.workspaceFilesCache !== undefined ? { memo: args.workspaceFilesCache } : {}),
104
137
  })
105
138
  // Dedupe: when the project dir IS the workspace root (or a glob
106
139
  // overlaps), the same absolute path can arrive via both lists —
107
140
  // it must contribute to the key exactly once.
108
141
  if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
109
142
  }
110
- const [runtimeValues, workspaceRuntimeValues] = await Promise.all([
111
- resolveRuntimeValues(
112
- args.inputs?.runtime ?? [],
113
- args.projectDir,
114
- args.runtimeCache,
115
- `${args.projectDir}\0`,
116
- ),
117
- resolveRuntimeValues(
118
- args.inputs?.workspaceRuntime ?? [],
119
- args.workspaceRoot,
120
- args.workspaceRuntimeCache,
121
- '',
122
- ),
123
- ])
143
+ // Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
144
+ // run (profiled 2026-09-09); a task declaring neither skips the fan-out.
145
+ const runtimeDecl = args.inputs?.runtime ?? []
146
+ const wsRuntimeDecl = args.inputs?.workspaceRuntime ?? []
147
+ const [runtimeValues, workspaceRuntimeValues] =
148
+ runtimeDecl.length === 0 && wsRuntimeDecl.length === 0
149
+ ? [[], []]
150
+ : await Promise.all([
151
+ resolveRuntimeValues(
152
+ runtimeDecl,
153
+ args.projectDir,
154
+ args.runtimeCache,
155
+ `${args.projectDir}\0`,
156
+ ),
157
+ resolveRuntimeValues(wsRuntimeDecl, args.workspaceRoot, args.workspaceRuntimeCache, ''),
158
+ ])
124
159
  return {
125
160
  files,
126
161
  envValues: resolveEnvValues(args.inputs?.env ?? [], args.envSource),
@@ -145,6 +180,7 @@ async function resolveWorkspaceFiles(args: {
145
180
  workspaceFiles: readonly string[]
146
181
  ownWorkspaceOutputs: readonly string[]
147
182
  gitFilesCache?: GitFilesCache
183
+ memo?: WorkspaceFilesCache
148
184
  }): Promise<string[]> {
149
185
  const positive: string[] = []
150
186
  const negative: string[] = []
@@ -154,10 +190,12 @@ async function resolveWorkspaceFiles(args: {
154
190
  }
155
191
  if (positive.length === 0) return []
156
192
 
157
- const excludeGlobs = [...ALWAYS_IGNORE, ...args.ownWorkspaceOutputs, ...negative].map(
158
- (p) => new Bun.Glob(p),
159
- )
160
- const positiveGlobs = positive.map((p) => new Bun.Glob(p))
193
+ const excludeGlobs = [
194
+ ...ALWAYS_IGNORE,
195
+ ...asTrees(args.ownWorkspaceOutputs),
196
+ ...asTrees(negative),
197
+ ].map(globFor)
198
+ const positiveGlobs = asTrees(positive).map(globFor)
161
199
  // Workspace-wide partition, keyed by the workspace root. Populated
162
200
  // up-front by `populateGitFilesCache(..., workspaceWide: true)` when
163
201
  // any loaded task declares workspaceFiles; a missing/invalidated
@@ -167,23 +205,70 @@ async function resolveWorkspaceFiles(args: {
167
205
  gitFiles = runGitLsFiles(args.workspaceRoot).files
168
206
  args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
169
207
  }
208
+ // The memo is valid for the snapshot it was computed over: a task that
209
+ // wrote workspace outputs mid-run replaces the partition, and the next
210
+ // caller sees a different array and scans again.
211
+ const memoKey =
212
+ args.memo === undefined
213
+ ? undefined
214
+ : JSON.stringify([positive, negative, args.ownWorkspaceOutputs])
215
+ if (memoKey !== undefined) {
216
+ const hit = args.memo!.get(memoKey)
217
+ if (hit !== undefined && hit.snapshot === gitFiles) return hit.result
218
+ }
219
+ const result = resolveWorkspaceFilesOver(args, gitFiles, positive, positiveGlobs, excludeGlobs)
220
+ if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
221
+ return result
222
+ }
223
+
224
+ async function resolveWorkspaceFilesOver(
225
+ args: { workspaceRoot: string; gitFilesCache?: GitFilesCache },
226
+ gitFiles: readonly string[],
227
+ positive: readonly string[],
228
+ positiveGlobs: readonly Bun.Glob[],
229
+ excludeGlobs: readonly Bun.Glob[],
230
+ ): Promise<string[]> {
231
+ // Second call site of the literal-input guard. `resolveWorkspaceFiles`
232
+ // carries its own copy of the filter-over-git-set design, so the same
233
+ // silently-folds-nothing hazard exists here — and a fix applied only to the
234
+ // project half would pass that half's tests while leaving this one live.
235
+ const unmatchedLiterals = new Set(
236
+ positive.map(normalizeGlob).filter(isLiteralPath).map(stripTrailingSlash),
237
+ )
170
238
  const candidates: string[] = []
171
239
  for (const rel of gitFiles) {
240
+ if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
172
241
  if (!positiveGlobs.some((g) => g.match(rel))) continue
173
242
  if (excludeGlobs.some((g) => g.match(rel))) continue
174
243
  candidates.push(path.resolve(args.workspaceRoot, rel))
175
244
  }
245
+ if (unmatchedLiterals.size > 0) {
246
+ await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
247
+ }
176
248
  // Same OID-trust shortcut as project files: a clean-per-status
177
249
  // tracked file necessarily exists on disk.
178
250
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
179
- const exists = await Promise.all(
180
- candidates.map((abs) => oids?.has(abs) === true || Bun.file(abs).exists()),
181
- )
182
- const matches: string[] = []
183
- for (let i = 0; i < candidates.length; i++) {
184
- if (exists[i]) matches.push(candidates[i]!)
251
+ return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
252
+ }
253
+
254
+ /**
255
+ * What an enumerated path must be to count as an input: a regular file, or
256
+ * a symlink (to anything, or to nothing — its target STRING is what folds,
257
+ * as in git). A directory is not one: git lists a gitlink (a submodule) at
258
+ * its path and the glob `**\/*` matches it, and the hasher has nothing to
259
+ * read there. `Bun.file(p).exists()` answered false for every symlink to a
260
+ * directory and every dangling link too, which silently dropped a tracked
261
+ * link from the key — retargeting it was a stale hit. lstat, synchronously:
262
+ * the paths that reach this probe are the ones without a trusted index OID
263
+ * (untracked and dirty files), a handful on a warm run.
264
+ */
265
+ function isInputOnDisk(abs: string): boolean {
266
+ try {
267
+ const st = lstatSync(abs)
268
+ return st.isFile() || st.isSymbolicLink()
269
+ } catch {
270
+ return false
185
271
  }
186
- return matches.sort()
187
272
  }
188
273
 
189
274
  function resolveEnvValues(
@@ -197,6 +282,11 @@ function resolveEnvValues(
197
282
  * Run one runtime-input command via `sh -c` (so pipelines / redirects
198
283
  * work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
199
284
  * exit is a hard UserError naming the command (fail-loud, like git).
285
+ *
286
+ * The probe inherits vx's AMBIENT environment on purpose — not the task's
287
+ * `exec.env.define` / `passThrough`, which describe the command's
288
+ * environment, not the machine's. That is what lets the run-scoped memo
289
+ * key on (projectDir, command) alone; see `runtimeCache`.
200
290
  */
201
291
  async function runRuntimeCommand(command: string, cwd: string): Promise<string> {
202
292
  let proc
@@ -262,10 +352,70 @@ export async function resolveOutputs(args: {
262
352
  nestedProjectDirs: string[]
263
353
  }): Promise<string[]> {
264
354
  if (args.outputs.length === 0) return []
265
- const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(
266
- (p) => new Bun.Glob(p),
267
- )
268
- return [...(await scanUnion(args.outputs, excludeGlobs, args.projectDir))].sort()
355
+ const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(globFor)
356
+ const scanned = [
357
+ ...(await scanUnion(asTrees(args.outputs), excludeGlobs, args.projectDir, 'outputs')),
358
+ ]
359
+ // Containment, enforced HERE and not only at the loader. `cleanOutputs`
360
+ // DELETES whatever this returns, and `Bun.Glob.scan` happily walks `..` out
361
+ // of its cwd — so the loader's `..`/absolute rejection alone was a single
362
+ // point of failure. The resolver that feeds the delete refuses to name a path
363
+ // outside the project, so any future caller reaching it by another route (a
364
+ // programmatic embedder, a config source that skips the loader) is contained
365
+ // by construction.
366
+ return (await containedIn(args.projectDir, scanned)).sort()
367
+ }
368
+
369
+ /**
370
+ * Keep only the paths that are REALLY inside `root` — lexically, and after
371
+ * resolving symlinks.
372
+ *
373
+ * Lexical alone was sufficient only while `Bun.Glob.scan` refused to descend
374
+ * into symlinked directories, which this repo pinned as a deliberate tripwire
375
+ * on a DEPENDENCY's behaviour. **Bun 1.4.0 tripped it**: with `dist ->
376
+ * ../victim` the scan now yields `dist/precious.txt`, a path that is lexically
377
+ * inside the project while the file it names is not — and the caller rm()s
378
+ * whatever this returns. Measured on 1.4.0 before this guard: a plain
379
+ * `outputs.files: ['dist/**']` deleted a file outside the project.
380
+ *
381
+ * DIRECTORIES are what matter, not files: `rm` on a symlinked FILE unlinks the
382
+ * link and never its target, so a link sitting inside a real output directory
383
+ * is harmless. Resolving per directory also keeps this cheap — one syscall per
384
+ * distinct output directory rather than per output file, concurrently, and a
385
+ * `dist/**` of ten thousand files in one directory costs exactly one.
386
+ *
387
+ * A path whose directory will not resolve (a broken link, or a race with the
388
+ * task that produced it) is REFUSED. When the caller deletes, unresolvable
389
+ * means leave it alone.
390
+ */
391
+ async function containedIn(root: string, paths: readonly string[]): Promise<string[]> {
392
+ // One `path.dirname` per path, kept alongside — computing it again in the
393
+ // final filter measured as the DOMINANT added cost on a wide output tree
394
+ // (string work, not syscalls: 10k files in 20 dirs cost more than 5k files
395
+ // in 200, which is the wrong shape for a per-directory probe).
396
+ const lexical: string[] = []
397
+ const lexDirs: string[] = []
398
+ for (const p of paths) {
399
+ if (!isInside(root, p)) continue
400
+ lexical.push(p)
401
+ lexDirs.push(path.dirname(p))
402
+ }
403
+ if (lexical.length === 0) return []
404
+ const realRoot = await realpath(root).catch(() => root)
405
+ const uniqueDirs = [...new Set(lexDirs)]
406
+ const resolved = await Promise.all(uniqueDirs.map((d) => realpath(d).catch(() => null)))
407
+ const contained = new Set<string>()
408
+ for (const [i, dir] of uniqueDirs.entries()) {
409
+ const real = resolved[i]
410
+ if (real !== null && real !== undefined && isInside(realRoot, real)) contained.add(dir)
411
+ }
412
+ return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
413
+ }
414
+
415
+ /** Is `abs` the directory `dir` itself or something beneath it? */
416
+ function isInside(dir: string, abs: string): boolean {
417
+ if (abs === dir) return true
418
+ return abs.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
269
419
  }
270
420
 
271
421
  /**
@@ -289,12 +439,43 @@ export async function cleanOutputs(args: {
289
439
  const files = await resolveOutputs(args)
290
440
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
291
441
  // globs overlap and a sibling already deleted a path mid-iteration).
442
+ // A symlink is unlinked, never followed.
292
443
  await Promise.all(files.map((f) => rm(f, { force: true })))
444
+ await pruneEmptiedDirs(args.projectDir, files)
293
445
  // Project-relative posix paths of what was removed — the caller
294
446
  // feeds these to GitFilesCache.markOutputsChanged after a restore.
295
447
  return files.map((f) => path.relative(args.projectDir, f).split(path.sep).join('/'))
296
448
  }
297
449
 
450
+ /**
451
+ * Remove the directories a clean emptied, bottom-up, never `root` itself.
452
+ * A restore writes files where the cached entry has them; a directory left
453
+ * standing where the entry holds a FILE of the same name (a task that once
454
+ * wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
455
+ * empty directory is not an output anyone declared. A directory that still
456
+ * holds something — a stray the globs do not cover — stays, and the restore
457
+ * says so if it is in the way.
458
+ */
459
+ async function pruneEmptiedDirs(root: string, removed: readonly string[]): Promise<void> {
460
+ 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
+ }
476
+ }
477
+ }
478
+
298
479
  /**
299
480
  * Resolve declared `outputs.workspaceFiles` globs (workspace-root-
300
481
  * relative) to actual produced files. Live-FS glob like
@@ -307,7 +488,12 @@ export async function resolveWorkspaceOutputs(args: {
307
488
  outputs: string[]
308
489
  }): Promise<string[]> {
309
490
  if (args.outputs.length === 0) return []
310
- return [...(await scanUnion(args.outputs, [], args.workspaceRoot))].sort()
491
+ const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot, 'outputs'))]
492
+ // Same containment as the project twin, anchored one level out. These globs
493
+ // deliberately ignore PROJECT boundaries — that is the escape hatch — but
494
+ // escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
495
+ // deletes what this returns.
496
+ return (await containedIn(args.workspaceRoot, scanned)).sort()
311
497
  }
312
498
 
313
499
  /**
@@ -324,166 +510,87 @@ export async function cleanWorkspaceOutputs(args: {
324
510
  }): Promise<string[]> {
325
511
  const files = await resolveWorkspaceOutputs(args)
326
512
  await Promise.all(files.map((f) => rm(f, { force: true })))
513
+ await pruneEmptiedDirs(args.workspaceRoot, files)
327
514
  return files.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
328
515
  }
329
516
 
330
- /**
331
- * Per-run memo of each project's `git ls-files` output, plus the
332
- * staleness bookkeeping that lets the warm path avoid re-spawning git.
333
- *
334
- * After a cache-hit restore we know EXACTLY which paths changed on
335
- * disk: the declared outputs `cleanOutputs` wiped plus the artifact's
336
- * output files. `markOutputsChanged` records them; `snapshotFor`
337
- * hands back the existing snapshot when a resolving task's input
338
- * globs can't match any changed path — provably identical to what a
339
- * re-spawn would return, since glob matching ignores gitignore status
340
- * entirely when the path doesn't match. When globs DO overlap,
341
- * returning undefined forces the caller down the re-spawn path so
342
- * gitignore semantics stay byte-identical.
343
- *
344
- * The cache-miss save path still uses plain `delete` — an executed
345
- * task may write files outside its declared outputs, and only git can
346
- * see those.
347
- */
348
- export class GitFilesCache extends Map<string, readonly string[]> {
349
- private changed = new Map<string, string[]>()
350
- /**
351
- * Per-project trusted index OIDs: absolute path → git blob OID, for
352
- * tracked regular files whose working-tree state matched the index
353
- * at populate time (per one `git status --porcelain` snapshot).
354
- * These feed `Cache.key` via `CacheKeyInput.fileHashes` so a clean
355
- * tree derives input hashes with zero reads / stats / SQLite.
356
- *
357
- * Dropped wholesale on `set` / `delete`: a mid-run re-enumeration
358
- * proves the project's tree changed, and index OIDs can't be
359
- * re-trusted without a fresh status — the per-file fallback
360
- * (`Cache.hashFile`) computes the identical blob OID from disk, so
361
- * dropping is a pure perf concession, never a correctness one.
362
- */
363
- private oids = new Map<string, Map<string, string>>()
364
- /**
365
- * Set when `populateGitFilesCache` stored a WORKSPACE-WIDE partition
366
- * (any loaded task declares `inputs.workspaceFiles`). Null when the
367
- * feature is unused — every workspace-partition hook below is then a
368
- * no-op, so unused-feature behavior stays byte-identical.
369
- */
370
- private wsRoot: string | null = null
371
- /**
372
- * Whether the worktree had any uncommitted changes at populate time,
373
- * derived from the SAME `git status --porcelain` spawn that prunes
374
- * dirty paths from the trusted-OID set. Lets the Tier-3 invocation
375
- * record report `dirty` without a SECOND status spawn. `null` until
376
- * populate runs, or when the status spawn failed (non-repo).
377
- */
378
- private dirty: boolean | null = null
379
-
380
- setWorkspaceRoot(root: string): void {
381
- this.wsRoot = root
382
- }
383
-
384
- /** Aggregate worktree dirtiness from the populate-time status spawn. */
385
- get worktreeDirty(): boolean | null {
386
- return this.dirty
387
- }
388
-
389
- setWorktreeDirty(dirty: boolean | null): void {
390
- this.dirty = dirty
391
- }
392
-
393
- markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
394
- this.recordChanged(projectDir, relPaths)
395
- // The workspace-wide partition sees the same files under
396
- // root-relative names; forward so a downstream workspaceFiles
397
- // task can't reuse a snapshot its globs could now contradict.
398
- if (this.wsRoot !== null && this.wsRoot !== projectDir) {
399
- this.recordChanged(
400
- this.wsRoot,
401
- relPaths.map((rel) =>
402
- path.relative(this.wsRoot!, path.resolve(projectDir, rel)).split(path.sep).join('/'),
403
- ),
404
- )
405
- }
406
- }
407
-
408
- /**
409
- * Record root-anchored changed paths (cleaned/restored workspace
410
- * outputs) against EVERY partition that can see them: the workspace
411
- * partition under their root-relative names, and any project
412
- * partition whose dir contains them (workspace outputs may land
413
- * inside other projects' dirs — the no-boundary escape hatch).
414
- */
415
- markWorkspaceOutputsChanged(workspaceRoot: string, relPaths: readonly string[]): void {
416
- if (relPaths.length === 0) return
417
- for (const key of this.keys()) {
418
- if (key === workspaceRoot) {
419
- this.recordChanged(key, relPaths)
420
- continue
421
- }
422
- const under: string[] = []
423
- for (const rel of relPaths) {
424
- const abs = path.resolve(workspaceRoot, rel)
425
- if (abs.startsWith(key + path.sep)) {
426
- under.push(path.relative(key, abs).split(path.sep).join('/'))
427
- }
428
- }
429
- if (under.length > 0) this.recordChanged(key, under)
430
- }
431
- }
432
-
433
- /**
434
- * Drop the workspace-wide partition (if one exists). Called after a
435
- * cache-miss save — an executed task may have written undeclared
436
- * files anywhere in its project dir, and the workspace partition
437
- * spans that subtree; only a fresh enumeration can see them. No-op
438
- * when the feature is unused.
439
- */
440
- invalidateWorkspacePartition(): void {
441
- if (this.wsRoot !== null) this.delete(this.wsRoot)
442
- }
443
-
444
- private recordChanged(partitionDir: string, relPaths: readonly string[]): void {
445
- if (!this.has(partitionDir)) return
446
- const cur = this.changed.get(partitionDir)
447
- if (cur) cur.push(...relPaths)
448
- else this.changed.set(partitionDir, [...relPaths])
449
- // These paths just changed on disk; their index OIDs (if any) no
450
- // longer describe the working-tree content.
451
- const partitionOids = this.oids.get(partitionDir)
452
- if (partitionOids) {
453
- for (const rel of relPaths) partitionOids.delete(path.resolve(partitionDir, rel))
454
- }
455
- }
456
-
457
- /** Trusted index OIDs for a project (abs path → oid), if any survive. */
458
- oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
459
- return this.oids.get(projectDir)
460
- }
517
+ function isLiteralPath(glob: string): boolean {
518
+ return !/[*?[\]{}]/.test(glob)
519
+ }
461
520
 
462
- setOids(projectDir: string, oids: Map<string, string>): void {
463
- this.oids.set(projectDir, oids)
464
- }
521
+ function stripTrailingSlash(p: string): string {
522
+ return p.replace(/\/+$/, '')
523
+ }
465
524
 
466
- /** Snapshot if still valid for these input globs; undefined → re-spawn. */
467
- snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
468
- const snap = this.get(projectDir)
469
- if (snap === undefined) return undefined
470
- const pending = this.changed.get(projectDir)
471
- if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
472
- return undefined
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
473
542
  }
474
- return snap
543
+ const lit = stripTrailingSlash(p)
544
+ if (lit.length === 0) continue
545
+ out.push(lit, `${lit}/**`)
475
546
  }
547
+ return out
548
+ }
476
549
 
477
- override set(key: string, value: readonly string[]): this {
478
- this.changed.delete(key)
479
- this.oids.delete(key)
480
- return super.set(key, value)
550
+ /** A literal is answered by the path itself or by anything under it. */
551
+ function settleLiterals(unmatched: Set<string>, rel: string): void {
552
+ for (const lit of unmatched) {
553
+ if (rel === lit || rel.startsWith(`${lit}/`)) unmatched.delete(lit)
481
554
  }
555
+ }
482
556
 
483
- override delete(key: string): boolean {
484
- this.changed.delete(key)
485
- this.oids.delete(key)
486
- return super.delete(key)
557
+ /**
558
+ * Refuse a literal `cache.inputs.files` entry that EXISTS ON DISK but is
559
+ * invisible to git — gitignored, or otherwise absent from
560
+ * `git ls-files --cached --others --exclude-standard`.
561
+ *
562
+ * Such an entry contributes nothing to the cache key, so the task stops
563
+ * tracking a file its own config names as an input. That is a stale hit, and a
564
+ * quiet one: the run replays cached stdout, so it even LOOKS like it executed.
565
+ *
566
+ * Turbo honours the declaration instead (an explicit `inputs` entry overrides
567
+ * gitignore). vx cannot afford to, and the reason is not cost: honouring it
568
+ * would make the cache key depend on a change that `git diff` and
569
+ * `git ls-files --others` CANNOT SEE, so editing that input would re-key the
570
+ * task while `--affected` selected nothing. `docs/cli.md` states the invariant
571
+ * those two surfaces owe each other as a principle — "input hashing sees it, so
572
+ * `--affected` must too" — and it was closed for lockfiles only recently.
573
+ * Honouring a gitignored input would reopen it from the other side.
574
+ *
575
+ * An entry that does NOT exist on disk stays silent: that is an ordinary stale
576
+ * declaration, and refusing it would break every config that lists an
577
+ * optional file.
578
+ */
579
+ async function assertNoInvisibleLiteralInputs(
580
+ literals: ReadonlySet<string>,
581
+ base: string,
582
+ field: 'files' | 'workspaceFiles' = 'files',
583
+ ): Promise<void> {
584
+ for (const rel of literals) {
585
+ if (!(await Bun.file(path.resolve(base, rel)).exists())) continue
586
+ throw new UserError(
587
+ `cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
588
+ `so it contributes NOTHING to the cache key — input globs filter the files git ` +
589
+ `lists, and a filter cannot add one back. The task would keep reporting ` +
590
+ `up-to-date after that file changed. If it is generated, depend on the task ` +
591
+ `that produces it via cache.inputs.tasks; if it should be tracked, remove it ` +
592
+ `from .gitignore.`,
593
+ )
487
594
  }
488
595
  }
489
596
 
@@ -512,9 +619,12 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
512
619
  if (positive.length === 0) return []
513
620
 
514
621
  const boundaryIgnores = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs)
515
- const excludeGlobs = [...ALWAYS_IGNORE, ...boundaryIgnores, ...args.ownOutputs, ...negative].map(
516
- (p) => new Bun.Glob(p),
517
- )
622
+ const excludeGlobs = [
623
+ ...ALWAYS_IGNORE,
624
+ ...boundaryIgnores,
625
+ ...asTrees(args.ownOutputs),
626
+ ...asTrees(negative),
627
+ ].map(globFor)
518
628
 
519
629
  // Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
520
630
  // files, .git/info/exclude, and global excludes all participate
@@ -524,7 +634,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
524
634
  // per task (build + test + lint + …). Spawning git N times for the
525
635
  // same project per run is wasteful; we cache the result for the
526
636
  // duration of one orchestrator run.
527
- const positiveGlobs = positive.map((p) => new Bun.Glob(p))
637
+ const positiveGlobs = asTrees(positive).map(globFor)
528
638
  let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
529
639
  if (gitFiles === undefined) {
530
640
  // Mid-run re-enumeration. The OIDs this spawn could yield are NOT
@@ -536,9 +646,24 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
536
646
  // set() also clears the project's pending-changed bookkeeping.
537
647
  args.gitFilesCache?.set(args.projectDir, gitFiles)
538
648
  }
649
+ // A LITERAL entry — one with no glob metacharacter — names exactly one file,
650
+ // so "this matched nothing" is unambiguous. For a glob it is not: matching
651
+ // nothing is perfectly legitimate (`src/**/*.gen.ts` in a package with no
652
+ // generated code), which is why only literals are tracked here.
653
+ //
654
+ // The reason it has to be tracked at all: the user's globs FILTER the set git
655
+ // reports, and a filter can only remove. So naming a gitignored file by hand
656
+ // folds NOTHING — silently. The task then reports `up-to-date` while replaying
657
+ // an artifact built from an older version of a file the config explicitly
658
+ // claims as an input. See the refusal below for why this is not simply
659
+ // honoured instead.
660
+ const unmatchedLiterals = new Set(
661
+ positive.map(normalizeGlob).filter(isLiteralPath).map(stripTrailingSlash),
662
+ )
539
663
  // First pass: glob-filter to candidate absolute paths (no I/O).
540
664
  const candidates: string[] = []
541
665
  for (const rel of gitFiles) {
666
+ if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
542
667
  let matched = false
543
668
  for (const g of positiveGlobs) {
544
669
  if (g.match(rel)) {
@@ -550,280 +675,62 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
550
675
  if (excludeGlobs.some((g) => g.match(rel))) continue
551
676
  candidates.push(path.resolve(args.projectDir, rel))
552
677
  }
553
- // Second pass: parallel existence check — but ONLY for paths
554
- // without a trusted index OID. A clean-per-status tracked file
555
- // necessarily exists on disk, so skipping its probe keeps the warm
556
- // path free of per-file syscalls. Paths without an OID keep the
557
- // probe: `git ls-files -s` can surface staged entries whose
558
- // working-tree file is gone; the hasher would otherwise throw
559
- // ENOENT.
560
- const oids = args.gitFilesCache?.oidsFor(args.projectDir)
561
- const exists = await Promise.all(
562
- candidates.map((abs) => oids?.has(abs) === true || Bun.file(abs).exists()),
563
- )
564
- const matches: string[] = []
565
- for (let i = 0; i < candidates.length; i++) {
566
- if (exists[i]) matches.push(candidates[i]!)
567
- }
568
- return matches.sort()
569
- }
570
-
571
- interface GitLsResult {
572
- /** cwd-relative paths — same visibility set as `--cached --others`. */
573
- files: string[]
574
- /**
575
- * cwd-relative path → index blob OID, for tracked REGULAR files
576
- * (mode 100644 / 100755) at stage 0 only. Symlinks are excluded
577
- * (their OID hashes the link-target string, not the dereferenced
578
- * content our fallback hasher would read); merge-conflict stages
579
- * and gitlinks are excluded too. NOT yet filtered by working-tree
580
- * dirtiness — callers intersect with `git status` before trusting.
581
- */
582
- oids: Map<string, string>
583
- }
584
-
585
- // `<mode> <oid> <stage>\t<path>` — the staged-entry form of
586
- // `ls-files -s`. `--others` paths print bare; with `-z`,
587
- // core.quotePath quoting is off, so a bare path containing a literal
588
- // tab still can't match this fixed-form prefix.
589
- const LS_FILES_STAGE_RE = /^([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
590
-
591
- /**
592
- * Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
593
- * One spawn yields BOTH the file list (identical visibility to the
594
- * pre-v20 `--cached --others` form, verified empirically: same set
595
- * including staged-but-deleted files and per-stage conflict
596
- * duplicates) AND each tracked file's index OID — the heart of the
597
- * Turbo-parity "hashes come from git's index" fast path. Throws a
598
- * `UserError` when git is unavailable or `cwd` isn't a git work
599
- * tree; vx requires git. `-z` survives filenames with newlines /
600
- * spaces.
601
- */
602
- function runGitLsFiles(cwd: string): GitLsResult {
603
- let proc
604
- try {
605
- proc = Bun.spawnSync({
606
- cmd: ['git', 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
607
- cwd,
608
- stdout: 'pipe',
609
- stderr: 'pipe',
610
- })
611
- } catch {
612
- throw new UserError(
613
- `vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
614
- )
615
- }
616
- if (proc.exitCode !== 0) {
617
- // Exit 128 = not a git work tree; other non-zero = git failure.
618
- // Either way we can't enumerate inputs reliably.
619
- const stderr = new TextDecoder().decode(proc.stderr).trim()
620
- throw new UserError(
621
- `vx requires git: ${cwd} is not inside a git work tree. ` +
622
- `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
623
- )
624
- }
625
- return parseLsFilesOutput(new TextDecoder().decode(proc.stdout))
626
- }
627
-
628
- function parseLsFilesOutput(out: string): GitLsResult {
629
- const files: string[] = []
630
- const oids = new Map<string, string>()
631
- if (out.length === 0) return { files, oids }
632
- // NUL-separated; trailing NUL produces an empty segment we skip.
633
- for (const record of out.split('\0')) {
634
- if (record.length === 0) continue
635
- const m = LS_FILES_STAGE_RE.exec(record)
636
- if (m === null) {
637
- files.push(record) // --others entry: bare path
638
- continue
639
- }
640
- const filePath = record.slice(m[0].length)
641
- files.push(filePath)
642
- const mode = m[1]!
643
- const stage = m[3]!
644
- if ((mode === '100644' || mode === '100755') && stage === '0') {
645
- oids.set(filePath, m[2]!)
646
- }
647
- }
648
- return { files, oids }
649
- }
650
-
651
- function parseStatusOutput(out: string): Set<string> {
652
- const tokens = out.split('\0')
653
- const dirty = new Set<string>()
654
- for (let i = 0; i < tokens.length; i++) {
655
- const token = tokens[i]!
656
- if (token.length < 4) continue
657
- dirty.add(token.slice(3))
658
- // X = R or C → the next token is the rename/copy source path.
659
- const x = token[0]
660
- if ((x === 'R' || x === 'C') && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
661
- i++
662
- dirty.add(tokens[i]!)
663
- }
664
- }
665
- return dirty
666
- }
667
-
668
- /**
669
- * Run `git ls-files` ONCE at the workspace root, then partition the
670
- * result by project. Populates `cache` for every project in
671
- * `projectDirs` with project-relative path lists matching what a
672
- * per-project spawn would have produced. Throws `UserError` if the
673
- * workspace isn't a git work tree (vx requires git).
674
- *
675
- * Why bulk: each spawn costs ~5-10ms (fork+exec). On a 200-project
676
- * workspace that's 1-2s of pure overhead reclaimed.
677
- *
678
- * Files in nested-project subtrees stay in their parent's list — the
679
- * boundary-ignore globs in `resolveFiles` filter them out the same way
680
- * they did before. Cheaper to filter once-per-task than to subtract
681
- * here.
682
- *
683
- * v20: the same `ls-files -s` spawn also yields each tracked file's
684
- * index OID. A second spawn (`git status --porcelain`) prunes paths
685
- * whose working tree diverges from the index; what survives is
686
- * stored per project via `cache.setOids` and feeds `Cache.key`
687
- * directly — clean-tree input hashing costs zero reads/stats/SQLite.
688
- *
689
- * `workspaceWide`: set when any loaded task declares
690
- * `cache.inputs.workspaceFiles`. Disables pathspec scoping (those
691
- * globs must see every file from the root) and additionally stores a
692
- * workspace-wide partition keyed by `workspaceRoot` (files + trusted
693
- * OIDs), which `resolveWorkspaceFiles` consumes. When false, the
694
- * enumeration behavior — pathspecs, spawn count, stored partitions —
695
- * is byte-identical to the pre-workspaceFiles code.
696
- */
697
- export async function populateGitFilesCache(
698
- workspaceRoot: string,
699
- projectDirs: readonly string[],
700
- cache: GitFilesCache,
701
- workspaceWide = false,
702
- ): Promise<void> {
703
- // The two spawns are independent — run them concurrently so the
704
- // bulk-populate costs max(ls-files, status) wall time, not the sum
705
- // (status alone is ~74 ms on a 1000-project tree; serial spawning
706
- // was a measurable warm-path regression vs the pre-OID code).
707
- const spawnGit = async (args: string[]) => {
708
- try {
709
- const proc = Bun.spawn({
710
- cmd: ['git', ...args],
711
- cwd: workspaceRoot,
712
- stdin: 'ignore',
713
- stdout: 'pipe',
714
- stderr: 'pipe',
715
- })
716
- const [stdout, stderr, exitCode] = await Promise.all([
717
- new Response(proc.stdout).text(),
718
- new Response(proc.stderr).text(),
719
- proc.exited,
720
- ])
721
- return { exitCode, stdout, stderr }
722
- } catch {
723
- return null
724
- }
725
- }
726
- // Pathspec scoping: when the run only needs a handful of projects
727
- // (scoped config loading), let git scan just those dirs — 75 ms →
728
- // 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
729
- // the root) the whole-tree scan wins on arg/exec overhead anyway.
730
- const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
731
- const scoped =
732
- !workspaceWide &&
733
- rels.length > 0 &&
734
- rels.length <= 64 &&
735
- rels.every((r) => r !== '' && r !== '.')
736
- const pathspecs = scoped ? rels : ['.']
737
- const [ls, status] = await Promise.all([
738
- spawnGit(['ls-files', '-s', '--others', '--exclude-standard', '-z', '--', ...pathspecs]),
739
- spawnGit(['status', '--porcelain', '-z', '--', ...pathspecs]),
740
- ])
741
- if (ls === null) {
742
- throw new UserError(
743
- `vx requires git: failed to spawn 'git' (working dir: ${workspaceRoot}). Install git and re-run.`,
744
- )
745
- }
746
- if (ls.exitCode !== 0) {
747
- const stderr = ls.stderr.trim()
748
- throw new UserError(
749
- `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
750
- `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
751
- )
752
- }
753
- const { files: all, oids } = parseLsFilesOutput(ls.stdout)
754
- const dirty = status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
755
- // Aggregate dirtiness for the Tier-3 invocation record — derived from
756
- // this same status spawn so `run()` needs no second `git status`.
757
- // null when the status spawn failed (non-repo / git error).
758
- cache.setWorktreeDirty(dirty === null ? null : dirty.size > 0)
759
- const trusted = dirty === null ? new Map<string, string>() : oids
760
- if (dirty !== null) {
761
- for (const rel of dirty) trusted.delete(rel)
762
- }
763
- // Sort once, then each project's files are a contiguous range found
764
- // by binary search on its `dir/` prefix — O((F+P) log F) instead of
765
- // the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
766
- // ~9k files; ~5 ms this way). '/' sorts below most filename chars,
767
- // so the range [prefix, prefix+'\xff…') is contiguous in the sorted
768
- // array; lowerBound on `prefix` and on `prefix + '￿'` bracket it.
769
- const sorted = [...all].sort()
770
- const lowerBound = (key: string): number => {
771
- let lo = 0
772
- let hi = sorted.length
773
- while (lo < hi) {
774
- const mid = (lo + hi) >>> 1
775
- if (sorted[mid]! < key) lo = mid + 1
776
- else hi = mid
777
- }
778
- return lo
779
- }
780
- for (const projectDir of projectDirs) {
781
- const relPrefix = path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
782
- if (relPrefix === '' || relPrefix === '.') {
783
- cache.set(projectDir, all)
784
- const rootOids = new Map<string, string>()
785
- for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
786
- cache.setOids(projectDir, rootOids)
787
- continue
788
- }
789
- const prefix = `${relPrefix}/`
790
- const start = lowerBound(prefix)
791
- const end = lowerBound(`${prefix}￿`)
792
- const matches: string[] = []
793
- const projOids = new Map<string, string>()
794
- for (let i = start; i < end; i++) {
795
- const rel = sorted[i]!
796
- matches.push(rel.slice(prefix.length))
797
- const oid = trusted.get(rel)
798
- if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
799
- }
800
- // setOids AFTER set — set() drops the project's OID slot.
801
- cache.set(projectDir, matches)
802
- cache.setOids(projectDir, projOids)
803
- }
804
- if (workspaceWide) {
805
- const rootOids = new Map<string, string>()
806
- for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
807
- cache.set(workspaceRoot, all)
808
- cache.setOids(workspaceRoot, rootOids)
809
- cache.setWorkspaceRoot(workspaceRoot)
678
+ if (unmatchedLiterals.size > 0) {
679
+ await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
810
680
  }
681
+ // Second pass: existence check — but ONLY for paths without a
682
+ // trusted index OID. A clean-per-status tracked file necessarily
683
+ // exists on disk, so skipping its probe keeps the warm path free of
684
+ // per-file syscalls. Paths without an OID keep the probe:
685
+ // `git ls-files -s` can surface staged entries whose working-tree
686
+ // file is gone; the hasher would otherwise throw ENOENT.
687
+ const oids = args.gitFilesCache?.oidsFor(args.projectDir)
688
+ return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
811
689
  }
812
690
 
813
691
  /**
814
692
  * Union of files matching any positive pattern in `cwd`, minus files
815
693
  * matching any exclude glob (tested by Bun.Glob.match on the relative
816
694
  * path). Bun.Glob takes a single pattern per instance, so we iterate.
695
+ *
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`.
817
702
  */
818
703
  async function scanUnion(
819
704
  positive: readonly string[],
820
705
  excludeGlobs: readonly Bun.Glob[],
821
706
  cwd: string,
707
+ mode: 'files' | 'outputs' = 'files',
822
708
  ): Promise<Set<string>> {
823
709
  const matches = new Set<string>()
824
710
  for (const pattern of positive) {
825
- const glob = new Bun.Glob(pattern)
826
- for await (const rel of glob.scan({ cwd, onlyFiles: true, dot: true })) {
711
+ 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 })) {
827
734
  if (excludeGlobs.some((g) => g.match(rel))) continue
828
735
  matches.add(path.resolve(cwd, rel))
829
736
  }
@@ -831,6 +738,16 @@ async function scanUnion(
831
738
  return matches
832
739
  }
833
740
 
741
+ // Compiled once per pattern string for the life of the process: a `Bun.Glob`
742
+ // is immutable, and the same handful of declared globs are compiled again
743
+ // for every task otherwise (three sites, up to thousands of times per run).
744
+ const globMemo = new Map<string, Bun.Glob>()
745
+ function globFor(pattern: string): Bun.Glob {
746
+ let g = globMemo.get(pattern)
747
+ if (g === undefined) globMemo.set(pattern, (g = new Bun.Glob(pattern)))
748
+ return g
749
+ }
750
+
834
751
  function boundaryIgnorePatterns(projectDir: string, nestedDirs: string[]): string[] {
835
752
  return nestedDirs.map((d) => {
836
753
  const rel = path.relative(projectDir, d).split(path.sep).join('/')