@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -21,6 +21,7 @@ import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } fro
21
21
  import { rm, rmdir } from 'node:fs/promises'
22
22
  import type { InputsConfig, TaskConfig } from '../config.js'
23
23
  import {
24
+ literalPath,
24
25
  anyTaskGlob,
25
26
  asTrees,
26
27
  isExecutableMissing,
@@ -35,13 +36,16 @@ import {
35
36
  shellArgv,
36
37
  slashBraceExpansions,
37
38
  braceAlternatives,
39
+ ESSENTIAL_ENV,
38
40
  splitNegations,
39
41
  staticPrefix,
40
42
  stripTrailingSlash,
41
43
  taskGlob,
42
44
  UserError,
45
+ writableUnder,
46
+ withoutWorkspaceBins,
43
47
  } from '../util/index.js'
44
- import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
48
+ import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
45
49
  import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
46
50
 
47
51
  // The git side lives in git-inputs.ts; its whole public surface is
@@ -142,8 +146,8 @@ export interface ResolveInputsArgs {
142
146
  * by `projectDir + '\0' + command`. Shared across a run's tasks so a
143
147
  * project's command runs once even across build/test/lint and across
144
148
  * the hash + sandbox-baseline resolveInputs calls. The key is sound
145
- * ONLY because the probe runs in vx's own ambient environment, never a
146
- * task's `exec.env` (`runRuntimeCommand`): two tasks with different
149
+ * ONLY because every probe runs in one environment, vx's essentials,
150
+ * never a task's `exec.env` (`runRuntimeCommand`): two tasks with different
147
151
  * `define`s share one value. Threading the task env in would have to
148
152
  * widen this key too, or task A's probe becomes task B's key component.
149
153
  */
@@ -287,6 +291,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
287
291
  runtimeDecl,
288
292
  args.projectDir,
289
293
  projectBinDirs(args.projectDir, args.workspaceRoot),
294
+ args.workspaceRoot,
290
295
  args.runtimeCache,
291
296
  args.runtimeScope === undefined
292
297
  ? `${args.projectDir}\0`
@@ -296,6 +301,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
296
301
  wsRuntimeDecl,
297
302
  args.workspaceRoot,
298
303
  [path.join(args.workspaceRoot, 'node_modules', '.bin')],
304
+ args.workspaceRoot,
299
305
  args.workspaceRuntimeCache,
300
306
  args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
301
307
  ),
@@ -364,6 +370,7 @@ function resolveWorkspaceFiles(args: {
364
370
  // caller sees a different array and scans again.
365
371
  let isExcluded: ((rel: string) => boolean) | undefined
366
372
  let isPositive: ((rel: string) => boolean) | undefined
373
+ let mayHold: ((rel: string) => boolean) | undefined
367
374
  const excludedByDeclaration = (rel: string): boolean =>
368
375
  (isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
369
376
  // A committed file stays an input whatever output reaches it (`committedFiles`).
@@ -375,11 +382,14 @@ function resolveWorkspaceFiles(args: {
375
382
  )
376
383
  const listing: InputListing = {
377
384
  root: args.workspaceRoot,
385
+ workspaceRoot: args.workspaceRoot,
378
386
  listed: gitFiles,
379
387
  isInput: (rel) =>
380
- (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
388
+ (isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive))))(rel) &&
381
389
  !excludedByDeclaration(rel) &&
382
390
  !own(rel),
391
+ linked: (rel) =>
392
+ (mayHold ??= mayHoldMatch(positive))(rel) && !excludedByDeclaration(rel) && !own(rel),
383
393
  nested: () => false,
384
394
  ...reachOf(positive),
385
395
  }
@@ -393,7 +403,7 @@ function resolveWorkspaceFiles(args: {
393
403
  const hit = args.memo?.get(declared)
394
404
  if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
395
405
  if (base === undefined) {
396
- isPositive ??= anyTaskGlob(asTrees(positive))
406
+ isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
397
407
  base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
398
408
  args.memo?.set(declared, { snapshot: gitFiles, result: base })
399
409
  }
@@ -410,12 +420,14 @@ function resolveWorkspaceFiles(args: {
410
420
  if (reach.length === 0) return kept
411
421
  // Outside the memo: an ignored file a task writes mid-run leaves the
412
422
  // snapshot as it was.
413
- isPositive ??= anyTaskGlob(asTrees(positive))
423
+ isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
414
424
  const accept = isPositive
415
425
  const extra = ignoredInputs(
416
426
  args.workspaceRoot,
417
427
  reach,
418
428
  (rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
429
+ listing.linked,
430
+ args.gitFilesCache?.vxDirs,
419
431
  )
420
432
  return extra.length === 0 ? kept : mergeSorted(kept, extra)
421
433
  })
@@ -430,14 +442,18 @@ async function resolveWorkspaceFilesOver(
430
442
  excluded: (rel: string) => boolean,
431
443
  ): Promise<string[]> {
432
444
  const candidates: string[] = []
433
- for (const rel of gitFiles) {
434
- if (!isPositive(rel)) continue
435
- if (excluded(rel)) continue
436
- candidates.push(path.resolve(args.workspaceRoot, rel))
437
- }
438
445
  // Same OID-trust shortcut as project files: a clean-per-status
439
446
  // tracked file necessarily exists on disk.
440
447
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
448
+ const mayHold = mayHoldMatch(positive)
449
+ for (const rel of gitFiles) {
450
+ if (isPositive(rel)) {
451
+ if (!excluded(rel)) candidates.push(path.resolve(args.workspaceRoot, rel))
452
+ } else if (mayHold(rel) && !excluded(rel)) {
453
+ const abs = path.resolve(args.workspaceRoot, rel)
454
+ if (dirLinkInput(abs, oids)) candidates.push(abs)
455
+ }
456
+ }
441
457
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
442
458
  }
443
459
 
@@ -524,6 +540,16 @@ function isInputOnDisk(abs: string): boolean {
524
540
  }
525
541
  }
526
542
 
543
+ /** vx's values of `ESSENTIAL_ENV`: the one environment every probe gets. */
544
+ function probeEnv(): Record<string, string> {
545
+ const env: Record<string, string> = {}
546
+ for (const name of ESSENTIAL_ENV) {
547
+ const value = process.env[name]
548
+ if (value !== undefined) env[name] = value
549
+ }
550
+ return env
551
+ }
552
+
527
553
  /** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
528
554
  function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
529
555
  const own = path.join(projectDir, 'node_modules', '.bin')
@@ -548,9 +574,16 @@ export function answerRuntime(
548
574
  command,
549
575
  workspaceRoot,
550
576
  [path.join(workspaceRoot, 'node_modules', '.bin')],
577
+ workspaceRoot,
578
+ undefined,
579
+ )
580
+ : runRuntimeCommand(
581
+ command,
582
+ projectDir,
583
+ projectBinDirs(projectDir, workspaceRoot),
584
+ workspaceRoot,
551
585
  undefined,
552
586
  )
553
- : runRuntimeCommand(command, projectDir, projectBinDirs(projectDir, workspaceRoot), undefined)
554
587
  }
555
588
 
556
589
  function resolveEnvValues(
@@ -565,10 +598,13 @@ function resolveEnvValues(
565
598
  * work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
566
599
  * exit is a hard UserError naming the command (fail-loud, like git).
567
600
  *
568
- * The probe inherits vx's AMBIENT environment on purpose — not the task's
569
- * `exec.env.define` / `passThrough`, which describe the command's
570
- * environment, not the machine's. That is what lets the run-scoped memo
571
- * key on (projectDir, command) alone; see `runtimeCache`.
601
+ * The probe sees the essential environment every task sees
602
+ * (`ESSENTIAL_ENV`), from vx's own — never the task's `exec.env`, which
603
+ * describes the command, not the machine, and never the rest of vx's: a
604
+ * child is dumpable after its exec, so an unsandboxed task read every
605
+ * secret vx holds from the probe's `/proc/<pid>/environ`. One env for
606
+ * every probe is what lets the run-scoped memo key on (projectDir,
607
+ * command) alone; see `runtimeCache`.
572
608
  *
573
609
  * Its PATH does lead with the task's own `node_modules/.bin` directories
574
610
  * (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
@@ -579,9 +615,11 @@ async function runRuntimeCommand(
579
615
  command: string,
580
616
  cwd: string,
581
617
  binDirs: readonly string[],
618
+ workspaceRoot: string,
582
619
  owner: RuntimeMemo | undefined,
583
620
  ): Promise<string> {
584
- const ambient = process.env['PATH']
621
+ // As a task's (exec/env.ts): no member's bin inherited from `npm run`.
622
+ const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
585
623
  // As the task's PATH does (exec/env.ts): a dir holding the delimiter
586
624
  // splits into an entry relative to the probe's cwd.
587
625
  const PATH = [...binDirs.filter((dir) => !dir.includes(path.delimiter)), ambient]
@@ -594,7 +632,7 @@ async function runRuntimeCommand(
594
632
  // `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
595
633
  proc = Bun.spawn(shellArgv(command), {
596
634
  cwd,
597
- env: { ...process.env, PATH },
635
+ env: { ...probeEnv(), PATH },
598
636
  stdin: 'ignore',
599
637
  stdout: 'pipe',
600
638
  stderr: 'pipe',
@@ -730,6 +768,7 @@ async function resolveRuntimeValues(
730
768
  commands: readonly string[],
731
769
  cwd: string,
732
770
  binDirs: readonly string[],
771
+ workspaceRoot: string,
733
772
  memo: Map<string, Promise<string>> | undefined,
734
773
  memoKeyPrefix: string,
735
774
  ): Promise<Array<[string, string]>> {
@@ -740,7 +779,7 @@ async function resolveRuntimeValues(
740
779
  const key = `${memoKeyPrefix}${cmd}`
741
780
  let p = memo?.get(key)
742
781
  if (p === undefined) {
743
- p = runRuntimeCommand(cmd, cwd, binDirs, memo)
782
+ p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
744
783
  memo?.set(key, p)
745
784
  }
746
785
  return [cmd, await p] as [string, string]
@@ -809,7 +848,7 @@ async function scanOutputs(
809
848
  if (positive.length === 0) return { files: [] }
810
849
  const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
811
850
  const trees = asTrees(positive)
812
- refuseDirLinkedOut(args.projectDir, trees)
851
+ refuseDirLinkedOut(args.projectDir, trees, 'project')
813
852
  const scanned = [
814
853
  ...(await scanUnion(
815
854
  trees,
@@ -826,12 +865,13 @@ async function scanOutputs(
826
865
  // outside the project, so any future caller reaching it by another route (a
827
866
  // programmatic embedder, a config source that skips the loader) is contained
828
867
  // by construction.
829
- return { files: containedIn(args.projectDir, scanned).sort() }
868
+ const contained = containedIn(args.projectDir, scanned)
869
+ return { files: outsideNested(args.nestedProjectDirs, contained).sort() }
830
870
  }
831
871
 
832
872
  /**
833
873
  * Whether git tracks a file under `dir`. No clean deletes a committed (or
834
- * `git add`ed) file an output glob reaches (owner, 2026-10-14: "never
874
+ * `git add`ed) file an output glob reaches (2026-10-14: "never
835
875
  * delete committed file"); the task may rewrite it and a restore writes the
836
876
  * bytes it left. It stays in the task's key, so those bytes follow from
837
877
  * the key. Turbo and Nx never clean an output at all.
@@ -846,16 +886,24 @@ export function committedFiles(
846
886
  /**
847
887
  * The literal head of each output glob, walked: the scan follows a link
848
888
  * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
849
- * below it is an output, kept as the link (`archive.ts`).
889
+ * below it is an output, kept as the link (`archive.ts`). One into `.git`
890
+ * or `.vx` is refused in either scope: for the root project and for
891
+ * workspace outputs those are inside, and the clean deleted the
892
+ * repository through `dist -> .git`. One out of a workspace output's root
893
+ * is left to `containedIn`.
850
894
  */
851
- function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
895
+ function refuseDirLinkedOut(
896
+ root: string,
897
+ trees: readonly string[],
898
+ scope: 'project' | 'workspace',
899
+ ): void {
852
900
  let realRoot: string | undefined
853
901
  for (const tree of trees) {
854
- let at = projectDir
902
+ let at = root
855
903
  for (const part of staticPrefix(tree).split('/')) {
856
904
  if (part === '' || part === '.') continue
857
905
  at = path.join(at, part)
858
- if (!isInside(projectDir, at)) break
906
+ if (!isInside(root, at)) break
859
907
  let st
860
908
  try {
861
909
  st = lstatSync(at, { throwIfNoEntry: false })
@@ -871,18 +919,41 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
871
919
  } catch {
872
920
  break
873
921
  }
874
- realRoot ??= realOr(projectDir)
922
+ realRoot ??= realOr(root)
875
923
  if (!isInside(realRoot, real)) {
924
+ if (scope === 'workspace') break
876
925
  throw new UserError(
877
- `${at} is a symbolic link to ${real}, outside ${projectDir} — vx never cleans, saves ` +
926
+ `${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
878
927
  'or restores declared outputs through a link that leaves the project. Remove the ' +
879
928
  'link and re-run, or stop declaring outputs under it.',
880
929
  )
881
930
  }
931
+ if (inMetadata(real, realRoot)) {
932
+ throw new UserError(
933
+ `${at} is a symbolic link to ${real}, Git's or vx's metadata — vx never cleans, ` +
934
+ 'saves or restores declared outputs through a link into .git or .vx. Remove the ' +
935
+ 'link and re-run, or stop declaring outputs under it.',
936
+ )
937
+ }
882
938
  }
883
939
  }
884
940
  }
885
941
 
942
+ /**
943
+ * Whether `real`, under `realRoot`, is in a `.git` there (`writableUnder`'s
944
+ * rule) or in its `.vx`. Asked only of where a link landed: a build's own
945
+ * `dist/.git/**` is an output (2026-10-17).
946
+ */
947
+ function inMetadata(real: string, realRoot: string): boolean {
948
+ if (!writableUnder(real, realRoot)) return true
949
+ return (
950
+ real
951
+ .slice(realRoot.length + 1)
952
+ .split(path.sep, 1)[0]!
953
+ .toLowerCase() === '.vx'
954
+ )
955
+ }
956
+
886
957
  function realOr(p: string): string {
887
958
  try {
888
959
  return realpathSync(p)
@@ -940,6 +1011,57 @@ function containedIn(root: string, paths: readonly string[]): string[] {
940
1011
  return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
941
1012
  }
942
1013
 
1014
+ /**
1015
+ * `committed`, also asked by each path's real directory: the output walk
1016
+ * follows a link at a glob's head that stays inside `root` (X-88), so
1017
+ * `public/logo.txt` may be `static/logo.txt`, the only name git lists.
1018
+ * Asked by the lexical name alone, an untracked `public -> static` link
1019
+ * let the clean delete a committed file. One realpath per directory.
1020
+ */
1021
+ function committedPastLinks(
1022
+ root: string,
1023
+ committed: (abs: string) => boolean,
1024
+ ): (abs: string) => boolean {
1025
+ let realRoot: string | undefined
1026
+ const canonical = new Map<string, string | null>()
1027
+ return (abs) => {
1028
+ if (committed(abs)) return true
1029
+ const dir = path.dirname(abs)
1030
+ let canon = canonical.get(dir)
1031
+ if (canon === undefined) {
1032
+ const real = realOrNull(dir)
1033
+ realRoot ??= realOrNull(root) ?? root
1034
+ canon =
1035
+ real === null || !isInside(realRoot, real)
1036
+ ? null
1037
+ : path.join(root, path.relative(realRoot, real))
1038
+ canonical.set(dir, canon)
1039
+ }
1040
+ return canon !== null && canon !== dir && committed(path.join(canon, path.basename(abs)))
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Drop the files whose real directory is in a nested project: a link at a
1046
+ * glob's head that stays in the project is followed (X-88), and the
1047
+ * lexical nested-project filter cannot see where it lands.
1048
+ */
1049
+ function outsideNested(nestedDirs: readonly string[], files: string[]): string[] {
1050
+ if (nestedDirs.length === 0 || files.length === 0) return files
1051
+ const realNested = nestedDirs.map((d) => realOrNull(d) ?? d)
1052
+ const verdict = new Map<string, boolean>()
1053
+ return files.filter((f) => {
1054
+ const dir = path.dirname(f)
1055
+ let ok = verdict.get(dir)
1056
+ if (ok === undefined) {
1057
+ const real = realOrNull(dir) ?? dir
1058
+ ok = !realNested.some((n) => isInside(n, real))
1059
+ verdict.set(dir, ok)
1060
+ }
1061
+ return ok
1062
+ })
1063
+ }
1064
+
943
1065
  /** Is `abs` the directory `dir` itself or something beneath it? */
944
1066
  function isInside(dir: string, abs: string): boolean {
945
1067
  if (abs === dir) return true
@@ -972,9 +1094,18 @@ export async function cleanOutputs(args: {
972
1094
  */
973
1095
  resolved?: readonly string[] | undefined
974
1096
  }): Promise<string[]> {
975
- const committed = args.committed ?? committedFiles(undefined, args.projectDir)
976
- sweepRestoreTemps(args.projectDir, args.outputs)
977
- const matched = args.resolved ?? (await resolveOutputs(args))
1097
+ const committed = committedPastLinks(
1098
+ args.projectDir,
1099
+ args.committed ?? committedFiles(undefined, args.projectDir),
1100
+ )
1101
+ sweepRestoreTemps(args.projectDir, args.outputs, committed)
1102
+ // An empty directory under the outputs is one too (a save keeps it): one
1103
+ // an older build left would otherwise be saved by this run.
1104
+ const tree =
1105
+ args.resolved === undefined
1106
+ ? await resolveOutputTree(args)
1107
+ : { files: args.resolved, emptyDirs: [] }
1108
+ const matched = tree.files
978
1109
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
979
1110
  // globs overlap and a sibling already deleted a path mid-iteration).
980
1111
  // A symlink is unlinked, never followed.
@@ -982,7 +1113,13 @@ export async function cleanOutputs(args: {
982
1113
  matched.filter((f) => !committed(f)),
983
1114
  args.projectDir,
984
1115
  )
985
- await pruneEmptiedDirs(args.projectDir, removed, pruneScope(args.projectDir, args.outputs))
1116
+ await pruneEmptiedDirs(
1117
+ args.projectDir,
1118
+ removed,
1119
+ pruneScope(args.projectDir, args.outputs),
1120
+ undefined,
1121
+ tree.emptyDirs,
1122
+ )
986
1123
  // Project-relative posix paths the outputs reach, for the caller's
987
1124
  // GitFilesCache.markOutputsChanged: a committed one kept here is the
988
1125
  // task's to rewrite or remove, so its index OID no longer vouches for it.
@@ -999,13 +1136,22 @@ export async function cleanOutputs(args: {
999
1136
  * whose writer is alive, this process included, is left: it may be
1000
1137
  * mid-restore.
1001
1138
  */
1002
- export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
1139
+ export function sweepRestoreTemps(
1140
+ base: string,
1141
+ outputs: readonly string[],
1142
+ committed: (abs: string) => boolean = () => false,
1143
+ ): void {
1003
1144
  const dirs = new Set<string>()
1004
1145
  for (const g of outputs) {
1005
1146
  if (g.startsWith('!') || !isLiteralPattern(g)) continue
1006
1147
  dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
1007
1148
  }
1149
+ // A directory that resolves out of `base` is not the outputs': the link
1150
+ // refusal runs after this sweep, and a delete never follows a link out.
1151
+ let realBase: string | undefined
1008
1152
  for (const dir of dirs) {
1153
+ const real = realOrNull(dir)
1154
+ if (real === null || !isInside((realBase ??= realOrNull(base) ?? base), real)) continue
1009
1155
  let names: string[]
1010
1156
  try {
1011
1157
  names = readdirSync(dir)
@@ -1017,6 +1163,8 @@ export function sweepRestoreTemps(base: string, outputs: readonly string[]): voi
1017
1163
  if (m === null) continue
1018
1164
  const pid = Number.parseInt(m[1]!, 36)
1019
1165
  if (pid === process.pid || writerAlive(pid)) continue
1166
+ // vx never deletes a committed file, whatever its name.
1167
+ if (committed(path.join(dir, name))) continue
1020
1168
  rmSync(path.join(dir, name), { force: true })
1021
1169
  }
1022
1170
  }
@@ -1067,6 +1215,8 @@ export async function cleanOutputPaths(args: {
1067
1215
  committed?: (abs: string) => boolean
1068
1216
  /** Directories the prune leaves standing even when emptied (absolute). */
1069
1217
  keep?: ReadonlySet<string>
1218
+ /** Empty directories to remove too (project-relative). */
1219
+ emptyDirs?: readonly string[]
1070
1220
  }): Promise<void> {
1071
1221
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
1072
1222
  const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
@@ -1075,6 +1225,7 @@ export async function cleanOutputPaths(args: {
1075
1225
  await removeAll(files, args.projectDir),
1076
1226
  pruneScope(args.projectDir, args.outputs),
1077
1227
  args.keep,
1228
+ args.emptyDirs?.map((r) => path.resolve(args.projectDir, r)),
1078
1229
  )
1079
1230
  }
1080
1231
 
@@ -1231,7 +1382,8 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
1231
1382
  * that skipped `dist -> real-out` left the last run's files for the next
1232
1383
  * entry to save (X-88). One that leaves the project is never deleted
1233
1384
  * through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
1234
- * contained set, so the check is here too.
1385
+ * contained set, so the check is here too, and so is the one into
1386
+ * `.git` or `.vx`.
1235
1387
  */
1236
1388
  function insideRoot(files: readonly string[], root: string): string[] {
1237
1389
  // Resolved only once a directory exists to compare: after a clean pruned
@@ -1246,7 +1398,14 @@ function insideRoot(files: readonly string[], root: string): string[] {
1246
1398
  // A directory already gone has nothing to delete through; its path
1247
1399
  // stays so the prune still reaches the parents it emptied.
1248
1400
  const r = realOrNull(dir)
1249
- ok = r === null || isInside((realRoot ??= realOrNull(root) ?? root), r)
1401
+ if (r === null) ok = true
1402
+ else {
1403
+ realRoot ??= realOrNull(root) ?? root
1404
+ // Where no link moved it, a `.git` is the build's own (inMetadata).
1405
+ ok =
1406
+ isInside(realRoot, r) &&
1407
+ (path.relative(realRoot, r) === path.relative(root, dir) || !inMetadata(r, realRoot))
1408
+ }
1250
1409
  own.set(dir, ok)
1251
1410
  }
1252
1411
  return ok
@@ -1254,11 +1413,11 @@ function insideRoot(files: readonly string[], root: string): string[] {
1254
1413
  }
1255
1414
 
1256
1415
  /**
1257
- * Remove the directories a clean emptied, bottom-up, never `root` itself.
1416
+ * Remove the directories a clean emptied, and the `emptyDirs` it found
1417
+ * (an older build's, which a save would keep), bottom-up, never `root` itself.
1258
1418
  * A restore writes files where the cached entry has them; a directory left
1259
1419
  * standing where the entry holds a FILE of the same name (a task that once
1260
- * wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
1261
- * empty directory is not an output anyone declared. A directory that still
1420
+ * wrote `dist/out/…` and now writes `dist/out`) blocks the rename. A directory that still
1262
1421
  * holds something — a stray the globs do not cover — stays, and the restore
1263
1422
  * says so if it is in the way. With a `scope`, only a directory inside it
1264
1423
  * goes (`pruneScope`).
@@ -1268,6 +1427,7 @@ async function pruneEmptiedDirs(
1268
1427
  removed: readonly string[],
1269
1428
  scope?: PruneScope,
1270
1429
  keep?: ReadonlySet<string>,
1430
+ emptyDirs: readonly string[] = [],
1271
1431
  ): Promise<void> {
1272
1432
  const rootResolved = path.resolve(root)
1273
1433
  // LEVEL ORDER, not a walk-up per directory. A parent is attempted only
@@ -1280,7 +1440,7 @@ async function pruneEmptiedDirs(
1280
1440
  // standing when a sibling's turn came first; the same walk-up without
1281
1441
  // the memo is correct and 60 ms, because every child re-attempts the
1282
1442
  // shared parent. This is correct at 44 ms.
1283
- let level = new Set(removed.map((f) => path.dirname(f)))
1443
+ let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
1284
1444
  while (level.size > 0) {
1285
1445
  const parents = new Set<string>()
1286
1446
  const dirs = [...level].filter(
@@ -1332,8 +1492,10 @@ function pruneScope(base: string, outputs: readonly string[]): PruneScope {
1332
1492
  const scope: PruneScope = { below: [], atOrBelow: [] }
1333
1493
  for (const g of outputs) {
1334
1494
  if (g.startsWith('!')) continue
1335
- if (isLiteralPattern(g)) {
1336
- scope.atOrBelow.push(path.resolve(base, stripTrailingSlash(normalizeGlob(g))))
1495
+ // `literalPath`, as `asTrees` reads it: an escaped `out/\[x\]` is one path.
1496
+ const lit = literalPath(normalizeGlob(g))
1497
+ if (lit !== null) {
1498
+ scope.atOrBelow.push(path.resolve(base, stripTrailingSlash(lit)))
1337
1499
  continue
1338
1500
  }
1339
1501
  const prefix = staticPrefix(g)
@@ -1356,14 +1518,21 @@ function inScope(dir: string, scope: PruneScope): boolean {
1356
1518
  * with NO project-dir exclusion: workspace outputs are the documented
1357
1519
  * boundary escape hatch.
1358
1520
  */
1359
- export async function resolveWorkspaceOutputs(args: {
1360
- workspaceRoot: string
1361
- outputs: readonly string[]
1362
- }): Promise<string[]> {
1521
+ export async function resolveWorkspaceOutputs(
1522
+ args: {
1523
+ workspaceRoot: string
1524
+ outputs: readonly string[]
1525
+ },
1526
+ emptyDirs?: Set<string>,
1527
+ ): Promise<string[]> {
1363
1528
  const { positive, negative } = splitNegations(args.outputs)
1364
1529
  if (positive.length === 0) return []
1365
1530
  const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
1366
- const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
1531
+ const trees = asTrees(positive)
1532
+ refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
1533
+ const scanned = [
1534
+ ...(await scanUnion(trees, excludeGlobs, args.workspaceRoot, undefined, emptyDirs)),
1535
+ ]
1367
1536
  // Same containment as the project twin, anchored one level out. These globs
1368
1537
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
1369
1538
  // escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
@@ -1371,6 +1540,16 @@ export async function resolveWorkspaceOutputs(args: {
1371
1540
  return containedIn(args.workspaceRoot, scanned).sort()
1372
1541
  }
1373
1542
 
1543
+ /** `resolveOutputTree` for `outputs.workspaceFiles`: the files and the empty directories. */
1544
+ export async function resolveWorkspaceOutputTree(args: {
1545
+ workspaceRoot: string
1546
+ outputs: readonly string[]
1547
+ }): Promise<{ files: string[]; emptyDirs: string[] }> {
1548
+ const emptyDirs = new Set<string>()
1549
+ const files = await resolveWorkspaceOutputs(args, emptyDirs)
1550
+ return { files, emptyDirs: containedIn(args.workspaceRoot, [...emptyDirs]).sort() }
1551
+ }
1552
+
1374
1553
  /**
1375
1554
  * `cleanOutputs` for the workspace-output namespace: wipe every file
1376
1555
  * currently matching the declared root-relative globs. Same contract
@@ -1386,9 +1565,16 @@ export async function cleanWorkspaceOutputs(args: {
1386
1565
  /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1387
1566
  resolved?: readonly string[] | undefined
1388
1567
  }): Promise<string[]> {
1389
- const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1390
- sweepRestoreTemps(args.workspaceRoot, args.outputs)
1391
- const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
1568
+ const committed = committedPastLinks(
1569
+ args.workspaceRoot,
1570
+ args.committed ?? committedFiles(undefined, args.workspaceRoot),
1571
+ )
1572
+ sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
1573
+ const tree =
1574
+ args.resolved === undefined
1575
+ ? await resolveWorkspaceOutputTree(args)
1576
+ : { files: args.resolved, emptyDirs: [] }
1577
+ const matched = tree.files
1392
1578
  await pruneEmptiedDirs(
1393
1579
  args.workspaceRoot,
1394
1580
  await removeAll(
@@ -1396,6 +1582,8 @@ export async function cleanWorkspaceOutputs(args: {
1396
1582
  args.workspaceRoot,
1397
1583
  ),
1398
1584
  pruneScope(args.workspaceRoot, args.outputs),
1585
+ undefined,
1586
+ tree.emptyDirs,
1399
1587
  )
1400
1588
  return matched.map((f) => relPosix(args.workspaceRoot, f))
1401
1589
  }
@@ -1456,7 +1644,7 @@ function lowerBound(files: readonly string[], key: string): number {
1456
1644
  * The entries a declaration names outright that git answers nothing for: a
1457
1645
  * literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
1458
1646
  * path at or under it. Such a path is gitignored or absent, and naming it
1459
- * is asking for it (owner, 2026-10-16), so {@link ignoredInputs} reads it
1647
+ * is asking for it (2026-10-16), so {@link ignoredInputs} reads it
1460
1648
  * from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
1461
1649
  * no ignored file: `node_modules` is never walked.
1462
1650
  */
@@ -1510,20 +1698,28 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
1510
1698
  * The files on disk under the {@link ignoredReach} entries that `accept`
1511
1699
  * keeps, sorted and absolute. A file is hashed from disk like any untracked
1512
1700
  * one; nothing here is stored or restored. `node_modules` is no special
1513
- * case (owner, 2026-10-16): an ignored path named outright is an input,
1701
+ * case (2026-10-16): an ignored path named outright is an input,
1514
1702
  * a generated client (`node_modules/.prisma/client/**`) too; skipping
1515
1703
  * installs here folded such an entry to nothing, silently. lstat, not
1516
1704
  * `exists()`: that answers false for a directory. Never memoized: a
1517
- * dependency writes these mid-run.
1705
+ * dependency writes these mid-run. Nothing under `vxDirs` (the run's cache
1706
+ * directory) is read: an ignored prefix over a `cache.dir` there walked the
1707
+ * index each run writes, and the task never hit. The walk does not follow
1708
+ * links, so a link to a directory a glob may reach below (`linked`) is an
1709
+ * input itself, as a listed one is (`dirLinkInput`).
1518
1710
  */
1519
1711
  function ignoredInputs(
1520
1712
  root: string,
1521
1713
  reach: readonly string[],
1522
1714
  accept: (rel: string) => boolean,
1715
+ linked: (rel: string) => boolean,
1716
+ vxDirs: readonly string[] = [],
1523
1717
  ): string[] {
1524
1718
  const found = new Set<string>()
1719
+ const vxOwn = (abs: string) => vxDirs.some((d) => abs === d || abs.startsWith(d + path.sep))
1525
1720
  for (const entry of reach) {
1526
1721
  const abs = path.resolve(root, entry)
1722
+ if (vxOwn(abs)) continue
1527
1723
  const st = lstatSync(abs, { throwIfNoEntry: false })
1528
1724
  if (st === undefined) continue
1529
1725
  if (!st.isDirectory()) {
@@ -1537,9 +1733,11 @@ function ignoredInputs(
1537
1733
  dot: true,
1538
1734
  })) {
1539
1735
  const full = `${entry}/${rel.split(path.sep).join('/')}`
1540
- if (!accept(full)) continue
1541
1736
  const file = path.join(abs, rel)
1542
- if (isInputOnDisk(file)) found.add(file)
1737
+ if (vxOwn(file)) continue
1738
+ if (accept(full)) {
1739
+ if (isInputOnDisk(file)) found.add(file)
1740
+ } else if (linked(full) && dirLinkInput(file, undefined)) found.add(file)
1543
1741
  }
1544
1742
  }
1545
1743
  return [...found].sort()
@@ -1606,6 +1804,8 @@ interface FilesPlan {
1606
1804
  ownOutput: (rel: string) => boolean
1607
1805
  positiveGlobs: Bun.Glob[]
1608
1806
  isPositive: (rel: string) => boolean
1807
+ /** Whether a positive glob may match a path under `rel` (`mayHoldMatch`). */
1808
+ mayHold: (rel: string) => boolean
1609
1809
  /** The literal entries, each naming one path (see `unanswered`). */
1610
1810
  literals: string[]
1611
1811
  /** The directories a positive entry reaches; see `InputListing`. */
@@ -1618,9 +1818,9 @@ interface FilesPlan {
1618
1818
  */
1619
1819
  verdicts: Map<string, boolean>
1620
1820
  /**
1621
- * Whether a path is an input but for the task's own outputs: one git
1622
- * tracks stays an input (`committedFiles`), so the key sees an edit to a
1623
- * committed file an output glob reaches.
1821
+ * Whether a path is under the task's own outputs, and no exclude takes it
1822
+ * back: one git tracks is an input (`committedFiles`) whether or not a
1823
+ * positive glob reaches it, since a hit writes the artifact's copy over it.
1624
1824
  */
1625
1825
  ownOnly: Map<string, boolean>
1626
1826
  }
@@ -1651,8 +1851,9 @@ function filesPlan(
1651
1851
  else positive.push(entry)
1652
1852
  }
1653
1853
  }
1854
+ // Outputs alone still make a plan: a committed file under them is keyed.
1654
1855
  const plan =
1655
- positive.length === 0
1856
+ positive.length === 0 && splitNegations(ownOutputs).positive.length === 0
1656
1857
  ? null
1657
1858
  : {
1658
1859
  positive,
@@ -1662,7 +1863,8 @@ function filesPlan(
1662
1863
  // stays an input: a tracked file under `dist` the build reads (A-44).
1663
1864
  ownOutput: outputMatcher(ownOutputs),
1664
1865
  positiveGlobs: asTrees(positive).map(globFor),
1665
- isPositive: anyTaskGlob(asTrees(positive)),
1866
+ isPositive: throughLinks(positive, anyTaskGlob(asTrees(positive))),
1867
+ mayHold: mayHoldMatch(positive),
1666
1868
  ...reachOf(positive),
1667
1869
  verdicts: new Map<string, boolean>(),
1668
1870
  ownOnly: new Map<string, boolean>(),
@@ -1671,17 +1873,88 @@ function filesPlan(
1671
1873
  return plan
1672
1874
  }
1673
1875
 
1876
+ /**
1877
+ * `isPositive`, and each path a glob's static directory passes through:
1878
+ * git lists a linked directory as one path, the link, so `lib/**` over
1879
+ * `lib -> ../shared` matched nothing, and an edit behind it kept the key
1880
+ * the task's last bytes were saved under (2026-10-25). The link is then
1881
+ * an input, and its key includes every file behind it.
1882
+ */
1883
+ function throughLinks(
1884
+ positive: readonly string[],
1885
+ isPositive: (rel: string) => boolean,
1886
+ ): (rel: string) => boolean {
1887
+ const through = new Set<string>()
1888
+ // Each brace alternative by its own prefix: whole, `{lib,src}/**` has
1889
+ // none, and a file behind `lib -> ../shared` was no input.
1890
+ for (const g of positive.flatMap(braceAlternatives)) {
1891
+ const prefix = staticPrefix(g)
1892
+ if (prefix === '.' || prefix === '' || path.isAbsolute(prefix)) continue
1893
+ const parts = prefix.split('/')
1894
+ for (let i = 1; i <= parts.length; i++) through.add(parts.slice(0, i).join('/'))
1895
+ }
1896
+ return through.size === 0 ? isPositive : (rel) => isPositive(rel) || through.has(rel)
1897
+ }
1898
+
1899
+ /**
1900
+ * Whether a positive glob may match a path under `rel`, by each
1901
+ * alternative's fixed prefix and depth (a `**` reaches any depth). A link
1902
+ * below a glob's fixed prefix is listed as one path too: `src/**\/*.txt`
1903
+ * over `src/v -> ../shared` matched the link's name, not what the task
1904
+ * read through it, and an edit there was a hit (2026-10-28). Such a path
1905
+ * that is a link to a directory is an input (`dirLinkInput`). Generous on
1906
+ * purpose: a wrong yes costs one lookup, and folds a linked directory.
1907
+ */
1908
+ function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
1909
+ const reach = asTrees(positive.flatMap(braceAlternatives)).map((g) => {
1910
+ const prefix = staticPrefix(g)
1911
+ return {
1912
+ prefix: prefix === '.' ? '' : `${prefix}/`,
1913
+ deep: g.includes('**'),
1914
+ depth: g.split('/').length,
1915
+ }
1916
+ })
1917
+ return (rel) => {
1918
+ let depth: number | undefined
1919
+ for (const r of reach) {
1920
+ if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
1921
+ if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
1922
+ }
1923
+ return false
1924
+ }
1925
+ }
1926
+
1927
+ /**
1928
+ * Whether `abs`, a path a glob may reach below, is a link to a directory.
1929
+ * A trusted index OID answers for a tracked file with no syscall: only a
1930
+ * link's (`120000:`) or an untracked path's is looked at.
1931
+ */
1932
+ function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined): boolean {
1933
+ const oid = oids?.get(abs)
1934
+ if (oid !== undefined && !oid.startsWith('120000:')) return false
1935
+ try {
1936
+ return lstatSync(abs).isSymbolicLink() && statSync(abs).isDirectory()
1937
+ } catch {
1938
+ return false
1939
+ }
1940
+ }
1941
+
1674
1942
  /**
1675
1943
  * Whether a changed path is one of a task's declared file inputs:
1676
1944
  * `projectRel` (the path relative to the project that owns it) against
1677
1945
  * `files`, `workspaceRel` (relative to the workspace root) against
1678
1946
  * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1679
- * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
1947
+ * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
1680
1948
  */
1681
1949
  export function declaresInput(
1682
1950
  task: TaskConfig,
1683
1951
  projectRel: string | null,
1684
1952
  workspaceRel: string | null,
1953
+ /**
1954
+ * The path is a link to a directory (or was one): a glob that may reach
1955
+ * below it keys what is behind it, as `mayHoldMatch` says at key time.
1956
+ */
1957
+ dirLink = false,
1685
1958
  ): boolean {
1686
1959
  const inputs = task.inputs
1687
1960
  if (inputs === undefined) return false
@@ -1691,10 +1964,13 @@ export function declaresInput(
1691
1964
  // lists: either way it may be an input, so it counts.
1692
1965
  const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1693
1966
  if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
1967
+ if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
1968
+ return true
1969
+ }
1694
1970
  }
1695
1971
  const ws = inputs.workspaceFiles
1696
1972
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1697
- return workspaceMatcher(ws)(workspaceRel)
1973
+ return workspaceMatcher(ws)(workspaceRel, dirLink)
1698
1974
  }
1699
1975
 
1700
1976
  /**
@@ -1738,22 +2014,23 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
1738
2014
  function ownOnly(plan: FilesPlan, rel: string): boolean {
1739
2015
  let own = plan.ownOnly.get(rel)
1740
2016
  if (own === undefined) {
1741
- own = plan.ownOutput(rel) && plan.isPositive(rel) && !plan.isExcluded(rel)
2017
+ own = plan.ownOutput(rel) && !plan.isExcluded(rel)
1742
2018
  plan.ownOnly.set(rel, own)
1743
2019
  }
1744
2020
  return own
1745
2021
  }
1746
2022
 
1747
- const workspaceMatchers = new Map<string, (rel: string) => boolean>()
2023
+ const workspaceMatchers = new Map<string, (rel: string, dirLink: boolean) => boolean>()
1748
2024
 
1749
- function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
2025
+ function workspaceMatcher(decl: readonly string[]): (rel: string, dirLink: boolean) => boolean {
1750
2026
  const key = JSON.stringify(decl)
1751
2027
  let m = workspaceMatchers.get(key)
1752
2028
  if (m !== undefined) return m
1753
2029
  const { positive, negative } = splitNegations(decl)
1754
- const isPositive = anyTaskGlob(asTrees(positive))
2030
+ const isPositive = throughLinks(positive, anyTaskGlob(asTrees(positive)))
2031
+ const mayHold = mayHoldMatch(positive)
1755
2032
  const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
1756
- m = (rel) => isPositive(rel) && !isExcluded(rel)
2033
+ m = (rel, dirLink) => (isPositive(rel) || (dirLink && mayHold(rel))) && !isExcluded(rel)
1757
2034
  workspaceMatchers.set(key, m)
1758
2035
  return m
1759
2036
  }
@@ -1802,6 +2079,7 @@ async function resolveFiles(
1802
2079
  plan,
1803
2080
  nested,
1804
2081
  ownWsOutput,
2082
+ args.gitFilesCache,
1805
2083
  ),
1806
2084
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1807
2085
  }
@@ -1817,7 +2095,7 @@ async function resolveFiles(
1817
2095
  // tree just changed); set() drops the project's OID slot and these
1818
2096
  // files fall back to Cache.hashFile, which computes the identical
1819
2097
  // blob OID from disk.
1820
- const ls = runGitLsFiles(args.projectDir)
2098
+ const ls = runGitLsFiles(args.projectDir, listingEnv(args.projectDir, args.workspaceRoot))
1821
2099
  gitFiles = ls.files
1822
2100
  // set() also clears the project's pending-changed bookkeeping.
1823
2101
  args.gitFilesCache?.set(args.projectDir, gitFiles)
@@ -1849,14 +2127,21 @@ async function resolveFiles(
1849
2127
  const candidates: string[] = []
1850
2128
  const verdicts = plan.verdicts
1851
2129
  const committed = committedFiles(args.gitFilesCache, args.projectDir)
2130
+ const oids = args.gitFilesCache?.oidsFor(args.projectDir)
1852
2131
  for (const rel of gitFiles) {
1853
2132
  let input = verdicts.get(rel)
1854
2133
  if (input === undefined) {
1855
2134
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1856
2135
  verdicts.set(rel, input)
1857
2136
  }
1858
- if ((!input && !ownOnly(plan, rel)) || nested(rel) || ownWsOutput(rel)) continue
2137
+ if (nested(rel) || ownWsOutput(rel)) continue
1859
2138
  const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
2139
+ if (!input && !ownOnly(plan, rel)) {
2140
+ if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
2141
+ candidates.push(abs)
2142
+ }
2143
+ continue
2144
+ }
1860
2145
  if (!input && !committed(abs)) continue
1861
2146
  candidates.push(abs)
1862
2147
  }
@@ -1868,7 +2153,6 @@ async function resolveFiles(
1868
2153
  // per-file syscalls. Paths without an OID keep the probe:
1869
2154
  // `git ls-files -s` can surface staged entries whose working-tree
1870
2155
  // file is gone; the hasher would otherwise throw ENOENT.
1871
- const oids = args.gitFilesCache?.oidsFor(args.projectDir)
1872
2156
  const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
1873
2157
  // Git's slice comes sorted and a common prefix keeps it so: one pass
1874
2158
  // proves it instead of a sort per task.
@@ -1880,7 +2164,15 @@ async function resolveFiles(
1880
2164
  }
1881
2165
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1882
2166
  return {
1883
- files: withIgnored(args.projectDir, resolved, reach, plan, nested, ownWsOutput),
2167
+ files: withIgnored(
2168
+ args.projectDir,
2169
+ resolved,
2170
+ reach,
2171
+ plan,
2172
+ nested,
2173
+ ownWsOutput,
2174
+ args.gitFilesCache,
2175
+ ),
1884
2176
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1885
2177
  }
1886
2178
  }
@@ -1893,18 +2185,26 @@ function withIgnored(
1893
2185
  plan: FilesPlan,
1894
2186
  nested: (rel: string) => boolean,
1895
2187
  ownWsOutput: (rel: string) => boolean,
2188
+ cache: GitFilesCache | undefined,
1896
2189
  ): string[] {
1897
2190
  if (reach.length === 0) return [...resolved]
1898
2191
  const extra = ignoredInputs(
1899
2192
  projectDir,
1900
2193
  reach,
1901
2194
  (rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
2195
+ (rel) =>
2196
+ plan.mayHold(rel) &&
2197
+ !plan.isExcluded(rel) &&
2198
+ !plan.ownOutput(rel) &&
2199
+ !nested(rel) &&
2200
+ !ownWsOutput(rel),
2201
+ cache?.vxDirs,
1902
2202
  )
1903
2203
  return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
1904
2204
  }
1905
2205
 
1906
2206
  function listingFor(
1907
- args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
2207
+ args: { projectDir: string; workspaceRoot: string; gitFilesCache?: GitFilesCache | undefined },
1908
2208
  listed: readonly string[],
1909
2209
  plan: FilesPlan,
1910
2210
  nested: (rel: string) => boolean,
@@ -1914,12 +2214,15 @@ function listingFor(
1914
2214
  let committed: ((abs: string) => boolean) | undefined
1915
2215
  return {
1916
2216
  root,
2217
+ workspaceRoot: args.workspaceRoot,
1917
2218
  listed,
1918
2219
  isInput: (rel) =>
1919
2220
  (inPlan(plan, rel) ||
1920
2221
  (ownOnly(plan, rel) &&
1921
2222
  (committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
1922
2223
  !ownWsOutput(rel),
2224
+ linked: (rel) =>
2225
+ plan.mayHold(rel) && !plan.isExcluded(rel) && !plan.ownOutput(rel) && !ownWsOutput(rel),
1923
2226
  nested,
1924
2227
  prefixes: plan.prefixes,
1925
2228
  literals: plan.literals,
@@ -1981,9 +2284,16 @@ function undecodableOnDisk(abs: string): boolean {
1981
2284
  */
1982
2285
  export interface InputListing {
1983
2286
  root: string
2287
+ /** Where git's location variables point; `root` in a repository below it is listed without them. */
2288
+ workspaceRoot: string
1984
2289
  /** Root-relative paths, as git listed them. */
1985
2290
  listed: readonly string[]
1986
2291
  isInput: (rel: string) => boolean
2292
+ /**
2293
+ * Whether a link to a directory at `rel` is an input: a glob may match
2294
+ * below it (`mayHoldMatch`), though its name fits none (`dirLinkInput`).
2295
+ */
2296
+ linked: (rel: string) => boolean
1987
2297
  nested: (rel: string) => boolean
1988
2298
  /** Root-relative directories a positive entry reaches, `''` for the root. */
1989
2299
  prefixes: readonly string[]
@@ -2089,7 +2399,10 @@ function addedTo(
2089
2399
  if (known.has(rel) || e.name === 'node_modules' || l.nested(`${rel}/`)) continue
2090
2400
  if (reaches(l.prefixes, rel)) candidates.push(rel)
2091
2401
  } else if (
2092
- l.isInput(rel) &&
2402
+ (l.isInput(rel) ||
2403
+ (e.isSymbolicLink() &&
2404
+ l.linked(rel) &&
2405
+ dirLinkInput(path.resolve(abs, e.name), undefined))) &&
2093
2406
  !l.nested(rel) &&
2094
2407
  !isInstalledPath(rel) &&
2095
2408
  !keyFiles.has(path.resolve(abs, e.name))
@@ -2099,10 +2412,11 @@ function addedTo(
2099
2412
  }
2100
2413
  }
2101
2414
  if (candidates.length === 0) return undefined
2102
- for (const rel of runGitLsFiles(l.root).files) {
2415
+ for (const rel of runGitLsFiles(l.root, listingEnv(l.root, l.workspaceRoot)).files) {
2103
2416
  if (!candidates.some((c) => c === '' || rel === c || rel.startsWith(`${c}/`))) continue
2104
- if (isInstalledPath(rel) || l.nested(rel) || !l.isInput(rel)) continue
2417
+ if (isInstalledPath(rel) || l.nested(rel)) continue
2105
2418
  const abs = path.resolve(l.root, rel)
2419
+ if (!l.isInput(rel) && !(l.linked(rel) && dirLinkInput(abs, undefined))) continue
2106
2420
  if (!keyFiles.has(abs) && isInputOnDisk(abs)) return abs
2107
2421
  }
2108
2422
  return undefined
@@ -2141,12 +2455,15 @@ async function scanUnion(
2141
2455
  emptyDirs?: Set<string>,
2142
2456
  ): Promise<Set<string>> {
2143
2457
  const matches = new Set<string>()
2458
+ const links: string[] = []
2144
2459
  const take = (rel: string): void => {
2145
2460
  if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
2146
2461
  const abs = path.resolve(cwd, rel)
2147
2462
  const st = lstatSync(abs, { throwIfNoEntry: false })
2148
- if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
2149
- else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2463
+ if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
2464
+ matches.add(abs)
2465
+ if (st.isSymbolicLink()) links.push(abs)
2466
+ } else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2150
2467
  if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2151
2468
  } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
2152
2469
  }
@@ -2158,18 +2475,83 @@ async function scanUnion(
2158
2475
  // A leading `**` walks the whole project whatever else is listed, so the
2159
2476
  // list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
2160
2477
  // ~2 ms of a warm hit on solid.
2161
- if (patterns.length > 1 && patterns.some((p) => p.startsWith('**'))) {
2478
+ // Not where a static head passes through a link: the one walk does not
2479
+ // follow links, and the per-pattern scan enters them (X-88).
2480
+ if (
2481
+ patterns.length > 1 &&
2482
+ patterns.some((p) => p.startsWith('**')) &&
2483
+ !patterns.some((p) => headLinked(cwd, p))
2484
+ ) {
2162
2485
  const any = anyTaskGlob(patterns)
2163
2486
  for (const rel of WHOLE_TREE.scanSync(opts)) if (any(rel)) take(rel)
2164
2487
  return matches
2165
2488
  }
2166
2489
  for (const pattern of patterns) {
2167
2490
  if (absentPrefix(cwd, pattern)) continue
2168
- for (const rel of globFor(pattern).scanSync(opts)) take(rel)
2491
+ const from = scanRoot(cwd, pattern)
2492
+ if (from === null) continue
2493
+ if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
2494
+ else
2495
+ for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
2496
+ take(`${from.prefix}/${rel}`)
2497
+ }
2498
+ // A literal names the path and its tree (`dist`, `dist/**`), and a tree
2499
+ // whose head is a directory link is walked through it: the link and the
2500
+ // files under it were one artifact holding `dist` as a file and a
2501
+ // directory, refused, so the task never cached. The walk is the output.
2502
+ if (links.length > 0) {
2503
+ const sorted = [...matches].sort()
2504
+ for (const link of links) {
2505
+ const under = `${link}/`
2506
+ const at = sorted.findIndex((m) => m >= under)
2507
+ if (at !== -1 && sorted[at]!.startsWith(under)) matches.delete(link)
2508
+ }
2169
2509
  }
2170
2510
  return matches
2171
2511
  }
2172
2512
 
2513
+ /** `pattern`'s static directory passes through a symlink under `cwd`. */
2514
+ function headLinked(cwd: string, pattern: string): boolean {
2515
+ const prefix = staticPrefix(pattern)
2516
+ if (prefix === '.' || path.isAbsolute(prefix)) return false
2517
+ let at = cwd
2518
+ for (const part of prefix.split('/')) {
2519
+ at = path.join(at, part)
2520
+ const st = lstatSync(at, { throwIfNoEntry: false })
2521
+ if (st === undefined || !st.isDirectory()) return st?.isSymbolicLink() === true
2522
+ }
2523
+ return false
2524
+ }
2525
+
2526
+ /**
2527
+ * Where `pattern`'s scan can start: its static directory, with the rest of
2528
+ * the pattern to match there. `Bun.Glob` walks the whole of `cwd` whatever
2529
+ * the prefix: `out/d1/**` beside 9000 sibling dirs cost 2.3 ms a scan, twice
2530
+ * per miss (2026-10-25). Null when the prefix is a file, which nothing lies
2531
+ * under (a literal's `<file>/**` tree). Undefined (scan from `cwd`) for no
2532
+ * prefix, an escape, or a prefix that does not stat (a dangling link, a
2533
+ * cycle), whose refusals the walk from `cwd` names. A prefix through a
2534
+ * link is entered either way.
2535
+ */
2536
+ function scanRoot(
2537
+ cwd: string,
2538
+ pattern: string,
2539
+ ): { prefix: string; rest: string; dir: string } | null | undefined {
2540
+ const prefix = staticPrefix(pattern)
2541
+ if (prefix === '.' || prefix.includes('\\') || path.isAbsolute(prefix)) return undefined
2542
+ if (!pattern.startsWith(`${prefix}/`) || prefix.split('/').includes('..')) return undefined
2543
+ const dir = path.join(cwd, prefix)
2544
+ let st
2545
+ try {
2546
+ st = statSync(dir, { throwIfNoEntry: false })
2547
+ } catch {
2548
+ return undefined
2549
+ }
2550
+ if (st === undefined) return undefined
2551
+ if (!st.isDirectory()) return null
2552
+ return { prefix, rest: pattern.slice(prefix.length + 1), dir }
2553
+ }
2554
+
2173
2555
  /**
2174
2556
  * True when `pattern`'s static directory is not on disk, so nothing can
2175
2557
  * match under it. A restore into a tree without its outputs scans each