@vzn/vx 0.0.18 → 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 +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- 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 +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- 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/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- 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 +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,42 +1,47 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { relPosix, UserError } from '../util/index.js'
|
|
10
|
-
import {
|
|
11
|
-
import { quote } from './migrate-emit.js'
|
|
12
|
-
import { migrateNx } from './migrate-nx.js'
|
|
13
|
-
import { migrateScripts } from './migrate-scripts.js'
|
|
14
|
-
import { migrateTurbo } from './migrate-turbo.js'
|
|
10
|
+
import type { ProjectMeta } from './workspace.js'
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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')}'`
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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'
|
|
40
45
|
|
|
41
46
|
/** Verbatim TS expression spliced into a generated array (preset spreads). */
|
|
42
47
|
export interface RawExpr {
|
|
@@ -71,65 +76,47 @@ export interface MigrationPlan {
|
|
|
71
76
|
notes: string[]
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
const graphRel = path.join('.nx', 'workspace-data', 'project-graph.json')
|
|
88
|
-
const hasGraph = await Bun.file(path.join(root, graphRel)).exists()
|
|
89
|
-
const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
|
|
106
|
+
export type MigrationFormat = 'ts' | 'mjs'
|
|
90
107
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let source: string
|
|
103
|
-
let plan: MigrationPlan
|
|
104
|
-
if (parsed.from === 'scripts') {
|
|
105
|
-
source = 'package.json scripts'
|
|
106
|
-
plan = migrateScripts(metas)
|
|
107
|
-
} else if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
|
|
108
|
-
if (hasGraph) {
|
|
109
|
-
source = '.nx/workspace-data/project-graph.json'
|
|
110
|
-
plan = await migrateNx(root, metas)
|
|
111
|
-
} else if (hasNxJson || parsed.from === 'nx') {
|
|
112
|
-
// Modern Nx stores the graph in SQLite — the JSON snapshot only
|
|
113
|
-
// exists when exported explicitly.
|
|
114
|
-
throw new UserError(
|
|
115
|
-
'no resolved Nx graph found — export one with ' +
|
|
116
|
-
'`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx migrate',
|
|
117
|
-
)
|
|
118
|
-
} else {
|
|
119
|
-
// A workspace from nowhere: the scripts are the source (`vx init`).
|
|
120
|
-
source = 'package.json scripts'
|
|
121
|
-
plan = migrateScripts(metas)
|
|
122
|
-
}
|
|
123
|
-
} else {
|
|
124
|
-
source = 'turbo.json'
|
|
125
|
-
plan = await migrateTurbo(root, metas)
|
|
126
|
-
}
|
|
127
|
-
// `vx init` on a workspace with no scripts still has a job: the
|
|
128
|
-
// workspace file, which every run needs, and a worked example of the
|
|
129
|
-
// config the user writes next. `vx migrate` with nothing to convert is
|
|
130
|
-
// an error: the user asked for a conversion that has no input.
|
|
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}`
|
|
131
118
|
const empty = plan.projects.length === 0
|
|
132
|
-
if (empty &&
|
|
119
|
+
if (empty && !init) {
|
|
133
120
|
throw new UserError(
|
|
134
121
|
`nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
|
|
135
122
|
)
|
|
@@ -138,8 +125,12 @@ export async function migrateCmd(
|
|
|
138
125
|
const files: { relPath: string; abs: string; contents: string }[] = []
|
|
139
126
|
for (const p of plan.projects) {
|
|
140
127
|
if (p.tasks.length === 0) continue
|
|
141
|
-
const abs = path.join(p.dir,
|
|
142
|
-
files.push({
|
|
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
|
+
})
|
|
143
134
|
}
|
|
144
135
|
for (const f of plan.extraFiles) {
|
|
145
136
|
files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
|
|
@@ -155,11 +146,11 @@ export async function migrateCmd(
|
|
|
155
146
|
)
|
|
156
147
|
).some(Boolean)
|
|
157
148
|
if (!hasWorkspaceFile) {
|
|
158
|
-
const abs = path.join(root,
|
|
159
|
-
files.push({ relPath: relPosix(root, abs), abs, contents:
|
|
149
|
+
const abs = path.join(root, workspaceName)
|
|
150
|
+
files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
|
|
160
151
|
}
|
|
161
152
|
|
|
162
|
-
if (!
|
|
153
|
+
if (!dry && !force) {
|
|
163
154
|
const conflicts = new Set<string>()
|
|
164
155
|
// A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
|
|
165
156
|
// so we never shadow a hand-written config with a fresh .ts.
|
|
@@ -183,7 +174,7 @@ export async function migrateCmd(
|
|
|
183
174
|
}
|
|
184
175
|
}
|
|
185
176
|
|
|
186
|
-
if (
|
|
177
|
+
if (dry) {
|
|
187
178
|
for (const f of files) {
|
|
188
179
|
process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
|
|
189
180
|
}
|
|
@@ -202,20 +193,22 @@ export async function migrateCmd(
|
|
|
202
193
|
const report: string[] = []
|
|
203
194
|
if (empty) {
|
|
204
195
|
report.push(
|
|
205
|
-
|
|
196
|
+
`${verb}: no package.json scripts to turn into tasks.`,
|
|
206
197
|
hasWorkspaceFile
|
|
207
|
-
?
|
|
208
|
-
:
|
|
209
|
-
?
|
|
210
|
-
:
|
|
211
|
-
|
|
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:`,
|
|
212
203
|
'',
|
|
213
|
-
...
|
|
204
|
+
...exampleConfig(format)
|
|
205
|
+
.trimEnd()
|
|
214
206
|
.split('\n')
|
|
215
207
|
.map((l) => ` ${l}`),
|
|
216
208
|
)
|
|
217
209
|
} else {
|
|
218
|
-
report.push(
|
|
210
|
+
report.push(`${verb}: ${source} → ${configName}`)
|
|
211
|
+
for (const n of args.notes ?? []) report.push(`note: ${n}`)
|
|
219
212
|
for (const n of plan.headerNotes) report.push(`note: ${n}`)
|
|
220
213
|
report.push(
|
|
221
214
|
'',
|
|
@@ -224,7 +217,7 @@ export async function migrateCmd(
|
|
|
224
217
|
)
|
|
225
218
|
for (const line of todoList) report.push(` ${line}`)
|
|
226
219
|
report.push(...plan.notes)
|
|
227
|
-
report.push(
|
|
220
|
+
report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
|
|
228
221
|
for (const f of files) report.push(` ${f.relPath}`)
|
|
229
222
|
}
|
|
230
223
|
const firstTask =
|
|
@@ -236,9 +229,7 @@ export async function migrateCmd(
|
|
|
236
229
|
return 0
|
|
237
230
|
}
|
|
238
231
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
export default {
|
|
232
|
+
const EXAMPLE_BODY = `export default {
|
|
242
233
|
tasks: {
|
|
243
234
|
build: {
|
|
244
235
|
exec: { command: 'tsc -b' },
|
|
@@ -246,19 +237,31 @@ export default {
|
|
|
246
237
|
cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
|
|
247
238
|
},
|
|
248
239
|
},
|
|
249
|
-
}
|
|
250
|
-
|
|
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
|
+
}
|
|
251
247
|
|
|
252
248
|
// ─── TS emission ──────────────────────────────────────────────────────
|
|
253
249
|
|
|
254
|
-
|
|
255
|
-
import {
|
|
256
|
-
|
|
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: [] }`
|
|
257
259
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
`
|
|
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
|
+
}
|
|
262
265
|
|
|
263
266
|
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
264
267
|
|
|
@@ -272,34 +275,40 @@ function renderValue(v: unknown, indent: string): string {
|
|
|
272
275
|
// with a raw stack mid-write. Mappers no longer produce a null, but a
|
|
273
276
|
// literal is a readable thing to leave behind if one ever does.
|
|
274
277
|
if (v === null) return 'null'
|
|
275
|
-
if (typeof v === 'string') return
|
|
278
|
+
if (typeof v === 'string') return quoteTsLiteral(v)
|
|
276
279
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
277
280
|
if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
|
|
278
281
|
const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined)
|
|
279
282
|
if (entries.length === 0) return '{}'
|
|
280
283
|
const inner = `${indent} `
|
|
281
284
|
const body = entries.map(
|
|
282
|
-
([k, x]) => `${inner}${IDENT.test(k) ? k :
|
|
285
|
+
([k, x]) => `${inner}${IDENT.test(k) ? k : quoteTsLiteral(k)}: ${renderValue(x, inner)},`,
|
|
283
286
|
)
|
|
284
287
|
return `{\n${body.join('\n')}\n${indent}}`
|
|
285
288
|
}
|
|
286
289
|
|
|
287
|
-
function renderConfigFile(
|
|
290
|
+
function renderConfigFile(
|
|
291
|
+
source: string,
|
|
292
|
+
p: GeneratedProject,
|
|
293
|
+
verb: string,
|
|
294
|
+
format: MigrationFormat,
|
|
295
|
+
): string {
|
|
288
296
|
// A type-only import: the editor type-checks against the installed
|
|
289
297
|
// package, and Bun erases it, so the config loads in a workspace that
|
|
290
|
-
// runs the vx binary without the package installed.
|
|
298
|
+
// runs the vx binary without the package installed. `mjs` is the same
|
|
299
|
+
// object with nothing to erase.
|
|
291
300
|
const lines: string[] = [
|
|
292
|
-
`// Generated by \`
|
|
293
|
-
"import type { ProjectConfig } from '@vzn/vx'",
|
|
301
|
+
`// Generated by \`${verb}\` from ${source}. Review the TODO(vx-migrate) comments.`,
|
|
294
302
|
]
|
|
303
|
+
if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx'")
|
|
295
304
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|
|
296
305
|
lines.push('', 'export default {', ' tasks: {')
|
|
297
306
|
for (const t of p.tasks) {
|
|
298
307
|
for (const todo of t.todos) lines.push(` // TODO(vx-migrate): ${todo}`)
|
|
299
308
|
if (t.task === null) continue // skipped target — the TODO above explains
|
|
300
|
-
const key = IDENT.test(t.name) ? t.name :
|
|
309
|
+
const key = IDENT.test(t.name) ? t.name : quoteTsLiteral(t.name)
|
|
301
310
|
lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
|
|
302
311
|
}
|
|
303
|
-
lines.push(' },', '} satisfies ProjectConfig', '')
|
|
312
|
+
lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
|
|
304
313
|
return lines.join('\n')
|
|
305
314
|
}
|
|
@@ -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)
|
|
@@ -110,22 +178,24 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
|
|
|
110
178
|
return [...seen].sort()
|
|
111
179
|
}
|
|
112
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.
|
|
113
186
|
function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
|
|
114
|
-
const closures = bitsetClosures(edges)
|
|
115
187
|
const memo = new Map<string, string[]>()
|
|
116
|
-
|
|
117
|
-
return (name) => {
|
|
118
|
-
const cached = memo.get(name)
|
|
119
|
-
if (cached) return cached
|
|
120
|
-
const result = reachableFrom(name, edges)
|
|
121
|
-
memo.set(name, result)
|
|
122
|
-
return result
|
|
123
|
-
}
|
|
124
|
-
}
|
|
188
|
+
let closures: Uint32Array | null | undefined
|
|
125
189
|
const words = (names.length + 31) >>> 5
|
|
126
190
|
return (name) => {
|
|
127
191
|
const cached = memo.get(name)
|
|
128
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
|
+
}
|
|
129
199
|
const i = index.get(name)
|
|
130
200
|
if (i === undefined) return []
|
|
131
201
|
const base = i * words
|
|
@@ -145,7 +215,7 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
|
|
|
145
215
|
|
|
146
216
|
return {
|
|
147
217
|
directDeps: (name) => directDeps.get(name) ?? [],
|
|
148
|
-
transitiveDeps: makeAccessor(
|
|
218
|
+
transitiveDeps: makeAccessor(reachDeps),
|
|
149
219
|
transitiveDependents: makeAccessor(directDependents),
|
|
150
220
|
}
|
|
151
221
|
}
|