@vzn/vx 0.0.21 → 0.0.22

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -1,5 +1,12 @@
1
- import type { TaskConfig } from '../config.js'
2
- import { UserError } from '../util/index.js'
1
+ import type { ProjectConfig, TaskConfig } from '../config.js'
2
+ import {
3
+ asTrees,
4
+ isLiteralPattern,
5
+ staticPrefix,
6
+ taskGlob,
7
+ UserError,
8
+ wholeSubtreePrefixes,
9
+ } from '../util/index.js'
3
10
  import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
4
11
  import {
5
12
  DependencySpecError,
@@ -8,6 +15,7 @@ import {
8
15
  parseDependencySpec,
9
16
  type DependencySpec,
10
17
  } from './dependency-spec.js'
18
+ import type { TaskOutcome } from './scheduler.js'
11
19
 
12
20
  // Re-exported so existing importers keep working while the type's home
13
21
  // moves to workspace (it's the joint product of discovery + loading).
@@ -44,31 +52,38 @@ export interface TaskNode {
44
52
  * Absent when no plugin declares the stage.
45
53
  */
46
54
  keyParts?: ReadonlyArray<readonly [name: string, value: string]>
55
+ /**
56
+ * Ids of upstream tasks whose declared outputs this task's outputs
57
+ * overlap: this task ADDS to their trees (twenty's `build:individual`
58
+ * into `build`'s `dist`; item 588). Its own output set is what its run
59
+ * added or changed, it cleans and restores only its recorded rows, and
60
+ * it is never restore-tier. Set by `detectOutputCollisions`, which refuses
61
+ * the same overlap without an edge.
62
+ */
63
+ addsToOutputsOf?: string[]
64
+ /**
65
+ * Declared output globs of dependants that add into this task's tree.
66
+ * The hit path ignores paths they match when it decides whether this
67
+ * task's tree is already current, so an addition below is not a stray.
68
+ */
69
+ outputsAddedToBy?: string[]
70
+ /**
71
+ * The dependencies `--exclude-dependencies` took out of the schedule, as
72
+ * outcomes carrying the key each would have (`excludeDependencies`, then
73
+ * `prepareRun`). The scheduler never sees them; the key folds them next
74
+ * to `deps`' own outcomes, so a key is the same whatever the selection.
75
+ */
76
+ excludedUpstream?: TaskOutcome[]
47
77
  }
48
78
 
49
79
  export function taskId(project: string, task: string): string {
50
80
  return `${project}#${task}`
51
81
  }
52
82
 
53
- /**
54
- * The inverse of `taskId`. Splits on the FIRST `#`, so a task name that itself
55
- * contains one round-trips: `taskId('a', 'b#c')` → `'a#b#c'` → `['a', 'b#c']`.
56
- *
57
- * This exists because it kept being written by hand as `id.split('#', 2)`,
58
- * which is NOT the inverse — it discards everything after the second segment,
59
- * so `'a#b#c'` reads back as task `'b'`. Six call sites had that form while
60
- * `parseDependencySpec` (the surface that decides what actually runs) has
61
- * always split on the first `#`, so the query layer and the graph disagreed
62
- * about the identity of the same task: a lookup either found nothing or, worse,
63
- * answered with a different task's history.
64
- *
65
- * A `#`-containing task name is legal and pinned elsewhere in the suite, so
66
- * this is reachable rather than theoretical.
67
- */
68
- export function splitTaskId(id: string): [project: string, task: string] {
69
- const i = id.indexOf('#')
70
- return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
71
- }
83
+ // `splitTaskId` lives in util so the cache (which may not import graph)
84
+ // reads the same rule instead of a copy; re-exported here because the
85
+ // graph is where every reader of a task id looks for it.
86
+ export { splitTaskId } from '../util/index.js'
72
87
 
73
88
  /**
74
89
  * A task is a "group" if it has no `exec` — it exists only to chain
@@ -170,9 +185,23 @@ export function expandRequested(
170
185
  return out
171
186
  }
172
187
 
188
+ /**
189
+ * The task a config declares under `name`, by OWN property. `tasks` is a
190
+ * plain object, so `tasks['constructor']` and `tasks['toString']` are
191
+ * Object.prototype's: `vx show constructor` printed a group task for every
192
+ * project and `vx run a#toString` ran nothing, exit 0 (item 897).
193
+ */
194
+ export function declaredTask(
195
+ config: ProjectConfig | null | undefined,
196
+ name: string,
197
+ ): TaskConfig | undefined {
198
+ const tasks = config?.tasks
199
+ return tasks !== undefined && Object.hasOwn(tasks, name) ? tasks[name] : undefined
200
+ }
201
+
173
202
  /** Shared by `expandRequested` + `unresolvedRequests` so the two can't drift. */
174
203
  function declaresTask(projects: Map<string, ProjectEntry>, project: string, task: string): boolean {
175
- return projects.get(project)?.config.tasks?.[task] !== undefined
204
+ return declaredTask(projects.get(project)?.config, task) !== undefined
176
205
  }
177
206
 
178
207
  /**
@@ -214,39 +243,87 @@ export interface BuildGraphOptions {
214
243
  /** Initial set: `{ project, task }` pairs the user asked to run. */
215
244
  requested: Array<{ project: string; task: string }>
216
245
  /**
217
- * Filter `dependsOn` expansion.
218
- * - `undefined` → every dependsOn entry is followed (default).
219
- * - `'all'` → no expansion; only the requested nodes are added.
220
- * - `string[]` → expand normally, but drop edges whose target
221
- * task name is in the list. `dependsOn.self` and
222
- * `dependsOn.dependencies` are both filtered.
246
+ * Set when `projects` is a scoped load rather than the whole workspace. A
247
+ * literal `^name` no project in `projects` declares may still be declared
248
+ * by one that was not loaded, so the builder hands it here instead of
249
+ * refusing it, and the caller judges it against the rest
250
+ * (`undeclaredDepsError` is the refusal). Unset, `projects` is the whole
251
+ * workspace and the builder refuses it itself.
223
252
  */
224
- excludeDependencies?: 'all' | readonly string[]
253
+ undeclaredDeps?: (taskId: string, name: string) => void
254
+ }
255
+
256
+ /**
257
+ * The refusal of a literal `^name` that no project in the workspace
258
+ * declares: it has no holder anywhere, so it can only be a typo (nx#32779
259
+ * ran such an edge as no edge, green). A name SOME project declares stays
260
+ * legal without a holder — a preset spreads `^build` over projects whose
261
+ * dependencies lack it.
262
+ */
263
+ export function undeclaredDepsError(taskId: string, name: string): UserError {
264
+ return new UserError(
265
+ `Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
266
+ )
267
+ }
268
+
269
+ /** One task mid-expansion in `buildTaskGraph`'s walk. */
270
+ interface Frame {
271
+ node: TaskNode
272
+ /** Index of the next `dependsOn` entry to resolve. */
273
+ entry: number
274
+ /** Whether the current entry has added a new node yet. */
275
+ added: boolean
276
+ /** The current entry's later new nodes, not yet in the graph. */
277
+ pending: TaskNode[] | null
278
+ /** Index into `pending` of the next one to add. */
279
+ next: number
225
280
  }
226
281
 
227
282
  export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode> {
228
- const { projects, packageGraph, requested, excludeDependencies } = options
229
- const skipAll = excludeDependencies === 'all'
230
- const skipNames =
231
- Array.isArray(excludeDependencies) && excludeDependencies.length > 0
232
- ? new Set(excludeDependencies)
233
- : null
283
+ const { projects, packageGraph, requested, undeclaredDeps } = options
234
284
  const nodes = new Map<string, TaskNode>()
235
285
 
236
- function addNode(projectName: string, taskName: string, requested: boolean): TaskNode | null {
286
+ // The walk keeps its own stack instead of recursing once per edge: a
287
+ // `dependsOn` chain or ring ~20,000 deep overflowed V8's call stack
288
+ // (nx#28788's shape) before `detectCycle` could name the cycle. It adds
289
+ // nodes in the order the recursion did — depth-first, each target's
290
+ // subtree before the next target — and `detectCycle` walks that order,
291
+ // so it names the same cycle. Resolving an entry only reads configs and
292
+ // the package graph, so its targets can all be found first: the first
293
+ // new one is added at once (its subtree is expanded next, on top of the
294
+ // stack), the later ones wait in `pending` for their turn.
295
+ const stack: Frame[] = []
296
+
297
+ function add(node: TaskNode): void {
298
+ nodes.set(node.id, node)
299
+ if ((node.config.dependsOn?.length ?? 0) > 0) {
300
+ stack.push({ node, entry: 0, added: false, pending: null, next: 0 })
301
+ }
302
+ }
303
+
304
+ // Adds `projectName#taskName` to the graph if it is new, and the edge to
305
+ // it from `frame`'s task (null for a requested task). False when the
306
+ // project or the task is not declared.
307
+ function visit(
308
+ frame: Frame | null,
309
+ projectName: string,
310
+ taskName: string,
311
+ requested: boolean,
312
+ ): boolean {
237
313
  const id = taskId(projectName, taskName)
238
314
  const existing = nodes.get(id)
239
315
  if (existing) {
240
316
  // Promote an already-added node to requested if any caller asked
241
317
  // for it directly. Once requested, never demoted.
242
318
  if (requested) existing.requested = true
243
- return existing
319
+ frame?.node.deps.push(id)
320
+ return true
244
321
  }
245
322
 
246
323
  const project = projects.get(projectName)
247
- if (!project) return null
248
- const taskConfig = project.config.tasks?.[taskName]
249
- if (!taskConfig) return null
324
+ if (!project) return false
325
+ const taskConfig = declaredTask(project.config, taskName)
326
+ if (!taskConfig) return false
250
327
 
251
328
  const node: TaskNode = {
252
329
  id,
@@ -257,146 +334,263 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
257
334
  deps: [],
258
335
  requested,
259
336
  }
260
- nodes.set(id, node)
261
-
262
- if (skipAll) return node
263
-
264
- const rawSpecs = taskConfig.dependsOn ?? []
265
- for (const raw of rawSpecs) {
266
- let spec: DependencySpec
267
- try {
268
- spec = parseDependencySpec(raw)
269
- } catch (err) {
270
- if (err instanceof DependencySpecError) {
271
- throw new UserError(`Task ${id}: ${err.message}`)
272
- }
273
- throw err
274
- }
337
+ if (frame === null) {
338
+ add(node)
339
+ return true
340
+ }
341
+ frame.node.deps.push(id)
342
+ if (frame.added) (frame.pending ??= []).push(node)
343
+ else {
344
+ frame.added = true
345
+ add(node)
346
+ }
347
+ return true
348
+ }
275
349
 
276
- // dependsOn is about which tasks to ADD to the graph, not which
277
- // to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
278
- // negation aren't meaningful here — they're cache.inputs.tasks
279
- // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
280
- // they name a namespace of tasks to add (Nx 19.5 parity).
281
- if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
282
- throw new UserError(`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`)
283
- }
284
- if (spec.negated) {
285
- throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
350
+ // Every task name `projects` declares, built on the first `^name` that
351
+ // finds no holder and whose own project does not declare it either.
352
+ let declared: Set<string> | null = null
353
+ function declaredAnywhere(projectName: string, name: string): boolean {
354
+ if (declaresTask(projects, projectName, name)) return true
355
+ if (declared === null) {
356
+ declared = new Set()
357
+ for (const p of projects.values()) {
358
+ for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
286
359
  }
287
- if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
288
- throw new UserError(
289
- `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
290
- )
360
+ }
361
+ return declared.has(name)
362
+ }
363
+
364
+ // Resolves one `dependsOn` entry of `frame`'s task into its edges.
365
+ function resolveEntry(frame: Frame, raw: string): void {
366
+ const { node } = frame
367
+ const { id, projectName, taskName } = node
368
+ let spec: DependencySpec
369
+ try {
370
+ spec = parseDependencySpec(raw)
371
+ } catch (err) {
372
+ if (err instanceof DependencySpecError) {
373
+ throw new UserError(`Task ${id}: ${err.message}`)
291
374
  }
292
- // CLI `--exclude-dependencies=name1,name2` drops edges whose target
293
- // task name matches, regardless of bucket (self / deps / cross).
294
- // Pattern specs re-apply the filter per EXPANDED name below.
295
- if (skipNames?.has(spec.task)) continue
296
-
297
- if (spec.kind === 'self') {
298
- if (isTaskPattern(spec.task)) {
299
- // `build.*` — every OTHER same-project task matching the
300
- // pattern (the declaring task never matches itself — that
301
- // would be an instant self-cycle). Zero matches is legal: a
302
- // preset-spread pattern needn't match in every project.
303
- const re = compileTaskPattern(spec.task)
304
- for (const name of Object.keys(project.config.tasks ?? {})) {
305
- if (name === taskName || !re.test(name)) continue
306
- if (skipNames?.has(name)) continue
307
- const child = addNode(projectName, name, false)
308
- if (child) node.deps.push(child.id)
309
- }
310
- } else {
311
- // Missing target is a hard error — the user typed a name that
312
- // doesn't resolve in this project.
313
- const child = addNode(projectName, spec.task, false)
314
- if (!child) {
315
- throw new UserError(
316
- `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
317
- )
318
- }
319
- node.deps.push(child.id)
320
- }
321
- } else if (spec.kind === 'deps') {
322
- // Nearest-holder frontier (Turbo/Nx direct-deps parity +
323
- // sparse bridging): walk the package dep graph from this
324
- // project's direct deps; each path stops at the FIRST package
325
- // declaring the task — a holder's own dependsOn is responsible
326
- // for anything deeper. Packages without the task are passed
327
- // through so a sparse dep doesn't break ordering to deeper
328
- // holders. The visited set both dedupes shared subtrees and
329
- // terminates on package-graph cycles (legal in PMs).
330
- //
331
- // With a pattern (`^build.*`), a holder is a package declaring
332
- // AT LEAST ONE matching task and it receives edges to ALL its
333
- // matches — holder-ness is about declaration, so a holder still
334
- // stops the walk even when every match is --exclude-dependencies'd.
335
- //
336
- // The declaring project seeds `visited`: package graphs may legally
337
- // contain cycles (the common "b devDepends on a for its tests"
338
- // shape), and a cycle walks the frontier straight back to the
339
- // origin. Mirrors the self-pattern rule below — a task can never
340
- // depend on itself.
341
- const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
342
- const visited = new Set<string>([projectName])
343
- const frontier = [...packageGraph.directDeps(projectName)]
344
- while (frontier.length > 0) {
345
- const target = frontier.pop()!
346
- if (visited.has(target)) continue
347
- visited.add(target)
348
- if (re === null) {
349
- const child = addNode(target, spec.task, false)
350
- if (child) node.deps.push(child.id)
351
- else frontier.push(...packageGraph.directDeps(target))
352
- } else {
353
- const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
354
- re.test(n),
355
- )
356
- if (names.length > 0) {
357
- for (const name of names) {
358
- if (skipNames?.has(name)) continue
359
- const child = addNode(target, name, false)
360
- if (child) node.deps.push(child.id)
361
- }
362
- } else {
363
- frontier.push(...packageGraph.directDeps(target))
364
- }
365
- }
375
+ throw err
376
+ }
377
+
378
+ // dependsOn is about which tasks to ADD to the graph, not which
379
+ // to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
380
+ // negation aren't meaningful here — they're cache.inputs.tasks
381
+ // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
382
+ // they name a namespace of tasks to add (Nx 19.5 parity).
383
+ if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
384
+ throw new UserError(`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`)
385
+ }
386
+ if (spec.negated) {
387
+ throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
388
+ }
389
+ if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
390
+ throw new UserError(
391
+ `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
392
+ )
393
+ }
394
+ if (spec.kind === 'self') {
395
+ if (isTaskPattern(spec.task)) {
396
+ // `build.*` — every OTHER same-project task matching the
397
+ // pattern (the declaring task never matches itself — that
398
+ // would be an instant self-cycle). Zero matches is legal: a
399
+ // preset-spread pattern needn't match in every project.
400
+ const re = compileTaskPattern(spec.task)
401
+ for (const name of Object.keys(projects.get(projectName)!.config.tasks ?? {})) {
402
+ if (name === taskName || !re.test(name)) continue
403
+ visit(frame, projectName, name, false)
366
404
  }
367
405
  } else {
368
- // Cross-project edge: pkg#task. Missing target is a hard error
369
- // because the user named the package + task explicitly.
370
- const child = addNode(spec.project, spec.task, false)
371
- if (!child) {
406
+ // Missing target is a hard error — the user typed a name that
407
+ // doesn't resolve in this project.
408
+ if (!visit(frame, projectName, spec.task, false)) {
372
409
  throw new UserError(
373
- `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
410
+ `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
411
+ )
412
+ }
413
+ }
414
+ } else if (spec.kind === 'deps') {
415
+ // Nearest-holder frontier (Turbo/Nx direct-deps parity +
416
+ // sparse bridging): walk the package dep graph from this
417
+ // project's direct deps; each path stops at the FIRST package
418
+ // declaring the task — a holder's own dependsOn is responsible
419
+ // for anything deeper. Packages without the task are passed
420
+ // through so a sparse dep doesn't break ordering to deeper
421
+ // holders. The visited set both dedupes shared subtrees and
422
+ // terminates on package-graph cycles (legal in PMs).
423
+ //
424
+ // With a pattern (`^build.*`), a holder is a package declaring
425
+ // AT LEAST ONE matching task and it receives edges to ALL its
426
+ // matches — holder-ness is about declaration, so a holder still
427
+ // stops the walk even when `--exclude-dependencies` drops every
428
+ // edge to it (`excludeDependencies`).
429
+ //
430
+ // The declaring project seeds `visited`: package graphs may legally
431
+ // contain cycles (the common "b devDepends on a for its tests"
432
+ // shape), and a cycle walks the frontier straight back to the
433
+ // origin. Mirrors the self-pattern rule above — a task can never
434
+ // depend on itself.
435
+ const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
436
+ const visited = new Set<string>([projectName])
437
+ const frontier = [...packageGraph.directDeps(projectName)]
438
+ let held = false
439
+ while (frontier.length > 0) {
440
+ const target = frontier.pop()!
441
+ if (visited.has(target)) continue
442
+ visited.add(target)
443
+ if (re === null) {
444
+ if (visit(frame, target, spec.task, false)) held = true
445
+ else frontier.push(...packageGraph.directDeps(target))
446
+ } else {
447
+ const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
448
+ re.test(n),
374
449
  )
450
+ if (names.length > 0) {
451
+ for (const name of names) visit(frame, target, name, false)
452
+ } else {
453
+ frontier.push(...packageGraph.directDeps(target))
454
+ }
375
455
  }
376
- node.deps.push(child.id)
456
+ }
457
+ // A pattern that matches nothing stays legal, as `build.*` does.
458
+ if (re === null && !held && !declaredAnywhere(projectName, spec.task)) {
459
+ if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
460
+ undeclaredDeps(id, spec.task)
461
+ }
462
+ } else {
463
+ // Cross-project edge: pkg#task. Missing target is a hard error
464
+ // because the user named the package + task explicitly.
465
+ if (!visit(frame, spec.project, spec.task, false)) {
466
+ throw new UserError(
467
+ `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
468
+ )
377
469
  }
378
470
  }
379
-
380
- // Stable ordering for deterministic scheduling and cache keys — deduped:
381
- // a target named twice (an exact entry + a pattern matching it, or a
382
- // literal duplicate) must contribute ONE edge, not a double-folded
383
- // upstream hash and a doubled DOT edge.
384
- node.deps = [...new Set(node.deps)].sort()
385
- return node
386
471
  }
387
472
 
388
473
  for (const { project, task } of requested) {
389
- addNode(project, task, true)
474
+ visit(null, project, task, true)
475
+ while (stack.length > 0) {
476
+ const frame = stack[stack.length - 1]!
477
+ const { node, pending } = frame
478
+ if (pending !== null && frame.next < pending.length) {
479
+ // An earlier target's subtree may have added it meanwhile.
480
+ const next = pending[frame.next++]!
481
+ if (!nodes.has(next.id)) add(next)
482
+ continue
483
+ }
484
+ const entries = node.config.dependsOn!
485
+ if (frame.entry < entries.length) {
486
+ frame.added = false
487
+ frame.pending = null
488
+ frame.next = 0
489
+ resolveEntry(frame, entries[frame.entry++]!)
490
+ continue
491
+ }
492
+ // Stable ordering for deterministic scheduling and cache keys — deduped:
493
+ // a target named twice (an exact entry + a pattern matching it, or a
494
+ // literal duplicate) must contribute ONE edge, not a double-folded
495
+ // upstream hash and a doubled DOT edge.
496
+ node.deps = [...new Set(node.deps)].sort()
497
+ stack.pop()
498
+ }
390
499
  }
391
500
 
501
+ checkGraph(nodes)
502
+ return nodes
503
+ }
504
+
505
+ /**
506
+ * What a task graph must hold before it is scheduled: each node under its
507
+ * own id, every dep a node, no cycle, and no two tasks deleting each
508
+ * other's outputs. The builder's output holds the first two by
509
+ * construction; the `graph` stage runs this again after its plugins, which
510
+ * could move a node to another key (the scheduler then died on a raw
511
+ * TypeError) or drop the edge that made two overlapping outputs an
512
+ * addition, and a green run lost one task's files (item 981). The addition
513
+ * marks are derived here, so they are cleared first and follow the edges
514
+ * the graph has now.
515
+ */
516
+ export function checkGraph(nodes: Map<string, TaskNode>): void {
517
+ for (const [key, node] of nodes) {
518
+ if (node.id !== key) {
519
+ throw new Error(`the task ${node.id} is stored under '${key}', not its own id`)
520
+ }
521
+ for (const dep of node.deps) {
522
+ if (!nodes.has(dep)) {
523
+ throw new Error(`${node.id} depends on '${dep}', which is not a task in this run's graph`)
524
+ }
525
+ }
526
+ delete node.addsToOutputsOf
527
+ delete node.outputsAddedToBy
528
+ }
392
529
  detectCycle(nodes)
393
530
  detectOutputCollisions(nodes)
394
- return nodes
395
531
  }
396
532
 
397
- /** A glob with no wildcard — it names exactly one path. */
398
- function isLiteralGlob(g: string): boolean {
399
- return g.search(/[*?[\]]/) === -1
533
+ /**
534
+ * `--exclude-dependencies`: take `dependsOn` edges out of the SCHEDULE, not
535
+ * out of the graph. `'all'` drops every edge; a name list drops each edge
536
+ * whose target task has one of those names, whichever `dependsOn` form
537
+ * reached it. What stays scheduled is what the requested tasks still reach;
538
+ * the rest leaves `nodes` and is returned with its `deps` intact, and each
539
+ * scheduled task that lost an edge is listed in `dropped` with the ids it
540
+ * lost. An edge to a task that stays scheduled (requested itself, or
541
+ * reached another way) is kept: the two still run in order.
542
+ *
543
+ * Under `'all'` a group's own edges stay: a group is its members (running
544
+ * one is running them), so `vx run ci --exclude-dependencies` runs what
545
+ * `ci` names without what THEY depend on. Dropping them left the group
546
+ * alone, and the run ran nothing and exited 0 (item 894). A name list
547
+ * still drops a group's edge it names: `--exclude-dependencies=lint.oxfmt`
548
+ * takes one member out of `lint`.
549
+ *
550
+ * Run on the whole graph, after the `graph` and `key` stages, because a
551
+ * dropped dependency is still KEYED: a key is a function of inputs, never of
552
+ * the selection (nx#35234), so the caller derives each dropped task's key
553
+ * as a full run would and hands it to the dependant (`excludedUpstream`).
554
+ */
555
+ export function excludeDependencies(
556
+ nodes: Map<string, TaskNode>,
557
+ exclude: 'all' | readonly string[],
558
+ ): { keyOnly: Map<string, TaskNode>; dropped: Map<string, string[]> } {
559
+ const names = exclude === 'all' ? null : new Set(exclude)
560
+ const keptBy =
561
+ (node: TaskNode) =>
562
+ (dep: string): boolean =>
563
+ names === null ? isGroupTask(node) : !names.has(nodes.get(dep)!.taskName)
564
+ const scheduled = new Set<string>()
565
+ const stack = [...nodes.values()].filter((n) => n.requested).map((n) => n.id)
566
+ while (stack.length > 0) {
567
+ const id = stack.pop()!
568
+ if (scheduled.has(id)) continue
569
+ scheduled.add(id)
570
+ const node = nodes.get(id)!
571
+ const kept = keptBy(node)
572
+ for (const dep of node.deps) if (kept(dep)) stack.push(dep)
573
+ }
574
+ const keyOnly = new Map<string, TaskNode>()
575
+ const dropped = new Map<string, string[]>()
576
+ for (const [id, node] of nodes) {
577
+ if (!scheduled.has(id)) {
578
+ keyOnly.set(id, node)
579
+ continue
580
+ }
581
+ // An edge to a task this run schedules anyway stays: dropped, the two
582
+ // ran unordered, and `a#build` read `b`'s output mid-rewrite or from
583
+ // the run before (item 980). Only an edge to a task that leaves the
584
+ // schedule is taken out.
585
+ const kept = keptBy(node)
586
+ const stays = (d: string): boolean => kept(d) || scheduled.has(d)
587
+ const lost = node.deps.filter((d) => !stays(d))
588
+ if (lost.length === 0) continue
589
+ dropped.set(id, lost)
590
+ node.deps = node.deps.filter(stays)
591
+ }
592
+ for (const id of keyOnly.keys()) nodes.delete(id)
593
+ return { keyOnly, dropped }
400
594
  }
401
595
 
402
596
  /**
@@ -409,7 +603,10 @@ function isLiteralGlob(g: string): boolean {
409
603
  *
410
604
  * both literal — equal paths
411
605
  * literal vs glob — ask the glob whether it matches the literal (exact)
412
- * both globs — only identical strings; anything else is undecided
606
+ * both globs — identical strings, or one a whole subtree `P/**`
607
+ * and the other's literal prefix P or under it: every
608
+ * path the second matches is under P, so the first
609
+ * covers it (item 941). Anything else is undecided
413
610
  * here and deliberately allowed through
414
611
  *
415
612
  * The rejected alternative was comparing each glob's static prefix. It is
@@ -417,12 +614,61 @@ function isLiteralGlob(g: string): boolean {
417
614
  * `dist/vx-*` and `dist/other.txt` share the prefix `dist` while matching
418
615
  * disjoint sets, so a prefix check refuses a legitimate config. (vx's own
419
616
  * `build.bun.*` tasks escape only because they declare distinct literals.)
617
+ *
618
+ * All three cases compare SPELLINGS, so each side is run through
619
+ * `asTrees` first — the same rule the resolver and `cleanOutputs` read,
620
+ * and `cleanOutputs` is what actually does the deleting. That folds two
621
+ * things this check used to miss, both of them the data loss it exists to
622
+ * prevent:
623
+ *
624
+ * - the SPELLING: `./dist/**` and `dist/**` are one tree to every
625
+ * matcher in vx, and `Bun.Glob('dist/**')` does not match the literal
626
+ * `./dist/app.js` either (item 441, probed one spelling at a time);
627
+ * - the literal DIRECTORY: `outputs: ['dist']` means everything under
628
+ * `dist` — `asTrees` compiles it to `dist` + `dist/**` — while this
629
+ * compared it to `dist/app.js` as two unequal literals. Measured end
630
+ * to end: the task declaring `dist` wiped the other's `dist/app.js`
631
+ * and the run reported success (item 442).
632
+ *
633
+ * Neither is a widening. Both read the declaration the way the code that
634
+ * deletes reads it, which is the only reading that decides the hazard.
635
+ *
636
+ * Exported through the façade because `@vzn/vx-migrate` asks the same
637
+ * question at MIGRATION time — it uncaches the losers so the generated
638
+ * config loads — and it used to ask it with a copy of this function. The
639
+ * copy did not get items 441 and 442, so it reported clean on configs
640
+ * core then refused, including `outputs: ['dist']` against
641
+ * `dist/app.js`, which is the commonest turbo.json shape there is (item
642
+ * 445). One rule, one place: the copy is gone.
420
643
  */
421
- function outputsOverlap(a: string, b: string): boolean {
422
- if (isLiteralGlob(a) && isLiteralGlob(b)) return a === b
423
- if (isLiteralGlob(a)) return new Bun.Glob(b).match(a)
424
- if (isLiteralGlob(b)) return new Bun.Glob(a).match(b)
425
- return a === b
644
+ export function outputsOverlap(rawA: string, rawB: string): boolean {
645
+ for (const a of asTrees([rawA])) {
646
+ for (const b of asTrees([rawB])) {
647
+ if (isLiteralPattern(a) && isLiteralPattern(b)) {
648
+ if (a === b) return true
649
+ } else if (isLiteralPattern(a)) {
650
+ if (taskGlob(b).match(a)) return true
651
+ } else if (isLiteralPattern(b)) {
652
+ if (taskGlob(a).match(b)) return true
653
+ } else if (a === b || covers(a, b) || covers(b, a)) return true
654
+ }
655
+ }
656
+ return false
657
+ }
658
+
659
+ /**
660
+ * Does the whole-subtree glob `tree` (`P/**`) match every path `glob` can?
661
+ * It does when `glob`'s literal prefix is P or a directory under it. Left
662
+ * undecided, `dist/**` beside `dist/extra/**` was no overlap: each task
663
+ * cleaned and restored the other's files, and a hit of the wide one put
664
+ * back its own stale copy of the narrow one's output under a green run
665
+ * (item 941).
666
+ */
667
+ function covers(tree: string, glob: string): boolean {
668
+ const dir = wholeSubtreePrefixes([tree])?.[0]
669
+ if (dir === undefined) return false
670
+ const prefix = staticPrefix(glob)
671
+ return prefix === dir || prefix.startsWith(`${dir}/`)
426
672
  }
427
673
 
428
674
  /**
@@ -445,6 +691,24 @@ function outputsOverlap(a: string, b: string): boolean {
445
691
  * this only refuses a graph that was already destroying files.
446
692
  */
447
693
  function detectOutputCollisions(nodes: Map<string, TaskNode>): void {
694
+ // Does `from` reach `to` through deps? Asked only for a colliding pair,
695
+ // so the walk is rare; memoised per source across the detector's calls.
696
+ const reachMemo = new Map<string, Set<string>>()
697
+ const reaches = (from: string, to: string): boolean => {
698
+ let seen = reachMemo.get(from)
699
+ if (seen === undefined) {
700
+ seen = new Set<string>()
701
+ const stack = [...(nodes.get(from)?.deps ?? [])]
702
+ while (stack.length > 0) {
703
+ const id = stack.pop()!
704
+ if (seen.has(id)) continue
705
+ seen.add(id)
706
+ for (const d of nodes.get(id)?.deps ?? []) if (!seen.has(d)) stack.push(d)
707
+ }
708
+ reachMemo.set(from, seen)
709
+ }
710
+ return seen.has(to)
711
+ }
448
712
  // INDEX FIRST, then compare — never all-pairs over the graph. A naive
449
713
  // pairwise loop that filters by project INSIDE the loop is quadratic in the
450
714
  // whole graph: measured 1.6 SECONDS at this project's stated target of 1000
@@ -454,11 +718,13 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>): void {
454
718
  //
455
719
  // Both namespaces have a much smaller natural domain:
456
720
  // files — project-relative, so only same-project tasks can
457
- // collide. Bucketing makes this O(sum of k^2) over
458
- // per-project task counts, and k is single digits.
721
+ // collide.
459
722
  // workspaceFiles — root-anchored and boundary-free, so any two tasks can
460
- // collide — but only tasks that DECLARE it participate,
461
- // and that set is nearly always empty.
723
+ // collide — but only tasks that DECLARE it participate.
724
+ // Within a domain all pairs was still quadratic in ONE project's tasks
725
+ // (4,000 with outputs spent 10.7 s here, item 741), so each domain is
726
+ // indexed again by path (`overlapCandidates`) and only the pairs that can
727
+ // overlap are compared, in the order the all-pairs loop met them.
462
728
  const byProject = new Map<string, TaskNode[]>()
463
729
  const wsDeclarers: TaskNode[] = []
464
730
  for (const n of nodes.values()) {
@@ -471,28 +737,110 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>): void {
471
737
  if ((outs?.workspaceFiles?.length ?? 0) > 0) wsDeclarers.push(n)
472
738
  }
473
739
 
740
+ const filesOf = (n: TaskNode): readonly string[] | undefined => n.config.cache?.outputs.files
741
+ const wsFilesOf = (n: TaskNode): readonly string[] | undefined =>
742
+ n.config.cache?.outputs.workspaceFiles
474
743
  for (const bucket of byProject.values()) {
475
- for (let i = 0; i < bucket.length; i++) {
476
- for (let j = i + 1; j < bucket.length; j++) {
477
- const a = bucket[i]!
478
- const b = bucket[j]!
479
- collide(a, b, a.config.cache?.outputs.files, b.config.cache?.outputs.files, 'files')
744
+ // Most projects hold one task with outputs, and a call per project
745
+ // cost the cold build a measured 0.4 ms at 1,000 projects.
746
+ if (bucket.length < 2) continue
747
+ for (const [i, j] of overlapCandidates(bucket, filesOf)) {
748
+ const a = bucket[i]!
749
+ const b = bucket[j]!
750
+ collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
751
+ }
752
+ }
753
+ if (wsDeclarers.length < 2) return
754
+ for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
755
+ const a = wsDeclarers[i]!
756
+ const b = wsDeclarers[j]!
757
+ collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Where a glob's literal head ends: every character `Bun.Glob` may read as
763
+ * something other than itself. `*?{}` are the task-glob wildcards; `\`
764
+ * escapes (`x\y/*` matches `xy/a`), and a leading `!` negates (`!a/**`
765
+ * matches `b`). Ending early only widens the candidate set. A fuzz of
766
+ * nearly four million glob/literal pairs (over 400,000 of them matching)
767
+ * found no match outside the head this cuts, and thousands once `\` and
768
+ * `!` were left out (item 746).
769
+ */
770
+ const GLOB_HEAD_END = /[*?{}\\!]/
771
+
772
+ /**
773
+ * The pairs `[i, j]`, `i < j`, of `tasks` whose declared outputs CAN
774
+ * overlap, ascending by `i` then `j`: a superset of the pairs
775
+ * `outputsOverlap` accepts, so `collide` over them decides exactly what it
776
+ * decided over all pairs, and in the same order (the first refusal named,
777
+ * and the addition marks pushed, are all-pairs' own).
778
+ *
779
+ * `outputsOverlap` compares the `asTrees` forms three ways. Two equal
780
+ * globs are one lookup. Two equal literals are too, since each literal
781
+ * carries its `/**` twin. And a glob matches a literal only when the
782
+ * literal lies under the directory the glob's literal head names
783
+ * (`dist/sub/**` → `dist/sub`; `*.js` → the root, so a wildcard in the
784
+ * first segment meets every literal): a literal meets the globs filed at
785
+ * each of its ancestors. The fuzz above found no glob matching the
786
+ * directory its own head names. A whole subtree meets the globs whose
787
+ * literal prefix lies at or under it, found the same way from the glob's
788
+ * side (item 941).
789
+ */
790
+ function overlapCandidates(
791
+ tasks: readonly TaskNode[],
792
+ globsOf: (n: TaskNode) => readonly string[] | undefined,
793
+ ): Array<[number, number]> {
794
+ const globs = new Map<string, number[]>()
795
+ const globsUnder = new Map<string, number[]>()
796
+ const literals: Array<[task: number, path: string]> = []
797
+ // `covers` (item 941): a whole subtree `P/**` meets every glob whose
798
+ // literal prefix is P or under it, so each glob looks up the subtrees
799
+ // filed at its prefix and at each of that prefix's ancestors.
800
+ const subtrees = new Map<string, number[]>()
801
+ const prefixes: Array<[task: number, prefix: string]> = []
802
+ const file = (index: Map<string, number[]>, key: string, i: number): void => {
803
+ const list = index.get(key)
804
+ if (list === undefined) index.set(key, [i])
805
+ else list.push(i)
806
+ }
807
+ for (let i = 0; i < tasks.length; i++) {
808
+ for (const tree of asTrees(globsOf(tasks[i]!) ?? [])) {
809
+ if (isLiteralPattern(tree)) {
810
+ literals.push([i, tree])
811
+ continue
480
812
  }
813
+ file(globs, tree, i)
814
+ const dir = wholeSubtreePrefixes([tree])?.[0]
815
+ if (dir !== undefined) file(subtrees, dir, i)
816
+ prefixes.push([i, staticPrefix(tree)])
817
+ const head = tree.slice(0, tree.search(GLOB_HEAD_END))
818
+ file(globsUnder, head.slice(0, Math.max(0, head.lastIndexOf('/'))), i)
481
819
  }
482
820
  }
483
- for (let i = 0; i < wsDeclarers.length; i++) {
484
- for (let j = i + 1; j < wsDeclarers.length; j++) {
485
- const a = wsDeclarers[i]!
486
- const b = wsDeclarers[j]!
487
- collide(
488
- a,
489
- b,
490
- a.config.cache?.outputs.workspaceFiles,
491
- b.config.cache?.outputs.workspaceFiles,
492
- 'workspaceFiles',
493
- )
821
+ const n = tasks.length
822
+ const pairs = new Set<number>()
823
+ const pair = (x: number, y: number): void => {
824
+ if (x !== y) pairs.add(x < y ? x * n + y : y * n + x)
825
+ }
826
+ for (const list of globs.values()) {
827
+ for (let x = 0; x < list.length; x++) {
828
+ for (let y = x + 1; y < list.length; y++) pair(list[x]!, list[y]!)
829
+ }
830
+ }
831
+ for (const [i, prefix] of prefixes) {
832
+ for (const j of subtrees.get(prefix) ?? []) pair(i, j)
833
+ for (let sep = prefix.indexOf('/'); sep !== -1; sep = prefix.indexOf('/', sep + 1)) {
834
+ for (const j of subtrees.get(prefix.slice(0, sep)) ?? []) pair(i, j)
494
835
  }
495
836
  }
837
+ for (const [i, path] of literals) {
838
+ for (const j of globsUnder.get('') ?? []) pair(i, j)
839
+ for (let sep = path.indexOf('/'); sep !== -1; sep = path.indexOf('/', sep + 1)) {
840
+ for (const j of globsUnder.get(path.slice(0, sep)) ?? []) pair(i, j)
841
+ }
842
+ }
843
+ return [...pairs].sort((x, y) => x - y).map((p) => [Math.floor(p / n), p % n])
496
844
  }
497
845
 
498
846
  /**
@@ -521,11 +869,27 @@ function collide(
521
869
  aGlobs: readonly string[] | undefined,
522
870
  bGlobs: readonly string[] | undefined,
523
871
  field: 'files' | 'workspaceFiles',
872
+ reaches: (from: string, to: string) => boolean,
524
873
  ): void {
525
874
  if (neverWritesLocally(a) || neverWritesLocally(b)) return
526
875
  for (const ga of aGlobs ?? []) {
527
876
  for (const gb of bGlobs ?? []) {
528
877
  if (!outputsOverlap(ga, gb)) continue
878
+ // An overlap WITH an edge is the addition shape (item 588): the
879
+ // dependant runs after its upstream and adds to that tree, so the
880
+ // order is fixed and the dependant's own set can be told apart from
881
+ // what it found. Marked on both, and the pair is allowed. Without an
882
+ // edge the two run in either order, and the refusal below stands.
883
+ const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
884
+ if (up !== undefined && down !== undefined) {
885
+ const downGlobs =
886
+ field === 'files'
887
+ ? down.config.cache?.outputs.files
888
+ : down.config.cache?.outputs.workspaceFiles
889
+ ;(down.addsToOutputsOf ??= []).push(up.id)
890
+ ;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
891
+ return
892
+ }
529
893
  throw new UserError(
530
894
  `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
531
895
  (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
@@ -537,7 +901,7 @@ function collide(
537
901
  }
538
902
  }
539
903
 
540
- export function detectCycle(nodes: Map<string, TaskNode>): void {
904
+ function detectCycle(nodes: Map<string, TaskNode>): void {
541
905
  // Iterative DFS over dense-indexed colors. Recursion + `Map<string,
542
906
  // number>` worked, but deep workspaces (long chains of `dependsOn`)
543
907
  // can blow V8's frame budget, and per-node Map lookups dominate the