@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -4,7 +4,7 @@
4
4
  // - envValues: [name, value] pairs from parent process.env (value
5
5
  // undefined for an unset name)
6
6
  //
7
- // `cache.inputs.env` is the cache-tracking axis for env vars; it's
7
+ // `inputs.env` is the cache-tracking axis for env vars; it's
8
8
  // independent of `exec.env`, which controls what reaches the child.
9
9
  //
10
10
  // File enumeration defers to git — same as Turbo and Nx. git-inputs.ts
@@ -19,7 +19,7 @@
19
19
  import path from 'node:path'
20
20
  import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
21
21
  import { rm, rmdir } from 'node:fs/promises'
22
- import type { CacheConfig, CacheInputs } from '../config.js'
22
+ import type { InputsConfig, TaskConfig } from '../config.js'
23
23
  import {
24
24
  anyTaskGlob,
25
25
  asTrees,
@@ -27,6 +27,8 @@ import {
27
27
  isInstalledPath,
28
28
  isLiteralPattern,
29
29
  normalizeGlob,
30
+ namesNodeModules,
31
+ OUTPUT_NEVER,
30
32
  outputMatcher,
31
33
  realpathOf,
32
34
  relPosix,
@@ -89,21 +91,8 @@ const ALWAYS_IGNORE = [
89
91
  // must not select every `**` reader under --affected.
90
92
  '**/.vx/.gitattributes',
91
93
  '**/.vx/.gitignore',
92
- // `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
93
- // in the cwd. It never rests on disk, so it can't be a real input — but a
94
- // broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
95
- // file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
96
- // exclude it (vx is Bun-native; compiling standalone binaries is a common task).
97
- '**/*.bun-build',
98
- // …and, cross-compiling for a target its cache lacks, the directory it
99
- // extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
100
- // 60 MB mid-write under the same race (2026-09-29).
101
- '**/.????????????????-????????.tmp/**',
102
- // vx's own restore temp (archive.ts), live only while an artifact lands
103
- // beside its target. A key taken meanwhile listed it, the rename removed
104
- // it, and every task reading that directory was withheld its save
105
- // (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
106
- '**/.vx-tmp-*',
94
+ // Where restores stage (archive.ts): vx's own, never an input.
95
+ '**/.vx/tmp/**',
107
96
  ]
108
97
 
109
98
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
@@ -121,7 +110,7 @@ export interface ResolveInputsArgs {
121
110
  projectDir: string
122
111
  workspaceRoot: string
123
112
  envSource: NodeJS.ProcessEnv
124
- inputs: CacheInputs | undefined
113
+ inputs: InputsConfig | undefined
125
114
  /** Project-relative output globs to exclude from inputs. */
126
115
  ownOutputs: readonly string[]
127
116
  /** Root-relative `outputs.workspaceFiles` globs to exclude from
@@ -137,7 +126,7 @@ export interface ResolveInputsArgs {
137
126
  */
138
127
  gitFilesCache?: GitFilesCache
139
128
  /**
140
- * Run-scoped memo for `cache.inputs.runtime` command execution, keyed
129
+ * Run-scoped memo for `inputs.runtime` command execution, keyed
141
130
  * by `projectDir + '\0' + command`. Shared across a run's tasks so a
142
131
  * project's command runs once even across build/test/lint and across
143
132
  * the hash + sandbox-baseline resolveInputs calls. The key is sound
@@ -148,7 +137,7 @@ export interface ResolveInputsArgs {
148
137
  */
149
138
  runtimeCache?: Map<string, Promise<string>>
150
139
  /**
151
- * Run-scoped memo for `cache.inputs.workspaceRuntime`, keyed by command
140
+ * Run-scoped memo for `inputs.workspaceRuntime`, keyed by command
152
141
  * only — global dedup so a root-level probe spawns once per run.
153
142
  */
154
143
  workspaceRuntimeCache?: Map<string, Promise<string>>
@@ -160,7 +149,7 @@ export interface ResolveInputsArgs {
160
149
  */
161
150
  runtimeScope?: string
162
151
  /**
163
- * Run-scoped memo for `cache.inputs.workspaceFiles`, keyed by the
152
+ * Run-scoped memo for `inputs.workspaceFiles`, keyed by the
164
153
  * declaration and valid for one enumeration snapshot. A Turbo-mapped
165
154
  * workspace gives every task the same `globalDependencies`, and
166
155
  * resolving one literal against medusa's 24k-file enumeration per task
@@ -177,7 +166,7 @@ export type WorkspaceFilesCache = Map<
177
166
  >
178
167
 
179
168
  /**
180
- * Per-run memo of `cache.inputs.files` resolution, keyed by the project and
169
+ * Per-run memo of `inputs.files` resolution, keyed by the project and
181
170
  * the DECLARATION — tasks of one project that declare the same inputs and
182
171
  * the same outputs resolve to the same list, and this repo's own config is
183
172
  * the shape that pays for it: twelve shard tasks, each declaring the same
@@ -263,7 +252,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
263
252
  }
264
253
 
265
254
  /**
266
- * Resolve `cache.inputs.workspaceFiles` — workspace-root-relative
255
+ * Resolve `inputs.workspaceFiles` — workspace-root-relative
267
256
  * globs matched against the workspace-wide git file set (tracked +
268
257
  * untracked-not-ignored, same visibility as project inputs).
269
258
  *
@@ -354,10 +343,21 @@ function resolveWorkspaceFiles(args: {
354
343
  const ownsAny =
355
344
  splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
356
345
  splitNegations(args.ownOutputs).positive.length > 0
346
+ const reach = ignoredReach(reachOf(positive), gitFiles)
357
347
  const files = base.then((all) => {
358
348
  const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
359
349
  refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
360
- return kept
350
+ if (reach.length === 0) return kept
351
+ // Outside the memo: an ignored file a task writes mid-run leaves the
352
+ // snapshot as it was.
353
+ isPositive ??= anyTaskGlob(asTrees(positive))
354
+ const accept = isPositive
355
+ const extra = ignoredInputs(
356
+ args.workspaceRoot,
357
+ reach,
358
+ (rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
359
+ )
360
+ return extra.length === 0 ? kept : mergeSorted(kept, extra)
361
361
  })
362
362
  return { files, listing }
363
363
  }
@@ -369,23 +369,12 @@ async function resolveWorkspaceFilesOver(
369
369
  isPositive: (rel: string) => boolean,
370
370
  excluded: (rel: string) => boolean,
371
371
  ): Promise<string[]> {
372
- // Second call site of the literal-input guard. `resolveWorkspaceFiles`
373
- // carries its own copy of the filter-over-git-set design, so the same
374
- // silently-folds-nothing hazard exists here — and a fix applied only to the
375
- // project half would pass that half's tests while leaving this one live.
376
- const unmatchedLiterals = unanswered(
377
- positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
378
- gitFiles,
379
- )
380
372
  const candidates: string[] = []
381
373
  for (const rel of gitFiles) {
382
374
  if (!isPositive(rel)) continue
383
375
  if (excluded(rel)) continue
384
376
  candidates.push(path.resolve(args.workspaceRoot, rel))
385
377
  }
386
- if (unmatchedLiterals.size > 0) {
387
- await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
388
- }
389
378
  // Same OID-trust shortcut as project files: a clean-per-status
390
379
  // tracked file necessarily exists on disk.
391
380
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
@@ -536,10 +525,10 @@ async function runRuntimeCommand(
536
525
  // the shell, not the command (item 244).
537
526
  if (isExecutableMissing(err)) {
538
527
  throw new UserError(
539
- `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.`,
528
+ `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.`,
540
529
  )
541
530
  }
542
- throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
531
+ throw new UserError(`inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
543
532
  }
544
533
  liveProbes.add(proc)
545
534
  let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
@@ -564,7 +553,7 @@ async function runRuntimeCommand(
564
553
  const lossy = new TextDecoder()
565
554
  const shown = `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim()
566
555
  throw new UserError(
567
- `cache.inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
556
+ `inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
568
557
  (shown ? `\n${shown}` : ''),
569
558
  )
570
559
  }
@@ -577,7 +566,7 @@ async function runRuntimeCommand(
577
566
  // A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
578
567
  // folded the same output and replayed each other's build.
579
568
  throw new UserError(
580
- `cache.inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
569
+ `inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
581
570
  `Pipe it through a hash or od.`,
582
571
  )
583
572
  }
@@ -677,17 +666,6 @@ async function resolveRuntimeValues(
677
666
  )
678
667
  }
679
668
 
680
- /**
681
- * What no output glob reaches. An output is otherwise taken as written —
682
- * `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
683
- * apply here — but no task produces these, and a root project declaring
684
- * `**` would otherwise wipe its repository and the cache it is restoring
685
- * from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
686
- * outputs, the clean deleted an uncommitted workspace config and a hit
687
- * restored a lock or ignore file the key does not see over the current one.
688
- */
689
- const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
690
-
691
669
  /**
692
670
  * What a set of output globs may not reach: `OUTPUT_NEVER`, and every
693
671
  * `node_modules` unless a glob names one. `**\/*.js` meant the build's
@@ -697,8 +675,9 @@ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
697
675
  * even `.git`.
698
676
  */
699
677
  function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
700
- const namesNodeModules = outputs.some((o) => normalizeGlob(o).split('/').includes('node_modules'))
701
- return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
678
+ return (namesNodeModules(outputs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(
679
+ globFor,
680
+ )
702
681
  }
703
682
 
704
683
  /**
@@ -877,6 +856,7 @@ export async function cleanOutputs(args: {
877
856
  resolved?: readonly string[] | undefined
878
857
  }): Promise<string[]> {
879
858
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
859
+ sweepRestoreTemps(args.projectDir, args.outputs)
880
860
  const matched = args.resolved ?? (await resolveOutputs(args))
881
861
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
882
862
  // globs overlap and a sibling already deleted a path mid-iteration).
@@ -892,6 +872,70 @@ export async function cleanOutputs(args: {
892
872
  return matched.map((f) => relPosix(args.projectDir, f))
893
873
  }
894
874
 
875
+ /**
876
+ * Remove the restore temps (`.vx-tmp-<pid>-<n>`, archive.ts) a killed run
877
+ * left beside an exact-file output. A restore stages in `.vx/tmp/` and
878
+ * writes one beside its target only where the target is on another
879
+ * filesystem (EXDEV); a glob over the directory takes it with the outputs,
880
+ * but `out/a.txt` reaches no sibling, and a partial temp stayed in `out/`
881
+ * through every later run (Growth's SIGKILL probe, 2026-10-16). A temp
882
+ * whose writer is alive, this process included, is left: it may be
883
+ * mid-restore.
884
+ */
885
+ export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
886
+ const dirs = new Set<string>()
887
+ for (const g of outputs) {
888
+ if (g.startsWith('!') || !isLiteralPattern(g)) continue
889
+ dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
890
+ }
891
+ for (const dir of dirs) {
892
+ let names: string[]
893
+ try {
894
+ names = readdirSync(dir)
895
+ } catch {
896
+ continue
897
+ }
898
+ for (const name of names) {
899
+ const m = /^\.vx-tmp-([0-9a-z]+)-[0-9a-z]+$/.exec(name)
900
+ if (m === null) continue
901
+ const pid = Number.parseInt(m[1]!, 36)
902
+ if (pid === process.pid || writerAlive(pid)) continue
903
+ rmSync(path.join(dir, name), { force: true })
904
+ }
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Empty `.vx/tmp/` (archive.ts `STAGING`) of the staging directories
910
+ * (`<pid>-<n>`) a killed run left, once per run. One whose writer is alive,
911
+ * another run's or this one's, is left: it may be mid-restore.
912
+ */
913
+ export function sweepStaging(workspaceRoot: string): void {
914
+ const top = path.join(workspaceRoot, '.vx', 'tmp')
915
+ let names: string[]
916
+ try {
917
+ names = readdirSync(top)
918
+ } catch {
919
+ return
920
+ }
921
+ for (const name of names) {
922
+ const m = /^([0-9a-z]+)-[0-9a-z]+$/.exec(name)
923
+ if (m === null) continue
924
+ const pid = Number.parseInt(m[1]!, 36)
925
+ if (pid === process.pid || writerAlive(pid)) continue
926
+ rmSync(path.join(top, name), { recursive: true, force: true })
927
+ }
928
+ }
929
+
930
+ function writerAlive(pid: number): boolean {
931
+ try {
932
+ process.kill(pid, 0)
933
+ return true
934
+ } catch (err) {
935
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
936
+ }
937
+ }
938
+
895
939
  /**
896
940
  * Remove exactly these project-relative paths — the recorded rows of an
897
941
  * ADDITIVE task's own artifact (item 588), never a glob: the glob would
@@ -1221,6 +1265,7 @@ export async function cleanWorkspaceOutputs(args: {
1221
1265
  resolved?: readonly string[] | undefined
1222
1266
  }): Promise<string[]> {
1223
1267
  const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1268
+ sweepRestoreTemps(args.workspaceRoot, args.outputs)
1224
1269
  const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
1225
1270
  await pruneEmptiedDirs(
1226
1271
  args.workspaceRoot,
@@ -1282,52 +1327,86 @@ function lowerBound(files: readonly string[], key: string): number {
1282
1327
  }
1283
1328
 
1284
1329
  /**
1285
- * Refuse a literal `cache.inputs.files` entry that EXISTS ON DISK but is
1286
- * invisible to git — gitignored, or otherwise absent from
1287
- * `git ls-files --cached --others --exclude-standard`.
1288
- *
1289
- * Such an entry contributes nothing to the cache key, so the task stops
1290
- * tracking a file its own config names as an input. That is a stale hit, and a
1291
- * quiet one: the run replays cached stdout, so it even LOOKS like it executed.
1292
- *
1293
- * Turbo honours the declaration instead (an explicit `inputs` entry overrides
1294
- * gitignore). vx cannot afford to, and the reason is not cost: honouring it
1295
- * would make the cache key depend on a change that `git diff` and
1296
- * `git ls-files --others` CANNOT SEE, so editing that input would re-key the
1297
- * task while `--affected` selected nothing. `docs/cli.md` states the invariant
1298
- * those two surfaces owe each other as a principle — "input hashing sees it, so
1299
- * `--affected` must too" — and it was closed for lockfiles only recently.
1300
- * Honouring a gitignored input would reopen it from the other side.
1301
- *
1302
- * An entry that does NOT exist on disk stays silent: that is an ordinary stale
1303
- * declaration, and refusing it would break every config that lists an
1304
- * optional file.
1305
- *
1306
- * "Exists" is lstat, not `Bun.file(p).exists()`: that answers false for a
1307
- * DIRECTORY, so a literal naming a gitignored `gen/` was never judged and
1308
- * folded nothing in silence — the stale hit this refusal exists to stop, one
1309
- * directory above where it looked (item 565, fixed in 576). A literal naming
1310
- * an EMPTY directory is refused by the same rule, and that is right: git
1311
- * lists no file under it, so it folds nothing whether tracked or not.
1330
+ * The entries a declaration names outright that git answers nothing for: a
1331
+ * literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
1332
+ * path at or under it. Such a path is gitignored or absent, and naming it
1333
+ * is asking for it (owner, 2026-10-16), so {@link ignoredInputs} reads it
1334
+ * from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
1335
+ * no ignored file: `node_modules` is never walked.
1312
1336
  */
1313
- async function assertNoInvisibleLiteralInputs(
1314
- literals: ReadonlySet<string>,
1315
- base: string,
1316
- field: 'files' | 'workspaceFiles' = 'files',
1317
- ): Promise<void> {
1318
- for (const rel of literals) {
1319
- if (!existsOnDisk(path.resolve(base, rel))) continue
1320
- throw new UserError(
1321
- `cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
1322
- `so it contributes NOTHING to the cache key — input globs filter the files git ` +
1323
- `lists, and a filter cannot add one back. The task would keep reporting ` +
1324
- `up-to-date after that file changed. If it is generated, depend on the task ` +
1325
- `that produces it via cache.inputs.tasks; if it should be tracked, remove it ` +
1326
- `from .gitignore.`,
1327
- )
1337
+ function ignoredReach(
1338
+ reach: { prefixes: readonly string[]; literals: readonly string[] },
1339
+ listed: readonly string[],
1340
+ ): readonly string[] {
1341
+ const named = outright(reach)
1342
+ if (named.length === 0) return []
1343
+ // Per listing and entries: thousands of tasks share a few declarations.
1344
+ let memo = reachMemo.get(listed)
1345
+ if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
1346
+ const key = named.join('\0')
1347
+ let left = memo.get(key)
1348
+ if (left === undefined) memo.set(key, (left = [...unanswered(named, listed)]))
1349
+ return left
1350
+ }
1351
+
1352
+ const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
1353
+
1354
+ /**
1355
+ * A declaration's literals and globs' fixed prefixes, each once: none for a
1356
+ * glob that starts with a wildcard, and none that leaves the directory the
1357
+ * entries are relative to (a project's boundary is hard).
1358
+ */
1359
+ function outright(reach: { prefixes: readonly string[]; literals: readonly string[] }): string[] {
1360
+ return [
1361
+ ...new Set(
1362
+ [...reach.literals, ...reach.prefixes].filter(
1363
+ (p) => p !== '' && p !== '.' && p !== '..' && !p.startsWith('../') && !path.isAbsolute(p),
1364
+ ),
1365
+ ),
1366
+ ]
1367
+ }
1368
+
1369
+ /**
1370
+ * The files on disk under the {@link ignoredReach} entries that `accept`
1371
+ * keeps, sorted and absolute. A file is hashed from disk like any untracked
1372
+ * one; nothing here is stored or restored. `node_modules` is no special
1373
+ * case (owner, 2026-10-16): an ignored path named outright is an input,
1374
+ * a generated client (`node_modules/.prisma/client/**`) too; skipping
1375
+ * installs here folded such an entry to nothing, silently. lstat, not
1376
+ * `exists()`: that answers false for a directory. Never memoized: a
1377
+ * dependency writes these mid-run.
1378
+ */
1379
+ function ignoredInputs(
1380
+ root: string,
1381
+ reach: readonly string[],
1382
+ accept: (rel: string) => boolean,
1383
+ ): string[] {
1384
+ const found = new Set<string>()
1385
+ for (const entry of reach) {
1386
+ const abs = path.resolve(root, entry)
1387
+ const st = lstatSync(abs, { throwIfNoEntry: false })
1388
+ if (st === undefined) continue
1389
+ if (!st.isDirectory()) {
1390
+ if ((st.isFile() || st.isSymbolicLink()) && accept(entry)) found.add(abs)
1391
+ continue
1392
+ }
1393
+ for (const rel of WALK.scanSync({
1394
+ cwd: abs,
1395
+ onlyFiles: false,
1396
+ followSymlinks: false,
1397
+ dot: true,
1398
+ })) {
1399
+ const full = `${entry}/${rel.split(path.sep).join('/')}`
1400
+ if (!accept(full)) continue
1401
+ const file = path.join(abs, rel)
1402
+ if (isInputOnDisk(file)) found.add(file)
1403
+ }
1328
1404
  }
1405
+ return [...found].sort()
1329
1406
  }
1330
1407
 
1408
+ const WALK = new Bun.Glob('**')
1409
+
1331
1410
  interface ResolveFilesArgs {
1332
1411
  projectDir: string
1333
1412
  workspaceRoot: string
@@ -1373,7 +1452,7 @@ function refuseUndecodable(
1373
1452
  if (bad.length === 0) return
1374
1453
  const names = bad.map((abs) => JSON.stringify(relPosix(root, abs)))
1375
1454
  throw new UserError(
1376
- `cache.inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
1455
+ `inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
1377
1456
  `(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
1378
1457
  `negated glob.`,
1379
1458
  )
@@ -1396,7 +1475,7 @@ function refuseOneAlternativeBrace(
1396
1475
  if (m === null) continue
1397
1476
  const inner = m[0].slice(1, -1)
1398
1477
  throw new UserError(
1399
- `cache.inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
1478
+ `inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
1400
1479
  `"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
1401
1480
  `or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
1402
1481
  )
@@ -1478,41 +1557,59 @@ function filesPlan(
1478
1557
  }
1479
1558
 
1480
1559
  /**
1481
- * Whether a changed path is one of a cached task's declared file inputs:
1560
+ * Whether a changed path is one of a task's declared file inputs:
1482
1561
  * `projectRel` (the path relative to the project that owns it) against
1483
1562
  * `files`, `workspaceRel` (relative to the workspace root) against
1484
1563
  * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1485
1564
  * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
1486
1565
  */
1487
1566
  export function declaresInput(
1488
- cache: CacheConfig,
1567
+ task: TaskConfig,
1489
1568
  projectRel: string | null,
1490
1569
  workspaceRel: string | null,
1491
1570
  ): boolean {
1571
+ const inputs = task.inputs
1572
+ if (inputs === undefined) return false
1492
1573
  if (projectRel !== null) {
1493
1574
  // A changed path under the task's own outputs is a committed file
1494
1575
  // (`git diff` lists only what git tracks) or a stray `git status`
1495
1576
  // lists: either way it may be an input, so it counts.
1496
- const plan = filesPlan(cache.inputs.files, cache.outputs.files)
1577
+ const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1497
1578
  if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
1498
1579
  }
1499
- const ws = cache.inputs.workspaceFiles
1580
+ const ws = inputs.workspaceFiles
1500
1581
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1501
1582
  return workspaceMatcher(ws)(workspaceRel)
1502
1583
  }
1503
1584
 
1504
1585
  /**
1505
- * Whether a cached task's `workspaceFiles` may name a file under `dir`
1586
+ * Whether a task's `workspaceFiles` may name a file under `dir`
1506
1587
  * (workspace-relative): a nested repository git reports as that one path,
1507
1588
  * whose files the key folds and a match against the path alone misses.
1508
1589
  * By the entries' static prefixes, so a glob that may descend counts.
1509
1590
  */
1510
- export function workspaceFilesReachInto(cache: CacheConfig, dir: string): boolean {
1511
- const ws = cache.inputs.workspaceFiles
1591
+ export function workspaceFilesReachInto(task: TaskConfig, dir: string): boolean {
1592
+ const ws = task.inputs?.workspaceFiles
1512
1593
  if (ws === undefined || ws.length === 0) return false
1513
1594
  return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
1514
1595
  }
1515
1596
 
1597
+ /**
1598
+ * The paths a task names outright (literals, and globs' fixed prefixes),
1599
+ * `files` relative to its project and `workspaceFiles` to the root: where a
1600
+ * gitignored input can be (`ignoredReach`).
1601
+ */
1602
+ export function namedOutright(task: TaskConfig): { files: string[]; workspaceFiles: string[] } {
1603
+ const inputs = task.inputs
1604
+ const plan =
1605
+ inputs === undefined ? null : filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1606
+ const ws = inputs?.workspaceFiles ?? []
1607
+ return {
1608
+ files: plan === null ? [] : outright(plan),
1609
+ workspaceFiles: ws.length === 0 ? [] : outright(reachOf(splitNegations(ws).positive)),
1610
+ }
1611
+ }
1612
+
1516
1613
  function inPlan(plan: FilesPlan, rel: string): boolean {
1517
1614
  let input = plan.verdicts.get(rel)
1518
1615
  if (input === undefined) {
@@ -1577,7 +1674,14 @@ async function resolveFiles(
1577
1674
  // inputs must be walked again.
1578
1675
  if (memo !== undefined && memo.snapshot === gitFiles) {
1579
1676
  return {
1580
- files: [...memo.result],
1677
+ files: withIgnored(
1678
+ args.projectDir,
1679
+ memo.result,
1680
+ ignoredReach(plan, gitFiles),
1681
+ plan,
1682
+ nested,
1683
+ ownWsOutput,
1684
+ ),
1581
1685
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1582
1686
  }
1583
1687
  }
@@ -1635,10 +1739,7 @@ async function resolveFiles(
1635
1739
  if (!input && !committed(abs)) continue
1636
1740
  candidates.push(abs)
1637
1741
  }
1638
- const unmatchedLiterals = unanswered(plan.literals, gitFiles)
1639
- if (unmatchedLiterals.size > 0) {
1640
- await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
1641
- }
1742
+ const reach = ignoredReach(plan, gitFiles)
1642
1743
  refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
1643
1744
  // Second pass: existence check — but ONLY for paths without a
1644
1745
  // trusted index OID. A clean-per-status tracked file necessarily
@@ -1656,15 +1757,31 @@ async function resolveFiles(
1656
1757
  break
1657
1758
  }
1658
1759
  }
1659
- // Stored only on the way out: a declaration whose literal named an
1660
- // invisible file threw above, and every task sharing it must throw too.
1661
1760
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1662
1761
  return {
1663
- files: [...resolved],
1762
+ files: withIgnored(args.projectDir, resolved, reach, plan, nested, ownWsOutput),
1664
1763
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1665
1764
  }
1666
1765
  }
1667
1766
 
1767
+ /** The resolved files, plus the gitignored ones the declaration names outright. */
1768
+ function withIgnored(
1769
+ projectDir: string,
1770
+ resolved: readonly string[],
1771
+ reach: readonly string[],
1772
+ plan: FilesPlan,
1773
+ nested: (rel: string) => boolean,
1774
+ ownWsOutput: (rel: string) => boolean,
1775
+ ): string[] {
1776
+ if (reach.length === 0) return [...resolved]
1777
+ const extra = ignoredInputs(
1778
+ projectDir,
1779
+ reach,
1780
+ (rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
1781
+ )
1782
+ return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
1783
+ }
1784
+
1668
1785
  function listingFor(
1669
1786
  args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
1670
1787
  listed: readonly string[],
@@ -28,7 +28,7 @@ import type { CacheKeyInput } from './layer.js'
28
28
  // in any cache key. SCHEMA v21 drops the now-unused outputs_hash
29
29
  // column. Early cutoff removed (an upstream that re-emits identical
30
30
  // output still re-runs dependents) — rare, not worth the cascade.
31
- // v23: fold cache.inputs.runtime / workspaceRuntime command output into
31
+ // v23: fold inputs.runtime / workspaceRuntime command output into
32
32
  // the key (two namespaced sections after env-values). Command strings
33
33
  // live in the resolved config (frozen by `vx lock`); the OUTPUT is
34
34
  // resolved live every run, so it stays correct under the lock. No
@@ -144,6 +144,12 @@ export async function foldKey(
144
144
  * prints nothing.
145
145
  */
146
146
  digestValue: (value: string) => string = xxh3hex,
147
+ /**
148
+ * The content behind an input that is a symlink (linked-inputs.ts), folded
149
+ * after the link's own target string. Absent, a link folds its target
150
+ * string alone: a caller that keys no worktree.
151
+ */
152
+ behind?: (link: string) => Promise<string>,
147
153
  ): Promise<string> {
148
154
  // Seed-chained xxHash3: each step folds one field into the
149
155
  // running digest via `xxh3(part, prevDigest)`. Equivalent to the
@@ -168,7 +174,7 @@ export async function foldKey(
168
174
  // NOTE on `cap` (entry_inputs capture): the `hash` field stores a
169
175
  // DIGEST of each component, never the raw value. For value-bearing kinds
170
176
  // (env, runtime, ws-runtime, forward) the payload is a secret or
171
- // sensitive string (API keys via cache.inputs.env, runtime-command
177
+ // sensitive string (API keys via inputs.env, runtime-command
172
178
  // output, args after `--`), so we push `digestValue(v)` — the diff
173
179
  // consumer only needs to know whether a component CHANGED, which a
174
180
  // digest preserves losslessly. cache.db must never hold plaintext
@@ -268,13 +274,27 @@ export async function foldKey(
268
274
  fileHashes ??= await Promise.all(
269
275
  sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
270
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
+ }
271
288
  for (let i = 0; i < sortedInputs.length; i++) {
272
289
  const file = sortedInputs[i]!
273
290
  const rel = relOf(file)
274
291
  const oid = fileHashes[i]!
275
- h = xxh3(`${rel}\0${oid}`, h)
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)
276
295
  // The OID is already awaited — file capture is zero extra I/O.
277
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) })
278
298
  }
279
299
 
280
300
  return h.toString(16).padStart(16, '0')