@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -17,12 +17,24 @@
17
17
  // be a git work tree; non-git environments are not supported.
18
18
 
19
19
  import path from 'node:path'
20
- import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
20
+ import {
21
+ chmodSync,
22
+ type Dirent,
23
+ lstatSync,
24
+ readdirSync,
25
+ realpathSync,
26
+ rmdirSync,
27
+ rmSync,
28
+ statSync,
29
+ } from 'node:fs'
21
30
  import { rm, rmdir } from 'node:fs/promises'
22
31
  import type { InputsConfig, TaskConfig } from '../config.js'
23
32
  import {
33
+ appendTail,
34
+ createTail,
24
35
  literalPath,
25
36
  anyTaskGlob,
37
+ tailText,
26
38
  asTrees,
27
39
  isExecutableMissing,
28
40
  isInstalledPath,
@@ -37,6 +49,7 @@ import {
37
49
  slashBraceExpansions,
38
50
  braceAlternatives,
39
51
  ESSENTIAL_ENV,
52
+ POST_EXIT_DRAIN_MS,
40
53
  splitNegations,
41
54
  staticPrefix,
42
55
  stripTrailingSlash,
@@ -45,7 +58,7 @@ import {
45
58
  writableUnder,
46
59
  withoutWorkspaceBins,
47
60
  } from '../util/index.js'
48
- import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
61
+ import { GitFilesCache, listingEnv, newFilesIgnoredIn, runGitLsFiles } from './git-inputs.js'
49
62
  import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
50
63
 
51
64
  // The git side lives in git-inputs.ts; its whole public surface is
@@ -106,13 +119,18 @@ const ALWAYS_IGNORE = [
106
119
  '.vx/dependencies.lock.json',
107
120
  // Where restores stage (archive.ts): vx's own, never an input.
108
121
  '.vx/tmp/**',
122
+ // The temps the locks and review copies are written to and renamed from
123
+ // (`writeAllOrNone`, `writeNoFollow`): a killed write leaves one.
124
+ '.vx/projects.lock.json.tmp-*',
125
+ '.vx/dependencies.lock.json.tmp-*',
126
+ 'vx.lock.json.tmp-*',
109
127
  ]
110
128
 
111
129
  /**
112
130
  * `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
113
131
  * those globs cross into other projects, and each one's `vx.lock.json` is vx's.
114
132
  */
115
- const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
133
+ const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json', '**/vx.lock.json.tmp-*']
116
134
 
117
135
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
118
136
 
@@ -214,7 +232,18 @@ export type ProjectFilesCache = Map<
214
232
  * relative to it) and everything `resolveFiles` keys its memo on.
215
233
  * Undefined when only resolving the files can say.
216
234
  */
217
- export function filesToken(args: ResolveInputsArgs): string | undefined {
235
+ export function filesToken(
236
+ args: Pick<
237
+ ResolveInputsArgs,
238
+ | 'projectDir'
239
+ | 'workspaceRoot'
240
+ | 'inputs'
241
+ | 'ownOutputs'
242
+ | 'ownWorkspaceOutputs'
243
+ | 'nestedProjectDirs'
244
+ | 'gitFilesCache'
245
+ >,
246
+ ): string | undefined {
218
247
  if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
219
248
  const plan = filesPlan(args.inputs?.files, args.ownOutputs)
220
249
  const cache = args.gitFilesCache
@@ -299,6 +328,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
299
328
  args.runtimeScope === undefined
300
329
  ? `${args.projectDir}\0`
301
330
  : `${args.projectDir}\0${args.runtimeScope}\0`,
331
+ 'runtime',
302
332
  ),
303
333
  resolveRuntimeValues(
304
334
  wsRuntimeDecl,
@@ -307,6 +337,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
307
337
  args.workspaceRoot,
308
338
  args.workspaceRuntimeCache,
309
339
  args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
340
+ 'workspaceRuntime',
310
341
  ),
311
342
  ])
312
343
  return {
@@ -579,6 +610,7 @@ export function answerRuntime(
579
610
  [path.join(workspaceRoot, 'node_modules', '.bin')],
580
611
  workspaceRoot,
581
612
  undefined,
613
+ 'workspaceRuntime',
582
614
  )
583
615
  : runRuntimeCommand(
584
616
  command,
@@ -586,6 +618,7 @@ export function answerRuntime(
586
618
  projectBinDirs(projectDir, workspaceRoot),
587
619
  workspaceRoot,
588
620
  undefined,
621
+ 'runtime',
589
622
  )
590
623
  }
591
624
 
@@ -593,7 +626,21 @@ function resolveEnvValues(
593
626
  names: readonly string[],
594
627
  source: NodeJS.ProcessEnv,
595
628
  ): Array<[string, string | undefined]> {
596
- return [...names].sort().map((name) => [name, source[name]])
629
+ return [...names].sort().map((name) => {
630
+ const value = source[name]
631
+ // Bun decodes the environment lossily and offers no raw bytes on every
632
+ // platform (Linux's /proc/self/environ misses what vx sets itself), so
633
+ // Latin-1 é and è both read as U+FFFD and replayed each other's build.
634
+ // A value that truly holds U+FFFD is refused with them: the two read
635
+ // alike. The value is never shown; it may be a secret.
636
+ if (value?.includes('�') === true) {
637
+ throw new UserError(
638
+ `inputs.env ${name} holds U+FFFD: its value is not UTF-8, or holds that character, ` +
639
+ `and a lossy decode keys every invalid byte alike. Set it as UTF-8.`,
640
+ )
641
+ }
642
+ return [name, value]
643
+ })
597
644
  }
598
645
 
599
646
  /**
@@ -620,7 +667,9 @@ async function runRuntimeCommand(
620
667
  binDirs: readonly string[],
621
668
  workspaceRoot: string,
622
669
  owner: RuntimeMemo | undefined,
670
+ field: RuntimeField,
623
671
  ): Promise<string> {
672
+ const probe = `inputs.${field} probe`
624
673
  // As a task's (exec/env.ts): no member's bin inherited from `npm run`.
625
674
  const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
626
675
  // As the task's PATH does (exec/env.ts): a dir holding the delimiter
@@ -648,10 +697,10 @@ async function runRuntimeCommand(
648
697
  // the shell, not the command (item 244).
649
698
  if (isExecutableMissing(err)) {
650
699
  throw new UserError(
651
- `inputs runtime command could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
700
+ `${probe} could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
652
701
  )
653
702
  }
654
- throw new UserError(`inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
703
+ throw new UserError(`${probe} failed to spawn: ${command} (cwd: ${cwd})`)
655
704
  }
656
705
  liveProbes.add(proc)
657
706
  let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
@@ -662,22 +711,41 @@ async function runRuntimeCommand(
662
711
  }
663
712
  killProbesOnExit()
664
713
  let stdout, stderr, exitCode
714
+ const ac = new AbortController()
665
715
  try {
666
- ;[stdout, stderr, exitCode] = await Promise.all([
667
- new Response(proc.stdout).bytes(),
668
- new Response(proc.stderr).bytes(),
669
- proc.exited,
716
+ const streams = Promise.all([
717
+ probeBytes(proc.stdout, ac.signal),
718
+ probeBytes(proc.stderr, ac.signal),
670
719
  ])
720
+ exitCode = await proc.exited
721
+ // A child the shell left holding the pipes (`sleep 6 & echo x`) kept
722
+ // them open past the shell's exit, and the run waited for the child. As
723
+ // a task's (exec/runner.ts): a short drain, then the reads stop and the
724
+ // group goes. What was read is what the shell printed, so the key folds
725
+ // it; what the child prints later is no part of the answer.
726
+ let timer: ReturnType<typeof setTimeout> | undefined
727
+ const cut = new Promise<void>((resolve) => {
728
+ timer = setTimeout(resolve, POST_EXIT_DRAIN_MS)
729
+ })
730
+ await Promise.race([streams, cut])
731
+ clearTimeout(timer)
732
+ ac.abort()
733
+ killGroup(proc.pid)
734
+ ;[stdout, stderr] = await streams
671
735
  } finally {
672
736
  liveProbes.delete(proc)
673
737
  mine?.delete(proc)
674
738
  }
675
739
  if (exitCode !== 0) {
676
740
  const lossy = new TextDecoder()
677
- const shown = `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim()
741
+ // Capped as a persistent task's log is: a chatty probe put megabytes
742
+ // into one error line. The tail is kept; the failure is usually last.
743
+ const tail = createTail()
744
+ appendTail(tail, `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim())
745
+ const shown = tailText(tail)
746
+ const lost = tail.dropped === 0 ? '' : `… ${tail.dropped} earlier characters dropped\n`
678
747
  throw new UserError(
679
- `inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
680
- (shown ? `\n${shown}` : ''),
748
+ `${probe} exited ${exitCode}: ${command} (cwd: ${cwd})` + (shown ? `\n${lost}${shown}` : ''),
681
749
  )
682
750
  }
683
751
  let out: string
@@ -689,7 +757,7 @@ async function runRuntimeCommand(
689
757
  // A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
690
758
  // folded the same output and replayed each other's build.
691
759
  throw new UserError(
692
- `inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
760
+ `${probe} printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
693
761
  `Pipe it through a hash or od.`,
694
762
  )
695
763
  }
@@ -722,6 +790,8 @@ const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
722
790
  let probeExitHooked = false
723
791
 
724
792
  type RuntimeMemo = Map<string, Promise<string>>
793
+ /** The field a probe is declared under, as its errors name it. */
794
+ type RuntimeField = 'runtime' | 'workspaceRuntime'
725
795
  /** The probes each run started, by the run's memo: one run's stop is not another's. */
726
796
  const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
727
797
 
@@ -736,24 +806,51 @@ const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
736
806
  export function stopRuntimeProbes(...memos: readonly RuntimeMemo[]): void {
737
807
  for (const memo of memos) {
738
808
  for (const p of probesOf.get(memo) ?? []) {
739
- try {
740
- process.kill(-p.pid, 'SIGKILL')
741
- } catch {
742
- // the group is gone
743
- }
809
+ killGroup(p.pid)
810
+ }
811
+ }
812
+ }
813
+ /** SIGKILL a probe's group, its own since the spawn (`detached`). */
814
+ function killGroup(pid: number): void {
815
+ try {
816
+ process.kill(-pid, 'SIGKILL')
817
+ } catch {
818
+ // the group is gone
819
+ }
820
+ }
821
+
822
+ /**
823
+ * A probe's stream, read to its end or until `signal`: then the pending
824
+ * read is cancelled and what was read is returned. `new Response().bytes()`
825
+ * offers no way out while a leftover child holds the pipe.
826
+ */
827
+ async function probeBytes(
828
+ stream: ReadableStream<Uint8Array>,
829
+ signal: AbortSignal,
830
+ ): Promise<Uint8Array> {
831
+ const reader = stream.getReader()
832
+ const onAbort = (): void => void reader.cancel().catch(() => {})
833
+ signal.addEventListener('abort', onAbort, { once: true })
834
+ const parts: Uint8Array[] = []
835
+ try {
836
+ while (true) {
837
+ const { value, done } = await reader.read()
838
+ if (done) break
839
+ parts.push(value)
744
840
  }
841
+ } finally {
842
+ signal.removeEventListener('abort', onAbort)
843
+ reader.releaseLock()
745
844
  }
845
+ return Buffer.concat(parts)
746
846
  }
847
+
747
848
  function killProbesOnExit(): void {
748
849
  if (probeExitHooked) return
749
850
  probeExitHooked = true
750
851
  process.on('exit', () => {
751
852
  for (const p of liveProbes) {
752
- try {
753
- process.kill(-p.pid, 'SIGKILL')
754
- } catch {
755
- // the group is gone
756
- }
853
+ killGroup(p.pid)
757
854
  }
758
855
  })
759
856
  }
@@ -774,6 +871,7 @@ async function resolveRuntimeValues(
774
871
  workspaceRoot: string,
775
872
  memo: Map<string, Promise<string>> | undefined,
776
873
  memoKeyPrefix: string,
874
+ field: RuntimeField,
777
875
  ): Promise<Array<[string, string]>> {
778
876
  if (commands.length === 0) return []
779
877
  const unique = [...new Set(commands)].sort()
@@ -782,7 +880,7 @@ async function resolveRuntimeValues(
782
880
  const key = `${memoKeyPrefix}${cmd}`
783
881
  let p = memo?.get(key)
784
882
  if (p === undefined) {
785
- p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
883
+ p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo, field)
786
884
  memo?.set(key, p)
787
885
  }
788
886
  return [cmd, await p] as [string, string]
@@ -800,6 +898,13 @@ function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspac
800
898
  return outputNever(outputs, scope).map(globFor)
801
899
  }
802
900
 
901
+ /** A task's `inputs` and the directories they anchor at, for `refuseLinkedIntoInputs`. */
902
+ export interface OwnInputs {
903
+ projectDir: string
904
+ workspaceRoot: string
905
+ declared: InputsConfig | undefined
906
+ }
907
+
803
908
  /**
804
909
  * Resolve declared output globs (project-relative) to actual produced files.
805
910
  * An output directory linked out of the project is refused by name (X-88):
@@ -809,6 +914,7 @@ export async function resolveOutputs(args: {
809
914
  projectDir: string
810
915
  outputs: readonly string[]
811
916
  nestedProjectDirs: string[]
917
+ inputs?: OwnInputs | undefined
812
918
  }): Promise<string[]> {
813
919
  return (await scanOutputs(args)).files
814
920
  }
@@ -837,21 +943,66 @@ export async function resolveOutputTree(args: {
837
943
  projectDir: string
838
944
  outputs: readonly string[]
839
945
  nestedProjectDirs: string[]
840
- }): Promise<{ files: string[]; emptyDirs: string[] }> {
946
+ /** The task's own inputs: an output directory linked into them is refused. */
947
+ inputs?: OwnInputs | undefined
948
+ /**
949
+ * Also list the FIFOs, sockets and devices the globs name: a save refuses
950
+ * them, a clean removes them. `'later'` leaves `special` empty and hands
951
+ * `findSpecial` instead, for a walk that needs them only if it restores.
952
+ */
953
+ special?: boolean | 'later'
954
+ /** The walk feeds a clean or a restore (`refuseUndecodableOutput`). */
955
+ clearing?: boolean
956
+ }): Promise<OutputTree> {
841
957
  const emptyDirs = new Set<string>()
842
- const { files } = await scanOutputs(args, emptyDirs)
843
- return { files, emptyDirs: containedIn(args.projectDir, [...emptyDirs]).sort() }
958
+ const probe: SpecialProbe | undefined =
959
+ args.special === undefined || args.special === false ? undefined : {}
960
+ const { files } = await scanOutputs(args, emptyDirs, probe)
961
+ return treeOf(args.projectDir, files, emptyDirs, args.special, probe)
962
+ }
963
+
964
+ /** What `resolveOutputTree` and its workspace twin return. */
965
+ type OutputTree = {
966
+ files: string[]
967
+ emptyDirs: string[]
968
+ special: string[]
969
+ findSpecial?: () => string[]
970
+ }
971
+
972
+ function treeOf(
973
+ root: string,
974
+ files: string[],
975
+ emptyDirs: Set<string>,
976
+ asked: boolean | 'later' | undefined,
977
+ probe: SpecialProbe | undefined,
978
+ ): OutputTree {
979
+ const findSpecial = (): string[] =>
980
+ probe?.find === undefined ? [] : containedIn(root, probe.find()).sort()
981
+ const tree: OutputTree = {
982
+ files,
983
+ emptyDirs: containedIn(root, [...emptyDirs]).sort(),
984
+ special: asked === true ? findSpecial() : [],
985
+ }
986
+ if (asked === 'later') tree.findSpecial = findSpecial
987
+ return tree
844
988
  }
845
989
 
846
990
  async function scanOutputs(
847
- args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
991
+ args: {
992
+ projectDir: string
993
+ outputs: readonly string[]
994
+ nestedProjectDirs: string[]
995
+ inputs?: OwnInputs | undefined
996
+ clearing?: boolean
997
+ },
848
998
  emptyDirs?: Set<string>,
999
+ special?: SpecialProbe,
849
1000
  ): Promise<{ files: string[] }> {
850
1001
  const { positive, negative } = splitNegations(args.outputs)
851
1002
  if (positive.length === 0) return { files: [] }
852
1003
  const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
853
1004
  const trees = asTrees(positive)
854
- refuseDirLinkedOut(args.projectDir, trees, 'project')
1005
+ const absent = refuseDirLinkedOut(args.projectDir, trees, 'project', args.inputs)
855
1006
  const scanned = [
856
1007
  ...(await scanUnion(
857
1008
  trees,
@@ -859,6 +1010,9 @@ async function scanOutputs(
859
1010
  args.projectDir,
860
1011
  inNestedProject(args.projectDir, args.nestedProjectDirs),
861
1012
  emptyDirs,
1013
+ special,
1014
+ absent,
1015
+ args.clearing,
862
1016
  )),
863
1017
  ]
864
1018
  // Containment, enforced HERE and not only at the loader. `cleanOutputs`
@@ -892,14 +1046,22 @@ export function committedFiles(
892
1046
  * below it is an output, kept as the link (`archive.ts`). One into `.git`
893
1047
  * or `.vx` is refused in either scope: for the root project and for
894
1048
  * workspace outputs those are inside, and the clean deleted the
895
- * repository through `dist -> .git`. One out of a workspace output's root
896
- * is left to `containedIn`.
1049
+ * repository through `dist -> .git`. One out of the root is refused in
1050
+ * either scope too: a workspace output's every file was dropped as outside
1051
+ * and an empty entry saved, while the project twin refused the task (M-65).
1052
+ * So is one holding an entry of the task's own `inputs`, in either scope:
1053
+ * X-55 read through the link, as `dist -> src` under `src/**` had the
1054
+ * clean delete every untracked source.
897
1055
  */
898
1056
  function refuseDirLinkedOut(
899
1057
  root: string,
900
1058
  trees: readonly string[],
901
1059
  scope: 'project' | 'workspace',
902
- ): void {
1060
+ inputs?: OwnInputs,
1061
+ ): Set<string> {
1062
+ // The heads found absent: the scan that follows skips them without
1063
+ // asking again (a restore after a clean asks of every one).
1064
+ const absent = new Set<string>()
903
1065
  let realRoot: string | undefined
904
1066
  for (const tree of trees) {
905
1067
  let at = root
@@ -913,7 +1075,10 @@ function refuseDirLinkedOut(
913
1075
  } catch {
914
1076
  break
915
1077
  }
916
- if (st === undefined) break
1078
+ if (st === undefined) {
1079
+ absent.add(at)
1080
+ break
1081
+ }
917
1082
  if (!st.isSymbolicLink()) continue
918
1083
  let real: string
919
1084
  try {
@@ -924,10 +1089,9 @@ function refuseDirLinkedOut(
924
1089
  }
925
1090
  realRoot ??= realOr(root)
926
1091
  if (!isInside(realRoot, real)) {
927
- if (scope === 'workspace') break
928
1092
  throw new UserError(
929
1093
  `${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
930
- 'or restores declared outputs through a link that leaves the project. Remove the ' +
1094
+ `or restores declared outputs through a link that leaves the ${scope}. Remove the ` +
931
1095
  'link and re-run, or stop declaring outputs under it.',
932
1096
  )
933
1097
  }
@@ -938,8 +1102,57 @@ function refuseDirLinkedOut(
938
1102
  'link and re-run, or stop declaring outputs under it.',
939
1103
  )
940
1104
  }
1105
+ if (inputs !== undefined) refuseLinkedIntoInputs(at, real, inputs)
941
1106
  }
942
1107
  }
1108
+ return absent
1109
+ }
1110
+
1111
+ /**
1112
+ * Throws when an entry of `inputs` can match a path under the real
1113
+ * directory `real`: `files` under the project, `workspaceFiles` under the
1114
+ * workspace root. A link to an ancestor of the anchor holds every entry.
1115
+ * Negations are not subtracted: a link onto files the task reads is wrong
1116
+ * even where one excludes them.
1117
+ */
1118
+ function refuseLinkedIntoInputs(at: string, real: string, inputs: OwnInputs): void {
1119
+ const holds = (anchor: string, globs: readonly string[] | undefined): boolean => {
1120
+ const { positive } = splitNegations(globs ?? [])
1121
+ if (positive.length === 0) return false
1122
+ const realAnchor = realOr(anchor)
1123
+ const rel = relPosix(realAnchor, real)
1124
+ if (rel === '..' || rel.startsWith('../')) return isInside(real, realAnchor)
1125
+ const dir = rel === '' ? [] : rel.split('/')
1126
+ return asTrees(positive).some((g) => reachesBelow(g.split('/'), dir))
1127
+ }
1128
+ if (
1129
+ !holds(inputs.projectDir, inputs.declared?.files) &&
1130
+ !holds(inputs.workspaceRoot, inputs.declared?.workspaceFiles)
1131
+ )
1132
+ return
1133
+ throw new UserError(
1134
+ `${at} is a symbolic link into ${real}, which the task's own inputs read — vx removes a ` +
1135
+ `task's outputs before it runs, so the task would delete its own sources. Remove the ` +
1136
+ 'link and re-run, or stop declaring outputs under it.',
1137
+ 'VX_E_INPUT_IS_OUTPUT',
1138
+ )
1139
+ }
1140
+
1141
+ /**
1142
+ * Whether the glob split into `segments` can match a path below the
1143
+ * directory `dir` (split too): each of `dir`'s names matches its segment
1144
+ * and the glob goes on past them, or a `**` comes first. `**\/*.ts` and
1145
+ * `src/**` reach below `src/lib`; `src/**` and `*.json` do not reach below
1146
+ * `build-out`.
1147
+ */
1148
+ function reachesBelow(segments: readonly string[], dir: readonly string[]): boolean {
1149
+ for (let i = 0; i < dir.length; i++) {
1150
+ const seg = segments[i]
1151
+ if (seg === undefined) return false
1152
+ if (seg === '**') return true
1153
+ if (!globFor(seg).match(dir[i]!)) return false
1154
+ }
1155
+ return segments.length > dir.length
943
1156
  }
944
1157
 
945
1158
  /**
@@ -1088,6 +1301,7 @@ export async function cleanOutputs(args: {
1088
1301
  projectDir: string
1089
1302
  outputs: readonly string[]
1090
1303
  nestedProjectDirs: string[]
1304
+ inputs?: OwnInputs | undefined
1091
1305
  committed?: (abs: string) => boolean
1092
1306
  /**
1093
1307
  * What `resolveOutputs` returned for these same arguments, when the
@@ -1103,17 +1317,18 @@ export async function cleanOutputs(args: {
1103
1317
  )
1104
1318
  sweepRestoreTemps(args.projectDir, args.outputs, committed)
1105
1319
  // An empty directory under the outputs is one too (a save keeps it): one
1106
- // an older build left would otherwise be saved by this run.
1320
+ // an older build left would otherwise be saved by this run. So is a FIFO,
1321
+ // socket or device: a task writing a FIFO's path blocks on it forever.
1107
1322
  const tree =
1108
1323
  args.resolved === undefined
1109
- ? await resolveOutputTree(args)
1110
- : { files: args.resolved, emptyDirs: [] }
1324
+ ? await resolveOutputTree({ ...args, special: true, clearing: true })
1325
+ : { files: args.resolved, emptyDirs: [], special: [] }
1111
1326
  const matched = tree.files
1112
1327
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
1113
1328
  // globs overlap and a sibling already deleted a path mid-iteration).
1114
1329
  // A symlink is unlinked, never followed.
1115
1330
  const removed = await removeAll(
1116
- matched.filter((f) => !committed(f)),
1331
+ [...matched, ...tree.special].filter((f) => !committed(f)),
1117
1332
  args.projectDir,
1118
1333
  )
1119
1334
  await pruneEmptiedDirs(
@@ -1254,18 +1469,42 @@ export async function stampOutputs(args: {
1254
1469
  projectDir: string
1255
1470
  outputs: readonly string[]
1256
1471
  nestedProjectDirs: string[]
1472
+ inputs?: OwnInputs | undefined
1257
1473
  }): Promise<Map<string, OutputStamp>> {
1258
1474
  return stampFiles(await resolveOutputs(args))
1259
1475
  }
1260
1476
 
1261
- /** `stampOutputs` for root-anchored `cache.outputs.workspaceFiles` (A-43). */
1477
+ /** `stampOutputs` for root-anchored `outputs.workspaceFiles` (A-43). */
1262
1478
  export async function stampWorkspaceOutputs(args: {
1263
1479
  workspaceRoot: string
1264
1480
  outputs: readonly string[]
1481
+ inputs?: OwnInputs | undefined
1265
1482
  }): Promise<Map<string, OutputStamp>> {
1266
1483
  return stampFiles(await resolveWorkspaceOutputs(args))
1267
1484
  }
1268
1485
 
1486
+ /**
1487
+ * The stamps of the committed files among `files`, the outputs a clean
1488
+ * found. No clean deletes one, so a failed attempt's change to it stays
1489
+ * for the retry: `committedMoved` tells.
1490
+ */
1491
+ export function stampCommitted(
1492
+ files: readonly string[],
1493
+ committed: (abs: string) => boolean,
1494
+ ): Map<string, OutputStamp> {
1495
+ return stampFiles(files.filter(committed))
1496
+ }
1497
+
1498
+ /** Whether a committed file among `files` was added, removed or touched since `before` (`stampCommitted`). */
1499
+ export function committedMoved(
1500
+ files: readonly string[],
1501
+ committed: (abs: string) => boolean,
1502
+ before: ReadonlyMap<string, OutputStamp>,
1503
+ ): boolean {
1504
+ const changed = changedSince(files.filter(committed), before)
1505
+ return changed === undefined || changed.length > 0
1506
+ }
1507
+
1269
1508
  function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
1270
1509
  const out = new Map<string, OutputStamp>()
1271
1510
  for (const f of files) {
@@ -1292,15 +1531,20 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
1292
1531
  * file back, so no entry reproduces that run and none is saved.
1293
1532
  */
1294
1533
  export async function ownOutputsSince(
1295
- args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
1534
+ args: {
1535
+ projectDir: string
1536
+ outputs: readonly string[]
1537
+ nestedProjectDirs: string[]
1538
+ inputs?: OwnInputs | undefined
1539
+ },
1296
1540
  before: ReadonlyMap<string, OutputStamp>,
1297
1541
  ): Promise<string[] | undefined> {
1298
1542
  return changedSince(await resolveOutputs(args), before)
1299
1543
  }
1300
1544
 
1301
- /** `ownOutputsSince` for root-anchored `cache.outputs.workspaceFiles` (A-43). */
1545
+ /** `ownOutputsSince` for root-anchored `outputs.workspaceFiles` (A-43). */
1302
1546
  export async function ownWorkspaceOutputsSince(
1303
- args: { workspaceRoot: string; outputs: readonly string[] },
1547
+ args: { workspaceRoot: string; outputs: readonly string[]; inputs?: OwnInputs | undefined },
1304
1548
  before: ReadonlyMap<string, OutputStamp>,
1305
1549
  ): Promise<string[] | undefined> {
1306
1550
  return changedSince(await resolveWorkspaceOutputs(args), before)
@@ -1348,9 +1592,18 @@ const SYNC_CLEAN_MAX = 128
1348
1592
  * wrote, a read-only checkout) is the environment's failure, not vx's:
1349
1593
  * the scheduler prints any other error as an "internal error", which
1350
1594
  * sends the reader to file a bug against a permission bit.
1595
+ *
1596
+ * A directory of this user's that withholds its own write (a task's
1597
+ * `chmod 555 dist/ro`, which a hit restores too) gets owner write and
1598
+ * search for the remove and its mode back after, as a restore's write
1599
+ * into it does (archive.ts `reopen`): else that task could never run or
1600
+ * restore again until someone chmodded the tree by hand. The prune after
1601
+ * reopens the same way for the subdirectories it removes (`pruneEmptiedDirs`).
1351
1602
  */
1352
1603
  async function removeAll(all: readonly string[], root: string): Promise<string[]> {
1353
1604
  const files = insideRoot(all, root)
1605
+ const reopened: Reopened = new Map()
1606
+ const reopen = (dir: string): boolean => reopenDir(dir, reopened)
1354
1607
  const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
1355
1608
  const rel = relPosix(root, f)
1356
1609
  return new UserError(
@@ -1359,24 +1612,64 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
1359
1612
  `by this user, or stop declaring it as an output`,
1360
1613
  )
1361
1614
  }
1362
- if (files.length <= SYNC_CLEAN_MAX) {
1363
- for (const f of files) {
1364
- try {
1365
- rmSync(f, { force: true })
1366
- } catch (err) {
1367
- throw refused(f, err as NodeJS.ErrnoException)
1615
+ const denied = (err: unknown, f: string): boolean =>
1616
+ (err as NodeJS.ErrnoException).code === 'EACCES' && reopen(path.dirname(f))
1617
+ try {
1618
+ if (files.length <= SYNC_CLEAN_MAX) {
1619
+ for (const f of files) {
1620
+ try {
1621
+ rmSync(f, { force: true })
1622
+ } catch (err) {
1623
+ if (!denied(err, f)) throw refused(f, err as NodeJS.ErrnoException)
1624
+ try {
1625
+ rmSync(f, { force: true })
1626
+ } catch (again) {
1627
+ throw refused(f, again as NodeJS.ErrnoException)
1628
+ }
1629
+ }
1368
1630
  }
1631
+ return files
1369
1632
  }
1633
+ await Promise.all(
1634
+ files.map((f) =>
1635
+ rm(f, { force: true })
1636
+ .catch((err: NodeJS.ErrnoException) => {
1637
+ if (!denied(err, f)) throw err
1638
+ return rm(f, { force: true })
1639
+ })
1640
+ .catch((err: NodeJS.ErrnoException) => {
1641
+ throw refused(f, err)
1642
+ }),
1643
+ ),
1644
+ )
1370
1645
  return files
1646
+ } finally {
1647
+ giveBack(reopened)
1648
+ }
1649
+ }
1650
+
1651
+ /** Directories given owner write and search for a remove, with the mode each had. */
1652
+ type Reopened = Map<string, number>
1653
+
1654
+ /** Whether `dir` is now open to a remove: this user's, and its missing owner bits given. */
1655
+ function reopenDir(dir: string, reopened: Reopened): boolean {
1656
+ if (reopened.has(dir)) return true
1657
+ const st = lstatSync(dir, { throwIfNoEntry: false })
1658
+ if (st?.isDirectory() !== true || st.uid !== process.getuid?.()) return false
1659
+ if ((st.mode & 0o300) === 0o300) return false
1660
+ reopened.set(dir, st.mode & 0o7777)
1661
+ chmodSync(dir, (st.mode & 0o7777) | 0o300)
1662
+ return true
1663
+ }
1664
+
1665
+ function giveBack(reopened: Reopened): void {
1666
+ for (const [dir, mode] of reopened) {
1667
+ try {
1668
+ chmodSync(dir, mode)
1669
+ } catch {
1670
+ // gone since (the prune removed it): nothing to give back
1671
+ }
1371
1672
  }
1372
- await Promise.all(
1373
- files.map((f) =>
1374
- rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
1375
- throw refused(f, err)
1376
- }),
1377
- ),
1378
- )
1379
- return files
1380
1673
  }
1381
1674
 
1382
1675
  /**
@@ -1444,33 +1737,53 @@ async function pruneEmptiedDirs(
1444
1737
  // the memo is correct and 60 ms, because every child re-attempts the
1445
1738
  // shared parent. This is correct at 44 ms.
1446
1739
  let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
1447
- while (level.size > 0) {
1448
- const parents = new Set<string>()
1449
- const dirs = [...level].filter(
1450
- (dir) =>
1451
- dir !== rootResolved &&
1452
- dir.startsWith(rootResolved + path.sep) &&
1453
- (scope === undefined || inScope(dir, scope)) &&
1454
- keep?.has(dir) !== true,
1455
- )
1456
- const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
1457
- if (dirs.length <= SYNC_CLEAN_MAX) {
1458
- for (const dir of dirs) {
1459
- try {
1460
- rmdirSync(dir)
1461
- parents.add(path.dirname(dir))
1462
- } catch (err) {
1463
- if (gone(err as NodeJS.ErrnoException)) parents.add(path.dirname(dir))
1740
+ // A subdirectory of a read-only directory of this user's (`dist/x/s` under
1741
+ // a `chmod 555 dist/x`) is removed as `removeAll` removes a file there.
1742
+ const reopened: Reopened = new Map()
1743
+ const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
1744
+ const denied = (err: NodeJS.ErrnoException, dir: string): boolean =>
1745
+ err.code === 'EACCES' && reopenDir(path.dirname(dir), reopened)
1746
+ try {
1747
+ while (level.size > 0) {
1748
+ const parents = new Set<string>()
1749
+ const dirs = [...level].filter(
1750
+ (dir) =>
1751
+ dir !== rootResolved &&
1752
+ dir.startsWith(rootResolved + path.sep) &&
1753
+ (scope === undefined || inScope(dir, scope)) &&
1754
+ keep?.has(dir) !== true,
1755
+ )
1756
+ if (dirs.length <= SYNC_CLEAN_MAX) {
1757
+ for (const dir of dirs) {
1758
+ try {
1759
+ try {
1760
+ rmdirSync(dir)
1761
+ } catch (err) {
1762
+ if (!denied(err as NodeJS.ErrnoException, dir)) throw err
1763
+ rmdirSync(dir)
1764
+ }
1765
+ parents.add(path.dirname(dir))
1766
+ } catch (err) {
1767
+ if (gone(err as NodeJS.ErrnoException)) parents.add(path.dirname(dir))
1768
+ }
1464
1769
  }
1770
+ } else {
1771
+ await Promise.all(
1772
+ dirs.map(async (dir) => {
1773
+ const ok = await rmdir(dir)
1774
+ .catch((err: NodeJS.ErrnoException) => {
1775
+ if (!denied(err, dir)) throw err
1776
+ return rmdir(dir)
1777
+ })
1778
+ .then(() => true, gone)
1779
+ if (ok) parents.add(path.dirname(dir))
1780
+ }),
1781
+ )
1465
1782
  }
1466
- } else {
1467
- await Promise.all(
1468
- dirs.map(async (dir) => {
1469
- if (await rmdir(dir).then(() => true, gone)) parents.add(path.dirname(dir))
1470
- }),
1471
- )
1783
+ level = parents
1472
1784
  }
1473
- level = parents
1785
+ } finally {
1786
+ giveBack(reopened)
1474
1787
  }
1475
1788
  }
1476
1789
 
@@ -1525,16 +1838,28 @@ export async function resolveWorkspaceOutputs(
1525
1838
  args: {
1526
1839
  workspaceRoot: string
1527
1840
  outputs: readonly string[]
1841
+ inputs?: OwnInputs | undefined
1842
+ clearing?: boolean
1528
1843
  },
1529
1844
  emptyDirs?: Set<string>,
1845
+ special?: SpecialProbe,
1530
1846
  ): Promise<string[]> {
1531
1847
  const { positive, negative } = splitNegations(args.outputs)
1532
1848
  if (positive.length === 0) return []
1533
1849
  const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
1534
1850
  const trees = asTrees(positive)
1535
- refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
1851
+ const absent = refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace', args.inputs)
1536
1852
  const scanned = [
1537
- ...(await scanUnion(trees, excludeGlobs, args.workspaceRoot, undefined, emptyDirs)),
1853
+ ...(await scanUnion(
1854
+ trees,
1855
+ excludeGlobs,
1856
+ args.workspaceRoot,
1857
+ undefined,
1858
+ emptyDirs,
1859
+ special,
1860
+ absent,
1861
+ args.clearing,
1862
+ )),
1538
1863
  ]
1539
1864
  // Same containment as the project twin, anchored one level out. These globs
1540
1865
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
@@ -1547,10 +1872,17 @@ export async function resolveWorkspaceOutputs(
1547
1872
  export async function resolveWorkspaceOutputTree(args: {
1548
1873
  workspaceRoot: string
1549
1874
  outputs: readonly string[]
1550
- }): Promise<{ files: string[]; emptyDirs: string[] }> {
1875
+ /** As `resolveOutputTree`'s. */
1876
+ inputs?: OwnInputs | undefined
1877
+ /** As `resolveOutputTree`'s. */
1878
+ special?: boolean | 'later'
1879
+ clearing?: boolean
1880
+ }): Promise<OutputTree> {
1551
1881
  const emptyDirs = new Set<string>()
1552
- const files = await resolveWorkspaceOutputs(args, emptyDirs)
1553
- return { files, emptyDirs: containedIn(args.workspaceRoot, [...emptyDirs]).sort() }
1882
+ const probe: SpecialProbe | undefined =
1883
+ args.special === undefined || args.special === false ? undefined : {}
1884
+ const files = await resolveWorkspaceOutputs(args, emptyDirs, probe)
1885
+ return treeOf(args.workspaceRoot, files, emptyDirs, args.special, probe)
1554
1886
  }
1555
1887
 
1556
1888
  /**
@@ -1564,6 +1896,7 @@ export async function resolveWorkspaceOutputTree(args: {
1564
1896
  export async function cleanWorkspaceOutputs(args: {
1565
1897
  workspaceRoot: string
1566
1898
  outputs: readonly string[]
1899
+ inputs?: OwnInputs | undefined
1567
1900
  committed?: (abs: string) => boolean
1568
1901
  /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1569
1902
  resolved?: readonly string[] | undefined
@@ -1575,13 +1908,13 @@ export async function cleanWorkspaceOutputs(args: {
1575
1908
  sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
1576
1909
  const tree =
1577
1910
  args.resolved === undefined
1578
- ? await resolveWorkspaceOutputTree(args)
1579
- : { files: args.resolved, emptyDirs: [] }
1911
+ ? await resolveWorkspaceOutputTree({ ...args, special: true, clearing: true })
1912
+ : { files: args.resolved, emptyDirs: [], special: [] }
1580
1913
  const matched = tree.files
1581
1914
  await pruneEmptiedDirs(
1582
1915
  args.workspaceRoot,
1583
1916
  await removeAll(
1584
- matched.filter((f) => !committed(f)),
1917
+ [...matched, ...tree.special].filter((f) => !committed(f)),
1585
1918
  args.workspaceRoot,
1586
1919
  ),
1587
1920
  pruneScope(args.workspaceRoot, args.outputs),
@@ -1619,16 +1952,61 @@ function unanswered(
1619
1952
  files: readonly string[],
1620
1953
  newIgnored?: (lit: string) => boolean,
1621
1954
  ): Set<string> {
1622
- const left = new Set<string>()
1623
- for (const lit of literals) {
1624
- const under = `${lit}/`
1625
- const answered =
1626
- files[lowerBound(files, lit)] === lit ||
1627
- files[lowerBound(files, under)]?.startsWith(under) === true ||
1628
- files.some((rel) => rel === lit || rel.startsWith(under))
1629
- if (!answered || newIgnored?.(lit) === true) left.add(lit)
1630
- }
1631
- return left
1955
+ return new Set(
1956
+ reachingIgnored(
1957
+ literals,
1958
+ (lit) => {
1959
+ const under = `${lit}/`
1960
+ return (
1961
+ files[lowerBound(files, lit)] === lit ||
1962
+ files[lowerBound(files, under)]?.startsWith(under) === true ||
1963
+ files.some((rel) => rel === lit || rel.startsWith(under))
1964
+ )
1965
+ },
1966
+ newIgnored,
1967
+ ),
1968
+ )
1969
+ }
1970
+
1971
+ /**
1972
+ * The rule a named entry reaches git-ignored files by: git lists nothing at
1973
+ * or under it, or ignores a new file directly in it (a kept `gen/.gitkeep`).
1974
+ * `src` of `src/**` over a listed `src` reaches none: an ignored `src/dev.log`
1975
+ * is no input.
1976
+ */
1977
+ function reachingIgnored(
1978
+ named: readonly string[],
1979
+ listed: (entry: string) => boolean,
1980
+ newIgnored?: (entry: string) => boolean,
1981
+ ): string[] {
1982
+ return named.filter((n) => !listed(n) || newIgnored?.(n) === true)
1983
+ }
1984
+
1985
+ /**
1986
+ * The entries of `named` (absolute, from {@link namedOutright}) a
1987
+ * git-ignored input can be at or under, by the rule a key reads them
1988
+ * ({@link ignoredReach}): `listed` holds what git lists, absolute, with every
1989
+ * directory above a listed file. Watch asks it, so an ignored file the key
1990
+ * never reads starts no cycle.
1991
+ */
1992
+ export function namedIgnoredReach(
1993
+ root: string,
1994
+ named: readonly string[],
1995
+ listed: ReadonlySet<string>,
1996
+ ): string[] {
1997
+ const held = named.filter((n) => listed.has(n))
1998
+ const fresh =
1999
+ held.length === 0
2000
+ ? new Set<string>()
2001
+ : newFilesIgnoredIn(
2002
+ root,
2003
+ held.map((n) => relPosix(root, n)),
2004
+ )
2005
+ return reachingIgnored(
2006
+ named,
2007
+ (n) => listed.has(n),
2008
+ (n) => fresh.has(relPosix(root, n)),
2009
+ )
1632
2010
  }
1633
2011
 
1634
2012
  /** The first index whose path is not below `key`. */
@@ -1907,9 +2285,43 @@ function throughLinks(
1907
2285
  * read through it, and an edit there was a hit (2026-10-28). Such a path
1908
2286
  * that is a link to a directory is an input (`dirLinkInput`). Generous on
1909
2287
  * purpose: a wrong yes costs one lookup, and folds a linked directory.
2288
+ *
2289
+ * Asked of every path no glob takes, so the prefixes are a map walked by
2290
+ * the path's own `/`s: a scan of every prefix per path was 8,000 globs ×
2291
+ * 200,000 files, minutes per key.
1910
2292
  */
1911
- function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
1912
- const reach = asTrees(positive.flatMap(braceAlternatives)).map((g) => {
2293
+ export function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
2294
+ // A prefix ends in `/` (or is empty), so the ones a path starts with are
2295
+ // its slices that end at one of its own `/`s.
2296
+ const byPrefix = new Map<string, { deep: boolean; depth: number }>()
2297
+ for (const r of holdReach(positive)) {
2298
+ const seen = byPrefix.get(r.prefix)
2299
+ if (seen === undefined) byPrefix.set(r.prefix, { deep: r.deep, depth: r.depth })
2300
+ else {
2301
+ seen.deep ||= r.deep
2302
+ seen.depth = Math.max(seen.depth, r.depth)
2303
+ }
2304
+ }
2305
+ if (byPrefix.size === 0) return () => false
2306
+ return (rel) => {
2307
+ let slashes = 0
2308
+ for (let i = 0; i < rel.length; i++) if (rel.charCodeAt(i) === 47) slashes++
2309
+ const depth = slashes + 1
2310
+ let r = rel.length > 0 ? byPrefix.get('') : undefined
2311
+ if (r !== undefined && (r.deep || depth < r.depth)) return true
2312
+ for (let i = rel.indexOf('/'); i !== -1 && i < rel.length - 1; i = rel.indexOf('/', i + 1)) {
2313
+ r = byPrefix.get(rel.slice(0, i + 1))
2314
+ if (r !== undefined && (r.deep || depth < r.depth)) return true
2315
+ }
2316
+ return false
2317
+ }
2318
+ }
2319
+
2320
+ /** Each positive alternative's fixed prefix (`''` or ending in `/`), whether it has a `**`, and its depth. */
2321
+ export function holdReach(
2322
+ positive: readonly string[],
2323
+ ): { prefix: string; deep: boolean; depth: number }[] {
2324
+ return asTrees(positive.flatMap(braceAlternatives)).map((g) => {
1913
2325
  const prefix = staticPrefix(g)
1914
2326
  return {
1915
2327
  prefix: prefix === '.' ? '' : `${prefix}/`,
@@ -1917,14 +2329,6 @@ function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
1917
2329
  depth: g.split('/').length,
1918
2330
  }
1919
2331
  })
1920
- return (rel) => {
1921
- let depth: number | undefined
1922
- for (const r of reach) {
1923
- if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
1924
- if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
1925
- }
1926
- return false
1927
- }
1928
2332
  }
1929
2333
 
1930
2334
  /**
@@ -1946,8 +2350,8 @@ function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined
1946
2350
  * Whether a changed path is one of a task's declared file inputs:
1947
2351
  * `projectRel` (the path relative to the project that owns it) against
1948
2352
  * `files`, `workspaceRel` (relative to the workspace root) against
1949
- * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1950
- * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
2353
+ * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a tracked path
2354
+ * under the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
1951
2355
  */
1952
2356
  export function declaresInput(
1953
2357
  task: TaskConfig,
@@ -1958,22 +2362,41 @@ export function declaresInput(
1958
2362
  * below it keys what is behind it, as `mayHoldMatch` says at key time.
1959
2363
  */
1960
2364
  dirLink = false,
2365
+ /** Git does not track the path, so under the task's own outputs it is no input. */
2366
+ untracked = false,
2367
+ /**
2368
+ * The task's project directory, workspace-relative: a `workspaceFiles`
2369
+ * glob reaches its `outputs.files` too, which the key leaves out as it
2370
+ * does `outputs.workspaceFiles` (`resolveWorkspaceFiles`).
2371
+ */
2372
+ projectDir?: string,
1961
2373
  ): boolean {
1962
2374
  const inputs = task.inputs
1963
2375
  if (inputs === undefined) return false
1964
2376
  if (projectRel !== null) {
1965
- // A changed path under the task's own outputs is a committed file
1966
- // (`git diff` lists only what git tracks) or a stray `git status`
1967
- // lists: either way it may be an input, so it counts.
1968
- const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
1969
- if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
2377
+ // Under the task's own outputs the key folds only what git tracks
2378
+ // (`committedFiles`); an untracked build output is not one.
2379
+ const plan = filesPlan(inputs.files, task.outputs?.files ?? [])
2380
+ if (plan !== null && (inPlan(plan, projectRel) || (!untracked && ownOnly(plan, projectRel)))) {
2381
+ return true
2382
+ }
1970
2383
  if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
1971
2384
  return true
1972
2385
  }
1973
2386
  }
1974
2387
  const ws = inputs.workspaceFiles
1975
2388
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1976
- return workspaceMatcher(ws)(workspaceRel, dirLink)
2389
+ if (!workspaceMatcher(ws)(workspaceRel, dirLink)) return false
2390
+ // As for `files`: under its own outputs the key folds only a tracked path.
2391
+ return !untracked || !ownWorkspaceOutput(task, workspaceRel, projectDir)
2392
+ }
2393
+
2394
+ /** Asked only of an untracked path a `workspaceFiles` glob matched: rare, so not memoised. */
2395
+ function ownWorkspaceOutput(task: TaskConfig, rel: string, projectDir?: string): boolean {
2396
+ const outputs = task.outputs
2397
+ if (outputMatcher(outputs?.workspaceFiles ?? [])(rel)) return true
2398
+ if (projectDir === undefined) return false
2399
+ return underProject(projectDir, outputMatcher(outputs?.files ?? []))(rel)
1977
2400
  }
1978
2401
 
1979
2402
  /**
@@ -1995,8 +2418,7 @@ export function workspaceFilesReachInto(task: TaskConfig, dir: string): boolean
1995
2418
  */
1996
2419
  export function namedOutright(task: TaskConfig): { files: string[]; workspaceFiles: string[] } {
1997
2420
  const inputs = task.inputs
1998
- const plan =
1999
- inputs === undefined ? null : filesPlan(inputs.files, task.cache?.outputs.files ?? [])
2421
+ const plan = inputs === undefined ? null : filesPlan(inputs.files, task.outputs?.files ?? [])
2000
2422
  const ws = inputs?.workspaceFiles ?? []
2001
2423
  return {
2002
2424
  files: plan === null ? [] : outright(plan),
@@ -2131,22 +2553,24 @@ async function resolveFiles(
2131
2553
  const verdicts = plan.verdicts
2132
2554
  const committed = committedFiles(args.gitFilesCache, args.projectDir)
2133
2555
  const oids = args.gitFilesCache?.oidsFor(args.projectDir)
2134
- for (const rel of gitFiles) {
2556
+ const paths = base === undefined ? undefined : args.gitFilesCache?.absPaths(gitFiles)
2557
+ for (let i = 0; i < gitFiles.length; i++) {
2558
+ const rel = gitFiles[i]!
2135
2559
  let input = verdicts.get(rel)
2136
2560
  if (input === undefined) {
2137
2561
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
2138
2562
  verdicts.set(rel, input)
2139
2563
  }
2140
2564
  if (nested(rel) || ownWsOutput(rel)) continue
2141
- const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
2565
+ const abs = base === undefined ? path.resolve(args.projectDir, rel) : (paths?.[i] ?? base + rel)
2142
2566
  if (!input && !ownOnly(plan, rel)) {
2143
2567
  if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
2144
- candidates.push(abs)
2568
+ candidates.push(paths === undefined ? abs : (paths[i] = abs))
2145
2569
  }
2146
2570
  continue
2147
2571
  }
2148
2572
  if (!input && !committed(abs)) continue
2149
- candidates.push(abs)
2573
+ candidates.push(paths === undefined ? abs : (paths[i] = abs))
2150
2574
  }
2151
2575
  const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
2152
2576
  refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
@@ -2237,13 +2661,17 @@ function listingFor(
2237
2661
  * yields names no file and the output dropped out of the artifact without
2238
2662
  * a word: a hit then restored a tree without it (turborepo#9345's output
2239
2663
  * half). A `\ufffd` that does not stat is that case or a file that left
2240
- * mid-scan; `undecodableOnDisk` tells them apart.
2664
+ * mid-scan; `undecodableOnDisk` tells them apart. `clearing`: the walk
2665
+ * feeds a clean or a restore, which nothing saves.
2241
2666
  */
2242
- function refuseUndecodableOutput(abs: string): void {
2667
+ function refuseUndecodableOutput(abs: string, clearing: boolean): void {
2243
2668
  if (!undecodableOnDisk(abs)) return
2669
+ const why = clearing
2670
+ ? 'vx cannot clear a file by it before the task runs or a hit restores'
2671
+ : 'vx cannot save a file by it'
2244
2672
  throw new UserError(
2245
- `cache.outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
2246
- `\ufffd), and vx cannot save a file by it. Rename it, or keep it out of the outputs.`,
2673
+ `outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
2674
+ `\ufffd), and ${why}. Rename it, or keep it out of the outputs.`,
2247
2675
  )
2248
2676
  }
2249
2677
 
@@ -2456,9 +2884,18 @@ async function scanUnion(
2456
2884
  cwd: string,
2457
2885
  nested: (rel: string) => boolean = () => false,
2458
2886
  emptyDirs?: Set<string>,
2887
+ special?: SpecialProbe,
2888
+ absent?: ReadonlySet<string>,
2889
+ clearing = false,
2459
2890
  ): Promise<Set<string>> {
2460
2891
  const matches = new Set<string>()
2461
2892
  const links: string[] = []
2893
+ // Bun's scan skips a FIFO, socket or device outright, so `special` is
2894
+ // found by listing the directories the walk entered: its roots, the
2895
+ // directories it took and the parents of what it took. Derived only when
2896
+ // asked, so a walk that may never need them pays a set add per directory.
2897
+ const roots = special === undefined ? undefined : new Set<string>()
2898
+ const dirs = special === undefined ? undefined : new Set<string>()
2462
2899
  const take = (rel: string): void => {
2463
2900
  if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
2464
2901
  const abs = path.resolve(cwd, rel)
@@ -2466,15 +2903,26 @@ async function scanUnion(
2466
2903
  if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
2467
2904
  matches.add(abs)
2468
2905
  if (st.isSymbolicLink()) links.push(abs)
2469
- } else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2470
- if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2471
- } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
2906
+ } else if (st?.isDirectory() === true) {
2907
+ dirs?.add(abs)
2908
+ if (emptyDirs !== undefined && readdirSync(abs).length === 0) emptyDirs.add(abs)
2909
+ } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs, clearing)
2472
2910
  }
2473
2911
  const opts = { cwd, onlyFiles: false, followSymlinks: false, dot: true }
2474
2912
  // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
2475
2913
  // `/`: `{dist,lib/esm}/**` saved an empty artifact, and a hit restored
2476
2914
  // nothing over a cleaned tree (A-10). Expanded here, as discovery does.
2477
2915
  const patterns = positive.flatMap(slashBraceExpansions)
2916
+ if (special !== undefined) {
2917
+ special.find = () => {
2918
+ const walked = new Set(roots)
2919
+ for (const f of [...matches, ...links]) walked.add(path.dirname(f))
2920
+ for (const d of dirs!) walked.add(d).add(path.dirname(d))
2921
+ const into = new Set<string>()
2922
+ findSpecial(walked, cwd, anyTaskGlob(patterns), excludeGlobs, nested, into)
2923
+ return [...into]
2924
+ }
2925
+ }
2478
2926
  // A leading `**` walks the whole project whatever else is listed, so the
2479
2927
  // list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
2480
2928
  // ~2 ms of a warm hit on solid.
@@ -2490,9 +2938,10 @@ async function scanUnion(
2490
2938
  return matches
2491
2939
  }
2492
2940
  for (const pattern of patterns) {
2493
- if (absentPrefix(cwd, pattern)) continue
2941
+ if (absentPrefix(cwd, pattern, absent)) continue
2494
2942
  const from = scanRoot(cwd, pattern)
2495
2943
  if (from === null) continue
2944
+ roots?.add(from === undefined ? cwd : from.dir)
2496
2945
  if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
2497
2946
  else
2498
2947
  for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
@@ -2513,6 +2962,36 @@ async function scanUnion(
2513
2962
  return matches
2514
2963
  }
2515
2964
 
2965
+ /** Filled by a walk asked for it: what lists the FIFOs, sockets and devices its globs name. */
2966
+ interface SpecialProbe {
2967
+ find?: () => string[]
2968
+ }
2969
+
2970
+ /** The FIFOs, sockets and devices in `dirs` that the output globs name. */
2971
+ function findSpecial(
2972
+ dirs: ReadonlySet<string>,
2973
+ cwd: string,
2974
+ any: (rel: string) => boolean,
2975
+ excludeGlobs: readonly Bun.Glob[],
2976
+ nested: (rel: string) => boolean,
2977
+ into: Set<string>,
2978
+ ): void {
2979
+ for (const dir of dirs) {
2980
+ let entries: Dirent[]
2981
+ try {
2982
+ entries = readdirSync(dir, { withFileTypes: true })
2983
+ } catch {
2984
+ continue
2985
+ }
2986
+ for (const e of entries) {
2987
+ if (e.isFile() || e.isDirectory() || e.isSymbolicLink()) continue
2988
+ const abs = path.join(dir, e.name)
2989
+ const rel = path.relative(cwd, abs).split(path.sep).join('/')
2990
+ if (any(rel) && !nested(rel) && !excludeGlobs.some((g) => g.match(rel))) into.add(abs)
2991
+ }
2992
+ }
2993
+ }
2994
+
2516
2995
  /** `pattern`'s static directory passes through a symlink under `cwd`. */
2517
2996
  function headLinked(cwd: string, pattern: string): boolean {
2518
2997
  const prefix = staticPrefix(pattern)
@@ -2562,11 +3041,13 @@ function scanRoot(
2562
3041
  * cost ~58 µs where the lstat costs a few. Anything this cannot read
2563
3042
  * plainly (no prefix, an escape, an absolute path, a refused stat) scans.
2564
3043
  */
2565
- function absentPrefix(cwd: string, pattern: string): boolean {
3044
+ function absentPrefix(cwd: string, pattern: string, known?: ReadonlySet<string>): boolean {
2566
3045
  const prefix = staticPrefix(pattern)
2567
3046
  if (prefix === '.' || prefix.includes('\\') || path.isAbsolute(prefix)) return false
3047
+ const at = path.join(cwd, prefix)
3048
+ if (known?.has(at) === true) return true
2568
3049
  try {
2569
- return lstatSync(path.join(cwd, prefix), { throwIfNoEntry: false }) === undefined
3050
+ return lstatSync(at, { throwIfNoEntry: false }) === undefined
2570
3051
  } catch {
2571
3052
  return false
2572
3053
  }