@vzn/vx 0.0.18 → 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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,293 @@
1
+ // What a sandboxed task was denied, as a report: the Linux strace pass
2
+ // (paired by pid, because a forking task interleaves its lines), the
3
+ // seatbelt record description, and the filters that keep only what the
4
+ // task's owner can act on — inside the project, minus the loopback denial
5
+ // no grant can avoid, minus what the task chose to ignore.
6
+
7
+ import path from 'node:path'
8
+ import { absolutize, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
9
+ import type {
10
+ ResolvedSandboxConfig,
11
+ SandboxedRunArgs,
12
+ SandboxViolation,
13
+ } from './sandbox-runtime.js'
14
+
15
+ /**
16
+ * Parse a strace log for denied filesystem syscalls and convert each
17
+ * one inside the workspace deny anchor (and not in allowRead) into a
18
+ * SandboxViolation. Dedups by (syscall, abs-path) so a tool that
19
+ * statx's the same missing path 10 times in a row produces one line.
20
+ *
21
+ * strace line shape (with -f):
22
+ * <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
23
+ * <pid> access("<path>", <mode>) = -1 EACCES (...)
24
+ * <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
25
+ *
26
+ * …but ONLY when the syscall completes without another traced process
27
+ * interleaving. Under `-f` strace splits an interrupted call across two
28
+ * lines and the result never appears next to the path:
29
+ * <pid> openat(AT_FDCWD, "<path>", <flags> <unfinished ...>
30
+ * <pid> <... openat resumed>) = -1 ENOENT (...)
31
+ * A single-line regex silently drops every one of those, so a task that
32
+ * forks concurrent children reading undeclared files reported an
33
+ * INCOMPLETE violation list — a sandboxed task that tripped would look
34
+ * clean. We pair them by pid instead (a process has at most
35
+ * one syscall in flight, so the pid is a sufficient key).
36
+ *
37
+ * We capture the first quoted-string argument as the path. paths that
38
+ * are relative resolve against the task's cwd (set by Bun.spawn).
39
+ */
40
+ const SYSCALLS = 'openat|access|statx|newfstatat'
41
+ const STRACE_DONE_RE = new RegExp(
42
+ `^(\\d+)\\s+(${SYSCALLS})\\([^"]*"([^"]+)"[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
43
+ )
44
+ const STRACE_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+(${SYSCALLS})\\([^"]*"([^"]+)"[^)]*<unfinished`)
45
+ const STRACE_RESUMED_RE = new RegExp(
46
+ `^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
47
+ )
48
+ /** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
49
+ const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
50
+
51
+ /** One denied syscall, however strace chose to lay it out. */
52
+ export interface DeniedCall {
53
+ syscall: string
54
+ rawPath: string
55
+ errno: string
56
+ }
57
+
58
+ /**
59
+ * Walk the trace, pairing `<unfinished ...>` with its `<... resumed>` line.
60
+ *
61
+ * Exported for testing: this is the security-relevant half of the Linux
62
+ * detector, and a synthetic trace pins the split-line shapes deterministically
63
+ * where an end-to-end run only produces them when strace happens to interleave.
64
+ */
65
+ export function deniedCalls(text: string): DeniedCall[] {
66
+ const pending = new Map<string, { syscall: string; rawPath: string }>()
67
+ const out: DeniedCall[] = []
68
+ for (const line of text.split('\n')) {
69
+ const done = STRACE_DONE_RE.exec(line)
70
+ if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
71
+ out.push({ syscall: done[2], rawPath: done[3], errno: done[4] })
72
+ continue
73
+ }
74
+ const unfinished = STRACE_UNFINISHED_RE.exec(line)
75
+ if (
76
+ unfinished?.[1] !== undefined &&
77
+ unfinished[2] !== undefined &&
78
+ unfinished[3] !== undefined
79
+ ) {
80
+ pending.set(unfinished[1], { syscall: unfinished[2], rawPath: unfinished[3] })
81
+ continue
82
+ }
83
+ const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
84
+ if (resumedOk?.[1] === undefined) continue
85
+ const held = pending.get(resumedOk[1])
86
+ pending.delete(resumedOk[1])
87
+ const resumed = STRACE_RESUMED_RE.exec(line)
88
+ // Only a resume that carries a DENIAL is a violation; a successful
89
+ // resume just retires the pending entry.
90
+ if (held !== undefined && resumed?.[3] !== undefined) {
91
+ out.push({ syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
92
+ }
93
+ }
94
+ return out
95
+ }
96
+
97
+ export async function parseStraceViolations(
98
+ logPath: string,
99
+ args: SandboxedRunArgs,
100
+ baselines: { allowRead: readonly string[]; denyRead: readonly string[]; cwd: string },
101
+ ): Promise<SandboxViolation[]> {
102
+ const text = await Bun.file(logPath).text()
103
+ if (text.length === 0) return []
104
+
105
+ // Treat every baseAllow + sandbox.allowRead path as "this was
106
+ // explicitly permitted; any -ENOENT here is the user's own missing
107
+ // file, not a sandbox-induced denial". Same for absolute denyRead
108
+ // checks below. Canonical on BOTH sides — the policy is expressed in
109
+ // real paths (see `canonicalBaselines`), so comparing a link-path here
110
+ // would report an explicitly-allowed read as a violation.
111
+ const allowAbs = new Set<string>(
112
+ [...baselines.allowRead, ...args.config.allowRead].map((p) => toRealPath(absolutize(p))),
113
+ )
114
+ const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
115
+
116
+ const seen = new Set<string>()
117
+ const out: SandboxViolation[] = []
118
+ for (const { syscall, rawPath, errno } of deniedCalls(text)) {
119
+ const abs = toRealPath(absolutize(rawPath, baselines.cwd))
120
+ // Only report paths under the workspace-root deny anchor — system
121
+ // libs / /proc / /sys / etc. probes are not interesting violations.
122
+ if (!denyAnchors.some((root) => abs === root || abs.startsWith(root + path.sep))) continue
123
+ // Skip paths the user explicitly allowed (and their descendants).
124
+ if (isUnderAny(abs, allowAbs)) continue
125
+ const key = `${syscall}|${abs}`
126
+ if (seen.has(key)) continue
127
+ seen.add(key)
128
+ out.push({
129
+ line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
130
+ timestamp: new Date(),
131
+ target: abs,
132
+ path: abs,
133
+ // The trace is `-e trace=openat`, and an openat is a read or a
134
+ // write depending on flags the trace does not carry — so either
135
+ // list can silence it.
136
+ ignorable: ['read', 'write'],
137
+ })
138
+ }
139
+ return out
140
+ }
141
+
142
+ /**
143
+ * Does a violation line match something the task said to ignore?
144
+ *
145
+ * The line names an operation and a target — `deny(1) file-write-create
146
+ * /path/x`, `deny(1) system-info vfs.disk-space` — so the operation picks
147
+ * the list and the target is matched against its patterns. Anything
148
+ * unparseable is NOT ignored: a record we cannot classify is exactly the
149
+ * one worth seeing.
150
+ */
151
+ function matchesIgnore(
152
+ v: SandboxViolation,
153
+ ignore: NonNullable<ResolvedSandboxConfig['ignore']>,
154
+ ): boolean {
155
+ if (v.target === undefined || v.ignorable === undefined) return false
156
+ for (const which of v.ignorable) {
157
+ const patterns = ignore[which]
158
+ if (patterns === undefined) continue
159
+ if (patterns.some((pat) => pat === v.target || new Bun.Glob(pat).match(v.target!))) return true
160
+ }
161
+ return false
162
+ }
163
+
164
+ /**
165
+ * Split a seatbelt record into the pieces the filters need. A record with
166
+ * no path (a `system-info` probe) keeps its target — it is not a boundary
167
+ * crossing, and the task can grant it.
168
+ */
169
+ function describeMacViolation(line: string): Partial<SandboxViolation> {
170
+ const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
171
+ if (m === null) return {}
172
+ const [op, target] = [m[1]!, m[2]!]
173
+ const which = op.startsWith('file-read')
174
+ ? 'read'
175
+ : op.startsWith('file-write')
176
+ ? 'write'
177
+ : op === 'system-info' || op === 'sysctl-read'
178
+ ? 'systemInfo'
179
+ : op.startsWith('network')
180
+ ? 'network'
181
+ : undefined
182
+ return {
183
+ target,
184
+ ...(target.startsWith('/') ? { path: toRealPath(target) } : {}),
185
+ ...(which !== undefined ? { ignorable: [which] } : {}),
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The violations a task's report should carry: inside the project or
191
+ * under a withheld dependency (`linked`), minus the loopback denial no
192
+ * config can avoid, minus what the task chose to ignore. Exported so a
193
+ * test can drive it with either platform's line shape without needing
194
+ * that platform.
195
+ */
196
+ export function reportableViolations(
197
+ violations: readonly SandboxViolation[],
198
+ opts: { within: string; linked?: readonly string[]; config: ResolvedSandboxConfig },
199
+ ): SandboxViolation[] {
200
+ // A record the producer did not describe is a seatbelt one, straight
201
+ // from SRT's store — parse it here so the filters below never see a
202
+ // platform's line format.
203
+ const described = violations.map((v) =>
204
+ v.target === undefined ? { ...v, ...describeMacViolation(v.line) } : v,
205
+ )
206
+ return filterIgnored(
207
+ loopbackNoise(withinReported(described, opts.within, opts.linked ?? []), opts.config),
208
+ opts.config.ignore,
209
+ )
210
+ }
211
+
212
+ /**
213
+ * Drop the loopback denial no grant can avoid.
214
+ *
215
+ * A runtime that opens a dual-stack socket reaches 127.0.0.1 as
216
+ * ::ffff:127.0.0.1, and seatbelt's only host tokens are `localhost` and
217
+ * `*` — no rule vx or SRT can write names that form. The first connect is
218
+ * denied, the runtime retries on AF_INET and succeeds (measured
219
+ * 2026-09-05: `fetch` to its own `Bun.serve` port returns 200 with one
220
+ * `deny(1) network-outbound` logged). It happens for a task's own server
221
+ * under `localBinding`, and again for SRT's filtering proxy whenever the
222
+ * task declared any network at all. The record has no address and no
223
+ * config can silence it, so under either grant it is noise.
224
+ *
225
+ * It is not a hole for the traffic that matters: a connection that tried
226
+ * to leave the machine goes through that proxy, which reports it WITH its
227
+ * host and port — a line this keeps.
228
+ */
229
+ function loopbackNoise(
230
+ violations: SandboxViolation[],
231
+ config: ResolvedSandboxConfig,
232
+ ): SandboxViolation[] {
233
+ const loopbackGranted = localBindingOn(config) || config.network !== undefined
234
+ if (!loopbackGranted) return violations
235
+ return violations.filter((v) => !/deny\(\d+\)\s+network-outbound\s*$/.test(v.line))
236
+ }
237
+
238
+ /**
239
+ * Keep only denials on a path inside `within`, or inside one of `linked`.
240
+ *
241
+ * A task may not leave its project — that is enforced by the deny anchor at
242
+ * the workspace root — but being STOPPED at the wall is the sandbox
243
+ * working, not a finding. Every process walks from `/` down to its own cwd
244
+ * (`bun build --compile` lists each directory on the way; traced
245
+ * 2026-09-05), and no configuration can declare that away.
246
+ *
247
+ * What is worth reporting is an undeclared touch of the project's OWN
248
+ * files: the cache key folds this project's inputs, so that is the read
249
+ * that makes a cached artifact wrong. A record with no path at all — a
250
+ * `system-info` probe — is kept, since it is not a boundary crossing and
251
+ * the task can grant it.
252
+ *
253
+ * `linked` are the workspace packages core withheld from a cached task's
254
+ * `node_modules` grant because its key does not answer for them. A denial
255
+ * there is the task reaching for a dependency through its own link — the
256
+ * read that would have been a stale hit, a finding and not the wall.
257
+ * Canonical already: sandbox-request.ts takes them from link targets.
258
+ */
259
+ function withinReported(
260
+ violations: SandboxViolation[],
261
+ within: string,
262
+ linked: readonly string[],
263
+ ): SandboxViolation[] {
264
+ const roots = [toRealPath(within), ...linked]
265
+ // `root === '/'` would otherwise compare against `'//'` and drop
266
+ // everything — the one prefix that needs no separator appended.
267
+ const prefixes = roots.map((root) => (root.endsWith(path.sep) ? root : root + path.sep))
268
+ return violations.filter(
269
+ (v) =>
270
+ v.path === undefined ||
271
+ roots.some((root, i) => v.path === root || v.path!.startsWith(prefixes[i]!)),
272
+ )
273
+ }
274
+
275
+ /**
276
+ * Apply the task's user-provided `sandbox.ignore` grants on top
277
+ * of whatever the macOS log monitor + Linux strace pass produced.
278
+ * Mirrors SRT's own substring-match semantics:
279
+ * - `'*'` entries match every command
280
+ * - other keys match commands whose userCommand string CONTAINS the key
281
+ * - the array of strings under each key is substring-matched against
282
+ * the violation line
283
+ *
284
+ * The defaults installed in `initSandbox` already filter on the macOS
285
+ * side; this pass catches per-task additions + Linux strace results.
286
+ */
287
+ function filterIgnored(
288
+ violations: SandboxViolation[],
289
+ ignore: ResolvedSandboxConfig['ignore'],
290
+ ): SandboxViolation[] {
291
+ if (ignore === undefined) return violations
292
+ return violations.filter((v) => !matchesIgnore(v, ignore))
293
+ }
@@ -18,20 +18,6 @@ export class DependencySpecError extends Error {
18
18
  }
19
19
  }
20
20
 
21
- /**
22
- * Parse one `dependsOn` / `cache.inputs.tasks` entry.
23
- *
24
- * `name` → self.name
25
- * `^name` → every dep workspace's `name` task
26
- * `pkg#name` → specific package's `name` task (cross-edge)
27
- * `*` → all same-project upstream (filter-only)
28
- * `^*` → all dep upstream (filter-only)
29
- * `!<form>` → negation of any of the above (filter-only)
30
- *
31
- * Wildcards and negation are accepted everywhere — callers (the graph
32
- * builder vs the filter) decide which ones make semantic sense and
33
- * surface their own validation errors. This keeps the parser pure.
34
- */
35
21
  /** True when a task form is a `*` pattern (`build.*`) rather than an exact name. */
36
22
  export function isTaskPattern(task: string): boolean {
37
23
  return task.includes('*')
@@ -48,6 +34,20 @@ export function compileTaskPattern(pattern: string): RegExp {
48
34
  return new RegExp(`^${escaped}$`)
49
35
  }
50
36
 
37
+ /**
38
+ * Parse one `dependsOn` / `cache.inputs.tasks` entry.
39
+ *
40
+ * `name` → self.name
41
+ * `^name` → every dep workspace's `name` task
42
+ * `pkg#name` → specific package's `name` task (cross-edge)
43
+ * `*` → all same-project upstream (filter-only)
44
+ * `^*` → all dep upstream (filter-only)
45
+ * `!<form>` → negation of any of the above (filter-only)
46
+ *
47
+ * Wildcards and negation are accepted everywhere — callers (the graph
48
+ * builder vs the filter) decide which ones make semantic sense and
49
+ * surface their own validation errors. This keeps the parser pure.
50
+ */
51
51
  export function parseDependencySpec(raw: string): DependencySpec {
52
52
  if (raw.length === 0) throw new DependencySpecError(raw, 'empty spec')
53
53
 
@@ -10,20 +10,22 @@ export {
10
10
  } from './dependency-spec.js'
11
11
  export {
12
12
  type ContinueMode,
13
- type OutputFingerprint,
14
- type ResourceCost,
13
+ RestoreDemoted,
15
14
  runGraph,
16
15
  type TaskOutcome,
17
16
  type TaskStatus,
18
- type VerifyVerdict,
19
17
  } from './scheduler.js'
20
18
  export {
21
19
  buildTaskGraph,
22
- detectCycle,
20
+ checkGraph,
21
+ declaredTask,
22
+ excludeDependencies,
23
23
  expandRequested,
24
24
  isGroupTask,
25
25
  markSurfacedDeps,
26
+ outputsOverlap,
26
27
  splitTaskId,
27
28
  type TaskNode,
29
+ undeclaredDepsError,
28
30
  unresolvedRequests,
29
31
  } from './task-graph.js'
@@ -0,0 +1,130 @@
1
+ // The ready-queue ranking, apart from the scheduler that uses it. Nothing
2
+ // here touches the platform: the Learn page's scheduler simulator
3
+ // (packages/vx-docs, through packages/vx-bench/schedule-policy.ts) bundles
4
+ // these two functions for the browser, and an import of the scheduler would
5
+ // bring `util/` and its load-time `Bun` reads with them.
6
+
7
+ import type { TaskNode } from './task-graph.js'
8
+
9
+ /**
10
+ * `computeReverseDepCount` for a run with a restore tier (item 754). A
11
+ * restore-tier task is a confirmed hit that never waits on its deps, so it
12
+ * blocks only the exec-tier tasks that depend on it: an exec-tier task's
13
+ * count is taken over the exec tier alone, which is exact, and a restore's
14
+ * is the sum over its direct exec-tier dependents of one plus theirs, so a
15
+ * restore that feeds pending work still goes first. A warm run's exec tier
16
+ * is its group tasks with no edges among them, and the closure over the
17
+ * whole graph (476 packages: 1,428 nodes, 12.8k edges of 45-word bitsets)
18
+ * was 6 ms of the run-graph stage for a ranking the restores never needed.
19
+ */
20
+ export function tieredReverseDepCount(
21
+ nodes: Map<string, TaskNode>,
22
+ restoreTier: ReadonlySet<string>,
23
+ ): Map<string, number> {
24
+ const exec = new Map<string, TaskNode>()
25
+ for (const [id, node] of nodes) if (!restoreTier.has(id)) exec.set(id, node)
26
+ const counts = computeReverseDepCount(exec)
27
+ for (const node of exec.values()) {
28
+ const weight = 1 + counts.get(node.id)!
29
+ for (const dep of node.deps) {
30
+ if (restoreTier.has(dep) && nodes.has(dep)) counts.set(dep, (counts.get(dep) ?? 0) + weight)
31
+ }
32
+ }
33
+ for (const id of restoreTier) if (nodes.has(id) && !counts.has(id)) counts.set(id, 0)
34
+ return counts
35
+ }
36
+
37
+ /**
38
+ * Compute, for each task in the graph, how many OTHER tasks are
39
+ * transitively blocked on it. Tasks with the highest count are the
40
+ * most valuable to schedule first — finishing them unlocks the most
41
+ * downstream work and minimizes worker idle time at the end of the
42
+ * run. Nx's schedule sort ranks by the same idea but counts DIRECT
43
+ * dependents only (`calculateReverseDeps` in
44
+ * `packages/nx/src/tasks-runner/utils.ts`, read 2026-09-24).
45
+ */
46
+ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
47
+ // Exact transitive-dependent COUNTS need closure SETS (diamonds
48
+ // double-count under naive summing). Set-of-strings closures are
49
+ // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
50
+ // bitsets make the same closure O(E·N/32) time and N²/8 bits of
51
+ // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
52
+ const ids = [...nodes.keys()]
53
+ const index = new Map<string, number>()
54
+ for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
55
+ const n = ids.length
56
+ const words = (n + 31) >>> 5
57
+
58
+ // Direct dependents as index lists + in-degree for the topo pass.
59
+ const directReverse: number[][] = Array.from({ length: n }, () => [])
60
+ const indegree = new Uint32Array(n)
61
+ for (const node of nodes.values()) {
62
+ const ni = index.get(node.id)!
63
+ for (const dep of node.deps) {
64
+ const di = index.get(dep)
65
+ if (di === undefined) continue
66
+ directReverse[di]!.push(ni)
67
+ indegree[ni]!++
68
+ }
69
+ }
70
+
71
+ // Kahn topo order over dependency edges (deps before dependents).
72
+ // Insertion order is topo today, but the closure's correctness
73
+ // must not hinge on an unstated property of buildTaskGraph.
74
+ const topo = new Int32Array(n)
75
+ let head = 0
76
+ let tail = 0
77
+ for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
78
+ while (head < tail) {
79
+ const v = topo[head++]!
80
+ for (const r of directReverse[v]!) {
81
+ if (--indegree[r]! === 0) topo[tail++] = r
82
+ }
83
+ }
84
+
85
+ // Reverse-topo sweep: every direct dependent's closure is final
86
+ // before its dependency folds it in. closure[i] = bitset over node
87
+ // indices of i's transitive dependents.
88
+ const closure = new Uint32Array(n * words)
89
+ const counts = new Map<string, number>()
90
+ for (let t = tail - 1; t >= 0; t--) {
91
+ const i = topo[t]!
92
+ const base = i * words
93
+ for (const r of directReverse[i]!) {
94
+ closure[base + (r >>> 5)]! |= 1 << (r & 31)
95
+ const rbase = r * words
96
+ for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
97
+ }
98
+ let count = 0
99
+ for (let w = 0; w < words; w++) {
100
+ let v = closure[base + w]!
101
+ v = v - ((v >>> 1) & 0x55555555)
102
+ v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
103
+ count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
104
+ }
105
+ counts.set(ids[i]!, count)
106
+ }
107
+ // Cycle-stranded nodes (never topo-visited) can't occur — the graph
108
+ // builder rejects cycles — but a missing Map entry would silently
109
+ // sort as undefined, so default them defensively to 0.
110
+ for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
111
+ return counts
112
+ }
113
+
114
+ export function mergePriorities(
115
+ baseline: ReadonlyMap<string, number>,
116
+ overrides: ReadonlyMap<string, number>,
117
+ ): ReadonlyMap<string, number> {
118
+ // The override caller scored "expected critical-path duration" in ms.
119
+ // Baseline reverse-deps counts are bounded by N; scale the override so
120
+ // it sorts above the baseline for any node it covers, and add the
121
+ // baseline as a tie-break for parity within the override set.
122
+ const SCALE = 1 << 20
123
+ const out = new Map<string, number>()
124
+ for (const [id, w] of baseline) out.set(id, w)
125
+ for (const [id, w] of overrides) {
126
+ const b = baseline.get(id) ?? 0
127
+ out.set(id, w * SCALE + b)
128
+ }
129
+ return out
130
+ }