@vzn/vx 0.0.652 → 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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  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 +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -18,6 +18,7 @@
18
18
 
19
19
  import path from 'node:path'
20
20
  import {
21
+ chmodSync,
21
22
  type Dirent,
22
23
  lstatSync,
23
24
  readdirSync,
@@ -29,8 +30,11 @@ import {
29
30
  import { rm, rmdir } from 'node:fs/promises'
30
31
  import type { InputsConfig, TaskConfig } from '../config.js'
31
32
  import {
33
+ appendTail,
34
+ createTail,
32
35
  literalPath,
33
36
  anyTaskGlob,
37
+ tailText,
34
38
  asTrees,
35
39
  isExecutableMissing,
36
40
  isInstalledPath,
@@ -45,6 +49,7 @@ import {
45
49
  slashBraceExpansions,
46
50
  braceAlternatives,
47
51
  ESSENTIAL_ENV,
52
+ POST_EXIT_DRAIN_MS,
48
53
  splitNegations,
49
54
  staticPrefix,
50
55
  stripTrailingSlash,
@@ -53,7 +58,7 @@ import {
53
58
  writableUnder,
54
59
  withoutWorkspaceBins,
55
60
  } from '../util/index.js'
56
- import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
61
+ import { GitFilesCache, listingEnv, newFilesIgnoredIn, runGitLsFiles } from './git-inputs.js'
57
62
  import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
58
63
 
59
64
  // The git side lives in git-inputs.ts; its whole public surface is
@@ -114,13 +119,18 @@ const ALWAYS_IGNORE = [
114
119
  '.vx/dependencies.lock.json',
115
120
  // Where restores stage (archive.ts): vx's own, never an input.
116
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-*',
117
127
  ]
118
128
 
119
129
  /**
120
130
  * `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
121
131
  * those globs cross into other projects, and each one's `vx.lock.json` is vx's.
122
132
  */
123
- const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
133
+ const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json', '**/vx.lock.json.tmp-*']
124
134
 
125
135
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
126
136
 
@@ -222,7 +232,18 @@ export type ProjectFilesCache = Map<
222
232
  * relative to it) and everything `resolveFiles` keys its memo on.
223
233
  * Undefined when only resolving the files can say.
224
234
  */
225
- 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 {
226
247
  if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
227
248
  const plan = filesPlan(args.inputs?.files, args.ownOutputs)
228
249
  const cache = args.gitFilesCache
@@ -307,6 +328,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
307
328
  args.runtimeScope === undefined
308
329
  ? `${args.projectDir}\0`
309
330
  : `${args.projectDir}\0${args.runtimeScope}\0`,
331
+ 'runtime',
310
332
  ),
311
333
  resolveRuntimeValues(
312
334
  wsRuntimeDecl,
@@ -315,6 +337,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
315
337
  args.workspaceRoot,
316
338
  args.workspaceRuntimeCache,
317
339
  args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
340
+ 'workspaceRuntime',
318
341
  ),
319
342
  ])
320
343
  return {
@@ -587,6 +610,7 @@ export function answerRuntime(
587
610
  [path.join(workspaceRoot, 'node_modules', '.bin')],
588
611
  workspaceRoot,
589
612
  undefined,
613
+ 'workspaceRuntime',
590
614
  )
591
615
  : runRuntimeCommand(
592
616
  command,
@@ -594,6 +618,7 @@ export function answerRuntime(
594
618
  projectBinDirs(projectDir, workspaceRoot),
595
619
  workspaceRoot,
596
620
  undefined,
621
+ 'runtime',
597
622
  )
598
623
  }
599
624
 
@@ -601,7 +626,21 @@ function resolveEnvValues(
601
626
  names: readonly string[],
602
627
  source: NodeJS.ProcessEnv,
603
628
  ): Array<[string, string | undefined]> {
604
- 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
+ })
605
644
  }
606
645
 
607
646
  /**
@@ -628,7 +667,9 @@ async function runRuntimeCommand(
628
667
  binDirs: readonly string[],
629
668
  workspaceRoot: string,
630
669
  owner: RuntimeMemo | undefined,
670
+ field: RuntimeField,
631
671
  ): Promise<string> {
672
+ const probe = `inputs.${field} probe`
632
673
  // As a task's (exec/env.ts): no member's bin inherited from `npm run`.
633
674
  const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
634
675
  // As the task's PATH does (exec/env.ts): a dir holding the delimiter
@@ -656,10 +697,10 @@ async function runRuntimeCommand(
656
697
  // the shell, not the command (item 244).
657
698
  if (isExecutableMissing(err)) {
658
699
  throw new UserError(
659
- `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.`,
660
701
  )
661
702
  }
662
- throw new UserError(`inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
703
+ throw new UserError(`${probe} failed to spawn: ${command} (cwd: ${cwd})`)
663
704
  }
664
705
  liveProbes.add(proc)
665
706
  let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
@@ -670,22 +711,41 @@ async function runRuntimeCommand(
670
711
  }
671
712
  killProbesOnExit()
672
713
  let stdout, stderr, exitCode
714
+ const ac = new AbortController()
673
715
  try {
674
- ;[stdout, stderr, exitCode] = await Promise.all([
675
- new Response(proc.stdout).bytes(),
676
- new Response(proc.stderr).bytes(),
677
- proc.exited,
716
+ const streams = Promise.all([
717
+ probeBytes(proc.stdout, ac.signal),
718
+ probeBytes(proc.stderr, ac.signal),
678
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
679
735
  } finally {
680
736
  liveProbes.delete(proc)
681
737
  mine?.delete(proc)
682
738
  }
683
739
  if (exitCode !== 0) {
684
740
  const lossy = new TextDecoder()
685
- 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`
686
747
  throw new UserError(
687
- `inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
688
- (shown ? `\n${shown}` : ''),
748
+ `${probe} exited ${exitCode}: ${command} (cwd: ${cwd})` + (shown ? `\n${lost}${shown}` : ''),
689
749
  )
690
750
  }
691
751
  let out: string
@@ -697,7 +757,7 @@ async function runRuntimeCommand(
697
757
  // A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
698
758
  // folded the same output and replayed each other's build.
699
759
  throw new UserError(
700
- `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}). ` +
701
761
  `Pipe it through a hash or od.`,
702
762
  )
703
763
  }
@@ -730,6 +790,8 @@ const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
730
790
  let probeExitHooked = false
731
791
 
732
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'
733
795
  /** The probes each run started, by the run's memo: one run's stop is not another's. */
734
796
  const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
735
797
 
@@ -744,24 +806,51 @@ const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
744
806
  export function stopRuntimeProbes(...memos: readonly RuntimeMemo[]): void {
745
807
  for (const memo of memos) {
746
808
  for (const p of probesOf.get(memo) ?? []) {
747
- try {
748
- process.kill(-p.pid, 'SIGKILL')
749
- } catch {
750
- // the group is gone
751
- }
809
+ killGroup(p.pid)
752
810
  }
753
811
  }
754
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)
840
+ }
841
+ } finally {
842
+ signal.removeEventListener('abort', onAbort)
843
+ reader.releaseLock()
844
+ }
845
+ return Buffer.concat(parts)
846
+ }
847
+
755
848
  function killProbesOnExit(): void {
756
849
  if (probeExitHooked) return
757
850
  probeExitHooked = true
758
851
  process.on('exit', () => {
759
852
  for (const p of liveProbes) {
760
- try {
761
- process.kill(-p.pid, 'SIGKILL')
762
- } catch {
763
- // the group is gone
764
- }
853
+ killGroup(p.pid)
765
854
  }
766
855
  })
767
856
  }
@@ -782,6 +871,7 @@ async function resolveRuntimeValues(
782
871
  workspaceRoot: string,
783
872
  memo: Map<string, Promise<string>> | undefined,
784
873
  memoKeyPrefix: string,
874
+ field: RuntimeField,
785
875
  ): Promise<Array<[string, string]>> {
786
876
  if (commands.length === 0) return []
787
877
  const unique = [...new Set(commands)].sort()
@@ -790,7 +880,7 @@ async function resolveRuntimeValues(
790
880
  const key = `${memoKeyPrefix}${cmd}`
791
881
  let p = memo?.get(key)
792
882
  if (p === undefined) {
793
- p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
883
+ p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo, field)
794
884
  memo?.set(key, p)
795
885
  }
796
886
  return [cmd, await p] as [string, string]
@@ -808,6 +898,13 @@ function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspac
808
898
  return outputNever(outputs, scope).map(globFor)
809
899
  }
810
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
+
811
908
  /**
812
909
  * Resolve declared output globs (project-relative) to actual produced files.
813
910
  * An output directory linked out of the project is refused by name (X-88):
@@ -817,6 +914,7 @@ export async function resolveOutputs(args: {
817
914
  projectDir: string
818
915
  outputs: readonly string[]
819
916
  nestedProjectDirs: string[]
917
+ inputs?: OwnInputs | undefined
820
918
  }): Promise<string[]> {
821
919
  return (await scanOutputs(args)).files
822
920
  }
@@ -845,29 +943,66 @@ export async function resolveOutputTree(args: {
845
943
  projectDir: string
846
944
  outputs: readonly string[]
847
945
  nestedProjectDirs: string[]
848
- /** Also list the FIFOs, sockets and devices the globs name (a save refuses them). */
849
- special?: boolean
850
- }): Promise<{ files: string[]; emptyDirs: string[]; special: 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> {
851
957
  const emptyDirs = new Set<string>()
852
- const special = args.special === true ? new Set<string>() : undefined
853
- const { files } = await scanOutputs(args, emptyDirs, special)
854
- return {
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 = {
855
982
  files,
856
- emptyDirs: containedIn(args.projectDir, [...emptyDirs]).sort(),
857
- special: special === undefined ? [] : containedIn(args.projectDir, [...special]).sort(),
983
+ emptyDirs: containedIn(root, [...emptyDirs]).sort(),
984
+ special: asked === true ? findSpecial() : [],
858
985
  }
986
+ if (asked === 'later') tree.findSpecial = findSpecial
987
+ return tree
859
988
  }
860
989
 
861
990
  async function scanOutputs(
862
- 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
+ },
863
998
  emptyDirs?: Set<string>,
864
- special?: Set<string>,
999
+ special?: SpecialProbe,
865
1000
  ): Promise<{ files: string[] }> {
866
1001
  const { positive, negative } = splitNegations(args.outputs)
867
1002
  if (positive.length === 0) return { files: [] }
868
1003
  const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
869
1004
  const trees = asTrees(positive)
870
- refuseDirLinkedOut(args.projectDir, trees, 'project')
1005
+ const absent = refuseDirLinkedOut(args.projectDir, trees, 'project', args.inputs)
871
1006
  const scanned = [
872
1007
  ...(await scanUnion(
873
1008
  trees,
@@ -876,6 +1011,8 @@ async function scanOutputs(
876
1011
  inNestedProject(args.projectDir, args.nestedProjectDirs),
877
1012
  emptyDirs,
878
1013
  special,
1014
+ absent,
1015
+ args.clearing,
879
1016
  )),
880
1017
  ]
881
1018
  // Containment, enforced HERE and not only at the loader. `cleanOutputs`
@@ -909,14 +1046,22 @@ export function committedFiles(
909
1046
  * below it is an output, kept as the link (`archive.ts`). One into `.git`
910
1047
  * or `.vx` is refused in either scope: for the root project and for
911
1048
  * workspace outputs those are inside, and the clean deleted the
912
- * repository through `dist -> .git`. One out of a workspace output's root
913
- * 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.
914
1055
  */
915
1056
  function refuseDirLinkedOut(
916
1057
  root: string,
917
1058
  trees: readonly string[],
918
1059
  scope: 'project' | 'workspace',
919
- ): 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>()
920
1065
  let realRoot: string | undefined
921
1066
  for (const tree of trees) {
922
1067
  let at = root
@@ -930,7 +1075,10 @@ function refuseDirLinkedOut(
930
1075
  } catch {
931
1076
  break
932
1077
  }
933
- if (st === undefined) break
1078
+ if (st === undefined) {
1079
+ absent.add(at)
1080
+ break
1081
+ }
934
1082
  if (!st.isSymbolicLink()) continue
935
1083
  let real: string
936
1084
  try {
@@ -941,10 +1089,9 @@ function refuseDirLinkedOut(
941
1089
  }
942
1090
  realRoot ??= realOr(root)
943
1091
  if (!isInside(realRoot, real)) {
944
- if (scope === 'workspace') break
945
1092
  throw new UserError(
946
1093
  `${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
947
- '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 ` +
948
1095
  'link and re-run, or stop declaring outputs under it.',
949
1096
  )
950
1097
  }
@@ -955,8 +1102,57 @@ function refuseDirLinkedOut(
955
1102
  'link and re-run, or stop declaring outputs under it.',
956
1103
  )
957
1104
  }
1105
+ if (inputs !== undefined) refuseLinkedIntoInputs(at, real, inputs)
958
1106
  }
959
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
960
1156
  }
961
1157
 
962
1158
  /**
@@ -1105,6 +1301,7 @@ export async function cleanOutputs(args: {
1105
1301
  projectDir: string
1106
1302
  outputs: readonly string[]
1107
1303
  nestedProjectDirs: string[]
1304
+ inputs?: OwnInputs | undefined
1108
1305
  committed?: (abs: string) => boolean
1109
1306
  /**
1110
1307
  * What `resolveOutputs` returned for these same arguments, when the
@@ -1120,17 +1317,18 @@ export async function cleanOutputs(args: {
1120
1317
  )
1121
1318
  sweepRestoreTemps(args.projectDir, args.outputs, committed)
1122
1319
  // An empty directory under the outputs is one too (a save keeps it): one
1123
- // 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.
1124
1322
  const tree =
1125
1323
  args.resolved === undefined
1126
- ? await resolveOutputTree(args)
1127
- : { files: args.resolved, emptyDirs: [] }
1324
+ ? await resolveOutputTree({ ...args, special: true, clearing: true })
1325
+ : { files: args.resolved, emptyDirs: [], special: [] }
1128
1326
  const matched = tree.files
1129
1327
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
1130
1328
  // globs overlap and a sibling already deleted a path mid-iteration).
1131
1329
  // A symlink is unlinked, never followed.
1132
1330
  const removed = await removeAll(
1133
- matched.filter((f) => !committed(f)),
1331
+ [...matched, ...tree.special].filter((f) => !committed(f)),
1134
1332
  args.projectDir,
1135
1333
  )
1136
1334
  await pruneEmptiedDirs(
@@ -1271,6 +1469,7 @@ export async function stampOutputs(args: {
1271
1469
  projectDir: string
1272
1470
  outputs: readonly string[]
1273
1471
  nestedProjectDirs: string[]
1472
+ inputs?: OwnInputs | undefined
1274
1473
  }): Promise<Map<string, OutputStamp>> {
1275
1474
  return stampFiles(await resolveOutputs(args))
1276
1475
  }
@@ -1279,10 +1478,33 @@ export async function stampOutputs(args: {
1279
1478
  export async function stampWorkspaceOutputs(args: {
1280
1479
  workspaceRoot: string
1281
1480
  outputs: readonly string[]
1481
+ inputs?: OwnInputs | undefined
1282
1482
  }): Promise<Map<string, OutputStamp>> {
1283
1483
  return stampFiles(await resolveWorkspaceOutputs(args))
1284
1484
  }
1285
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
+
1286
1508
  function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
1287
1509
  const out = new Map<string, OutputStamp>()
1288
1510
  for (const f of files) {
@@ -1309,7 +1531,12 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
1309
1531
  * file back, so no entry reproduces that run and none is saved.
1310
1532
  */
1311
1533
  export async function ownOutputsSince(
1312
- 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
+ },
1313
1540
  before: ReadonlyMap<string, OutputStamp>,
1314
1541
  ): Promise<string[] | undefined> {
1315
1542
  return changedSince(await resolveOutputs(args), before)
@@ -1317,7 +1544,7 @@ export async function ownOutputsSince(
1317
1544
 
1318
1545
  /** `ownOutputsSince` for root-anchored `outputs.workspaceFiles` (A-43). */
1319
1546
  export async function ownWorkspaceOutputsSince(
1320
- args: { workspaceRoot: string; outputs: readonly string[] },
1547
+ args: { workspaceRoot: string; outputs: readonly string[]; inputs?: OwnInputs | undefined },
1321
1548
  before: ReadonlyMap<string, OutputStamp>,
1322
1549
  ): Promise<string[] | undefined> {
1323
1550
  return changedSince(await resolveWorkspaceOutputs(args), before)
@@ -1365,9 +1592,18 @@ const SYNC_CLEAN_MAX = 128
1365
1592
  * wrote, a read-only checkout) is the environment's failure, not vx's:
1366
1593
  * the scheduler prints any other error as an "internal error", which
1367
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`).
1368
1602
  */
1369
1603
  async function removeAll(all: readonly string[], root: string): Promise<string[]> {
1370
1604
  const files = insideRoot(all, root)
1605
+ const reopened: Reopened = new Map()
1606
+ const reopen = (dir: string): boolean => reopenDir(dir, reopened)
1371
1607
  const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
1372
1608
  const rel = relPosix(root, f)
1373
1609
  return new UserError(
@@ -1376,24 +1612,64 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
1376
1612
  `by this user, or stop declaring it as an output`,
1377
1613
  )
1378
1614
  }
1379
- if (files.length <= SYNC_CLEAN_MAX) {
1380
- for (const f of files) {
1381
- try {
1382
- rmSync(f, { force: true })
1383
- } catch (err) {
1384
- 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
+ }
1385
1630
  }
1631
+ return files
1386
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
+ )
1387
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
+ }
1388
1672
  }
1389
- await Promise.all(
1390
- files.map((f) =>
1391
- rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
1392
- throw refused(f, err)
1393
- }),
1394
- ),
1395
- )
1396
- return files
1397
1673
  }
1398
1674
 
1399
1675
  /**
@@ -1461,33 +1737,53 @@ async function pruneEmptiedDirs(
1461
1737
  // the memo is correct and 60 ms, because every child re-attempts the
1462
1738
  // shared parent. This is correct at 44 ms.
1463
1739
  let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
1464
- while (level.size > 0) {
1465
- const parents = new Set<string>()
1466
- const dirs = [...level].filter(
1467
- (dir) =>
1468
- dir !== rootResolved &&
1469
- dir.startsWith(rootResolved + path.sep) &&
1470
- (scope === undefined || inScope(dir, scope)) &&
1471
- keep?.has(dir) !== true,
1472
- )
1473
- const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
1474
- if (dirs.length <= SYNC_CLEAN_MAX) {
1475
- for (const dir of dirs) {
1476
- try {
1477
- rmdirSync(dir)
1478
- parents.add(path.dirname(dir))
1479
- } catch (err) {
1480
- 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
+ }
1481
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
+ )
1482
1782
  }
1483
- } else {
1484
- await Promise.all(
1485
- dirs.map(async (dir) => {
1486
- if (await rmdir(dir).then(() => true, gone)) parents.add(path.dirname(dir))
1487
- }),
1488
- )
1783
+ level = parents
1489
1784
  }
1490
- level = parents
1785
+ } finally {
1786
+ giveBack(reopened)
1491
1787
  }
1492
1788
  }
1493
1789
 
@@ -1542,17 +1838,28 @@ export async function resolveWorkspaceOutputs(
1542
1838
  args: {
1543
1839
  workspaceRoot: string
1544
1840
  outputs: readonly string[]
1841
+ inputs?: OwnInputs | undefined
1842
+ clearing?: boolean
1545
1843
  },
1546
1844
  emptyDirs?: Set<string>,
1547
- special?: Set<string>,
1845
+ special?: SpecialProbe,
1548
1846
  ): Promise<string[]> {
1549
1847
  const { positive, negative } = splitNegations(args.outputs)
1550
1848
  if (positive.length === 0) return []
1551
1849
  const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
1552
1850
  const trees = asTrees(positive)
1553
- refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
1851
+ const absent = refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace', args.inputs)
1554
1852
  const scanned = [
1555
- ...(await scanUnion(trees, excludeGlobs, args.workspaceRoot, undefined, emptyDirs, special)),
1853
+ ...(await scanUnion(
1854
+ trees,
1855
+ excludeGlobs,
1856
+ args.workspaceRoot,
1857
+ undefined,
1858
+ emptyDirs,
1859
+ special,
1860
+ absent,
1861
+ args.clearing,
1862
+ )),
1556
1863
  ]
1557
1864
  // Same containment as the project twin, anchored one level out. These globs
1558
1865
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
@@ -1566,16 +1873,16 @@ export async function resolveWorkspaceOutputTree(args: {
1566
1873
  workspaceRoot: string
1567
1874
  outputs: readonly string[]
1568
1875
  /** As `resolveOutputTree`'s. */
1569
- special?: boolean
1570
- }): Promise<{ files: string[]; emptyDirs: string[]; special: string[] }> {
1876
+ inputs?: OwnInputs | undefined
1877
+ /** As `resolveOutputTree`'s. */
1878
+ special?: boolean | 'later'
1879
+ clearing?: boolean
1880
+ }): Promise<OutputTree> {
1571
1881
  const emptyDirs = new Set<string>()
1572
- const special = args.special === true ? new Set<string>() : undefined
1573
- const files = await resolveWorkspaceOutputs(args, emptyDirs, special)
1574
- return {
1575
- files,
1576
- emptyDirs: containedIn(args.workspaceRoot, [...emptyDirs]).sort(),
1577
- special: special === undefined ? [] : containedIn(args.workspaceRoot, [...special]).sort(),
1578
- }
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)
1579
1886
  }
1580
1887
 
1581
1888
  /**
@@ -1589,6 +1896,7 @@ export async function resolveWorkspaceOutputTree(args: {
1589
1896
  export async function cleanWorkspaceOutputs(args: {
1590
1897
  workspaceRoot: string
1591
1898
  outputs: readonly string[]
1899
+ inputs?: OwnInputs | undefined
1592
1900
  committed?: (abs: string) => boolean
1593
1901
  /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1594
1902
  resolved?: readonly string[] | undefined
@@ -1600,13 +1908,13 @@ export async function cleanWorkspaceOutputs(args: {
1600
1908
  sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
1601
1909
  const tree =
1602
1910
  args.resolved === undefined
1603
- ? await resolveWorkspaceOutputTree(args)
1604
- : { files: args.resolved, emptyDirs: [] }
1911
+ ? await resolveWorkspaceOutputTree({ ...args, special: true, clearing: true })
1912
+ : { files: args.resolved, emptyDirs: [], special: [] }
1605
1913
  const matched = tree.files
1606
1914
  await pruneEmptiedDirs(
1607
1915
  args.workspaceRoot,
1608
1916
  await removeAll(
1609
- matched.filter((f) => !committed(f)),
1917
+ [...matched, ...tree.special].filter((f) => !committed(f)),
1610
1918
  args.workspaceRoot,
1611
1919
  ),
1612
1920
  pruneScope(args.workspaceRoot, args.outputs),
@@ -1644,16 +1952,61 @@ function unanswered(
1644
1952
  files: readonly string[],
1645
1953
  newIgnored?: (lit: string) => boolean,
1646
1954
  ): Set<string> {
1647
- const left = new Set<string>()
1648
- for (const lit of literals) {
1649
- const under = `${lit}/`
1650
- const answered =
1651
- files[lowerBound(files, lit)] === lit ||
1652
- files[lowerBound(files, under)]?.startsWith(under) === true ||
1653
- files.some((rel) => rel === lit || rel.startsWith(under))
1654
- if (!answered || newIgnored?.(lit) === true) left.add(lit)
1655
- }
1656
- 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
+ )
1657
2010
  }
1658
2011
 
1659
2012
  /** The first index whose path is not below `key`. */
@@ -1932,9 +2285,43 @@ function throughLinks(
1932
2285
  * read through it, and an edit there was a hit (2026-10-28). Such a path
1933
2286
  * that is a link to a directory is an input (`dirLinkInput`). Generous on
1934
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.
1935
2292
  */
1936
- function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
1937
- 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) => {
1938
2325
  const prefix = staticPrefix(g)
1939
2326
  return {
1940
2327
  prefix: prefix === '.' ? '' : `${prefix}/`,
@@ -1942,14 +2329,6 @@ function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
1942
2329
  depth: g.split('/').length,
1943
2330
  }
1944
2331
  })
1945
- return (rel) => {
1946
- let depth: number | undefined
1947
- for (const r of reach) {
1948
- if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
1949
- if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
1950
- }
1951
- return false
1952
- }
1953
2332
  }
1954
2333
 
1955
2334
  /**
@@ -1971,8 +2350,8 @@ function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined
1971
2350
  * Whether a changed path is one of a task's declared file inputs:
1972
2351
  * `projectRel` (the path relative to the project that owns it) against
1973
2352
  * `files`, `workspaceRel` (relative to the workspace root) against
1974
- * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1975
- * 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).
1976
2355
  */
1977
2356
  export function declaresInput(
1978
2357
  task: TaskConfig,
@@ -1983,22 +2362,41 @@ export function declaresInput(
1983
2362
  * below it keys what is behind it, as `mayHoldMatch` says at key time.
1984
2363
  */
1985
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,
1986
2373
  ): boolean {
1987
2374
  const inputs = task.inputs
1988
2375
  if (inputs === undefined) return false
1989
2376
  if (projectRel !== null) {
1990
- // A changed path under the task's own outputs is a committed file
1991
- // (`git diff` lists only what git tracks) or a stray `git status`
1992
- // lists: either way it may be an input, so it counts.
2377
+ // Under the task's own outputs the key folds only what git tracks
2378
+ // (`committedFiles`); an untracked build output is not one.
1993
2379
  const plan = filesPlan(inputs.files, task.outputs?.files ?? [])
1994
- if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
2380
+ if (plan !== null && (inPlan(plan, projectRel) || (!untracked && ownOnly(plan, projectRel)))) {
2381
+ return true
2382
+ }
1995
2383
  if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
1996
2384
  return true
1997
2385
  }
1998
2386
  }
1999
2387
  const ws = inputs.workspaceFiles
2000
2388
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
2001
- 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)
2002
2400
  }
2003
2401
 
2004
2402
  /**
@@ -2155,22 +2553,24 @@ async function resolveFiles(
2155
2553
  const verdicts = plan.verdicts
2156
2554
  const committed = committedFiles(args.gitFilesCache, args.projectDir)
2157
2555
  const oids = args.gitFilesCache?.oidsFor(args.projectDir)
2158
- 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]!
2159
2559
  let input = verdicts.get(rel)
2160
2560
  if (input === undefined) {
2161
2561
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
2162
2562
  verdicts.set(rel, input)
2163
2563
  }
2164
2564
  if (nested(rel) || ownWsOutput(rel)) continue
2165
- 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)
2166
2566
  if (!input && !ownOnly(plan, rel)) {
2167
2567
  if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
2168
- candidates.push(abs)
2568
+ candidates.push(paths === undefined ? abs : (paths[i] = abs))
2169
2569
  }
2170
2570
  continue
2171
2571
  }
2172
2572
  if (!input && !committed(abs)) continue
2173
- candidates.push(abs)
2573
+ candidates.push(paths === undefined ? abs : (paths[i] = abs))
2174
2574
  }
2175
2575
  const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
2176
2576
  refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
@@ -2261,13 +2661,17 @@ function listingFor(
2261
2661
  * yields names no file and the output dropped out of the artifact without
2262
2662
  * a word: a hit then restored a tree without it (turborepo#9345's output
2263
2663
  * half). A `\ufffd` that does not stat is that case or a file that left
2264
- * 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.
2265
2666
  */
2266
- function refuseUndecodableOutput(abs: string): void {
2667
+ function refuseUndecodableOutput(abs: string, clearing: boolean): void {
2267
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'
2268
2672
  throw new UserError(
2269
2673
  `outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
2270
- `\ufffd), and vx cannot save a file by it. Rename it, or keep it out of the outputs.`,
2674
+ `\ufffd), and ${why}. Rename it, or keep it out of the outputs.`,
2271
2675
  )
2272
2676
  }
2273
2677
 
@@ -2480,31 +2884,45 @@ async function scanUnion(
2480
2884
  cwd: string,
2481
2885
  nested: (rel: string) => boolean = () => false,
2482
2886
  emptyDirs?: Set<string>,
2483
- special?: Set<string>,
2887
+ special?: SpecialProbe,
2888
+ absent?: ReadonlySet<string>,
2889
+ clearing = false,
2484
2890
  ): Promise<Set<string>> {
2485
2891
  const matches = new Set<string>()
2486
2892
  const links: string[] = []
2487
- // Directories the walk entered: Bun's scan skips a FIFO, socket or
2488
- // device outright, so `special` is found by listing them.
2489
- const walked = special === undefined ? undefined : new Set<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>()
2490
2899
  const take = (rel: string): void => {
2491
2900
  if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
2492
2901
  const abs = path.resolve(cwd, rel)
2493
- walked?.add(path.dirname(abs))
2494
2902
  const st = lstatSync(abs, { throwIfNoEntry: false })
2495
- if (walked !== undefined && st?.isDirectory() === true) walked.add(abs)
2496
2903
  if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
2497
2904
  matches.add(abs)
2498
2905
  if (st.isSymbolicLink()) links.push(abs)
2499
- } else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2500
- if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2501
- } 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)
2502
2910
  }
2503
2911
  const opts = { cwd, onlyFiles: false, followSymlinks: false, dot: true }
2504
2912
  // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
2505
2913
  // `/`: `{dist,lib/esm}/**` saved an empty artifact, and a hit restored
2506
2914
  // nothing over a cleaned tree (A-10). Expanded here, as discovery does.
2507
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
+ }
2508
2926
  // A leading `**` walks the whole project whatever else is listed, so the
2509
2927
  // list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
2510
2928
  // ~2 ms of a warm hit on solid.
@@ -2517,22 +2935,18 @@ async function scanUnion(
2517
2935
  ) {
2518
2936
  const any = anyTaskGlob(patterns)
2519
2937
  for (const rel of WHOLE_TREE.scanSync(opts)) if (any(rel)) take(rel)
2520
- if (special !== undefined) findSpecial(walked!, cwd, any, excludeGlobs, nested, special)
2521
2938
  return matches
2522
2939
  }
2523
2940
  for (const pattern of patterns) {
2524
- if (absentPrefix(cwd, pattern)) continue
2941
+ if (absentPrefix(cwd, pattern, absent)) continue
2525
2942
  const from = scanRoot(cwd, pattern)
2526
2943
  if (from === null) continue
2527
- walked?.add(from === undefined ? cwd : from.dir)
2944
+ roots?.add(from === undefined ? cwd : from.dir)
2528
2945
  if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
2529
2946
  else
2530
2947
  for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
2531
2948
  take(`${from.prefix}/${rel}`)
2532
2949
  }
2533
- if (special !== undefined) {
2534
- findSpecial(walked!, cwd, anyTaskGlob(patterns), excludeGlobs, nested, special)
2535
- }
2536
2950
  // A literal names the path and its tree (`dist`, `dist/**`), and a tree
2537
2951
  // whose head is a directory link is walked through it: the link and the
2538
2952
  // files under it were one artifact holding `dist` as a file and a
@@ -2548,6 +2962,11 @@ async function scanUnion(
2548
2962
  return matches
2549
2963
  }
2550
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
+
2551
2970
  /** The FIFOs, sockets and devices in `dirs` that the output globs name. */
2552
2971
  function findSpecial(
2553
2972
  dirs: ReadonlySet<string>,
@@ -2622,11 +3041,13 @@ function scanRoot(
2622
3041
  * cost ~58 µs where the lstat costs a few. Anything this cannot read
2623
3042
  * plainly (no prefix, an escape, an absolute path, a refused stat) scans.
2624
3043
  */
2625
- function absentPrefix(cwd: string, pattern: string): boolean {
3044
+ function absentPrefix(cwd: string, pattern: string, known?: ReadonlySet<string>): boolean {
2626
3045
  const prefix = staticPrefix(pattern)
2627
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
2628
3049
  try {
2629
- return lstatSync(path.join(cwd, prefix), { throwIfNoEntry: false }) === undefined
3050
+ return lstatSync(at, { throwIfNoEntry: false }) === undefined
2630
3051
  } catch {
2631
3052
  return false
2632
3053
  }