@vzn/vx 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- 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-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- 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/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// The claimant's shell: what every lockfile plugin needs around its
|
|
2
|
+
// parser. A plugin that keys each project on its own dependency closure
|
|
3
|
+
// from a lockfile (`@vzn/vx-lockfile`) claims the file
|
|
4
|
+
// (`VxPlugin.fingerprint`), folds one digest per project through `key`,
|
|
5
|
+
// and answers `--affected` by digesting both sides of a change. Only the
|
|
6
|
+
// parser differs per package manager; the memo, the per-run gate, the
|
|
7
|
+
// fallback for a project the file does not list and the `--affected`
|
|
8
|
+
// diff are one implementation here, so a third lockfile is a parser.
|
|
9
|
+
//
|
|
10
|
+
// The digests are memoised on disk under the cache dir by the file's
|
|
11
|
+
// xxh3, so a warm run pays one read + hash + a small JSON read, never a
|
|
12
|
+
// parse; within one process (a run, a watch cycle) the file's size and
|
|
13
|
+
// mtime gate even the read; and per run the read happens once (a WeakMap
|
|
14
|
+
// on the context core hands every `key` call of a run).
|
|
15
|
+
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { rename, stat } from 'node:fs/promises'
|
|
18
|
+
import { xxh3, xxh3hex } from '../util/index.js'
|
|
19
|
+
import type { TaskNode } from '../graph/index.js'
|
|
20
|
+
import type {
|
|
21
|
+
FingerprintChange,
|
|
22
|
+
FingerprintClaim,
|
|
23
|
+
FingerprintContext,
|
|
24
|
+
KeyHookContext,
|
|
25
|
+
} from './plugin.js'
|
|
26
|
+
|
|
27
|
+
export interface LockfileClaimOptions {
|
|
28
|
+
/** The root-relative lockfile name, one of `WORKSPACE_FINGERPRINT_FILES`. */
|
|
29
|
+
readonly file: string
|
|
30
|
+
/**
|
|
31
|
+
* Every project's digest from the file's text: importer directory
|
|
32
|
+
* (root-relative POSIX, `.` for the root) → digest. Called once per
|
|
33
|
+
* distinct content; must be deterministic and throw on a file it
|
|
34
|
+
* cannot read (the plugin's name heads the error).
|
|
35
|
+
*/
|
|
36
|
+
readonly digest: (text: string, files: ReadonlyMap<string, string>) => ReadonlyMap<string, string>
|
|
37
|
+
/**
|
|
38
|
+
* Root-relative files the lockfile names whose BYTES the install uses but
|
|
39
|
+
* the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
|
|
40
|
+
* file and records no hash of it). Their content hashes ('' when absent)
|
|
41
|
+
* reach `digest` as `files` and join the memo's identity, so an edited
|
|
42
|
+
* patch moves the key (item 1014). Called only when the memo misses.
|
|
43
|
+
*/
|
|
44
|
+
readonly extraFiles?: (text: string) => readonly string[]
|
|
45
|
+
/** Folded into the memo's identity: bump when `digest` folds differently. */
|
|
46
|
+
readonly version: number
|
|
47
|
+
/**
|
|
48
|
+
* `'project'` (default): each task folds its own project's digest.
|
|
49
|
+
* `'workspace'`: the whole file's hash into every task — the coarse key
|
|
50
|
+
* core would fold, through the plugin.
|
|
51
|
+
*/
|
|
52
|
+
readonly scope?: 'project' | 'workspace'
|
|
53
|
+
/**
|
|
54
|
+
* The key part's name, as `vx why` shows it under the plugin's name
|
|
55
|
+
* (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
|
|
56
|
+
*/
|
|
57
|
+
readonly part?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The two hooks a lockfile plugin spreads into `definePlugin`. */
|
|
61
|
+
export interface LockfileClaimHooks {
|
|
62
|
+
readonly fingerprint: FingerprintClaim
|
|
63
|
+
key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Digests {
|
|
67
|
+
/** xxh3 of the lockfile bytes; '' when the file is absent. */
|
|
68
|
+
readonly lock: string
|
|
69
|
+
readonly importers: ReadonlyMap<string, string>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Memo {
|
|
73
|
+
version: number
|
|
74
|
+
lock: string
|
|
75
|
+
/** `extraFiles` → content hash when the digests were computed. */
|
|
76
|
+
extras?: Record<string, string>
|
|
77
|
+
importers: Record<string, string>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
|
|
81
|
+
async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
|
|
82
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
|
|
83
|
+
try {
|
|
84
|
+
return xxh3hex(await Bun.file(path.join(workspaceRoot, rel)).bytes())
|
|
85
|
+
} catch {
|
|
86
|
+
return ''
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function statsOf(
|
|
91
|
+
workspaceRoot: string,
|
|
92
|
+
rels: readonly string[],
|
|
93
|
+
): Promise<Array<{ size: number; mtimeMs: number }>> {
|
|
94
|
+
return Promise.all(
|
|
95
|
+
rels.map((rel) =>
|
|
96
|
+
stat(path.join(workspaceRoot, rel)).then(
|
|
97
|
+
(st) => ({ size: st.size, mtimeMs: st.mtimeMs }),
|
|
98
|
+
() => ({ size: -1, mtimeMs: -1 }),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
|
|
105
|
+
const { file, digest, version } = options
|
|
106
|
+
const scope = options.scope ?? 'project'
|
|
107
|
+
const part = options.part ?? 'deps'
|
|
108
|
+
if (scope !== 'project' && scope !== 'workspace') {
|
|
109
|
+
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
110
|
+
}
|
|
111
|
+
// Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
|
|
112
|
+
// re-read; the content hash decides whether the digests are current.
|
|
113
|
+
const seen = new Map<
|
|
114
|
+
string,
|
|
115
|
+
{
|
|
116
|
+
size: number
|
|
117
|
+
mtimeMs: number
|
|
118
|
+
extras: readonly string[]
|
|
119
|
+
extraStats: ReadonlyArray<{ size: number; mtimeMs: number }>
|
|
120
|
+
digests: Digests
|
|
121
|
+
}
|
|
122
|
+
>()
|
|
123
|
+
// Per run: every `key` call of one run gets the same context object.
|
|
124
|
+
const perRun = new WeakMap<object, Promise<Digests>>()
|
|
125
|
+
|
|
126
|
+
const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
|
|
127
|
+
const full = path.join(workspaceRoot, file)
|
|
128
|
+
let st: { size: number; mtimeMs: number }
|
|
129
|
+
try {
|
|
130
|
+
st = await stat(full)
|
|
131
|
+
} catch {
|
|
132
|
+
const digests: Digests = { lock: '', importers: new Map() }
|
|
133
|
+
seen.set(workspaceRoot, { size: -1, mtimeMs: -1, extras: [], extraStats: [], digests })
|
|
134
|
+
return digests
|
|
135
|
+
}
|
|
136
|
+
const last = seen.get(workspaceRoot)
|
|
137
|
+
if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
|
|
138
|
+
const now = await statsOf(workspaceRoot, last.extras)
|
|
139
|
+
if (
|
|
140
|
+
now.every(
|
|
141
|
+
(s, i) =>
|
|
142
|
+
s.size === last.extraStats[i]!.size && s.mtimeMs === last.extraStats[i]!.mtimeMs,
|
|
143
|
+
)
|
|
144
|
+
) {
|
|
145
|
+
return last.digests
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const bytes = await Bun.file(full).bytes()
|
|
149
|
+
const lockHash = xxh3hex(bytes)
|
|
150
|
+
const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
|
|
151
|
+
// The extra files the last digest read, and their hashes now: a warm run
|
|
152
|
+
// re-hashes the files the memo names and never parses to learn them.
|
|
153
|
+
const memo = await readMemo(memoFile, version, lockHash)
|
|
154
|
+
let extras: Record<string, string> = {}
|
|
155
|
+
let importers: ReadonlyMap<string, string> | undefined
|
|
156
|
+
if (memo !== undefined) {
|
|
157
|
+
const now = Object.fromEntries(
|
|
158
|
+
await Promise.all(
|
|
159
|
+
Object.keys(memo.extras).map(
|
|
160
|
+
async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const,
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
if (Object.entries(memo.extras).every(([rel, h]) => now[rel] === h)) {
|
|
165
|
+
extras = memo.extras
|
|
166
|
+
importers = memo.importers
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (importers === undefined) {
|
|
170
|
+
const text = decode(bytes)
|
|
171
|
+
const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
|
|
172
|
+
extras = Object.fromEntries(
|
|
173
|
+
await Promise.all(
|
|
174
|
+
rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
importers =
|
|
178
|
+
scope === 'workspace'
|
|
179
|
+
? new Map<string, string>()
|
|
180
|
+
: await computeAndMemo(
|
|
181
|
+
memoFile,
|
|
182
|
+
version,
|
|
183
|
+
lockHash,
|
|
184
|
+
extras,
|
|
185
|
+
digest(text, new Map(Object.entries(extras))),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
const rels = Object.keys(extras)
|
|
189
|
+
const lock =
|
|
190
|
+
rels.length === 0
|
|
191
|
+
? lockHash
|
|
192
|
+
: xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
|
|
193
|
+
const digests: Digests = { lock, importers }
|
|
194
|
+
seen.set(workspaceRoot, {
|
|
195
|
+
size: st.size,
|
|
196
|
+
mtimeMs: st.mtimeMs,
|
|
197
|
+
extras: rels,
|
|
198
|
+
extraStats: await statsOf(workspaceRoot, rels),
|
|
199
|
+
digests,
|
|
200
|
+
})
|
|
201
|
+
return digests
|
|
202
|
+
}
|
|
203
|
+
const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
|
|
204
|
+
let p = perRun.get(ctx)
|
|
205
|
+
if (p === undefined) {
|
|
206
|
+
p = load(ctx.workspaceRoot, ctx.cacheDir)
|
|
207
|
+
perRun.set(ctx, p)
|
|
208
|
+
}
|
|
209
|
+
return p
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
fingerprint: {
|
|
214
|
+
files: [file],
|
|
215
|
+
affected(change: FingerprintChange, ctx: FingerprintContext) {
|
|
216
|
+
// One side absent: the file appeared or went — every project's
|
|
217
|
+
// installed tree is in question, and core's answer (all) is right.
|
|
218
|
+
if (change.before === null || change.after === null) return undefined
|
|
219
|
+
if (scope === 'workspace') return undefined
|
|
220
|
+
// WHICH side failed is the whole difference between "your
|
|
221
|
+
// lockfile is broken" and "the base commit's is" — a migration
|
|
222
|
+
// commit hits the second, and the bare parser message named
|
|
223
|
+
// neither (2026-09-20).
|
|
224
|
+
const sideOf = (bytes: Uint8Array, side: string): ReadonlyMap<string, string> => {
|
|
225
|
+
try {
|
|
226
|
+
return digest(decode(bytes), new Map())
|
|
227
|
+
} catch (err) {
|
|
228
|
+
throw new Error(`${err instanceof Error ? err.message : String(err)} (${side})`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const before = sideOf(change.before, `as of the base ref`)
|
|
232
|
+
const after = sideOf(change.after, `in the working tree`)
|
|
233
|
+
const names: string[] = []
|
|
234
|
+
for (const p of ctx.projects) {
|
|
235
|
+
const importer = importerOf(ctx.workspaceRoot, p.dir)
|
|
236
|
+
if (digestFor(before, importer) !== digestFor(after, importer)) names.push(p.name)
|
|
237
|
+
}
|
|
238
|
+
return names
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
async key(task, ctx) {
|
|
242
|
+
const digests = await loadOnce(ctx)
|
|
243
|
+
if (digests.lock === '') return undefined
|
|
244
|
+
if (scope === 'workspace') return { [part]: digests.lock }
|
|
245
|
+
return {
|
|
246
|
+
[part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A project's digest: its own importer's AND the root importer's. The
|
|
254
|
+
* root package's dependencies reach every task — their bins through the
|
|
255
|
+
* root `node_modules/.bin` core puts on every PATH, their modules through
|
|
256
|
+
* Node's resolution walking up to the root `node_modules` (`@types/*`,
|
|
257
|
+
* a config's plugins) — so a root devDependency bump that moved only the
|
|
258
|
+
* root's digest replayed the old tool's output (nx#36415 class,
|
|
259
|
+
* 2026-09-24). A project the lockfile has no importer for (outside the
|
|
260
|
+
* workspace's `packages`) folds the root's alone; a file with neither
|
|
261
|
+
* folds a constant.
|
|
262
|
+
*/
|
|
263
|
+
function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
|
|
264
|
+
const own = importers.get(importer)
|
|
265
|
+
const root = importers.get('.')
|
|
266
|
+
if (own === undefined) return root ?? 'no-importer'
|
|
267
|
+
if (root === undefined || importer === '.') return own
|
|
268
|
+
return xxh3hex(`${own}\0${root}`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
|
|
272
|
+
function importerOf(workspaceRoot: string, projectDir: string): string {
|
|
273
|
+
const rel = path.relative(workspaceRoot, projectDir)
|
|
274
|
+
return rel === '' ? '.' : rel.split(path.sep).join('/')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function readMemo(
|
|
278
|
+
memoFile: string,
|
|
279
|
+
version: number,
|
|
280
|
+
lock: string,
|
|
281
|
+
): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
|
|
282
|
+
try {
|
|
283
|
+
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
284
|
+
if (memo.version !== version || memo.lock !== lock) return undefined
|
|
285
|
+
return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
|
|
286
|
+
} catch {
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function computeAndMemo(
|
|
292
|
+
memoFile: string,
|
|
293
|
+
version: number,
|
|
294
|
+
lock: string,
|
|
295
|
+
extras: Record<string, string>,
|
|
296
|
+
importers: ReadonlyMap<string, string>,
|
|
297
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
298
|
+
const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
|
|
299
|
+
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
300
|
+
// concurrent runs each land a whole one.
|
|
301
|
+
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
302
|
+
try {
|
|
303
|
+
await Bun.write(tmp, JSON.stringify(memo))
|
|
304
|
+
await rename(tmp, memoFile)
|
|
305
|
+
} catch {
|
|
306
|
+
// The memo is a speed-up, never a requirement: the digests are
|
|
307
|
+
// computed either way, and the next run computes them again.
|
|
308
|
+
}
|
|
309
|
+
return importers
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function decode(bytes: Uint8Array): string {
|
|
313
|
+
return new TextDecoder().decode(bytes)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- Reach digests -----------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/** A dependency graph: one material string per node and its out-edges by index. */
|
|
319
|
+
export interface ReachGraph {
|
|
320
|
+
readonly material: readonly string[]
|
|
321
|
+
readonly edges: ReadonlyArray<readonly number[]>
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* One digest per node over everything the node reaches, Merkle-style: a
|
|
326
|
+
* change anywhere in a node's reach moves its digest, a change elsewhere
|
|
327
|
+
* does not. Lockfiles carry dependency cycles, so the unit is the
|
|
328
|
+
* strongly connected component: Tarjan's walk (iterative — a dependency
|
|
329
|
+
* chain can be thousands deep) emits components children-first, and each
|
|
330
|
+
* folds its members (sorted), each as its material and the materials its
|
|
331
|
+
* edges land on, and its child components' digests (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in
|
|
332
|
+
* ~20 ms where one traversal per importer took 400.
|
|
333
|
+
*/
|
|
334
|
+
export function reachDigests(g: ReachGraph): string[] {
|
|
335
|
+
const n = g.material.length
|
|
336
|
+
const idx = new Int32Array(n).fill(-1)
|
|
337
|
+
const low = new Int32Array(n)
|
|
338
|
+
const onStack = new Uint8Array(n)
|
|
339
|
+
const stack: number[] = []
|
|
340
|
+
const comp = new Int32Array(n).fill(-1)
|
|
341
|
+
const compHash: string[] = []
|
|
342
|
+
let counter = 0
|
|
343
|
+
for (let root = 0; root < n; root++) {
|
|
344
|
+
if (idx[root] !== -1) continue
|
|
345
|
+
const frames: Array<[number, number]> = [[root, 0]]
|
|
346
|
+
idx[root] = low[root] = counter++
|
|
347
|
+
stack.push(root)
|
|
348
|
+
onStack[root] = 1
|
|
349
|
+
while (frames.length > 0) {
|
|
350
|
+
const frame = frames[frames.length - 1]!
|
|
351
|
+
const v = frame[0]
|
|
352
|
+
const out = g.edges[v]!
|
|
353
|
+
if (frame[1] < out.length) {
|
|
354
|
+
const w = out[frame[1]++]!
|
|
355
|
+
if (idx[w] === -1) {
|
|
356
|
+
idx[w] = low[w] = counter++
|
|
357
|
+
stack.push(w)
|
|
358
|
+
onStack[w] = 1
|
|
359
|
+
frames.push([w, 0])
|
|
360
|
+
} else if (onStack[w] === 1) {
|
|
361
|
+
low[v] = Math.min(low[v]!, idx[w]!)
|
|
362
|
+
}
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
frames.pop()
|
|
366
|
+
if (frames.length > 0) {
|
|
367
|
+
const u = frames[frames.length - 1]![0]
|
|
368
|
+
low[u] = Math.min(low[u]!, low[v]!)
|
|
369
|
+
}
|
|
370
|
+
if (low[v] !== idx[v]) continue
|
|
371
|
+
const members: number[] = []
|
|
372
|
+
for (;;) {
|
|
373
|
+
const w = stack.pop()!
|
|
374
|
+
onStack[w] = 0
|
|
375
|
+
comp[w] = compHash.length
|
|
376
|
+
members.push(w)
|
|
377
|
+
if (w === v) break
|
|
378
|
+
}
|
|
379
|
+
const id = compHash.length
|
|
380
|
+
const children = new Set<string>()
|
|
381
|
+
for (const m of members) {
|
|
382
|
+
for (const w of g.edges[m]!) if (comp[w] !== id) children.add(compHash[comp[w]!]!)
|
|
383
|
+
}
|
|
384
|
+
// A member is its material AND where its edges land: all members
|
|
385
|
+
// share one digest, so retargeting an edge between two of them (an
|
|
386
|
+
// importer's `y@1.0.0` → `y@1.1.0` inside one cycle) moved nothing
|
|
387
|
+
// and replayed a stale output (item 1013). A self-loop reaches
|
|
388
|
+
// nothing new and is left out.
|
|
389
|
+
const lines = members.map((m) => {
|
|
390
|
+
const targets = [...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!))]
|
|
391
|
+
return `${g.material[m]!}\0${targets.sort().join('\0')}`
|
|
392
|
+
})
|
|
393
|
+
let h = xxh3(`members:${members.length}`)
|
|
394
|
+
for (const line of lines.sort()) h = xxh3(`${line}\n`, h)
|
|
395
|
+
h = xxh3(`children:${children.size}`, h)
|
|
396
|
+
for (const c of [...children].sort()) h = xxh3(`${c}\n`, h)
|
|
397
|
+
compHash.push(h.toString(16).padStart(16, '0'))
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return Array.from({ length: n }, (_, i) => compHash[comp[i]!]!)
|
|
401
|
+
}
|