@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- 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:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 (
|
|
29
|
-
|
|
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
|
|
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
|
|
8
|
+
/** All transitive workspace dependents (packages a change here can reach) — peers included. */
|
|
9
9
|
transitiveDependents: (name: string) => string[]
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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)
|
|
62
|
+
if (!obj) return
|
|
27
63
|
for (const name of Object.keys(obj)) {
|
|
28
|
-
if (name
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
stack.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
for (const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
218
|
+
transitiveDeps: makeAccessor(reachDeps),
|
|
146
219
|
transitiveDependents: makeAccessor(directDependents),
|
|
147
220
|
}
|
|
148
221
|
}
|