@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -27,13 +27,13 @@ import {
27
27
  isInstalledPath,
28
28
  isLiteralPattern,
29
29
  normalizeGlob,
30
- namesNodeModules,
31
- OUTPUT_NEVER,
32
30
  outputMatcher,
31
+ outputNever,
33
32
  realpathOf,
34
33
  relPosix,
35
34
  shellArgv,
36
35
  slashBraceExpansions,
36
+ braceAlternatives,
37
37
  splitNegations,
38
38
  staticPrefix,
39
39
  stripTrailingSlash,
@@ -66,6 +66,11 @@ export {
66
66
  // would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
67
67
  // cache is a leaf module and must not import from workspace.)
68
68
  //
69
+ // Relative to the project, so only where vx writes them: its root's `.vx`
70
+ // and the `vx.lock.json` beside its config. Matched at any depth, a
71
+ // project's own `src/.vx/tmp/*` or `src/sub/vx.lock.json` keyed nothing,
72
+ // and an edit to one was a hit (2026-10-17).
73
+ //
69
74
  // `node_modules` is not here: the enumeration drops an untracked file
70
75
  // under one (`isInstalledPath`), and a tracked one is a source.
71
76
  const ALWAYS_IGNORE = [
@@ -80,21 +85,27 @@ const ALWAYS_IGNORE = [
80
85
  // member of this list is load-bearing and has a row that fails without
81
86
  // it. Kept: the day an enumeration stops going through git, this is
82
87
  // what keeps a repository's object store out of a cache key.
83
- '**/.git/**',
88
+ '.git/**',
84
89
  // vx's machine-local state; the workspace config beside it is a source.
85
- '**/.vx/cache/**',
86
- '**/.vx/projects.lock.json',
90
+ '.vx/cache/**',
91
+ '.vx/projects.lock.json',
87
92
  // `vx lock`'s review copy of a project's resolved config: the config is
88
93
  // keyed already, and a re-lock must not move every `**` reader's key.
89
- '**/vx.lock.json',
94
+ 'vx.lock.json',
90
95
  // What `vx lock` and `vx init` write beside the lock: a re-lock or init
91
96
  // must not select every `**` reader under --affected.
92
- '**/.vx/.gitattributes',
93
- '**/.vx/.gitignore',
97
+ '.vx/.gitattributes',
98
+ '.vx/.gitignore',
94
99
  // Where restores stage (archive.ts): vx's own, never an input.
95
- '**/.vx/tmp/**',
100
+ '.vx/tmp/**',
96
101
  ]
97
102
 
103
+ /**
104
+ * `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
105
+ * those globs cross into other projects, and each one's `vx.lock.json` is vx's.
106
+ */
107
+ const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
108
+
98
109
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
99
110
 
100
111
  export interface ResolvedInputs {
@@ -158,6 +169,8 @@ export interface ResolveInputsArgs {
158
169
  workspaceFilesCache?: WorkspaceFilesCache
159
170
  /** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
160
171
  projectFilesCache?: ProjectFilesCache
172
+ /** Resolve no files: the caller folds them from a `filesToken` memo. */
173
+ skipFiles?: boolean
161
174
  }
162
175
 
163
176
  export type WorkspaceFilesCache = Map<
@@ -184,23 +197,67 @@ export type ProjectFilesCache = Map<
184
197
  { snapshot: readonly string[]; result: readonly string[] }
185
198
  >
186
199
 
200
+ /**
201
+ * What a task's input files fold to is a function of this token when it is
202
+ * one of the git records alone: the project's partition holds only files
203
+ * the enumeration trusted an index OID for (`GitFilesCache.filesDigest`),
204
+ * and the declaration reaches no gitignored path and no workspace file.
205
+ * The token is the digest, the workspace root (the fold folds paths
206
+ * relative to it) and everything `resolveFiles` keys its memo on.
207
+ * Undefined when only resolving the files can say.
208
+ */
209
+ export function filesToken(args: ResolveInputsArgs): string | undefined {
210
+ if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
211
+ const plan = filesPlan(args.inputs?.files, args.ownOutputs)
212
+ const cache = args.gitFilesCache
213
+ if (plan === null || cache === undefined) return undefined
214
+ const snapshot = cache.snapshotFor(args.projectDir, plan.positiveGlobs)
215
+ if (snapshot === undefined) return undefined
216
+ const digest = cache.filesDigest(args.projectDir, snapshot)
217
+ if (
218
+ digest === undefined ||
219
+ ignoredReach(plan, snapshot, { dir: args.projectDir, cache }).length > 0
220
+ )
221
+ return undefined
222
+ return `${args.workspaceRoot}\0${filesMemoKey(args.projectDir, plan, args.ownOutputs, args.ownWorkspaceOutputs ?? [], args.nestedProjectDirs)}\0${digest}`
223
+ }
224
+
225
+ /** Everything below the git snapshot that decides `resolveFiles`' result. */
226
+ function filesMemoKey(
227
+ projectDir: string,
228
+ plan: FilesPlan,
229
+ ownOutputs: readonly string[],
230
+ ownWorkspaceOutputs: readonly string[],
231
+ nestedProjectDirs: readonly string[],
232
+ ): string {
233
+ return `${projectDir}\0${plan.positive.join('\u0001')}\0${plan.negative.join('\u0001')}\0${ownOutputs.join('\u0001')}\0${ownWorkspaceOutputs.join('\u0001')}\0${nestedProjectDirs.join('\u0001')}`
234
+ }
235
+
187
236
  export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
188
- const projectRel = path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
189
- const { files: projectFiles, listing } = await resolveFiles({
190
- projectDir: args.projectDir,
191
- workspaceRoot: args.workspaceRoot,
192
- files: args.inputs?.files,
193
- ownOutputs: args.ownOutputs,
194
- ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
195
- projectRel,
196
- nestedProjectDirs: args.nestedProjectDirs,
197
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
198
- ...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
199
- })
237
+ const projectRel =
238
+ args.skipFiles === true
239
+ ? ''
240
+ : path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
241
+ const { files: projectFiles, listing } =
242
+ args.skipFiles === true
243
+ ? { files: [], listing: undefined }
244
+ : await resolveFiles({
245
+ projectDir: args.projectDir,
246
+ workspaceRoot: args.workspaceRoot,
247
+ files: args.inputs?.files,
248
+ ownOutputs: args.ownOutputs,
249
+ ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
250
+ projectRel,
251
+ nestedProjectDirs: args.nestedProjectDirs,
252
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
253
+ ...(args.projectFilesCache !== undefined
254
+ ? { projectFilesCache: args.projectFilesCache }
255
+ : {}),
256
+ })
200
257
  let files = projectFiles
201
258
  const listings = listing === undefined ? [] : [listing]
202
259
  const wsDecl = args.inputs?.workspaceFiles
203
- if (wsDecl !== undefined && wsDecl.length > 0) {
260
+ if (wsDecl !== undefined && wsDecl.length > 0 && args.skipFiles !== true) {
204
261
  const ws = resolveWorkspaceFiles({
205
262
  workspaceRoot: args.workspaceRoot,
206
263
  workspaceFiles: wsDecl,
@@ -274,7 +331,6 @@ function resolveWorkspaceFiles(args: {
274
331
  }): { files: Promise<string[]>; listing: InputListing } | undefined {
275
332
  const positive: string[] = []
276
333
  const negative: string[] = []
277
- refuseOneAlternativeBrace(args.workspaceFiles, 'workspaceFiles')
278
334
  for (const entry of args.workspaceFiles) {
279
335
  if (entry.startsWith('!')) negative.push(entry.slice(1))
280
336
  else positive.push(entry)
@@ -308,7 +364,7 @@ function resolveWorkspaceFiles(args: {
308
364
  let isExcluded: ((rel: string) => boolean) | undefined
309
365
  let isPositive: ((rel: string) => boolean) | undefined
310
366
  const excludedByDeclaration = (rel: string): boolean =>
311
- (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
367
+ (isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
312
368
  // A committed file stays an input whatever output reaches it (`committedFiles`).
313
369
  let committed: ((abs: string) => boolean) | undefined
314
370
  const own = (rel: string): boolean =>
@@ -343,7 +399,10 @@ function resolveWorkspaceFiles(args: {
343
399
  const ownsAny =
344
400
  splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
345
401
  splitNegations(args.ownOutputs).positive.length > 0
346
- const reach = ignoredReach(reachOf(positive), gitFiles)
402
+ const reach = ignoredReach(reachOf(positive), gitFiles, {
403
+ dir: args.workspaceRoot,
404
+ cache: args.gitFilesCache,
405
+ })
347
406
  const files = base.then((all) => {
348
407
  const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
349
408
  refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
@@ -667,17 +726,13 @@ async function resolveRuntimeValues(
667
726
  }
668
727
 
669
728
  /**
670
- * What a set of output globs may not reach: `OUTPUT_NEVER`, and every
671
- * `node_modules` unless a glob names one. `**\/*.js` meant the build's
672
- * files, and the clean before each run deleted every installed `.js`
673
- * under `node_modules` with them (A-13); an install task declares
674
- * `node_modules/**` and keeps it. Workspace outputs took none of this, not
675
- * even `.git`.
729
+ * What a set of output globs may not reach (`outputNever`). `**\/*.js`
730
+ * meant the build's files, and the clean before each run deleted every
731
+ * installed `.js` under `node_modules` with them (A-13); an install task
732
+ * declares `node_modules/**` and keeps it.
676
733
  */
677
- function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
678
- return (namesNodeModules(outputs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(
679
- globFor,
680
- )
734
+ function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspace'): Bun.Glob[] {
735
+ return outputNever(outputs, scope).map(globFor)
681
736
  }
682
737
 
683
738
  /**
@@ -690,9 +745,31 @@ export async function resolveOutputs(args: {
690
745
  outputs: readonly string[]
691
746
  nestedProjectDirs: string[]
692
747
  }): Promise<string[]> {
748
+ return (await scanOutputs(args)).files
749
+ }
750
+
751
+ /**
752
+ * `resolveOutputs`, plus the empty directories the globs match: a save
753
+ * keeps them, so a hit leaves the tree a miss left (Turbo and Nx restore
754
+ * them too). Only a save asks; a hit's walk compares files.
755
+ */
756
+ export async function resolveOutputTree(args: {
757
+ projectDir: string
758
+ outputs: readonly string[]
759
+ nestedProjectDirs: string[]
760
+ }): Promise<{ files: string[]; emptyDirs: string[] }> {
761
+ const emptyDirs = new Set<string>()
762
+ const { files } = await scanOutputs(args, emptyDirs)
763
+ return { files, emptyDirs: containedIn(args.projectDir, [...emptyDirs]).sort() }
764
+ }
765
+
766
+ async function scanOutputs(
767
+ args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
768
+ emptyDirs?: Set<string>,
769
+ ): Promise<{ files: string[] }> {
693
770
  const { positive, negative } = splitNegations(args.outputs)
694
- if (positive.length === 0) return []
695
- const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
771
+ if (positive.length === 0) return { files: [] }
772
+ const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
696
773
  const trees = asTrees(positive)
697
774
  refuseDirLinkedOut(args.projectDir, trees)
698
775
  const scanned = [
@@ -701,6 +778,7 @@ export async function resolveOutputs(args: {
701
778
  excludeGlobs,
702
779
  args.projectDir,
703
780
  inNestedProject(args.projectDir, args.nestedProjectDirs),
781
+ emptyDirs,
704
782
  )),
705
783
  ]
706
784
  // Containment, enforced HERE and not only at the loader. `cleanOutputs`
@@ -710,7 +788,7 @@ export async function resolveOutputs(args: {
710
788
  // outside the project, so any future caller reaching it by another route (a
711
789
  // programmatic embedder, a config source that skips the loader) is contained
712
790
  // by construction.
713
- return containedIn(args.projectDir, scanned).sort()
791
+ return { files: containedIn(args.projectDir, scanned).sort() }
714
792
  }
715
793
 
716
794
  /**
@@ -729,8 +807,8 @@ export function committedFiles(
729
807
 
730
808
  /**
731
809
  * The literal head of each output glob, walked: the scan follows a link
732
- * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
733
- * FILE is the archive's to judge (L-23); a dangling one the restore's.
810
+ * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
811
+ * below it is an output, kept as the link (`archive.ts`).
734
812
  */
735
813
  function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
736
814
  let realRoot: string | undefined
@@ -742,10 +820,11 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
742
820
  if (!isInside(projectDir, at)) break
743
821
  let st
744
822
  try {
745
- st = lstatSync(at)
823
+ st = lstatSync(at, { throwIfNoEntry: false })
746
824
  } catch {
747
825
  break
748
826
  }
827
+ if (st === undefined) break
749
828
  if (!st.isSymbolicLink()) continue
750
829
  let real: string
751
830
  try {
@@ -948,6 +1027,8 @@ export async function cleanOutputPaths(args: {
948
1027
  rels: readonly string[]
949
1028
  outputs: readonly string[]
950
1029
  committed?: (abs: string) => boolean
1030
+ /** Directories the prune leaves standing even when emptied (absolute). */
1031
+ keep?: ReadonlySet<string>
951
1032
  }): Promise<void> {
952
1033
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
953
1034
  const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
@@ -955,6 +1036,7 @@ export async function cleanOutputPaths(args: {
955
1036
  args.projectDir,
956
1037
  await removeAll(files, args.projectDir),
957
1038
  pruneScope(args.projectDir, args.outputs),
1039
+ args.keep,
958
1040
  )
959
1041
  }
960
1042
 
@@ -1147,6 +1229,7 @@ async function pruneEmptiedDirs(
1147
1229
  root: string,
1148
1230
  removed: readonly string[],
1149
1231
  scope?: PruneScope,
1232
+ keep?: ReadonlySet<string>,
1150
1233
  ): Promise<void> {
1151
1234
  const rootResolved = path.resolve(root)
1152
1235
  // LEVEL ORDER, not a walk-up per directory. A parent is attempted only
@@ -1166,7 +1249,8 @@ async function pruneEmptiedDirs(
1166
1249
  (dir) =>
1167
1250
  dir !== rootResolved &&
1168
1251
  dir.startsWith(rootResolved + path.sep) &&
1169
- (scope === undefined || inScope(dir, scope)),
1252
+ (scope === undefined || inScope(dir, scope)) &&
1253
+ keep?.has(dir) !== true,
1170
1254
  )
1171
1255
  const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
1172
1256
  if (dirs.length <= SYNC_CLEAN_MAX) {
@@ -1240,7 +1324,7 @@ export async function resolveWorkspaceOutputs(args: {
1240
1324
  }): Promise<string[]> {
1241
1325
  const { positive, negative } = splitNegations(args.outputs)
1242
1326
  if (positive.length === 0) return []
1243
- const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
1327
+ const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
1244
1328
  const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
1245
1329
  // Same containment as the project twin, anchored one level out. These globs
1246
1330
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
@@ -1301,7 +1385,11 @@ export { asTrees } from '../util/index.js'
1301
1385
  * unanswered. Testing every literal against every file was ~1.5 ms of a
1302
1386
  * 1,800-file all-cached run (2026-10-06).
1303
1387
  */
1304
- function unanswered(literals: readonly string[], files: readonly string[]): Set<string> {
1388
+ function unanswered(
1389
+ literals: readonly string[],
1390
+ files: readonly string[],
1391
+ newIgnored?: (lit: string) => boolean,
1392
+ ): Set<string> {
1305
1393
  const left = new Set<string>()
1306
1394
  for (const lit of literals) {
1307
1395
  const under = `${lit}/`
@@ -1309,7 +1397,7 @@ function unanswered(literals: readonly string[], files: readonly string[]): Set<
1309
1397
  files[lowerBound(files, lit)] === lit ||
1310
1398
  files[lowerBound(files, under)]?.startsWith(under) === true ||
1311
1399
  files.some((rel) => rel === lit || rel.startsWith(under))
1312
- if (!answered) left.add(lit)
1400
+ if (!answered || newIgnored?.(lit) === true) left.add(lit)
1313
1401
  }
1314
1402
  return left
1315
1403
  }
@@ -1337,19 +1425,33 @@ function lowerBound(files: readonly string[], key: string): number {
1337
1425
  function ignoredReach(
1338
1426
  reach: { prefixes: readonly string[]; literals: readonly string[] },
1339
1427
  listed: readonly string[],
1428
+ ignoredIn?: { dir: string; cache: GitFilesCache | undefined },
1340
1429
  ): readonly string[] {
1341
- const named = outright(reach)
1430
+ let named = outrightMemo.get(reach)
1431
+ if (named === undefined) outrightMemo.set(reach, (named = outright(reach)))
1342
1432
  if (named.length === 0) return []
1343
1433
  // Per listing and entries: thousands of tasks share a few declarations.
1344
1434
  let memo = reachMemo.get(listed)
1345
1435
  if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
1346
1436
  const key = named.join('\0')
1347
1437
  let left = memo.get(key)
1348
- if (left === undefined) memo.set(key, (left = [...unanswered(named, listed)]))
1438
+ if (left === undefined) {
1439
+ // A listed path under a named directory git otherwise ignores (a kept
1440
+ // `gen/.gitkeep`) answered for the whole of it, and the untracked files
1441
+ // beside it never entered the key.
1442
+ const cache = ignoredIn?.cache
1443
+ const newIgnored =
1444
+ cache === undefined
1445
+ ? undefined
1446
+ : (lit: string) => cache.newFilesIgnored(path.resolve(ignoredIn!.dir, lit))
1447
+ memo.set(key, (left = [...unanswered(named, listed, newIgnored)]))
1448
+ }
1349
1449
  return left
1350
1450
  }
1351
1451
 
1352
1452
  const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
1453
+ // A declaration's plan is memoized, so its entries are reckoned once.
1454
+ const outrightMemo = new WeakMap<object, string[]>()
1353
1455
 
1354
1456
  /**
1355
1457
  * A declaration's literals and globs' fixed prefixes, each once: none for a
@@ -1458,30 +1560,6 @@ function refuseUndecodable(
1458
1560
  )
1459
1561
  }
1460
1562
 
1461
- const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
1462
-
1463
- /**
1464
- * `Bun.Glob` reads `{b}` as a brace of one alternative, so `src/{b}.ts`
1465
- * matches `src/b.ts` and never a file named `{b}.ts`: that file stays out
1466
- * of the key and an edit to it is a hit. Neither reading is sure to be
1467
- * the one meant, so the entry is refused with both spellings.
1468
- */
1469
- function refuseOneAlternativeBrace(
1470
- entries: readonly string[],
1471
- field: 'files' | 'workspaceFiles',
1472
- ): void {
1473
- for (const entry of entries) {
1474
- const m = ONE_ALTERNATIVE_BRACE.exec(entry)
1475
- if (m === null) continue
1476
- const inner = m[0].slice(1, -1)
1477
- throw new UserError(
1478
- `inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
1479
- `"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
1480
- `or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
1481
- )
1482
- }
1483
- }
1484
-
1485
1563
  /** What a `files` declaration and its task's outputs compile to, whatever the project. */
1486
1564
  interface FilesPlan {
1487
1565
  positive: string[]
@@ -1530,7 +1608,6 @@ function filesPlan(
1530
1608
  if (files === undefined) {
1531
1609
  positive.push(...DEFAULT_FILE_GLOBS)
1532
1610
  } else {
1533
- refuseOneAlternativeBrace(files, 'files')
1534
1611
  for (const entry of files) {
1535
1612
  if (entry.startsWith('!')) negative.push(entry.slice(1))
1536
1613
  else positive.push(entry)
@@ -1637,7 +1714,7 @@ function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
1637
1714
  if (m !== undefined) return m
1638
1715
  const { positive, negative } = splitNegations(decl)
1639
1716
  const isPositive = anyTaskGlob(asTrees(positive))
1640
- const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
1717
+ const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
1641
1718
  m = (rel) => isPositive(rel) && !isExcluded(rel)
1642
1719
  workspaceMatchers.set(key, m)
1643
1720
  return m
@@ -1648,7 +1725,7 @@ async function resolveFiles(
1648
1725
  ): Promise<{ files: string[]; listing?: InputListing }> {
1649
1726
  const plan = filesPlan(args.files, args.ownOutputs)
1650
1727
  if (plan === null) return { files: [] }
1651
- const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1728
+ const { isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1652
1729
 
1653
1730
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
1654
1731
  // Its root-anchored outputs that land in the project, as `files` sees them.
@@ -1664,7 +1741,13 @@ async function resolveFiles(
1664
1741
  // duration of one orchestrator run.
1665
1742
  // Everything below the snapshot that decides the result: the project, what
1666
1743
  // it declares, what it excludes as its own outputs, and the boundaries.
1667
- const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.ownWorkspaceOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
1744
+ const memoKey = filesMemoKey(
1745
+ args.projectDir,
1746
+ plan,
1747
+ args.ownOutputs,
1748
+ args.ownWorkspaceOutputs,
1749
+ args.nestedProjectDirs,
1750
+ )
1668
1751
  let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
1669
1752
  let undecodable = args.gitFilesCache?.undecodableNames
1670
1753
  if (gitFiles !== undefined) {
@@ -1677,7 +1760,7 @@ async function resolveFiles(
1677
1760
  files: withIgnored(
1678
1761
  args.projectDir,
1679
1762
  memo.result,
1680
- ignoredReach(plan, gitFiles),
1763
+ ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache }),
1681
1764
  plan,
1682
1765
  nested,
1683
1766
  ownWsOutput,
@@ -1739,7 +1822,7 @@ async function resolveFiles(
1739
1822
  if (!input && !committed(abs)) continue
1740
1823
  candidates.push(abs)
1741
1824
  }
1742
- const reach = ignoredReach(plan, gitFiles)
1825
+ const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
1743
1826
  refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
1744
1827
  // Second pass: existence check — but ONLY for paths without a
1745
1828
  // trusted index OID. A clean-per-status tracked file necessarily
@@ -1870,7 +1953,11 @@ export interface InputListing {
1870
1953
  literals: readonly string[]
1871
1954
  }
1872
1955
 
1873
- function reachOf(positive: readonly string[]): { prefixes: string[]; literals: string[] } {
1956
+ function reachOf(entries: readonly string[]): { prefixes: string[]; literals: string[] } {
1957
+ // Each alternative names what it would alone: `{gen/a.txt,gen/b.txt}`
1958
+ // names both files, as `gen/{a,b}.txt` does; whole, it had no fixed
1959
+ // prefix and named nothing (Growth's parity, 2026-10-17).
1960
+ const positive = entries.flatMap(braceAlternatives)
1874
1961
  const prefixes = new Set<string>()
1875
1962
  for (const p of asTrees(positive)) {
1876
1963
  if (isLiteralPattern(p)) continue
@@ -2013,6 +2100,7 @@ async function scanUnion(
2013
2100
  excludeGlobs: readonly Bun.Glob[],
2014
2101
  cwd: string,
2015
2102
  nested: (rel: string) => boolean = () => false,
2103
+ emptyDirs?: Set<string>,
2016
2104
  ): Promise<Set<string>> {
2017
2105
  const matches = new Set<string>()
2018
2106
  // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
@@ -2026,7 +2114,9 @@ async function scanUnion(
2026
2114
  const abs = path.resolve(cwd, rel)
2027
2115
  const st = lstatSync(abs, { throwIfNoEntry: false })
2028
2116
  if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
2029
- else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
2117
+ else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2118
+ if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2119
+ } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
2030
2120
  }
2031
2121
  }
2032
2122
  return matches
@@ -110,7 +110,14 @@ import type { CacheKeyInput } from './layer.js'
110
110
  // v43 stays when core stopped folding package files (2026-10-14): the
111
111
  // `workspace:` and `pkg:` parts left the chain, so every key moved and no
112
112
  // unchanged key holds wrong bytes.
113
- export const CACHE_VERSION = 'vx-cache-v43'
113
+ // v44: the sidecar lists the empty directories under the outputs; a v43
114
+ // artifact holds none and a hit left them out (Growth's parity, 2026-10-16).
115
+ // v45: the sidecar lists the symlinks under the outputs and a hit makes
116
+ // them; a v44 artifact holds a link as a copy of its target (M, 2026-10-16).
117
+ // v46: vx's names are reserved only at the project root; a v45 artifact
118
+ // left out a build's own `dist/.git/**`, `dist/.vx/**` and nested
119
+ // `vx.lock.json` (Growth's parity, 2026-10-17).
120
+ export const CACHE_VERSION = 'vx-cache-v46'
114
121
 
115
122
  /**
116
123
  * The digest a key folds for an input gone between its enumeration and its
@@ -151,6 +158,87 @@ export async function foldKey(
151
158
  */
152
159
  behind?: (link: string) => Promise<string>,
153
160
  ): Promise<string> {
161
+ const cap = input.captureInto
162
+ let h = foldHead(input, digestValue)
163
+
164
+ // `resolveFiles` already returns its paths sorted, and a copy-and-sort
165
+ // of three thousand strings per task is not free: 7.4 ms of a 44-task
166
+ // run here (2026-09-20). A linear order check costs one comparison per
167
+ // file instead of n log n, and a caller that hands over an unsorted set
168
+ // — a plugin's own input list — still gets one. `>` on strings compares
169
+ // UTF-16 code units, which is what a comparator-less `sort` does.
170
+ let sortedInputs: readonly string[] = input.inputFiles
171
+ for (let i = 1; i < sortedInputs.length; i++) {
172
+ if (sortedInputs[i - 1]! > sortedInputs[i]!) {
173
+ sortedInputs = [...input.inputFiles].sort()
174
+ break
175
+ }
176
+ }
177
+ h = xxh3(`inputs:${sortedInputs.length}`, h)
178
+ // Per-file hash source, in preference order: the caller-supplied
179
+ // index-OID map (clean tracked files — zero I/O), then hashFile's
180
+ // stat memo (no read), then a full in-process blob-OID computation.
181
+ // All three describe the WORKTREE bytes, which is what the task
182
+ // reads — the index-OID map is only populated for paths whose
183
+ // worktree form matches their index form, because a clean filter
184
+ // (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) makes the index blob a DIFFERENT sequence
185
+ // of bytes and folding it would let two distinct worktree contents
186
+ // share a key. The fold order is locked to `sortedInputs` so
187
+ // results are stable across runs.
188
+ // The common warm shape is that the caller's OID map covers every file,
189
+ // and then there is nothing to await: building three thousand promises
190
+ // per task to resolve values already in hand cost 8.4 ms of that same
191
+ // run. One pass fills the array, and the first gap falls back to the
192
+ // awaited form for the whole list.
193
+ let fileHashes: readonly string[] | undefined
194
+ const provided = input.fileHashes
195
+ if (provided !== undefined) {
196
+ const out: string[] = []
197
+ for (const file of sortedInputs) {
198
+ const oid = provided.get(file)
199
+ if (oid === undefined) break
200
+ out.push(oid)
201
+ }
202
+ if (out.length === sortedInputs.length) fileHashes = out
203
+ }
204
+ fileHashes ??= await Promise.all(
205
+ sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
206
+ )
207
+ // Links are rare, so a list without one awaits nothing more.
208
+ let behindLinks: Map<number, string> | undefined
209
+ if (behind !== undefined) {
210
+ const links: number[] = []
211
+ for (let i = 0; i < fileHashes.length; i++)
212
+ if (fileHashes[i]!.startsWith('120000:')) links.push(i)
213
+ if (links.length > 0) {
214
+ const digests = await Promise.all(links.map((i) => behind(sortedInputs[i]!)))
215
+ behindLinks = new Map(links.map((i, n) => [i, digests[n]!]))
216
+ }
217
+ }
218
+ for (let i = 0; i < sortedInputs.length; i++) {
219
+ const file = sortedInputs[i]!
220
+ const rel = relOf(file)
221
+ const oid = fileHashes[i]!
222
+ const linked = behindLinks?.get(i)
223
+ // A link folds what it points to in its own step, so a key without one is unchanged.
224
+ h = xxh3(linked === undefined ? `${rel}\0${oid}` : `${rel}\0${oid}\0${linked}`, h)
225
+ // The OID is already awaited — file capture is zero extra I/O.
226
+ if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
227
+ if (linked !== undefined && cap) cap.push({ kind: 'linked', name: rel, hash: xxh3hex(linked) })
228
+ }
229
+
230
+ return h.toString(16).padStart(16, '0')
231
+ }
232
+
233
+ /**
234
+ * The fold up to the input files, which come last: what a task's key is
235
+ * once its files are folded onto it. `computeTaskHash` asks it alone when
236
+ * a remembered fold of the same files answers the rest (`FoldMemo`).
237
+ */
238
+ export function foldHead(
239
+ input: CacheKeyInput,
240
+ digestValue: (value: string) => string = xxh3hex,
241
+ ): bigint {
154
242
  // Seed-chained xxHash3: each step folds one field into the
155
243
  // running digest via `xxh3(part, prevDigest)`. Equivalent to the
156
244
  // old CryptoHasher.update() pattern, no intermediate buffer.
@@ -230,72 +318,5 @@ export async function foldKey(
230
318
  for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
231
319
  }
232
320
  }
233
-
234
- // `resolveFiles` already returns its paths sorted, and a copy-and-sort
235
- // of three thousand strings per task is not free: 7.4 ms of a 44-task
236
- // run here (2026-09-20). A linear order check costs one comparison per
237
- // file instead of n log n, and a caller that hands over an unsorted set
238
- // — a plugin's own input list — still gets one. `>` on strings compares
239
- // UTF-16 code units, which is what a comparator-less `sort` does.
240
- let sortedInputs: readonly string[] = input.inputFiles
241
- for (let i = 1; i < sortedInputs.length; i++) {
242
- if (sortedInputs[i - 1]! > sortedInputs[i]!) {
243
- sortedInputs = [...input.inputFiles].sort()
244
- break
245
- }
246
- }
247
- h = xxh3(`inputs:${sortedInputs.length}`, h)
248
- // Per-file hash source, in preference order: the caller-supplied
249
- // index-OID map (clean tracked files — zero I/O), then hashFile's
250
- // stat memo (no read), then a full in-process blob-OID computation.
251
- // All three describe the WORKTREE bytes, which is what the task
252
- // reads — the index-OID map is only populated for paths whose
253
- // worktree form matches their index form, because a clean filter
254
- // (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) makes the index blob a DIFFERENT sequence
255
- // of bytes and folding it would let two distinct worktree contents
256
- // share a key. The fold order is locked to `sortedInputs` so
257
- // results are stable across runs.
258
- // The common warm shape is that the caller's OID map covers every file,
259
- // and then there is nothing to await: building three thousand promises
260
- // per task to resolve values already in hand cost 8.4 ms of that same
261
- // run. One pass fills the array, and the first gap falls back to the
262
- // awaited form for the whole list.
263
- let fileHashes: readonly string[] | undefined
264
- const provided = input.fileHashes
265
- if (provided !== undefined) {
266
- const out: string[] = []
267
- for (const file of sortedInputs) {
268
- const oid = provided.get(file)
269
- if (oid === undefined) break
270
- out.push(oid)
271
- }
272
- if (out.length === sortedInputs.length) fileHashes = out
273
- }
274
- fileHashes ??= await Promise.all(
275
- sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
276
- )
277
- // Links are rare, so a list without one awaits nothing more.
278
- let behindLinks: Map<number, string> | undefined
279
- if (behind !== undefined) {
280
- const links: number[] = []
281
- for (let i = 0; i < fileHashes.length; i++)
282
- if (fileHashes[i]!.startsWith('120000:')) links.push(i)
283
- if (links.length > 0) {
284
- const digests = await Promise.all(links.map((i) => behind(sortedInputs[i]!)))
285
- behindLinks = new Map(links.map((i, n) => [i, digests[n]!]))
286
- }
287
- }
288
- for (let i = 0; i < sortedInputs.length; i++) {
289
- const file = sortedInputs[i]!
290
- const rel = relOf(file)
291
- const oid = fileHashes[i]!
292
- const linked = behindLinks?.get(i)
293
- // A link folds what it points to in its own step, so a key without one is unchanged.
294
- h = xxh3(linked === undefined ? `${rel}\0${oid}` : `${rel}\0${oid}\0${linked}`, h)
295
- // The OID is already awaited — file capture is zero extra I/O.
296
- if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
297
- if (linked !== undefined && cap) cap.push({ kind: 'linked', name: rel, hash: xxh3hex(linked) })
298
- }
299
-
300
- return h.toString(16).padStart(16, '0')
321
+ return h
301
322
  }