@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,314 @@
1
+ // The migration seam: what any adoption tool needs once it has a plan —
2
+ // the plan's shape, TS emission, the overwrite guard, the writes and the
3
+ // report. `vx init` (package.json scripts, in core) and `@vzn/vx-migrate`
4
+ // (Turbo, Nx) share it, so a generated config reads the same whichever
5
+ // tool wrote it. Core knows no source format here: a mapper returns a
6
+ // `MigrationPlan` and this file does the rest.
7
+
8
+ import path from 'node:path'
9
+ import { relPosix, UserError } from '../util/index.js'
10
+ import type { ProjectMeta } from './workspace.js'
11
+
12
+ /**
13
+ * Escape an arbitrary string into a single-quoted TS literal. Escapes
14
+ * backslash + quote AND raw newlines/CR — a value with an embedded newline
15
+ * (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
16
+ * otherwise splice into a single-quoted literal as an unterminated / malformed
17
+ * string that fails to load (generated files must round-trip through the
18
+ * loader).
19
+ */
20
+ export function quoteTsLiteral(s: string): string {
21
+ return `'${s
22
+ .replaceAll('\\', '\\\\')
23
+ .replaceAll("'", "\\'")
24
+ .replaceAll('\n', '\\n')
25
+ .replaceAll('\r', '\\r')}'`
26
+ }
27
+
28
+ /**
29
+ * Task names that conventionally never exit. Every mapper guesses the same
30
+ * way from a name — a source that SAYS a task is long-running (turbo's
31
+ * `persistent: true`, an Nx dev-server executor) is believed instead.
32
+ */
33
+ export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
34
+ 'dev',
35
+ 'start',
36
+ 'serve',
37
+ 'watch',
38
+ 'preview',
39
+ ])
40
+
41
+ /** The one wording every mapper emits for a task it made persistent. */
42
+ export const PERSISTENT_TODO =
43
+ 'persistent task — set persistent.readyWhen (regex matched against output) so ' +
44
+ 'dependents unblock on readiness, and consider exec.timeout to bound the wait'
45
+
46
+ /** Verbatim TS expression spliced into a generated array (preset spreads). */
47
+ export interface RawExpr {
48
+ readonly raw: string
49
+ }
50
+
51
+ export interface GeneratedTask {
52
+ name: string
53
+ /** Rendered as `// TODO(vx-migrate): …` above the task + listed in the report. */
54
+ todos: string[]
55
+ /** TaskConfig-shaped object; arrays may contain RawExpr splices.
56
+ * null = target has no vx representation (skipped; todos explain). */
57
+ task: Record<string, unknown> | null
58
+ }
59
+
60
+ export interface GeneratedProject {
61
+ /** package.json name */
62
+ name: string
63
+ /** absolute project dir */
64
+ dir: string
65
+ importLines: string[]
66
+ tasks: GeneratedTask[]
67
+ }
68
+
69
+ export interface MigrationPlan {
70
+ /** report lines printed right under the source line */
71
+ headerNotes: string[]
72
+ projects: GeneratedProject[]
73
+ /** extra root-relative files (e.g. the preset) */
74
+ extraFiles: { relPath: string; contents: string }[]
75
+ /** trailing report lines (e.g. implicit Nx deps) */
76
+ notes: string[]
77
+ }
78
+
79
+ export interface ApplyMigrationArgs {
80
+ root: string
81
+ metas: readonly ProjectMeta[]
82
+ plan: MigrationPlan
83
+ /** Named in the report and the generated header, e.g. `turbo.json`. */
84
+ source: string
85
+ /** The command the user typed, e.g. `vx init` — named in the report. */
86
+ verb: string
87
+ dry: boolean
88
+ force: boolean
89
+ /**
90
+ * `vx init` on a workspace with no scripts still has a job (the workspace
91
+ * file, and a worked example); a migration with nothing to convert is an
92
+ * error. Default false.
93
+ */
94
+ init?: boolean
95
+ /** Report lines printed under the source line (e.g. "turbo.json found and not read"). */
96
+ notes?: readonly string[]
97
+ /**
98
+ * `ts` (default) writes `vx.config.ts` with the type-only import and
99
+ * `satisfies`; `mjs` writes `vx.config.mjs` — the same object, untyped —
100
+ * for a package whose own `tsc --build` includes every `.ts` under it
101
+ * and compiled the config into its dist (TanStack/query, 2026-09-11).
102
+ */
103
+ format?: MigrationFormat
104
+ }
105
+
106
+ export type MigrationFormat = 'ts' | 'mjs'
107
+
108
+ /**
109
+ * Render a plan to files, refuse to overwrite without `force`, write (or
110
+ * print, under `dry`), and report. Returns the process exit code.
111
+ */
112
+ export async function applyMigration(args: ApplyMigrationArgs): Promise<number> {
113
+ const { root, metas, plan, source, verb, dry, force } = args
114
+ const init = args.init === true
115
+ const format: MigrationFormat = args.format ?? 'ts'
116
+ const configName = `vx.config.${format}`
117
+ const workspaceName = `vx.workspace.${format}`
118
+ const empty = plan.projects.length === 0
119
+ if (empty && !init) {
120
+ throw new UserError(
121
+ `nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
122
+ )
123
+ }
124
+
125
+ const files: { relPath: string; abs: string; contents: string }[] = []
126
+ for (const p of plan.projects) {
127
+ if (p.tasks.length === 0) continue
128
+ const abs = path.join(p.dir, configName)
129
+ files.push({
130
+ relPath: relPosix(root, abs),
131
+ abs,
132
+ contents: renderConfigFile(source, p, verb, format),
133
+ })
134
+ }
135
+ for (const f of plan.extraFiles) {
136
+ files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
137
+ }
138
+ // A migrated workspace must declare its executor and cache — nothing is
139
+ // applied by default. Emit the workspace file unless the repo already has
140
+ // one in any supported extension.
141
+ const hasWorkspaceFile = (
142
+ await Promise.all(
143
+ ['vx.workspace.ts', 'vx.workspace.mjs', 'vx.workspace.js'].map((n) =>
144
+ Bun.file(path.join(root, n)).exists(),
145
+ ),
146
+ )
147
+ ).some(Boolean)
148
+ if (!hasWorkspaceFile) {
149
+ const abs = path.join(root, workspaceName)
150
+ files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
151
+ }
152
+
153
+ if (!dry && !force) {
154
+ const conflicts = new Set<string>()
155
+ // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
156
+ // so we never shadow a hand-written config with a fresh .ts.
157
+ for (const p of plan.projects) {
158
+ if (p.tasks.length === 0) continue
159
+ const meta = metas.find((m) => m.dir === p.dir)
160
+ if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
161
+ }
162
+ // ALSO stat every actual write target. A SYNTHESIZED project (e.g. the Nx
163
+ // workspace-root node, dir === root) has no discovered meta, so the meta
164
+ // scan alone would miss an existing vx.config.ts at that path and clobber
165
+ // it. This also covers the extraFiles (vx-preset.ts).
166
+ for (const f of files) {
167
+ if (await Bun.file(f.abs).exists()) conflicts.add(f.relPath)
168
+ }
169
+ if (conflicts.size > 0) {
170
+ throw new UserError(
171
+ 'refusing to overwrite existing files (pass --force to overwrite):\n' +
172
+ ` ${[...conflicts].join('\n ')}`,
173
+ )
174
+ }
175
+ }
176
+
177
+ if (dry) {
178
+ for (const f of files) {
179
+ process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
180
+ }
181
+ } else {
182
+ for (const f of files) await Bun.write(f.abs, f.contents)
183
+ }
184
+
185
+ const todoList: string[] = []
186
+ let clean = 0
187
+ for (const p of plan.projects) {
188
+ for (const t of p.tasks) {
189
+ if (t.todos.length === 0 && t.task !== null) clean++
190
+ for (const reason of t.todos) todoList.push(`${p.name}#${t.name}: ${reason}`)
191
+ }
192
+ }
193
+ const report: string[] = []
194
+ if (empty) {
195
+ report.push(
196
+ `${verb}: no package.json scripts to turn into tasks.`,
197
+ hasWorkspaceFile
198
+ ? `${workspaceName} already exists.`
199
+ : dry
200
+ ? `would write ${workspaceName} (dry run, nothing written).`
201
+ : `wrote ${workspaceName}.`,
202
+ `Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
203
+ '',
204
+ ...exampleConfig(format)
205
+ .trimEnd()
206
+ .split('\n')
207
+ .map((l) => ` ${l}`),
208
+ )
209
+ } else {
210
+ report.push(`${verb}: ${source} → ${configName}`)
211
+ for (const n of args.notes ?? []) report.push(`note: ${n}`)
212
+ for (const n of plan.headerNotes) report.push(`note: ${n}`)
213
+ report.push(
214
+ '',
215
+ `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
216
+ `${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
217
+ )
218
+ for (const line of todoList) report.push(` ${line}`)
219
+ report.push(...plan.notes)
220
+ report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
221
+ for (const f of files) report.push(` ${f.relPath}`)
222
+ }
223
+ const firstTask =
224
+ plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
225
+ plan.projects[0]?.tasks[0]?.name ??
226
+ 'build'
227
+ report.push('', `next: vx run ${firstTask} --all`)
228
+ process.stdout.write(`${report.join('\n')}\n`)
229
+ return 0
230
+ }
231
+
232
+ const EXAMPLE_BODY = `export default {
233
+ tasks: {
234
+ build: {
235
+ exec: { command: 'tsc -b' },
236
+ dependsOn: ['^build'],
237
+ cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
238
+ },
239
+ },
240
+ }`
241
+
242
+ function exampleConfig(format: MigrationFormat): string {
243
+ return format === 'ts'
244
+ ? `import type { ProjectConfig } from '@vzn/vx'\n\n${EXAMPLE_BODY} satisfies ProjectConfig\n`
245
+ : `${EXAMPLE_BODY}\n`
246
+ }
247
+
248
+ // ─── TS emission ──────────────────────────────────────────────────────
249
+
250
+ // Type-only import + `satisfies`, like the project configs: a runtime
251
+ // `import { defineWorkspace } from '@vzn/vx'` loads a SECOND copy of core
252
+ // into every run — measured 2026-09-09 at ~17 ms on a two-package
253
+ // workspace, a fifth of the whole run — for an identity function.
254
+ const WORKSPACE_BODY = `// Plugins are consulted in this order; running here and caching in
255
+ // .vx/cache are the floor under all of them, so an empty list is a
256
+ // complete workspace. Add a remote cache, a remote executor or telemetry
257
+ // as one entry each — those imports are the runtime ones.
258
+ export default { plugins: [] }`
259
+
260
+ function workspaceFile(format: MigrationFormat): string {
261
+ return format === 'ts'
262
+ ? `import type { WorkspaceConfig } from '@vzn/vx'\n\n${WORKSPACE_BODY} satisfies WorkspaceConfig\n`
263
+ : `${WORKSPACE_BODY}\n`
264
+ }
265
+
266
+ const IDENT = /^[A-Za-z_$][\w$]*$/
267
+
268
+ function isRawExpr(v: unknown): v is RawExpr {
269
+ return typeof v === 'object' && v !== null && typeof (v as RawExpr).raw === 'string'
270
+ }
271
+
272
+ function renderValue(v: unknown, indent: string): string {
273
+ if (isRawExpr(v)) return v.raw
274
+ // `Object.entries(null)` throws, which would abort the whole migration
275
+ // with a raw stack mid-write. Mappers no longer produce a null, but a
276
+ // literal is a readable thing to leave behind if one ever does.
277
+ if (v === null) return 'null'
278
+ if (typeof v === 'string') return quoteTsLiteral(v)
279
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
280
+ if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
281
+ const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined)
282
+ if (entries.length === 0) return '{}'
283
+ const inner = `${indent} `
284
+ const body = entries.map(
285
+ ([k, x]) => `${inner}${IDENT.test(k) ? k : quoteTsLiteral(k)}: ${renderValue(x, inner)},`,
286
+ )
287
+ return `{\n${body.join('\n')}\n${indent}}`
288
+ }
289
+
290
+ function renderConfigFile(
291
+ source: string,
292
+ p: GeneratedProject,
293
+ verb: string,
294
+ format: MigrationFormat,
295
+ ): string {
296
+ // A type-only import: the editor type-checks against the installed
297
+ // package, and Bun erases it, so the config loads in a workspace that
298
+ // runs the vx binary without the package installed. `mjs` is the same
299
+ // object with nothing to erase.
300
+ const lines: string[] = [
301
+ `// Generated by \`${verb}\` from ${source}. Review the TODO(vx-migrate) comments.`,
302
+ ]
303
+ if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx'")
304
+ if (p.importLines.length > 0) lines.push(...p.importLines)
305
+ lines.push('', 'export default {', ' tasks: {')
306
+ for (const t of p.tasks) {
307
+ for (const todo of t.todos) lines.push(` // TODO(vx-migrate): ${todo}`)
308
+ if (t.task === null) continue // skipped target — the TODO above explains
309
+ const key = IDENT.test(t.name) ? t.name : quoteTsLiteral(t.name)
310
+ lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
311
+ }
312
+ lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
313
+ return lines.join('\n')
314
+ }
@@ -7,11 +7,18 @@ import type { ProjectEntry } from './workspace.js'
7
7
  * project's task cannot see files inside another project, even if its
8
8
  * globs would otherwise match.
9
9
  *
10
- * Sort once, then scan: nested projects of `p` form a contiguous run
11
- * immediately after `p` in the sorted order (any non-descendant breaks
12
- * the prefix-match). O(P log P) instead of the naive O(P²) cross
13
- * product. Stable for any project set with no dir collisions, which
14
- * the workspace loader already enforces.
10
+ * Sort once, then scan forward: every dir sharing `p.dir` as a STRING
11
+ * prefix is contiguous in sorted order, and `p`'s nested descendants
12
+ * (`p.dir + sep + …`) are a sub-run within that block. But a SIBLING whose
13
+ * name extends `p.dir` by a char that sorts BELOW `sep` (`-`, `.`, `+`, a
14
+ * space; `/` is 0x2F) lands BETWEEN `p.dir` and `p.dir + sep` — so a plain
15
+ * `break` on the first non-descendant would stop before reaching the nested
16
+ * children (e.g. `foo`, `foo-utils`, `foo/nested` → `foo/nested` missed,
17
+ * silently breaking the project-boundary invariant). So: collect descendants,
18
+ * SKIP interlopers that merely share the `p.dir` string prefix, and break only
19
+ * once we leave the `p.dir` block entirely. The inner loop is still bounded by
20
+ * that block (near-O(P log P) on real trees; interlopers are few), not the
21
+ * full tail.
15
22
  */
16
23
  export function computeNestedProjectDirs(
17
24
  entries: Array<Pick<ProjectEntry, 'name' | 'dir'>>,
@@ -25,8 +32,15 @@ export function computeNestedProjectDirs(
25
32
  const nested: string[] = []
26
33
  for (let j = i + 1; j < sorted.length; j++) {
27
34
  const other = sorted[j]!
28
- if (!other.dir.startsWith(prefix)) break
29
- nested.push(other.dir)
35
+ if (other.dir.startsWith(prefix)) {
36
+ nested.push(other.dir)
37
+ } else if (!other.dir.startsWith(p.dir)) {
38
+ // Past every dir sharing `p.dir`'s string prefix — no descendant can
39
+ // follow (they'd sort inside this block), so stop.
40
+ break
41
+ }
42
+ // else: an interloping sibling (`foo-utils` for `foo`) that shares the
43
+ // string prefix but isn't nested — skip it, descendants may follow.
30
44
  }
31
45
  result.set(p.name, nested)
32
46
  }
@@ -1,40 +1,108 @@
1
1
  import type { ProjectMeta } from './workspace.js'
2
2
 
3
3
  export interface PackageGraph {
4
- /** Immediate workspace deps (sorted). */
4
+ /** Immediate workspace deps the package builds AFTER (sorted): every bucket but peers. */
5
5
  directDeps: (name: string) => string[]
6
- /** All transitive workspace deps for each project. */
6
+ /** All transitive workspace deps a change can reach the package through — peers included. */
7
7
  transitiveDeps: (name: string) => string[]
8
- /** All transitive workspace dependents (packages that depend on the named one). */
8
+ /** All transitive workspace dependents (packages a change here can reach) — peers included. */
9
9
  transitiveDependents: (name: string) => string[]
10
10
  }
11
11
 
12
- export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
12
+ /**
13
+ * `taskEdges`: project → the projects its tasks name in a cross-project
14
+ * `dependsOn` (`e2e` → `app` from `dependsOn: ['app#build']`). A dependent
15
+ * the manifest does not know but the task graph does; without it
16
+ * `--filter '...app'` never selected `e2e`, and a CI that runs "what
17
+ * changed and everything depending on it" silently left it out
18
+ * (2026-09-10). The `^task` walk still reads `directDeps`, which carries
19
+ * both — a task edge IS a dependency.
20
+ */
21
+ export function buildPackageGraph(
22
+ projects: ProjectMeta[],
23
+ taskEdges?: ReadonlyMap<string, readonly string[]>,
24
+ ): PackageGraph {
13
25
  const byName = new Map<string, ProjectMeta>()
14
26
  for (const p of projects) byName.set(p.name, p)
15
27
 
28
+ // Two adjacencies. ORDER (`directDeps`, the `^task` walk) is what the
29
+ // package imports at build time: dependencies, devDependencies,
30
+ // optionalDependencies, the task edges, and a workspace peer that does
31
+ // not close a cycle. REACH (the transitive closures `--filter pkg...`
32
+ // and `--affected` read) is ORDER plus every workspace peer.
33
+ //
34
+ // A peer on a workspace sibling is an import that resolves to that
35
+ // sibling's build: every package manager links or hoists it (pnpm's
36
+ // `linkWorkspacePackages`, the hoisted root of bun/npm/yarn), and
37
+ // TanStack/router's `router-devtools-core` peers on `router-core` and
38
+ // type-checks against its `dist` — Nx orders `^build` on the peer,
39
+ // vx built the devtools first and failed (2026-09-11). But peers are
40
+ // the one bucket that routinely cycles (medusa's test-utils peers on
41
+ // medusa, which dev-depends on it through analytics; Turbo, which
42
+ // reads no peers, runs it; an unconditional order edge made `^build`
43
+ // a task cycle, 2026-09-11), so a peer edge that would close a cycle
44
+ // through the order graph is reach only: the consumer above provides
45
+ // that peer. Peers are tried in (package, peer) name order, so which
46
+ // edge of a two-peer cycle stays is stable across runs.
16
47
  const directDeps = new Map<string, string[]>()
17
- for (const p of projects) {
18
- const seen = new Set<string>()
19
- for (const field of [
20
- 'dependencies',
21
- 'devDependencies',
22
- 'peerDependencies',
23
- 'optionalDependencies',
24
- ] as const) {
48
+ const reachDeps = new Map<string, string[]>()
49
+ const order = new Map<string, Set<string>>()
50
+ const peers = new Map<string, Set<string>>()
51
+ const sorted = [...projects].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
52
+ for (const p of sorted) {
53
+ const own = new Set<string>()
54
+ for (const name of taskEdges?.get(p.name) ?? []) {
55
+ if (name !== p.name && byName.has(name)) own.add(name)
56
+ }
57
+ const add = (
58
+ field: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies',
59
+ into: Set<string>,
60
+ ) => {
25
61
  const obj = p.packageJson[field]
26
- if (!obj) continue
62
+ if (!obj) return
27
63
  for (const name of Object.keys(obj)) {
28
- if (name === p.name) continue
29
- if (byName.has(name)) seen.add(name)
64
+ if (name !== p.name && byName.has(name)) into.add(name)
65
+ }
66
+ }
67
+ add('dependencies', own)
68
+ add('devDependencies', own)
69
+ add('optionalDependencies', own)
70
+ order.set(p.name, own)
71
+ const peer = new Set<string>()
72
+ add('peerDependencies', peer)
73
+ peers.set(p.name, peer)
74
+ }
75
+ // `to` reaches `from` through the order edges so far ⇒ from → to
76
+ // would close a cycle.
77
+ const reaches = (start: string, goal: string): boolean => {
78
+ const seen = new Set<string>([start])
79
+ const stack = [start]
80
+ while (stack.length > 0) {
81
+ for (const next of order.get(stack.pop()!) ?? []) {
82
+ if (next === goal) return true
83
+ if (!seen.has(next)) {
84
+ seen.add(next)
85
+ stack.push(next)
86
+ }
30
87
  }
31
88
  }
32
- directDeps.set(p.name, [...seen].sort())
89
+ return false
90
+ }
91
+ for (const p of sorted) {
92
+ const own = order.get(p.name)!
93
+ for (const peer of [...peers.get(p.name)!].sort()) {
94
+ if (!own.has(peer) && !reaches(peer, p.name)) own.add(peer)
95
+ }
96
+ }
97
+ for (const p of sorted) {
98
+ const own = order.get(p.name)!
99
+ directDeps.set(p.name, [...own].sort())
100
+ reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
33
101
  }
34
102
 
35
103
  // Reverse adjacency: who declares X as a workspace dep.
36
104
  const directDependents = new Map<string, string[]>()
37
- for (const [name, deps] of directDeps) {
105
+ for (const [name, deps] of reachDeps) {
38
106
  for (const d of deps) {
39
107
  const arr = directDependents.get(d)
40
108
  if (arr) arr.push(name)
@@ -51,7 +119,7 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
51
119
  // per-call sort. Package graphs (unlike task graphs) may legally
52
120
  // contain cycles; the bitset sweep requires a DAG, so a cycle
53
121
  // (detected by the Kahn pass not draining) falls back wholesale to
54
- // the legacy DFS — byte-identical behavior for that rare case.
122
+ // a per-query reachability search for that rare case.
55
123
  const names = [...byName.keys()].sort()
56
124
  const index = new Map<string, number>()
57
125
  for (let i = 0; i < names.length; i++) index.set(names[i]!, i)
@@ -92,37 +160,42 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
92
160
  return closure
93
161
  }
94
162
 
95
- function legacyTransitive(
96
- name: string,
97
- edges: Map<string, string[]>,
98
- cache: Map<string, string[]>,
99
- stack: Set<string> = new Set(),
100
- ): string[] {
101
- const cached = cache.get(name)
102
- if (cached) return cached
103
- if (stack.has(name)) return []
104
- stack.add(name)
105
- const out = new Set<string>()
106
- for (const d of edges.get(name) ?? []) {
107
- out.add(d)
108
- for (const t of legacyTransitive(d, edges, cache, stack)) out.add(t)
163
+ // Cyclic fallback: everything reachable from `name` via ≥1 edge (so a
164
+ // node inside a cycle includes itself). Deliberately a self-contained
165
+ // per-query search rather than a memoized recursion over sub-results:
166
+ // in a cycle the back-edge contributes nothing, so a shared recursion
167
+ // computes TRUNCATED closures for the nodes below it and caching those
168
+ // makes every later answer depend on which node was asked for first.
169
+ function reachableFrom(name: string, edges: Map<string, string[]>): string[] {
170
+ const seen = new Set<string>()
171
+ const stack = [...(edges.get(name) ?? [])]
172
+ while (stack.length > 0) {
173
+ const cur = stack.pop()!
174
+ if (seen.has(cur)) continue
175
+ seen.add(cur)
176
+ for (const d of edges.get(cur) ?? []) stack.push(d)
109
177
  }
110
- stack.delete(name)
111
- const result = [...out].sort()
112
- cache.set(name, result)
113
- return result
178
+ return [...seen].sort()
114
179
  }
115
180
 
181
+ // The closures are built on the FIRST query, not here: an unscoped run
182
+ // seeds every project and never asks for a transitive set, and neither
183
+ // does a `^task` walk (it reads `directDeps`). Building both closures
184
+ // eagerly was 12 ms of a 240 ms warm run at 1000 projects × 30 deps
185
+ // (profiled 2026-09-09) for answers nobody read.
116
186
  function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
117
- const closures = bitsetClosures(edges)
118
187
  const memo = new Map<string, string[]>()
119
- if (closures === null) {
120
- return (name) => legacyTransitive(name, edges, memo)
121
- }
188
+ let closures: Uint32Array | null | undefined
122
189
  const words = (names.length + 31) >>> 5
123
190
  return (name) => {
124
191
  const cached = memo.get(name)
125
192
  if (cached) return cached
193
+ if (closures === undefined) closures = bitsetClosures(edges)
194
+ if (closures === null) {
195
+ const result = reachableFrom(name, edges)
196
+ memo.set(name, result)
197
+ return result
198
+ }
126
199
  const i = index.get(name)
127
200
  if (i === undefined) return []
128
201
  const base = i * words
@@ -142,7 +215,7 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
142
215
 
143
216
  return {
144
217
  directDeps: (name) => directDeps.get(name) ?? [],
145
- transitiveDeps: makeAccessor(directDeps),
218
+ transitiveDeps: makeAccessor(reachDeps),
146
219
  transitiveDependents: makeAccessor(directDependents),
147
220
  }
148
221
  }