@vzn/vx 0.0.21 → 0.0.23
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -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/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -32,16 +32,19 @@
|
|
|
32
32
|
// - Only STABLE-key, cacheable, local-read tasks are classified. A
|
|
33
33
|
// stable miss stays in the normal (dep-gated) schedule; an unstable
|
|
34
34
|
// task is never probed here.
|
|
35
|
-
// -
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
35
|
+
// - A task that declares `cache.outputs.workspaceFiles` (the
|
|
36
|
+
// boundary-ignoring escape hatch) can write where a restore touches,
|
|
37
|
+
// so every task whose project directory a workspace-output glob's
|
|
38
|
+
// static prefix REACHES stays out of the restore tier, and so does
|
|
39
|
+
// every transitive dependant of one (its up-front key folds a key
|
|
40
|
+
// that is preliminary). A glob with no literal prefix reaches every
|
|
41
|
+
// project, which is the old graph-wide rule. Excluded tasks still get
|
|
42
|
+
// an exec-tier probe reuse entry so there's no double work.
|
|
40
43
|
|
|
41
|
-
import { span } from '../util/index.js'
|
|
44
|
+
import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
|
|
42
45
|
import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
43
46
|
import type { TaskNode } from '../graph/index.js'
|
|
44
|
-
import { deriveStableKeys } from './stable-keys.js'
|
|
47
|
+
import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
|
|
45
48
|
import type { HashCache } from './task-hash.js'
|
|
46
49
|
|
|
47
50
|
export interface ShortCircuitArgs {
|
|
@@ -104,22 +107,13 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
104
107
|
} finally {
|
|
105
108
|
endKeys()
|
|
106
109
|
}
|
|
107
|
-
|
|
110
|
+
// Every stable key is a cacheable task's: deriveStableKeys pushes only
|
|
111
|
+
// `cacheEnabled && !unstable` (item 640 deleted a second filter here and
|
|
112
|
+
// nothing reddened).
|
|
113
|
+
const candidates = stableKeys
|
|
108
114
|
if (candidates.length === 0) return EMPTY
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
// root-anchored output can land anywhere, so if ANY task declares
|
|
112
|
-
// workspace outputs a task could in principle write where a
|
|
113
|
-
// restore-tier task restores. Disable the RESTORE tier graph-wide —
|
|
114
|
-
// workspace outputs are rare bad-practice. (Probe reuse still applies,
|
|
115
|
-
// so there's no double work; those tasks just stay dep-gated.)
|
|
116
|
-
let anyWorkspaceOutputs = false
|
|
117
|
-
for (const node of args.nodes.values()) {
|
|
118
|
-
if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) {
|
|
119
|
-
anyWorkspaceOutputs = true
|
|
120
|
-
break
|
|
121
|
-
}
|
|
122
|
-
}
|
|
116
|
+
const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
|
|
123
117
|
|
|
124
118
|
const preProbed = new Map<string, ProbedEntry>()
|
|
125
119
|
const restoreTier = new Set<string>()
|
|
@@ -139,7 +133,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
139
133
|
for (const { hash, node } of candidates) {
|
|
140
134
|
const hit = hits.get(hash) ?? null
|
|
141
135
|
preProbed.set(node.id, { hash, hit })
|
|
142
|
-
if (hit !== null && !
|
|
136
|
+
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
143
137
|
}
|
|
144
138
|
return { preProbed, restoreTier }
|
|
145
139
|
} catch {
|
|
@@ -156,7 +150,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
156
150
|
const command = node.config.exec?.command ?? ''
|
|
157
151
|
const hit = await args.cache.get(hash, { taskId: node.id, command })
|
|
158
152
|
preProbed.set(node.id, { hash, hit })
|
|
159
|
-
if (hit !== null && !
|
|
153
|
+
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
160
154
|
} catch {
|
|
161
155
|
// Leave this task out of preProbed → it probes lazily in
|
|
162
156
|
// execute(), exactly as today.
|
|
@@ -167,3 +161,69 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
167
161
|
|
|
168
162
|
return { preProbed, restoreTier }
|
|
169
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The tasks a workspace-output declaration keeps out of the restore tier.
|
|
167
|
+
*
|
|
168
|
+
* A root-anchored output can land in any project's directory, edge or no
|
|
169
|
+
* edge (`tests/local-shortcircuit.test.ts` § "a workspace-output writer"
|
|
170
|
+
* rows, item 425), so the question is not who depends on the writer but
|
|
171
|
+
* WHERE it can write: each declared glob's static prefix is tested for
|
|
172
|
+
* reach against every project directory, and against every task's own
|
|
173
|
+
* `workspaceFiles` inputs, with the ancestor-or-equal relation
|
|
174
|
+
* `workspaceInputsReach` already uses. A prefix that is the root reaches
|
|
175
|
+
* everything — the graph-wide rule this replaced (item 584). Exclusion then
|
|
176
|
+
* follows the edges DOWN: a dependant's up-front key folds an excluded
|
|
177
|
+
* task's key, which is preliminary, so it cannot restore early either.
|
|
178
|
+
* Cost: one pass over the nodes, no filesystem.
|
|
179
|
+
*/
|
|
180
|
+
function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: string): Set<string> {
|
|
181
|
+
const prefixes: string[] = []
|
|
182
|
+
let everything = false
|
|
183
|
+
for (const node of nodes.values()) {
|
|
184
|
+
for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
|
|
185
|
+
// No negation to skip: the schema refuses '!' in output globs
|
|
186
|
+
// (`validateWorkspaceGlobs`), so the `continue` that stood here
|
|
187
|
+
// guarded a shape no config can carry (item 640).
|
|
188
|
+
const glob = normalizeGlob(raw)
|
|
189
|
+
const prefix = staticPrefix(glob)
|
|
190
|
+
if (prefix === '.' || prefix === '' || prefix === '/') everything = true
|
|
191
|
+
else prefixes.push(prefix.replace(/^\.\//, ''))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const out = new Set<string>()
|
|
195
|
+
if (everything) {
|
|
196
|
+
for (const id of nodes.keys()) out.add(id)
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
if (prefixes.length === 0) return out
|
|
200
|
+
const reaches = (dir: string): boolean =>
|
|
201
|
+
dir === '' ||
|
|
202
|
+
dir === '.' ||
|
|
203
|
+
prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
|
|
204
|
+
const dependants = new Map<string, string[]>()
|
|
205
|
+
for (const node of nodes.values()) {
|
|
206
|
+
const dir = relPosix(workspaceRoot, node.projectDir)
|
|
207
|
+
const wsInputs = node.config.cache?.inputs?.workspaceFiles ?? []
|
|
208
|
+
if (reaches(dir) || (wsInputs.length > 0 && workspaceInputsReach(wsInputs, prefixes))) {
|
|
209
|
+
out.add(node.id)
|
|
210
|
+
}
|
|
211
|
+
for (const dep of node.deps) {
|
|
212
|
+
const list = dependants.get(dep)
|
|
213
|
+
if (list === undefined) dependants.set(dep, [node.id])
|
|
214
|
+
else list.push(node.id)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Up the dependant edges on an explicit stack: a chain is as deep as the
|
|
218
|
+
// graph, and a recursion per edge threw `RangeError` at the 50,000 the
|
|
219
|
+
// builder takes (item 737).
|
|
220
|
+
const stack = [...out]
|
|
221
|
+
while (stack.length > 0) {
|
|
222
|
+
for (const up of dependants.get(stack.pop()!) ?? []) {
|
|
223
|
+
if (out.has(up)) continue
|
|
224
|
+
out.add(up)
|
|
225
|
+
stack.push(up)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import { rename, stat } from 'node:fs/promises'
|
|
18
|
-
import { xxh3hex } from '../util/index.js'
|
|
18
|
+
import { xxh3, xxh3hex } from '../util/index.js'
|
|
19
19
|
import type { TaskNode } from '../graph/index.js'
|
|
20
20
|
import type {
|
|
21
21
|
FingerprintChange,
|
|
@@ -33,7 +33,15 @@ export interface LockfileClaimOptions {
|
|
|
33
33
|
* distinct content; must be deterministic and throw on a file it
|
|
34
34
|
* cannot read (the plugin's name heads the error).
|
|
35
35
|
*/
|
|
36
|
-
readonly digest: (text: string) => ReadonlyMap<string, string>
|
|
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[]
|
|
37
45
|
/** Folded into the memo's identity: bump when `digest` folds differently. */
|
|
38
46
|
readonly version: number
|
|
39
47
|
/**
|
|
@@ -64,9 +72,35 @@ interface Digests {
|
|
|
64
72
|
interface Memo {
|
|
65
73
|
version: number
|
|
66
74
|
lock: string
|
|
75
|
+
/** `extraFiles` → content hash when the digests were computed. */
|
|
76
|
+
extras?: Record<string, string>
|
|
67
77
|
importers: Record<string, string>
|
|
68
78
|
}
|
|
69
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
|
+
|
|
70
104
|
export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
|
|
71
105
|
const { file, digest, version } = options
|
|
72
106
|
const scope = options.scope ?? 'project'
|
|
@@ -76,7 +110,16 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
76
110
|
}
|
|
77
111
|
// Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
|
|
78
112
|
// re-read; the content hash decides whether the digests are current.
|
|
79
|
-
const seen = new Map<
|
|
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
|
+
>()
|
|
80
123
|
// Per run: every `key` call of one run gets the same context object.
|
|
81
124
|
const perRun = new WeakMap<object, Promise<Digests>>()
|
|
82
125
|
|
|
@@ -87,27 +130,74 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
87
130
|
st = await stat(full)
|
|
88
131
|
} catch {
|
|
89
132
|
const digests: Digests = { lock: '', importers: new Map() }
|
|
90
|
-
seen.set(workspaceRoot, { size: -1, mtimeMs: -1, digests })
|
|
133
|
+
seen.set(workspaceRoot, { size: -1, mtimeMs: -1, extras: [], extraStats: [], digests })
|
|
91
134
|
return digests
|
|
92
135
|
}
|
|
93
136
|
const last = seen.get(workspaceRoot)
|
|
94
137
|
if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
|
|
95
|
-
|
|
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
|
+
}
|
|
96
147
|
}
|
|
97
148
|
const bytes = await Bun.file(full).bytes()
|
|
98
|
-
const
|
|
99
|
-
if (last !== undefined && last.digests.lock === lock) {
|
|
100
|
-
seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests: last.digests })
|
|
101
|
-
return last.digests
|
|
102
|
-
}
|
|
149
|
+
const lockHash = xxh3hex(bytes)
|
|
103
150
|
const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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'))
|
|
109
193
|
const digests: Digests = { lock, importers }
|
|
110
|
-
seen.set(workspaceRoot, {
|
|
194
|
+
seen.set(workspaceRoot, {
|
|
195
|
+
size: st.size,
|
|
196
|
+
mtimeMs: st.mtimeMs,
|
|
197
|
+
extras: rels,
|
|
198
|
+
extraStats: await statsOf(workspaceRoot, rels),
|
|
199
|
+
digests,
|
|
200
|
+
})
|
|
111
201
|
return digests
|
|
112
202
|
}
|
|
113
203
|
const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
|
|
@@ -127,8 +217,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
127
217
|
// installed tree is in question, and core's answer (all) is right.
|
|
128
218
|
if (change.before === null || change.after === null) return undefined
|
|
129
219
|
if (scope === 'workspace') return undefined
|
|
130
|
-
|
|
131
|
-
|
|
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`)
|
|
132
233
|
const names: string[] = []
|
|
133
234
|
for (const p of ctx.projects) {
|
|
134
235
|
const importer = importerOf(ctx.workspaceRoot, p.dir)
|
|
@@ -149,13 +250,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
149
250
|
}
|
|
150
251
|
|
|
151
252
|
/**
|
|
152
|
-
* A project's digest: its own importer's
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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.
|
|
156
262
|
*/
|
|
157
263
|
function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
|
|
158
|
-
|
|
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}`)
|
|
159
269
|
}
|
|
160
270
|
|
|
161
271
|
/** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
|
|
@@ -168,11 +278,11 @@ async function readMemo(
|
|
|
168
278
|
memoFile: string,
|
|
169
279
|
version: number,
|
|
170
280
|
lock: string,
|
|
171
|
-
): Promise<Map<string, string> | undefined> {
|
|
281
|
+
): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
|
|
172
282
|
try {
|
|
173
283
|
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
174
284
|
if (memo.version !== version || memo.lock !== lock) return undefined
|
|
175
|
-
return new Map(Object.entries(memo.importers))
|
|
285
|
+
return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
|
|
176
286
|
} catch {
|
|
177
287
|
return undefined
|
|
178
288
|
}
|
|
@@ -182,9 +292,10 @@ async function computeAndMemo(
|
|
|
182
292
|
memoFile: string,
|
|
183
293
|
version: number,
|
|
184
294
|
lock: string,
|
|
295
|
+
extras: Record<string, string>,
|
|
185
296
|
importers: ReadonlyMap<string, string>,
|
|
186
297
|
): Promise<ReadonlyMap<string, string>> {
|
|
187
|
-
const memo: Memo = { version, lock, importers: Object.fromEntries(importers) }
|
|
298
|
+
const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
|
|
188
299
|
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
189
300
|
// concurrent runs each land a whole one.
|
|
190
301
|
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
@@ -216,8 +327,8 @@ export interface ReachGraph {
|
|
|
216
327
|
* does not. Lockfiles carry dependency cycles, so the unit is the
|
|
217
328
|
* strongly connected component: Tarjan's walk (iterative — a dependency
|
|
218
329
|
* chain can be thousands deep) emits components children-first, and each
|
|
219
|
-
* folds its members
|
|
220
|
-
* (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in
|
|
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
|
|
221
332
|
* ~20 ms where one traversal per importer took 400.
|
|
222
333
|
*/
|
|
223
334
|
export function reachDigests(g: ReachGraph): string[] {
|
|
@@ -270,10 +381,19 @@ export function reachDigests(g: ReachGraph): string[] {
|
|
|
270
381
|
for (const m of members) {
|
|
271
382
|
for (const w of g.edges[m]!) if (comp[w] !== id) children.add(compHash[comp[w]!]!)
|
|
272
383
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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)
|
|
277
397
|
compHash.push(h.toString(16).padStart(16, '0'))
|
|
278
398
|
}
|
|
279
399
|
}
|