@vzn/vx 0.0.21 → 0.0.23
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.
- package/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import type { TaskConfig } from '../config.js'
|
|
2
|
-
import {
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
frame?.node.deps.push(id)
|
|
320
|
+
return true
|
|
244
321
|
}
|
|
245
322
|
|
|
246
323
|
const project = projects.get(projectName)
|
|
247
|
-
if (!project) return
|
|
248
|
-
const taskConfig = project.config
|
|
249
|
-
if (!taskConfig) return
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
398
|
-
|
|
399
|
-
|
|
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 —
|
|
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(
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|