@vzn/vx 0.0.639 → 0.0.640

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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +111 -83
  3. package/docs/cli.md +70 -40
  4. package/docs/execution.md +4 -4
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +235 -180
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/show.json +14 -14
  11. package/skills/vx/SKILL.md +4 -3
  12. package/src/cache/cache.ts +4 -1
  13. package/src/cache/file-hashes.ts +7 -12
  14. package/src/cache/fs-clock.ts +62 -0
  15. package/src/cache/git-inputs.ts +2 -2
  16. package/src/cache/index.ts +3 -0
  17. package/src/cache/inputs.ts +154 -83
  18. package/src/cache/key-fold.ts +23 -3
  19. package/src/cache/layer.ts +8 -7
  20. package/src/cache/linked-inputs.ts +87 -0
  21. package/src/cli/foreign-flags.ts +1 -1
  22. package/src/cli/init.ts +23 -11
  23. package/src/cli/last.ts +24 -1
  24. package/src/cli/plugin-templates.ts +2 -2
  25. package/src/cli/select.ts +16 -6
  26. package/src/cli/show.ts +9 -6
  27. package/src/cli/upgrade.ts +95 -19
  28. package/src/cli/watch-filter.ts +22 -6
  29. package/src/cli/watch-judge.ts +49 -5
  30. package/src/cli/watch-set.ts +18 -8
  31. package/src/cli/watch.ts +74 -2
  32. package/src/cli/why.ts +5 -3
  33. package/src/config.ts +19 -15
  34. package/src/exec/executor.ts +4 -4
  35. package/src/graph/dependency-spec.ts +2 -2
  36. package/src/graph/folds.ts +236 -0
  37. package/src/graph/index.ts +8 -1
  38. package/src/graph/scheduler.ts +41 -15
  39. package/src/graph/task-graph.ts +135 -73
  40. package/src/index.ts +1 -1
  41. package/src/orchestrator/affected-tasks.ts +20 -19
  42. package/src/orchestrator/download-policy.ts +16 -22
  43. package/src/orchestrator/excluded-keys.ts +4 -5
  44. package/src/orchestrator/execute-task.ts +29 -25
  45. package/src/orchestrator/index.ts +1 -0
  46. package/src/orchestrator/keyed-projects.ts +12 -51
  47. package/src/orchestrator/local-shortcircuit.ts +1 -1
  48. package/src/orchestrator/persistent-task.ts +3 -3
  49. package/src/orchestrator/placement.ts +2 -2
  50. package/src/orchestrator/plan.ts +1 -1
  51. package/src/orchestrator/plugin-host.ts +62 -22
  52. package/src/orchestrator/plugin.ts +12 -2
  53. package/src/orchestrator/prepare.ts +12 -5
  54. package/src/orchestrator/projects.ts +7 -10
  55. package/src/orchestrator/run-report.ts +124 -46
  56. package/src/orchestrator/run-steps.ts +57 -0
  57. package/src/orchestrator/run.ts +51 -21
  58. package/src/orchestrator/sandbox-fix.ts +1 -1
  59. package/src/orchestrator/sandbox-request.ts +2 -2
  60. package/src/orchestrator/stable-keys.ts +59 -26
  61. package/src/orchestrator/steps.ts +26 -6
  62. package/src/orchestrator/task-hash.ts +31 -9
  63. package/src/orchestrator/telemetry-host.ts +3 -3
  64. package/src/orchestrator/telemetry.ts +9 -4
  65. package/src/orchestrator/upstream.ts +4 -181
  66. package/src/report/framed-output.ts +1 -1
  67. package/src/report/status-line.ts +7 -3
  68. package/src/settings.ts +11 -17
  69. package/src/util/paths.ts +1 -1
  70. package/src/workspace/affected.ts +81 -2
  71. package/src/workspace/config-imports.ts +1 -1
  72. package/src/workspace/config-schema.ts +142 -143
  73. package/src/workspace/lockfile.ts +1 -1
  74. package/src/workspace/migration.ts +19 -3
@@ -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,
@@ -121,7 +121,7 @@ export interface ResolveInputsArgs {
121
121
  projectDir: string
122
122
  workspaceRoot: string
123
123
  envSource: NodeJS.ProcessEnv
124
- inputs: CacheInputs | undefined
124
+ inputs: InputsConfig | undefined
125
125
  /** Project-relative output globs to exclude from inputs. */
126
126
  ownOutputs: readonly string[]
127
127
  /** Root-relative `outputs.workspaceFiles` globs to exclude from
@@ -137,7 +137,7 @@ export interface ResolveInputsArgs {
137
137
  */
138
138
  gitFilesCache?: GitFilesCache
139
139
  /**
140
- * Run-scoped memo for `cache.inputs.runtime` command execution, keyed
140
+ * Run-scoped memo for `inputs.runtime` command execution, keyed
141
141
  * by `projectDir + '\0' + command`. Shared across a run's tasks so a
142
142
  * project's command runs once even across build/test/lint and across
143
143
  * the hash + sandbox-baseline resolveInputs calls. The key is sound
@@ -148,7 +148,7 @@ export interface ResolveInputsArgs {
148
148
  */
149
149
  runtimeCache?: Map<string, Promise<string>>
150
150
  /**
151
- * Run-scoped memo for `cache.inputs.workspaceRuntime`, keyed by command
151
+ * Run-scoped memo for `inputs.workspaceRuntime`, keyed by command
152
152
  * only — global dedup so a root-level probe spawns once per run.
153
153
  */
154
154
  workspaceRuntimeCache?: Map<string, Promise<string>>
@@ -160,7 +160,7 @@ export interface ResolveInputsArgs {
160
160
  */
161
161
  runtimeScope?: string
162
162
  /**
163
- * Run-scoped memo for `cache.inputs.workspaceFiles`, keyed by the
163
+ * Run-scoped memo for `inputs.workspaceFiles`, keyed by the
164
164
  * declaration and valid for one enumeration snapshot. A Turbo-mapped
165
165
  * workspace gives every task the same `globalDependencies`, and
166
166
  * resolving one literal against medusa's 24k-file enumeration per task
@@ -177,7 +177,7 @@ export type WorkspaceFilesCache = Map<
177
177
  >
178
178
 
179
179
  /**
180
- * Per-run memo of `cache.inputs.files` resolution, keyed by the project and
180
+ * Per-run memo of `inputs.files` resolution, keyed by the project and
181
181
  * the DECLARATION — tasks of one project that declare the same inputs and
182
182
  * the same outputs resolve to the same list, and this repo's own config is
183
183
  * the shape that pays for it: twelve shard tasks, each declaring the same
@@ -263,7 +263,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
263
263
  }
264
264
 
265
265
  /**
266
- * Resolve `cache.inputs.workspaceFiles` — workspace-root-relative
266
+ * Resolve `inputs.workspaceFiles` — workspace-root-relative
267
267
  * globs matched against the workspace-wide git file set (tracked +
268
268
  * untracked-not-ignored, same visibility as project inputs).
269
269
  *
@@ -354,10 +354,21 @@ function resolveWorkspaceFiles(args: {
354
354
  const ownsAny =
355
355
  splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
356
356
  splitNegations(args.ownOutputs).positive.length > 0
357
+ const reach = ignoredReach(reachOf(positive), gitFiles)
357
358
  const files = base.then((all) => {
358
359
  const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
359
360
  refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
360
- return kept
361
+ if (reach.length === 0) return kept
362
+ // Outside the memo: an ignored file a task writes mid-run leaves the
363
+ // snapshot as it was.
364
+ isPositive ??= anyTaskGlob(asTrees(positive))
365
+ const accept = isPositive
366
+ const extra = ignoredInputs(
367
+ args.workspaceRoot,
368
+ reach,
369
+ (rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
370
+ )
371
+ return extra.length === 0 ? kept : mergeSorted(kept, extra)
361
372
  })
362
373
  return { files, listing }
363
374
  }
@@ -369,23 +380,12 @@ async function resolveWorkspaceFilesOver(
369
380
  isPositive: (rel: string) => boolean,
370
381
  excluded: (rel: string) => boolean,
371
382
  ): 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
383
  const candidates: string[] = []
381
384
  for (const rel of gitFiles) {
382
385
  if (!isPositive(rel)) continue
383
386
  if (excluded(rel)) continue
384
387
  candidates.push(path.resolve(args.workspaceRoot, rel))
385
388
  }
386
- if (unmatchedLiterals.size > 0) {
387
- await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
388
- }
389
389
  // Same OID-trust shortcut as project files: a clean-per-status
390
390
  // tracked file necessarily exists on disk.
391
391
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
@@ -536,10 +536,10 @@ async function runRuntimeCommand(
536
536
  // the shell, not the command (item 244).
537
537
  if (isExecutableMissing(err)) {
538
538
  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.`,
539
+ `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
540
  )
541
541
  }
542
- throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
542
+ throw new UserError(`inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
543
543
  }
544
544
  liveProbes.add(proc)
545
545
  let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
@@ -564,7 +564,7 @@ async function runRuntimeCommand(
564
564
  const lossy = new TextDecoder()
565
565
  const shown = `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim()
566
566
  throw new UserError(
567
- `cache.inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
567
+ `inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
568
568
  (shown ? `\n${shown}` : ''),
569
569
  )
570
570
  }
@@ -577,7 +577,7 @@ async function runRuntimeCommand(
577
577
  // A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
578
578
  // folded the same output and replayed each other's build.
579
579
  throw new UserError(
580
- `cache.inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
580
+ `inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
581
581
  `Pipe it through a hash or od.`,
582
582
  )
583
583
  }
@@ -1282,52 +1282,85 @@ function lowerBound(files: readonly string[], key: string): number {
1282
1282
  }
1283
1283
 
1284
1284
  /**
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.
1285
+ * The entries a declaration names outright that git answers nothing for: a
1286
+ * literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
1287
+ * path at or under it. Such a path is gitignored or absent, and naming it
1288
+ * is asking for it (owner, 2026-10-16), so {@link ignoredInputs} reads it
1289
+ * from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
1290
+ * no ignored file: `node_modules` is never walked.
1312
1291
  */
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
- )
1292
+ function ignoredReach(
1293
+ reach: { prefixes: readonly string[]; literals: readonly string[] },
1294
+ listed: readonly string[],
1295
+ ): readonly string[] {
1296
+ const named = outright(reach)
1297
+ if (named.length === 0) return []
1298
+ // Per listing and entries: thousands of tasks share a few declarations.
1299
+ let memo = reachMemo.get(listed)
1300
+ if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
1301
+ const key = named.join('\0')
1302
+ let left = memo.get(key)
1303
+ if (left === undefined) memo.set(key, (left = [...unanswered(named, listed)]))
1304
+ return left
1305
+ }
1306
+
1307
+ const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
1308
+
1309
+ /**
1310
+ * A declaration's literals and globs' fixed prefixes, each once: none for a
1311
+ * glob that starts with a wildcard, and none that leaves the directory the
1312
+ * entries are relative to (a project's boundary is hard).
1313
+ */
1314
+ function outright(reach: { prefixes: readonly string[]; literals: readonly string[] }): string[] {
1315
+ return [
1316
+ ...new Set(
1317
+ [...reach.literals, ...reach.prefixes].filter(
1318
+ (p) => p !== '' && p !== '.' && p !== '..' && !p.startsWith('../') && !path.isAbsolute(p),
1319
+ ),
1320
+ ),
1321
+ ]
1322
+ }
1323
+
1324
+ /**
1325
+ * The files on disk under the {@link ignoredReach} entries that `accept`
1326
+ * keeps, sorted and absolute. A file is hashed from disk like any untracked
1327
+ * one; nothing here is stored or restored. An install (`node_modules`)
1328
+ * stays out, as in the enumeration: lockfile plugins key it. lstat, not
1329
+ * `exists()`: that answers false for a directory. Never memoized: a dependency writes these
1330
+ * mid-run.
1331
+ */
1332
+ function ignoredInputs(
1333
+ root: string,
1334
+ reach: readonly string[],
1335
+ accept: (rel: string) => boolean,
1336
+ ): string[] {
1337
+ const found = new Set<string>()
1338
+ for (const entry of reach) {
1339
+ const abs = path.resolve(root, entry)
1340
+ const st = lstatSync(abs, { throwIfNoEntry: false })
1341
+ if (st === undefined) continue
1342
+ if (!st.isDirectory()) {
1343
+ if ((st.isFile() || st.isSymbolicLink()) && !isInstalledPath(entry) && accept(entry))
1344
+ found.add(abs)
1345
+ continue
1346
+ }
1347
+ for (const rel of WALK.scanSync({
1348
+ cwd: abs,
1349
+ onlyFiles: false,
1350
+ followSymlinks: false,
1351
+ dot: true,
1352
+ })) {
1353
+ const full = `${entry}/${rel.split(path.sep).join('/')}`
1354
+ if (isInstalledPath(full) || !accept(full)) continue
1355
+ const file = path.join(abs, rel)
1356
+ if (isInputOnDisk(file)) found.add(file)
1357
+ }
1328
1358
  }
1359
+ return [...found].sort()
1329
1360
  }
1330
1361
 
1362
+ const WALK = new Bun.Glob('**')
1363
+
1331
1364
  interface ResolveFilesArgs {
1332
1365
  projectDir: string
1333
1366
  workspaceRoot: string
@@ -1373,7 +1406,7 @@ function refuseUndecodable(
1373
1406
  if (bad.length === 0) return
1374
1407
  const names = bad.map((abs) => JSON.stringify(relPosix(root, abs)))
1375
1408
  throw new UserError(
1376
- `cache.inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
1409
+ `inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
1377
1410
  `(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
1378
1411
  `negated glob.`,
1379
1412
  )
@@ -1396,7 +1429,7 @@ function refuseOneAlternativeBrace(
1396
1429
  if (m === null) continue
1397
1430
  const inner = m[0].slice(1, -1)
1398
1431
  throw new UserError(
1399
- `cache.inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
1432
+ `inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
1400
1433
  `"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
1401
1434
  `or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
1402
1435
  )
@@ -1478,41 +1511,59 @@ function filesPlan(
1478
1511
  }
1479
1512
 
1480
1513
  /**
1481
- * Whether a changed path is one of a cached task's declared file inputs:
1514
+ * Whether a changed path is one of a task's declared file inputs:
1482
1515
  * `projectRel` (the path relative to the project that owns it) against
1483
1516
  * `files`, `workspaceRel` (relative to the workspace root) against
1484
1517
  * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1485
1518
  * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
1486
1519
  */
1487
1520
  export function declaresInput(
1488
- cache: CacheConfig,
1521
+ task: TaskConfig,
1489
1522
  projectRel: string | null,
1490
1523
  workspaceRel: string | null,
1491
1524
  ): boolean {
1525
+ const inputs = task.inputs
1526
+ if (inputs === undefined) return false
1492
1527
  if (projectRel !== null) {
1493
1528
  // A changed path under the task's own outputs is a committed file
1494
1529
  // (`git diff` lists only what git tracks) or a stray `git status`
1495
1530
  // lists: either way it may be an input, so it counts.
1496
- const plan = filesPlan(cache.inputs.files, cache.outputs.files)
1531
+ const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1497
1532
  if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
1498
1533
  }
1499
- const ws = cache.inputs.workspaceFiles
1534
+ const ws = inputs.workspaceFiles
1500
1535
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1501
1536
  return workspaceMatcher(ws)(workspaceRel)
1502
1537
  }
1503
1538
 
1504
1539
  /**
1505
- * Whether a cached task's `workspaceFiles` may name a file under `dir`
1540
+ * Whether a task's `workspaceFiles` may name a file under `dir`
1506
1541
  * (workspace-relative): a nested repository git reports as that one path,
1507
1542
  * whose files the key folds and a match against the path alone misses.
1508
1543
  * By the entries' static prefixes, so a glob that may descend counts.
1509
1544
  */
1510
- export function workspaceFilesReachInto(cache: CacheConfig, dir: string): boolean {
1511
- const ws = cache.inputs.workspaceFiles
1545
+ export function workspaceFilesReachInto(task: TaskConfig, dir: string): boolean {
1546
+ const ws = task.inputs?.workspaceFiles
1512
1547
  if (ws === undefined || ws.length === 0) return false
1513
1548
  return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
1514
1549
  }
1515
1550
 
1551
+ /**
1552
+ * The paths a task names outright (literals, and globs' fixed prefixes),
1553
+ * `files` relative to its project and `workspaceFiles` to the root: where a
1554
+ * gitignored input can be (`ignoredReach`).
1555
+ */
1556
+ export function namedOutright(task: TaskConfig): { files: string[]; workspaceFiles: string[] } {
1557
+ const inputs = task.inputs
1558
+ const plan =
1559
+ inputs === undefined ? null : filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1560
+ const ws = inputs?.workspaceFiles ?? []
1561
+ return {
1562
+ files: plan === null ? [] : outright(plan),
1563
+ workspaceFiles: ws.length === 0 ? [] : outright(reachOf(splitNegations(ws).positive)),
1564
+ }
1565
+ }
1566
+
1516
1567
  function inPlan(plan: FilesPlan, rel: string): boolean {
1517
1568
  let input = plan.verdicts.get(rel)
1518
1569
  if (input === undefined) {
@@ -1577,7 +1628,14 @@ async function resolveFiles(
1577
1628
  // inputs must be walked again.
1578
1629
  if (memo !== undefined && memo.snapshot === gitFiles) {
1579
1630
  return {
1580
- files: [...memo.result],
1631
+ files: withIgnored(
1632
+ args.projectDir,
1633
+ memo.result,
1634
+ ignoredReach(plan, gitFiles),
1635
+ plan,
1636
+ nested,
1637
+ ownWsOutput,
1638
+ ),
1581
1639
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1582
1640
  }
1583
1641
  }
@@ -1635,10 +1693,7 @@ async function resolveFiles(
1635
1693
  if (!input && !committed(abs)) continue
1636
1694
  candidates.push(abs)
1637
1695
  }
1638
- const unmatchedLiterals = unanswered(plan.literals, gitFiles)
1639
- if (unmatchedLiterals.size > 0) {
1640
- await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
1641
- }
1696
+ const reach = ignoredReach(plan, gitFiles)
1642
1697
  refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
1643
1698
  // Second pass: existence check — but ONLY for paths without a
1644
1699
  // trusted index OID. A clean-per-status tracked file necessarily
@@ -1656,15 +1711,31 @@ async function resolveFiles(
1656
1711
  break
1657
1712
  }
1658
1713
  }
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
1714
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1662
1715
  return {
1663
- files: [...resolved],
1716
+ files: withIgnored(args.projectDir, resolved, reach, plan, nested, ownWsOutput),
1664
1717
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1665
1718
  }
1666
1719
  }
1667
1720
 
1721
+ /** The resolved files, plus the gitignored ones the declaration names outright. */
1722
+ function withIgnored(
1723
+ projectDir: string,
1724
+ resolved: readonly string[],
1725
+ reach: readonly string[],
1726
+ plan: FilesPlan,
1727
+ nested: (rel: string) => boolean,
1728
+ ownWsOutput: (rel: string) => boolean,
1729
+ ): string[] {
1730
+ if (reach.length === 0) return [...resolved]
1731
+ const extra = ignoredInputs(
1732
+ projectDir,
1733
+ reach,
1734
+ (rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
1735
+ )
1736
+ return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
1737
+ }
1738
+
1668
1739
  function listingFor(
1669
1740
  args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
1670
1741
  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')
@@ -5,13 +5,14 @@
5
5
  // reference to `Cache` below is a type (the local handle a layer may wrap).
6
6
 
7
7
  import type { Cache } from './cache.js'
8
+ import { fsClockLag, stampGrainMs } from './fs-clock.js'
8
9
 
9
10
  export interface CacheKeyInput {
10
11
  taskId: string
11
12
  /**
12
13
  * Hash of the resolved task config (post-evaluation). Folds in everything
13
14
  * the user wrote — command, env declarations (passThrough names + define
14
- * key/value pairs), dependsOn, cache.inputs declarations, outputs — including
15
+ * key/value pairs), dependsOn, inputs declarations, outputs — including
15
16
  * values that arrived via `import` at config-load time.
16
17
  */
17
18
  taskConfigHash: string
@@ -22,12 +23,12 @@ export interface CacheKeyInput {
22
23
  */
23
24
  envValues: Array<[name: string, value: string | undefined]>
24
25
  /**
25
- * Resolved `cache.inputs.runtime` commands as [command, output] pairs
26
+ * Resolved `inputs.runtime` commands as [command, output] pairs
26
27
  * (output = trimmed stdout+stderr, resolved live at hash time). Folded
27
28
  * into the key in a namespace distinct from workspaceRuntimeValues.
28
29
  */
29
30
  runtimeValues?: Array<[command: string, output: string]>
30
- /** Resolved `cache.inputs.workspaceRuntime` pairs (root-cwd commands). */
31
+ /** Resolved `inputs.workspaceRuntime` pairs (root-cwd commands). */
31
32
  workspaceRuntimeValues?: Array<[command: string, output: string]>
32
33
  /** Absolute paths to input files. */
33
34
  inputFiles: string[]
@@ -51,7 +52,7 @@ export interface CacheKeyInput {
51
52
  * - It expands groups, which contribute a synthetic roll-up hash and no
52
53
  * outputs of their own, so a dependent of one would otherwise describe
53
54
  * an empty closure.
54
- * - It ignores `cache.inputs.tasks`. That filter says which upstream KEYS
55
+ * - It ignores `inputs.tasks`. That filter says which upstream KEYS
55
56
  * this task's key folds; what the task may READ is `dependsOn`, and
56
57
  * locally every dependency's outputs are on disk before the command runs
57
58
  * however the filter is written.
@@ -411,11 +412,11 @@ export const FILE_HASH_RACY_MS = 50
411
412
  * field (A-38). Pass the UNFLOORED stamp: a kernel's nanosecond stamp lands
412
413
  * on a whole second about once in 10^9, one set through a millisecond
413
414
  * `utimes` once in a thousand, and either is then trusted up to two seconds
414
- * later — never wrongly.
415
+ * later — never wrongly. And every window is widened by how far the file
416
+ * system's clock runs behind this process's (`fsClockLag`, fs-clock.ts).
415
417
  */
416
418
  export function racyWindowMs(stampMs: number, windowMs: number): number {
417
- if (stampMs % 2000 === 0) return windowMs + 2000
418
- return stampMs % 1000 === 0 ? windowMs + 1000 : windowMs
419
+ return windowMs + stampGrainMs(stampMs) + fsClockLag()
419
420
  }
420
421
 
421
422
  /**
@@ -0,0 +1,87 @@
1
+ // A symlinked input keys the content behind it, as Nx reads a linked file
2
+ // where it points (owner, 2026-10-16: "Do like nx"). Its own fold stays the
3
+ // blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
4
+ // moves the key; this is what follows it. The target string alone let an
5
+ // edit behind the link replay the old output (Growth, 2026-10-16), wherever
6
+ // the target lies: in the project, elsewhere in the workspace, outside it.
7
+ // A directory link keys every file beneath it, tracked or not, where Nx
8
+ // does not enter one: an edit there is an edit to what the task reads.
9
+ // `--affected` (workspace/affected.ts) and `vx watch` (cli/watch.ts) read
10
+ // the same targets.
11
+
12
+ import { readdirSync, realpathSync, statSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import { xxh3hex } from '../util/index.js'
15
+ import { absentOr } from './key-fold.js'
16
+
17
+ /** What a dangling link, or a loop of links, holds behind it: nothing to read. */
18
+ const NOTHING_BEHIND = ''
19
+
20
+ /**
21
+ * Directories a linked directory's walk never enters, as Nx's walker never
22
+ * does (its HARDCODED_IGNORE_PATTERNS): a link to a workspace would
23
+ * otherwise key the cache each run writes.
24
+ */
25
+ const SKIPPED = ['.git', 'node_modules', '.vx/cache', '.yarn/cache']
26
+ const skipped = (rel: string): boolean => SKIPPED.some((s) => rel === s || rel.endsWith(`/${s}`))
27
+
28
+ /**
29
+ * The files under `dir` a linked directory keys, relative and `/`-separated,
30
+ * sorted: every regular file and link at any depth. A link is listed, never
31
+ * entered (`behindLink` follows it), so a loop of directories ends.
32
+ */
33
+ export function filesBehind(dir: string): string[] {
34
+ const out: string[] = []
35
+ const walk = (abs: string, rel: string): void => {
36
+ let entries
37
+ try {
38
+ entries = readdirSync(abs, { withFileTypes: true })
39
+ } catch {
40
+ return
41
+ }
42
+ for (const e of entries) {
43
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
44
+ if (e.isDirectory()) {
45
+ if (!skipped(childRel)) walk(path.join(abs, e.name), childRel)
46
+ } else if (e.isFile() || e.isSymbolicLink()) out.push(childRel)
47
+ }
48
+ }
49
+ walk(dir, '')
50
+ return out.sort()
51
+ }
52
+
53
+ /**
54
+ * The content behind the symlink at `link`, as one string: a file's identity
55
+ * (`hashFile` of its real path), a directory's files and theirs, or
56
+ * `NOTHING_BEHIND` for a dangling link or a loop. A special file (a FIFO, a
57
+ * device) is named, never read: a read can block. Never memoized: an upstream
58
+ * task may write behind the link between two keys.
59
+ */
60
+ export async function behindLink(
61
+ link: string,
62
+ hashFile: (file: string) => Promise<string>,
63
+ seen: ReadonlySet<string> = new Set(),
64
+ ): Promise<string> {
65
+ let real: string
66
+ let st
67
+ try {
68
+ real = realpathSync(link)
69
+ st = statSync(real)
70
+ } catch {
71
+ return NOTHING_BEHIND
72
+ }
73
+ if (seen.has(real)) return NOTHING_BEHIND
74
+ if (st.isFile()) return `file\0${await hashFile(real).catch(absentOr)}`
75
+ if (!st.isDirectory()) return 'special'
76
+ const inner = new Set(seen).add(real)
77
+ const parts = await Promise.all(
78
+ filesBehind(real).map(async (rel) => {
79
+ const abs = path.join(real, rel)
80
+ const id = await hashFile(abs).catch(absentOr)
81
+ return id.startsWith('120000:')
82
+ ? `${rel}\0${id}\0${await behindLink(abs, hashFile, inner)}`
83
+ : `${rel}\0${id}`
84
+ }),
85
+ )
86
+ return `dir\0${xxh3hex(parts.join('\n'))}`
87
+ }
@@ -343,7 +343,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
343
343
  names: ['--global-deps'],
344
344
  value: true,
345
345
  outcome: 'refuse',
346
- vx: "declare them in `cache.inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
346
+ vx: "declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
347
347
  },
348
348
  {
349
349
  runner: 'turbo',