@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -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,
@@ -31,16 +32,20 @@ import {
31
32
  outputNever,
32
33
  realpathOf,
33
34
  relPosix,
35
+ restorableOutput,
34
36
  shellArgv,
35
37
  slashBraceExpansions,
36
38
  braceAlternatives,
39
+ ESSENTIAL_ENV,
37
40
  splitNegations,
38
41
  staticPrefix,
39
42
  stripTrailingSlash,
40
43
  taskGlob,
41
44
  UserError,
45
+ writableUnder,
46
+ withoutWorkspaceBins,
42
47
  } from '../util/index.js'
43
- import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
48
+ import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
44
49
  import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
45
50
 
46
51
  // The git side lives in git-inputs.ts; its whole public surface is
@@ -141,8 +146,8 @@ export interface ResolveInputsArgs {
141
146
  * by `projectDir + '\0' + command`. Shared across a run's tasks so a
142
147
  * project's command runs once even across build/test/lint and across
143
148
  * the hash + sandbox-baseline resolveInputs calls. The key is sound
144
- * ONLY because the probe runs in vx's own ambient environment, never a
145
- * 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
146
151
  * `define`s share one value. Threading the task env in would have to
147
152
  * widen this key too, or task A's probe becomes task B's key component.
148
153
  */
@@ -286,6 +291,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
286
291
  runtimeDecl,
287
292
  args.projectDir,
288
293
  projectBinDirs(args.projectDir, args.workspaceRoot),
294
+ args.workspaceRoot,
289
295
  args.runtimeCache,
290
296
  args.runtimeScope === undefined
291
297
  ? `${args.projectDir}\0`
@@ -295,6 +301,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
295
301
  wsRuntimeDecl,
296
302
  args.workspaceRoot,
297
303
  [path.join(args.workspaceRoot, 'node_modules', '.bin')],
304
+ args.workspaceRoot,
298
305
  args.workspaceRuntimeCache,
299
306
  args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
300
307
  ),
@@ -363,6 +370,7 @@ function resolveWorkspaceFiles(args: {
363
370
  // caller sees a different array and scans again.
364
371
  let isExcluded: ((rel: string) => boolean) | undefined
365
372
  let isPositive: ((rel: string) => boolean) | undefined
373
+ let mayHold: ((rel: string) => boolean) | undefined
366
374
  const excludedByDeclaration = (rel: string): boolean =>
367
375
  (isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
368
376
  // A committed file stays an input whatever output reaches it (`committedFiles`).
@@ -374,11 +382,14 @@ function resolveWorkspaceFiles(args: {
374
382
  )
375
383
  const listing: InputListing = {
376
384
  root: args.workspaceRoot,
385
+ workspaceRoot: args.workspaceRoot,
377
386
  listed: gitFiles,
378
387
  isInput: (rel) =>
379
- (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
388
+ (isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive))))(rel) &&
380
389
  !excludedByDeclaration(rel) &&
381
390
  !own(rel),
391
+ linked: (rel) =>
392
+ (mayHold ??= mayHoldMatch(positive))(rel) && !excludedByDeclaration(rel) && !own(rel),
382
393
  nested: () => false,
383
394
  ...reachOf(positive),
384
395
  }
@@ -392,7 +403,7 @@ function resolveWorkspaceFiles(args: {
392
403
  const hit = args.memo?.get(declared)
393
404
  if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
394
405
  if (base === undefined) {
395
- isPositive ??= anyTaskGlob(asTrees(positive))
406
+ isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
396
407
  base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
397
408
  args.memo?.set(declared, { snapshot: gitFiles, result: base })
398
409
  }
@@ -409,12 +420,14 @@ function resolveWorkspaceFiles(args: {
409
420
  if (reach.length === 0) return kept
410
421
  // Outside the memo: an ignored file a task writes mid-run leaves the
411
422
  // snapshot as it was.
412
- isPositive ??= anyTaskGlob(asTrees(positive))
423
+ isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
413
424
  const accept = isPositive
414
425
  const extra = ignoredInputs(
415
426
  args.workspaceRoot,
416
427
  reach,
417
428
  (rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
429
+ listing.linked,
430
+ args.gitFilesCache?.vxDirs,
418
431
  )
419
432
  return extra.length === 0 ? kept : mergeSorted(kept, extra)
420
433
  })
@@ -429,14 +442,18 @@ async function resolveWorkspaceFilesOver(
429
442
  excluded: (rel: string) => boolean,
430
443
  ): Promise<string[]> {
431
444
  const candidates: string[] = []
432
- for (const rel of gitFiles) {
433
- if (!isPositive(rel)) continue
434
- if (excluded(rel)) continue
435
- candidates.push(path.resolve(args.workspaceRoot, rel))
436
- }
437
445
  // Same OID-trust shortcut as project files: a clean-per-status
438
446
  // tracked file necessarily exists on disk.
439
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
+ }
440
457
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
441
458
  }
442
459
 
@@ -523,6 +540,16 @@ function isInputOnDisk(abs: string): boolean {
523
540
  }
524
541
  }
525
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
+
526
553
  /** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
527
554
  function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
528
555
  const own = path.join(projectDir, 'node_modules', '.bin')
@@ -530,6 +557,35 @@ function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
530
557
  return own === root ? [own] : [own, root]
531
558
  }
532
559
 
560
+ /**
561
+ * One runtime probe answered on this machine as a key folds it: an
562
+ * `inputs.runtime` command run in `projectDir`, or with `workspace` a
563
+ * `workspaceRuntime` one run in `workspaceRoot`. For an executor that
564
+ * checks a task's probes where it runs it (`TaskExecutor.runtime`).
565
+ */
566
+ export function answerRuntime(
567
+ command: string,
568
+ projectDir: string,
569
+ workspaceRoot: string,
570
+ workspace = false,
571
+ ): Promise<string> {
572
+ return workspace
573
+ ? runRuntimeCommand(
574
+ command,
575
+ workspaceRoot,
576
+ [path.join(workspaceRoot, 'node_modules', '.bin')],
577
+ workspaceRoot,
578
+ undefined,
579
+ )
580
+ : runRuntimeCommand(
581
+ command,
582
+ projectDir,
583
+ projectBinDirs(projectDir, workspaceRoot),
584
+ workspaceRoot,
585
+ undefined,
586
+ )
587
+ }
588
+
533
589
  function resolveEnvValues(
534
590
  names: readonly string[],
535
591
  source: NodeJS.ProcessEnv,
@@ -542,10 +598,13 @@ function resolveEnvValues(
542
598
  * work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
543
599
  * exit is a hard UserError naming the command (fail-loud, like git).
544
600
  *
545
- * The probe inherits vx's AMBIENT environment on purpose — not the task's
546
- * `exec.env.define` / `passThrough`, which describe the command's
547
- * environment, not the machine's. That is what lets the run-scoped memo
548
- * 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`.
549
608
  *
550
609
  * Its PATH does lead with the task's own `node_modules/.bin` directories
551
610
  * (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
@@ -556,9 +615,11 @@ async function runRuntimeCommand(
556
615
  command: string,
557
616
  cwd: string,
558
617
  binDirs: readonly string[],
618
+ workspaceRoot: string,
559
619
  owner: RuntimeMemo | undefined,
560
620
  ): Promise<string> {
561
- 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)
562
623
  // As the task's PATH does (exec/env.ts): a dir holding the delimiter
563
624
  // splits into an entry relative to the probe's cwd.
564
625
  const PATH = [...binDirs.filter((dir) => !dir.includes(path.delimiter)), ambient]
@@ -571,7 +632,7 @@ async function runRuntimeCommand(
571
632
  // `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
572
633
  proc = Bun.spawn(shellArgv(command), {
573
634
  cwd,
574
- env: { ...process.env, PATH },
635
+ env: { ...probeEnv(), PATH },
575
636
  stdin: 'ignore',
576
637
  stdout: 'pipe',
577
638
  stderr: 'pipe',
@@ -707,6 +768,7 @@ async function resolveRuntimeValues(
707
768
  commands: readonly string[],
708
769
  cwd: string,
709
770
  binDirs: readonly string[],
771
+ workspaceRoot: string,
710
772
  memo: Map<string, Promise<string>> | undefined,
711
773
  memoKeyPrefix: string,
712
774
  ): Promise<Array<[string, string]>> {
@@ -717,7 +779,7 @@ async function resolveRuntimeValues(
717
779
  const key = `${memoKeyPrefix}${cmd}`
718
780
  let p = memo?.get(key)
719
781
  if (p === undefined) {
720
- p = runRuntimeCommand(cmd, cwd, binDirs, memo)
782
+ p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
721
783
  memo?.set(key, p)
722
784
  }
723
785
  return [cmd, await p] as [string, string]
@@ -748,6 +810,21 @@ export async function resolveOutputs(args: {
748
810
  return (await scanOutputs(args)).files
749
811
  }
750
812
 
813
+ /**
814
+ * Whether `resolveOutputs` takes the project-relative file `rel`, decided
815
+ * without the walk: a directory snapshot's file list is held to the
816
+ * entry's rows through it.
817
+ */
818
+ export function outputWalkTakes(
819
+ projectDir: string,
820
+ outputs: readonly string[],
821
+ nestedProjectDirs: string[],
822
+ ): (rel: string) => boolean {
823
+ const restorable = restorableOutput(outputs)
824
+ const nested = inNestedProject(projectDir, nestedProjectDirs)
825
+ return (rel) => restorable(rel) && !nested(rel)
826
+ }
827
+
751
828
  /**
752
829
  * `resolveOutputs`, plus the empty directories the globs match: a save
753
830
  * keeps them, so a hit leaves the tree a miss left (Turbo and Nx restore
@@ -771,7 +848,7 @@ async function scanOutputs(
771
848
  if (positive.length === 0) return { files: [] }
772
849
  const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
773
850
  const trees = asTrees(positive)
774
- refuseDirLinkedOut(args.projectDir, trees)
851
+ refuseDirLinkedOut(args.projectDir, trees, 'project')
775
852
  const scanned = [
776
853
  ...(await scanUnion(
777
854
  trees,
@@ -788,12 +865,13 @@ async function scanOutputs(
788
865
  // outside the project, so any future caller reaching it by another route (a
789
866
  // programmatic embedder, a config source that skips the loader) is contained
790
867
  // by construction.
791
- return { files: containedIn(args.projectDir, scanned).sort() }
868
+ const contained = containedIn(args.projectDir, scanned)
869
+ return { files: outsideNested(args.nestedProjectDirs, contained).sort() }
792
870
  }
793
871
 
794
872
  /**
795
873
  * Whether git tracks a file under `dir`. No clean deletes a committed (or
796
- * `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
797
875
  * delete committed file"); the task may rewrite it and a restore writes the
798
876
  * bytes it left. It stays in the task's key, so those bytes follow from
799
877
  * the key. Turbo and Nx never clean an output at all.
@@ -808,16 +886,24 @@ export function committedFiles(
808
886
  /**
809
887
  * The literal head of each output glob, walked: the scan follows a link
810
888
  * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
811
- * 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`.
812
894
  */
813
- function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
895
+ function refuseDirLinkedOut(
896
+ root: string,
897
+ trees: readonly string[],
898
+ scope: 'project' | 'workspace',
899
+ ): void {
814
900
  let realRoot: string | undefined
815
901
  for (const tree of trees) {
816
- let at = projectDir
902
+ let at = root
817
903
  for (const part of staticPrefix(tree).split('/')) {
818
904
  if (part === '' || part === '.') continue
819
905
  at = path.join(at, part)
820
- if (!isInside(projectDir, at)) break
906
+ if (!isInside(root, at)) break
821
907
  let st
822
908
  try {
823
909
  st = lstatSync(at, { throwIfNoEntry: false })
@@ -833,18 +919,41 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
833
919
  } catch {
834
920
  break
835
921
  }
836
- realRoot ??= realOr(projectDir)
922
+ realRoot ??= realOr(root)
837
923
  if (!isInside(realRoot, real)) {
924
+ if (scope === 'workspace') break
838
925
  throw new UserError(
839
- `${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 ` +
840
927
  'or restores declared outputs through a link that leaves the project. Remove the ' +
841
928
  'link and re-run, or stop declaring outputs under it.',
842
929
  )
843
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
+ }
844
938
  }
845
939
  }
846
940
  }
847
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
+
848
957
  function realOr(p: string): string {
849
958
  try {
850
959
  return realpathSync(p)
@@ -902,6 +1011,57 @@ function containedIn(root: string, paths: readonly string[]): string[] {
902
1011
  return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
903
1012
  }
904
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
+
905
1065
  /** Is `abs` the directory `dir` itself or something beneath it? */
906
1066
  function isInside(dir: string, abs: string): boolean {
907
1067
  if (abs === dir) return true
@@ -934,9 +1094,18 @@ export async function cleanOutputs(args: {
934
1094
  */
935
1095
  resolved?: readonly string[] | undefined
936
1096
  }): Promise<string[]> {
937
- const committed = args.committed ?? committedFiles(undefined, args.projectDir)
938
- sweepRestoreTemps(args.projectDir, args.outputs)
939
- 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
940
1109
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
941
1110
  // globs overlap and a sibling already deleted a path mid-iteration).
942
1111
  // A symlink is unlinked, never followed.
@@ -944,7 +1113,13 @@ export async function cleanOutputs(args: {
944
1113
  matched.filter((f) => !committed(f)),
945
1114
  args.projectDir,
946
1115
  )
947
- 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
+ )
948
1123
  // Project-relative posix paths the outputs reach, for the caller's
949
1124
  // GitFilesCache.markOutputsChanged: a committed one kept here is the
950
1125
  // task's to rewrite or remove, so its index OID no longer vouches for it.
@@ -961,13 +1136,22 @@ export async function cleanOutputs(args: {
961
1136
  * whose writer is alive, this process included, is left: it may be
962
1137
  * mid-restore.
963
1138
  */
964
- 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 {
965
1144
  const dirs = new Set<string>()
966
1145
  for (const g of outputs) {
967
1146
  if (g.startsWith('!') || !isLiteralPattern(g)) continue
968
1147
  dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
969
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
970
1152
  for (const dir of dirs) {
1153
+ const real = realOrNull(dir)
1154
+ if (real === null || !isInside((realBase ??= realOrNull(base) ?? base), real)) continue
971
1155
  let names: string[]
972
1156
  try {
973
1157
  names = readdirSync(dir)
@@ -979,6 +1163,8 @@ export function sweepRestoreTemps(base: string, outputs: readonly string[]): voi
979
1163
  if (m === null) continue
980
1164
  const pid = Number.parseInt(m[1]!, 36)
981
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
982
1168
  rmSync(path.join(dir, name), { force: true })
983
1169
  }
984
1170
  }
@@ -1029,6 +1215,8 @@ export async function cleanOutputPaths(args: {
1029
1215
  committed?: (abs: string) => boolean
1030
1216
  /** Directories the prune leaves standing even when emptied (absolute). */
1031
1217
  keep?: ReadonlySet<string>
1218
+ /** Empty directories to remove too (project-relative). */
1219
+ emptyDirs?: readonly string[]
1032
1220
  }): Promise<void> {
1033
1221
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
1034
1222
  const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
@@ -1037,6 +1225,7 @@ export async function cleanOutputPaths(args: {
1037
1225
  await removeAll(files, args.projectDir),
1038
1226
  pruneScope(args.projectDir, args.outputs),
1039
1227
  args.keep,
1228
+ args.emptyDirs?.map((r) => path.resolve(args.projectDir, r)),
1040
1229
  )
1041
1230
  }
1042
1231
 
@@ -1193,7 +1382,8 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
1193
1382
  * that skipped `dist -> real-out` left the last run's files for the next
1194
1383
  * entry to save (X-88). One that leaves the project is never deleted
1195
1384
  * through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
1196
- * 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`.
1197
1387
  */
1198
1388
  function insideRoot(files: readonly string[], root: string): string[] {
1199
1389
  // Resolved only once a directory exists to compare: after a clean pruned
@@ -1208,7 +1398,14 @@ function insideRoot(files: readonly string[], root: string): string[] {
1208
1398
  // A directory already gone has nothing to delete through; its path
1209
1399
  // stays so the prune still reaches the parents it emptied.
1210
1400
  const r = realOrNull(dir)
1211
- 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
+ }
1212
1409
  own.set(dir, ok)
1213
1410
  }
1214
1411
  return ok
@@ -1216,11 +1413,11 @@ function insideRoot(files: readonly string[], root: string): string[] {
1216
1413
  }
1217
1414
 
1218
1415
  /**
1219
- * 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.
1220
1418
  * A restore writes files where the cached entry has them; a directory left
1221
1419
  * standing where the entry holds a FILE of the same name (a task that once
1222
- * wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
1223
- * 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
1224
1421
  * holds something — a stray the globs do not cover — stays, and the restore
1225
1422
  * says so if it is in the way. With a `scope`, only a directory inside it
1226
1423
  * goes (`pruneScope`).
@@ -1230,6 +1427,7 @@ async function pruneEmptiedDirs(
1230
1427
  removed: readonly string[],
1231
1428
  scope?: PruneScope,
1232
1429
  keep?: ReadonlySet<string>,
1430
+ emptyDirs: readonly string[] = [],
1233
1431
  ): Promise<void> {
1234
1432
  const rootResolved = path.resolve(root)
1235
1433
  // LEVEL ORDER, not a walk-up per directory. A parent is attempted only
@@ -1242,7 +1440,7 @@ async function pruneEmptiedDirs(
1242
1440
  // standing when a sibling's turn came first; the same walk-up without
1243
1441
  // the memo is correct and 60 ms, because every child re-attempts the
1244
1442
  // shared parent. This is correct at 44 ms.
1245
- let level = new Set(removed.map((f) => path.dirname(f)))
1443
+ let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
1246
1444
  while (level.size > 0) {
1247
1445
  const parents = new Set<string>()
1248
1446
  const dirs = [...level].filter(
@@ -1294,8 +1492,10 @@ function pruneScope(base: string, outputs: readonly string[]): PruneScope {
1294
1492
  const scope: PruneScope = { below: [], atOrBelow: [] }
1295
1493
  for (const g of outputs) {
1296
1494
  if (g.startsWith('!')) continue
1297
- if (isLiteralPattern(g)) {
1298
- 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)))
1299
1499
  continue
1300
1500
  }
1301
1501
  const prefix = staticPrefix(g)
@@ -1318,14 +1518,21 @@ function inScope(dir: string, scope: PruneScope): boolean {
1318
1518
  * with NO project-dir exclusion: workspace outputs are the documented
1319
1519
  * boundary escape hatch.
1320
1520
  */
1321
- export async function resolveWorkspaceOutputs(args: {
1322
- workspaceRoot: string
1323
- outputs: readonly string[]
1324
- }): Promise<string[]> {
1521
+ export async function resolveWorkspaceOutputs(
1522
+ args: {
1523
+ workspaceRoot: string
1524
+ outputs: readonly string[]
1525
+ },
1526
+ emptyDirs?: Set<string>,
1527
+ ): Promise<string[]> {
1325
1528
  const { positive, negative } = splitNegations(args.outputs)
1326
1529
  if (positive.length === 0) return []
1327
1530
  const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
1328
- 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
+ ]
1329
1536
  // Same containment as the project twin, anchored one level out. These globs
1330
1537
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
1331
1538
  // escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
@@ -1333,6 +1540,16 @@ export async function resolveWorkspaceOutputs(args: {
1333
1540
  return containedIn(args.workspaceRoot, scanned).sort()
1334
1541
  }
1335
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
+
1336
1553
  /**
1337
1554
  * `cleanOutputs` for the workspace-output namespace: wipe every file
1338
1555
  * currently matching the declared root-relative globs. Same contract
@@ -1348,9 +1565,16 @@ export async function cleanWorkspaceOutputs(args: {
1348
1565
  /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1349
1566
  resolved?: readonly string[] | undefined
1350
1567
  }): Promise<string[]> {
1351
- const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1352
- sweepRestoreTemps(args.workspaceRoot, args.outputs)
1353
- 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
1354
1578
  await pruneEmptiedDirs(
1355
1579
  args.workspaceRoot,
1356
1580
  await removeAll(
@@ -1358,6 +1582,8 @@ export async function cleanWorkspaceOutputs(args: {
1358
1582
  args.workspaceRoot,
1359
1583
  ),
1360
1584
  pruneScope(args.workspaceRoot, args.outputs),
1585
+ undefined,
1586
+ tree.emptyDirs,
1361
1587
  )
1362
1588
  return matched.map((f) => relPosix(args.workspaceRoot, f))
1363
1589
  }
@@ -1418,7 +1644,7 @@ function lowerBound(files: readonly string[], key: string): number {
1418
1644
  * The entries a declaration names outright that git answers nothing for: a
1419
1645
  * literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
1420
1646
  * path at or under it. Such a path is gitignored or absent, and naming it
1421
- * 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
1422
1648
  * from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
1423
1649
  * no ignored file: `node_modules` is never walked.
1424
1650
  */
@@ -1472,20 +1698,28 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
1472
1698
  * The files on disk under the {@link ignoredReach} entries that `accept`
1473
1699
  * keeps, sorted and absolute. A file is hashed from disk like any untracked
1474
1700
  * one; nothing here is stored or restored. `node_modules` is no special
1475
- * 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,
1476
1702
  * a generated client (`node_modules/.prisma/client/**`) too; skipping
1477
1703
  * installs here folded such an entry to nothing, silently. lstat, not
1478
1704
  * `exists()`: that answers false for a directory. Never memoized: a
1479
- * 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`).
1480
1710
  */
1481
1711
  function ignoredInputs(
1482
1712
  root: string,
1483
1713
  reach: readonly string[],
1484
1714
  accept: (rel: string) => boolean,
1715
+ linked: (rel: string) => boolean,
1716
+ vxDirs: readonly string[] = [],
1485
1717
  ): string[] {
1486
1718
  const found = new Set<string>()
1719
+ const vxOwn = (abs: string) => vxDirs.some((d) => abs === d || abs.startsWith(d + path.sep))
1487
1720
  for (const entry of reach) {
1488
1721
  const abs = path.resolve(root, entry)
1722
+ if (vxOwn(abs)) continue
1489
1723
  const st = lstatSync(abs, { throwIfNoEntry: false })
1490
1724
  if (st === undefined) continue
1491
1725
  if (!st.isDirectory()) {
@@ -1499,9 +1733,11 @@ function ignoredInputs(
1499
1733
  dot: true,
1500
1734
  })) {
1501
1735
  const full = `${entry}/${rel.split(path.sep).join('/')}`
1502
- if (!accept(full)) continue
1503
1736
  const file = path.join(abs, rel)
1504
- 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)
1505
1741
  }
1506
1742
  }
1507
1743
  return [...found].sort()
@@ -1568,6 +1804,8 @@ interface FilesPlan {
1568
1804
  ownOutput: (rel: string) => boolean
1569
1805
  positiveGlobs: Bun.Glob[]
1570
1806
  isPositive: (rel: string) => boolean
1807
+ /** Whether a positive glob may match a path under `rel` (`mayHoldMatch`). */
1808
+ mayHold: (rel: string) => boolean
1571
1809
  /** The literal entries, each naming one path (see `unanswered`). */
1572
1810
  literals: string[]
1573
1811
  /** The directories a positive entry reaches; see `InputListing`. */
@@ -1580,9 +1818,9 @@ interface FilesPlan {
1580
1818
  */
1581
1819
  verdicts: Map<string, boolean>
1582
1820
  /**
1583
- * Whether a path is an input but for the task's own outputs: one git
1584
- * tracks stays an input (`committedFiles`), so the key sees an edit to a
1585
- * 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.
1586
1824
  */
1587
1825
  ownOnly: Map<string, boolean>
1588
1826
  }
@@ -1613,8 +1851,9 @@ function filesPlan(
1613
1851
  else positive.push(entry)
1614
1852
  }
1615
1853
  }
1854
+ // Outputs alone still make a plan: a committed file under them is keyed.
1616
1855
  const plan =
1617
- positive.length === 0
1856
+ positive.length === 0 && splitNegations(ownOutputs).positive.length === 0
1618
1857
  ? null
1619
1858
  : {
1620
1859
  positive,
@@ -1624,7 +1863,8 @@ function filesPlan(
1624
1863
  // stays an input: a tracked file under `dist` the build reads (A-44).
1625
1864
  ownOutput: outputMatcher(ownOutputs),
1626
1865
  positiveGlobs: asTrees(positive).map(globFor),
1627
- isPositive: anyTaskGlob(asTrees(positive)),
1866
+ isPositive: throughLinks(positive, anyTaskGlob(asTrees(positive))),
1867
+ mayHold: mayHoldMatch(positive),
1628
1868
  ...reachOf(positive),
1629
1869
  verdicts: new Map<string, boolean>(),
1630
1870
  ownOnly: new Map<string, boolean>(),
@@ -1633,17 +1873,88 @@ function filesPlan(
1633
1873
  return plan
1634
1874
  }
1635
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
+
1636
1942
  /**
1637
1943
  * Whether a changed path is one of a task's declared file inputs:
1638
1944
  * `projectRel` (the path relative to the project that owns it) against
1639
1945
  * `files`, `workspaceRel` (relative to the workspace root) against
1640
1946
  * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1641
- * 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).
1642
1948
  */
1643
1949
  export function declaresInput(
1644
1950
  task: TaskConfig,
1645
1951
  projectRel: string | null,
1646
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,
1647
1958
  ): boolean {
1648
1959
  const inputs = task.inputs
1649
1960
  if (inputs === undefined) return false
@@ -1653,10 +1964,13 @@ export function declaresInput(
1653
1964
  // lists: either way it may be an input, so it counts.
1654
1965
  const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1655
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
+ }
1656
1970
  }
1657
1971
  const ws = inputs.workspaceFiles
1658
1972
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1659
- return workspaceMatcher(ws)(workspaceRel)
1973
+ return workspaceMatcher(ws)(workspaceRel, dirLink)
1660
1974
  }
1661
1975
 
1662
1976
  /**
@@ -1700,22 +2014,23 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
1700
2014
  function ownOnly(plan: FilesPlan, rel: string): boolean {
1701
2015
  let own = plan.ownOnly.get(rel)
1702
2016
  if (own === undefined) {
1703
- own = plan.ownOutput(rel) && plan.isPositive(rel) && !plan.isExcluded(rel)
2017
+ own = plan.ownOutput(rel) && !plan.isExcluded(rel)
1704
2018
  plan.ownOnly.set(rel, own)
1705
2019
  }
1706
2020
  return own
1707
2021
  }
1708
2022
 
1709
- const workspaceMatchers = new Map<string, (rel: string) => boolean>()
2023
+ const workspaceMatchers = new Map<string, (rel: string, dirLink: boolean) => boolean>()
1710
2024
 
1711
- function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
2025
+ function workspaceMatcher(decl: readonly string[]): (rel: string, dirLink: boolean) => boolean {
1712
2026
  const key = JSON.stringify(decl)
1713
2027
  let m = workspaceMatchers.get(key)
1714
2028
  if (m !== undefined) return m
1715
2029
  const { positive, negative } = splitNegations(decl)
1716
- const isPositive = anyTaskGlob(asTrees(positive))
2030
+ const isPositive = throughLinks(positive, anyTaskGlob(asTrees(positive)))
2031
+ const mayHold = mayHoldMatch(positive)
1717
2032
  const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
1718
- m = (rel) => isPositive(rel) && !isExcluded(rel)
2033
+ m = (rel, dirLink) => (isPositive(rel) || (dirLink && mayHold(rel))) && !isExcluded(rel)
1719
2034
  workspaceMatchers.set(key, m)
1720
2035
  return m
1721
2036
  }
@@ -1764,6 +2079,7 @@ async function resolveFiles(
1764
2079
  plan,
1765
2080
  nested,
1766
2081
  ownWsOutput,
2082
+ args.gitFilesCache,
1767
2083
  ),
1768
2084
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1769
2085
  }
@@ -1779,7 +2095,7 @@ async function resolveFiles(
1779
2095
  // tree just changed); set() drops the project's OID slot and these
1780
2096
  // files fall back to Cache.hashFile, which computes the identical
1781
2097
  // blob OID from disk.
1782
- const ls = runGitLsFiles(args.projectDir)
2098
+ const ls = runGitLsFiles(args.projectDir, listingEnv(args.projectDir, args.workspaceRoot))
1783
2099
  gitFiles = ls.files
1784
2100
  // set() also clears the project's pending-changed bookkeeping.
1785
2101
  args.gitFilesCache?.set(args.projectDir, gitFiles)
@@ -1811,14 +2127,21 @@ async function resolveFiles(
1811
2127
  const candidates: string[] = []
1812
2128
  const verdicts = plan.verdicts
1813
2129
  const committed = committedFiles(args.gitFilesCache, args.projectDir)
2130
+ const oids = args.gitFilesCache?.oidsFor(args.projectDir)
1814
2131
  for (const rel of gitFiles) {
1815
2132
  let input = verdicts.get(rel)
1816
2133
  if (input === undefined) {
1817
2134
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1818
2135
  verdicts.set(rel, input)
1819
2136
  }
1820
- if ((!input && !ownOnly(plan, rel)) || nested(rel) || ownWsOutput(rel)) continue
2137
+ if (nested(rel) || ownWsOutput(rel)) continue
1821
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
+ }
1822
2145
  if (!input && !committed(abs)) continue
1823
2146
  candidates.push(abs)
1824
2147
  }
@@ -1830,7 +2153,6 @@ async function resolveFiles(
1830
2153
  // per-file syscalls. Paths without an OID keep the probe:
1831
2154
  // `git ls-files -s` can surface staged entries whose working-tree
1832
2155
  // file is gone; the hasher would otherwise throw ENOENT.
1833
- const oids = args.gitFilesCache?.oidsFor(args.projectDir)
1834
2156
  const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
1835
2157
  // Git's slice comes sorted and a common prefix keeps it so: one pass
1836
2158
  // proves it instead of a sort per task.
@@ -1842,7 +2164,15 @@ async function resolveFiles(
1842
2164
  }
1843
2165
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1844
2166
  return {
1845
- 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
+ ),
1846
2176
  listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1847
2177
  }
1848
2178
  }
@@ -1855,18 +2185,26 @@ function withIgnored(
1855
2185
  plan: FilesPlan,
1856
2186
  nested: (rel: string) => boolean,
1857
2187
  ownWsOutput: (rel: string) => boolean,
2188
+ cache: GitFilesCache | undefined,
1858
2189
  ): string[] {
1859
2190
  if (reach.length === 0) return [...resolved]
1860
2191
  const extra = ignoredInputs(
1861
2192
  projectDir,
1862
2193
  reach,
1863
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,
1864
2202
  )
1865
2203
  return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
1866
2204
  }
1867
2205
 
1868
2206
  function listingFor(
1869
- args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
2207
+ args: { projectDir: string; workspaceRoot: string; gitFilesCache?: GitFilesCache | undefined },
1870
2208
  listed: readonly string[],
1871
2209
  plan: FilesPlan,
1872
2210
  nested: (rel: string) => boolean,
@@ -1876,12 +2214,15 @@ function listingFor(
1876
2214
  let committed: ((abs: string) => boolean) | undefined
1877
2215
  return {
1878
2216
  root,
2217
+ workspaceRoot: args.workspaceRoot,
1879
2218
  listed,
1880
2219
  isInput: (rel) =>
1881
2220
  (inPlan(plan, rel) ||
1882
2221
  (ownOnly(plan, rel) &&
1883
2222
  (committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
1884
2223
  !ownWsOutput(rel),
2224
+ linked: (rel) =>
2225
+ plan.mayHold(rel) && !plan.isExcluded(rel) && !plan.ownOutput(rel) && !ownWsOutput(rel),
1885
2226
  nested,
1886
2227
  prefixes: plan.prefixes,
1887
2228
  literals: plan.literals,
@@ -1943,9 +2284,16 @@ function undecodableOnDisk(abs: string): boolean {
1943
2284
  */
1944
2285
  export interface InputListing {
1945
2286
  root: string
2287
+ /** Where git's location variables point; `root` in a repository below it is listed without them. */
2288
+ workspaceRoot: string
1946
2289
  /** Root-relative paths, as git listed them. */
1947
2290
  listed: readonly string[]
1948
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
1949
2297
  nested: (rel: string) => boolean
1950
2298
  /** Root-relative directories a positive entry reaches, `''` for the root. */
1951
2299
  prefixes: readonly string[]
@@ -2051,7 +2399,10 @@ function addedTo(
2051
2399
  if (known.has(rel) || e.name === 'node_modules' || l.nested(`${rel}/`)) continue
2052
2400
  if (reaches(l.prefixes, rel)) candidates.push(rel)
2053
2401
  } else if (
2054
- l.isInput(rel) &&
2402
+ (l.isInput(rel) ||
2403
+ (e.isSymbolicLink() &&
2404
+ l.linked(rel) &&
2405
+ dirLinkInput(path.resolve(abs, e.name), undefined))) &&
2055
2406
  !l.nested(rel) &&
2056
2407
  !isInstalledPath(rel) &&
2057
2408
  !keyFiles.has(path.resolve(abs, e.name))
@@ -2061,10 +2412,11 @@ function addedTo(
2061
2412
  }
2062
2413
  }
2063
2414
  if (candidates.length === 0) return undefined
2064
- for (const rel of runGitLsFiles(l.root).files) {
2415
+ for (const rel of runGitLsFiles(l.root, listingEnv(l.root, l.workspaceRoot)).files) {
2065
2416
  if (!candidates.some((c) => c === '' || rel === c || rel.startsWith(`${c}/`))) continue
2066
- if (isInstalledPath(rel) || l.nested(rel) || !l.isInput(rel)) continue
2417
+ if (isInstalledPath(rel) || l.nested(rel)) continue
2067
2418
  const abs = path.resolve(l.root, rel)
2419
+ if (!l.isInput(rel) && !(l.linked(rel) && dirLinkInput(abs, undefined))) continue
2068
2420
  if (!keyFiles.has(abs) && isInputOnDisk(abs)) return abs
2069
2421
  }
2070
2422
  return undefined
@@ -2103,25 +2455,103 @@ async function scanUnion(
2103
2455
  emptyDirs?: Set<string>,
2104
2456
  ): Promise<Set<string>> {
2105
2457
  const matches = new Set<string>()
2458
+ const links: string[] = []
2459
+ const take = (rel: string): void => {
2460
+ if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
2461
+ const abs = path.resolve(cwd, rel)
2462
+ const st = lstatSync(abs, { throwIfNoEntry: false })
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) {
2467
+ if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2468
+ } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
2469
+ }
2470
+ const opts = { cwd, onlyFiles: false, followSymlinks: false, dot: true }
2106
2471
  // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
2107
2472
  // `/`: `{dist,lib/esm}/**` saved an empty artifact, and a hit restored
2108
2473
  // nothing over a cleaned tree (A-10). Expanded here, as discovery does.
2109
- for (const pattern of positive.flatMap(slashBraceExpansions)) {
2474
+ const patterns = positive.flatMap(slashBraceExpansions)
2475
+ // A leading `**` walks the whole project whatever else is listed, so the
2476
+ // list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
2477
+ // ~2 ms of a warm hit on solid.
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
+ ) {
2485
+ const any = anyTaskGlob(patterns)
2486
+ for (const rel of WHOLE_TREE.scanSync(opts)) if (any(rel)) take(rel)
2487
+ return matches
2488
+ }
2489
+ for (const pattern of patterns) {
2110
2490
  if (absentPrefix(cwd, pattern)) continue
2111
- const glob = globFor(pattern)
2112
- for (const rel of glob.scanSync({ cwd, onlyFiles: false, followSymlinks: false, dot: true })) {
2113
- if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) continue
2114
- const abs = path.resolve(cwd, rel)
2115
- const st = lstatSync(abs, { throwIfNoEntry: false })
2116
- if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
2117
- else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2118
- if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2119
- } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
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)
2120
2508
  }
2121
2509
  }
2122
2510
  return matches
2123
2511
  }
2124
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
+
2125
2555
  /**
2126
2556
  * True when `pattern`'s static directory is not on disk, so nothing can
2127
2557
  * match under it. A restore into a tree without its outputs scans each
@@ -2139,6 +2569,8 @@ function absentPrefix(cwd: string, pattern: string): boolean {
2139
2569
  }
2140
2570
  }
2141
2571
 
2572
+ const WHOLE_TREE = new Bun.Glob('**')
2573
+
2142
2574
  // Compiled once per pattern string for the life of the process: a `Bun.Glob`
2143
2575
  // is immutable, and the same handful of declared globs are compiled again
2144
2576
  // for every task otherwise (three sites, up to thousands of times per run).