breakage 0.9.0 → 0.9.1
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 +1 -1
- package/dist/{breakage-DIY49fzP.js → breakage-DJiIiLzL.js} +632 -9
- package/dist/breakage-DJiIiLzL.js.map +1 -0
- package/dist/breakage.d.ts +198 -86
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +2 -2
- package/dist/breakage.x.js +142 -3
- package/dist/breakage.x.js.map +1 -1
- package/npm.md +144 -12
- package/package.json +1 -1
- package/src/breakage.ts +17 -0
- package/src/breakage.x.ts +26 -0
- package/src/config.ts +3 -0
- package/src/npm/check.ts +19 -0
- package/src/npm/cli.ts +4 -0
- package/src/npm/matrix-cli.ts +170 -0
- package/src/npm/matrix-model.ts +395 -0
- package/src/npm/matrix.ts +657 -0
- package/dist/breakage-DIY49fzP.js.map +0 -1
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
glob,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
import { simpleGit } from "simple-git"
|
|
15
|
+
import { loadConfig } from "../config.ts"
|
|
16
|
+
import { withDirectoryLock } from "../directory-lock.ts"
|
|
17
|
+
import {
|
|
18
|
+
resolveReleaseDelta,
|
|
19
|
+
versionDelta,
|
|
20
|
+
type ReleaseDeltaContext,
|
|
21
|
+
} from "../release.ts"
|
|
22
|
+
import { prepare, errorMessage } from "../task.ts"
|
|
23
|
+
import { npmCheck, npmTestPlanned, formatNpmReport } from "./check.ts"
|
|
24
|
+
import { validateConfig } from "./config.ts"
|
|
25
|
+
import { discover, within } from "./environment.ts"
|
|
26
|
+
import { readManifest } from "./manifest.ts"
|
|
27
|
+
import type { NpmConfig } from "./types.ts"
|
|
28
|
+
import {
|
|
29
|
+
canonical,
|
|
30
|
+
digest,
|
|
31
|
+
npmMatrixConfigSchema,
|
|
32
|
+
peerIdentities,
|
|
33
|
+
planDigest,
|
|
34
|
+
requireMatrix,
|
|
35
|
+
validateNpmPlan,
|
|
36
|
+
verifyNpmReceipts,
|
|
37
|
+
type NpmArchiveRequest,
|
|
38
|
+
type NpmMatrixConfig,
|
|
39
|
+
type NpmPlan,
|
|
40
|
+
type NpmReceipt,
|
|
41
|
+
type NpmVerification,
|
|
42
|
+
} from "./matrix-model.ts"
|
|
43
|
+
|
|
44
|
+
export { verifyNpmReceipts } from "./matrix-model.ts"
|
|
45
|
+
export type {
|
|
46
|
+
NpmMatrixConfig,
|
|
47
|
+
NpmMatrixLabelContext,
|
|
48
|
+
NpmPrepareContext,
|
|
49
|
+
NpmArchiveRequest,
|
|
50
|
+
NpmPlan,
|
|
51
|
+
NpmReceipt,
|
|
52
|
+
NpmVerification,
|
|
53
|
+
} from "./matrix-model.ts"
|
|
54
|
+
export type NpmMatrixLocation = {
|
|
55
|
+
rootDirectory?: string
|
|
56
|
+
bundleDirectory?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function relative(root: string, filename: string): string {
|
|
60
|
+
const result =
|
|
61
|
+
path.relative(root, path.resolve(filename)).split(path.sep).join(`/`) || `.`
|
|
62
|
+
requireMatrix(
|
|
63
|
+
!result.startsWith(`../`) && result !== `..` && !path.isAbsolute(result),
|
|
64
|
+
`Matrix input escapes the source checkout: ${filename}.`,
|
|
65
|
+
)
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
68
|
+
function portablePath(value: string): void {
|
|
69
|
+
requireMatrix(
|
|
70
|
+
value.length &&
|
|
71
|
+
!value.includes(`\\`) &&
|
|
72
|
+
!path.posix.isAbsolute(value) &&
|
|
73
|
+
!/^[A-Za-z]:/.test(value) &&
|
|
74
|
+
!value.split(`/`).includes(`..`),
|
|
75
|
+
`Invalid portable path: ${value}.`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
async function contained(root: string, filename: string): Promise<string> {
|
|
79
|
+
portablePath(filename)
|
|
80
|
+
const absolute = path.resolve(root, filename)
|
|
81
|
+
// Check the nearest existing ancestor as well as the final path.
|
|
82
|
+
let ancestor = absolute
|
|
83
|
+
for (;;) {
|
|
84
|
+
try {
|
|
85
|
+
await lstat(ancestor)
|
|
86
|
+
break
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if ((error as NodeJS.ErrnoException).code !== `ENOENT`) throw error
|
|
89
|
+
ancestor = path.dirname(ancestor)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
requireMatrix(
|
|
93
|
+
within(await realpath(root), await realpath(ancestor)),
|
|
94
|
+
`Path escapes its root through a symlink: ${filename}.`,
|
|
95
|
+
)
|
|
96
|
+
return absolute
|
|
97
|
+
}
|
|
98
|
+
async function fileDigest(filename: string): Promise<string> {
|
|
99
|
+
requireMatrix(
|
|
100
|
+
(await lstat(filename)).isFile(),
|
|
101
|
+
`Expected a regular file: ${filename}.`,
|
|
102
|
+
)
|
|
103
|
+
return digest(await readFile(filename))
|
|
104
|
+
}
|
|
105
|
+
async function sourceIdentity(root: string): Promise<string> {
|
|
106
|
+
const git = simpleGit(root)
|
|
107
|
+
requireMatrix(
|
|
108
|
+
(await realpath(await git.revparse([`--show-toplevel`]))) ===
|
|
109
|
+
(await realpath(root)),
|
|
110
|
+
`Matrix rootDirectory must be the Git checkout root.`,
|
|
111
|
+
)
|
|
112
|
+
requireMatrix(
|
|
113
|
+
!(await git.status()).files.length,
|
|
114
|
+
`Matrix planning and execution require a clean checkout, including untracked files. Ignore generated archives, bundles, and receipts.`,
|
|
115
|
+
)
|
|
116
|
+
return await git.revparse([`HEAD`])
|
|
117
|
+
}
|
|
118
|
+
async function ignoredOutput(root: string, filename: string): Promise<void> {
|
|
119
|
+
const git = simpleGit(root)
|
|
120
|
+
requireMatrix(
|
|
121
|
+
!(await git.raw([`ls-files`, `--`, filename])).trim() &&
|
|
122
|
+
(await git.raw([`check-ignore`, `--`, filename])).trim(),
|
|
123
|
+
`Matrix output must be gitignored and untracked: ${filename}.`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function discoverConfigs(
|
|
128
|
+
root: string,
|
|
129
|
+
patterns: string[],
|
|
130
|
+
): Promise<string[]> {
|
|
131
|
+
const files = new Set<string>()
|
|
132
|
+
for (const pattern of patterns) portablePath(pattern)
|
|
133
|
+
for await (const filename of glob(patterns, {
|
|
134
|
+
cwd: root,
|
|
135
|
+
exclude: [`**/node_modules/**`, `**/.git/**`, `**/test/**`],
|
|
136
|
+
})) {
|
|
137
|
+
const name = relative(root, path.resolve(root, filename))
|
|
138
|
+
const config = await loadConfig(await contained(root, name))
|
|
139
|
+
if (config.npm) files.add(name)
|
|
140
|
+
}
|
|
141
|
+
requireMatrix(files.size, `No npm configurations matched the matrix configs.`)
|
|
142
|
+
return [...files].sort()
|
|
143
|
+
}
|
|
144
|
+
function configDigest(config: NpmConfig, root: string, base: string): string {
|
|
145
|
+
return digest(
|
|
146
|
+
canonical({
|
|
147
|
+
...config,
|
|
148
|
+
packageDirectory: relative(root, base),
|
|
149
|
+
artifact: relative(root, path.resolve(base, config.artifact)),
|
|
150
|
+
baseline:
|
|
151
|
+
`baselineFile` in config.baseline && config.baseline.baselineFile
|
|
152
|
+
? {
|
|
153
|
+
...config.baseline,
|
|
154
|
+
baselineFile: relative(
|
|
155
|
+
root,
|
|
156
|
+
path.resolve(base, config.baseline.baselineFile),
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
: config.baseline,
|
|
160
|
+
localPeers: Object.fromEntries(
|
|
161
|
+
Object.entries(config.localPeers ?? {}).map(([name, peer]) => [
|
|
162
|
+
name,
|
|
163
|
+
{
|
|
164
|
+
directory: relative(root, path.resolve(base, peer.directory)),
|
|
165
|
+
artifact: relative(root, path.resolve(base, peer.artifact)),
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
),
|
|
169
|
+
release: config.release
|
|
170
|
+
? {
|
|
171
|
+
resolveDelta: !!config.release.resolveDelta,
|
|
172
|
+
policy: !!config.release.policy,
|
|
173
|
+
}
|
|
174
|
+
: undefined,
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
async function packageConfig(root: string, filename: string) {
|
|
179
|
+
const config = (await loadConfig(await contained(root, filename))).npm
|
|
180
|
+
validateConfig(config)
|
|
181
|
+
const baseDirectory = path.dirname(path.resolve(root, filename))
|
|
182
|
+
const base = path.resolve(baseDirectory, config.packageDirectory ?? `.`)
|
|
183
|
+
await contained(root, relative(root, base))
|
|
184
|
+
return { config, base, baseDirectory }
|
|
185
|
+
}
|
|
186
|
+
function portableContext(
|
|
187
|
+
root: string,
|
|
188
|
+
context: ReleaseDeltaContext,
|
|
189
|
+
): ReleaseDeltaContext {
|
|
190
|
+
return {
|
|
191
|
+
...context,
|
|
192
|
+
packageDirectory: relative(root, context.packageDirectory),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function archiveRequests(
|
|
197
|
+
root: string,
|
|
198
|
+
config: NpmConfig,
|
|
199
|
+
base: string,
|
|
200
|
+
): Promise<NpmArchiveRequest[]> {
|
|
201
|
+
const manifest = await readManifest(path.join(base, `package.json`))
|
|
202
|
+
const requests: NpmArchiveRequest[] = [
|
|
203
|
+
{
|
|
204
|
+
packageName: manifest.name,
|
|
205
|
+
directory: relative(root, base),
|
|
206
|
+
artifact: relative(root, path.resolve(base, config.artifact)),
|
|
207
|
+
},
|
|
208
|
+
]
|
|
209
|
+
for (const [packageName, peer] of Object.entries(config.localPeers ?? {})) {
|
|
210
|
+
const directory = relative(root, path.resolve(base, peer.directory))
|
|
211
|
+
try {
|
|
212
|
+
await lstat(await contained(root, directory))
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Preserve registrations needed only to interpret a removed historical peer.
|
|
215
|
+
if (
|
|
216
|
+
(error as NodeJS.ErrnoException).code === `ENOENT` &&
|
|
217
|
+
!manifest.peerDependencies?.[packageName]
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
requests.push({
|
|
223
|
+
packageName,
|
|
224
|
+
directory,
|
|
225
|
+
artifact: relative(root, path.resolve(base, peer.artifact)),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
return requests
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Include ignored/generated contracts and frozen inputs: a clean Git tree alone
|
|
232
|
+
// cannot bind them to the coverage audit. File discovery is part of the digest.
|
|
233
|
+
async function inputDigest(
|
|
234
|
+
root: string,
|
|
235
|
+
filename: string,
|
|
236
|
+
config: NpmConfig,
|
|
237
|
+
base: string,
|
|
238
|
+
): Promise<string> {
|
|
239
|
+
const files = new Set([
|
|
240
|
+
filename,
|
|
241
|
+
relative(root, path.join(base, `package.json`)),
|
|
242
|
+
])
|
|
243
|
+
const directories = await discover(config, base)
|
|
244
|
+
for (const directory of directories)
|
|
245
|
+
for (const name of [`package.json`, config.lockfile])
|
|
246
|
+
files.add(relative(root, path.join(directory, name)))
|
|
247
|
+
for (const request of await archiveRequests(root, config, base))
|
|
248
|
+
files.add(
|
|
249
|
+
relative(root, path.resolve(root, request.directory, `package.json`)),
|
|
250
|
+
)
|
|
251
|
+
if (`baselineFile` in config.baseline && config.baseline.baselineFile)
|
|
252
|
+
files.add(relative(root, path.resolve(base, config.baseline.baselineFile)))
|
|
253
|
+
const exclude = [
|
|
254
|
+
`**/node_modules/**`,
|
|
255
|
+
`**/.git/**`,
|
|
256
|
+
...directories.flatMap((directory) => {
|
|
257
|
+
const stage = relative(base, path.join(directory, `test`))
|
|
258
|
+
return [stage, `${stage}/**`]
|
|
259
|
+
}),
|
|
260
|
+
]
|
|
261
|
+
for (const suite of Object.values(config.suites)) {
|
|
262
|
+
for (const pattern of suite.files) {
|
|
263
|
+
portablePath(pattern)
|
|
264
|
+
let count = 0
|
|
265
|
+
for await (const file of glob(pattern, { cwd: base, exclude })) {
|
|
266
|
+
const absolute = path.resolve(base, file)
|
|
267
|
+
if ((await lstat(absolute)).isDirectory()) continue
|
|
268
|
+
files.add(relative(root, absolute))
|
|
269
|
+
count++
|
|
270
|
+
}
|
|
271
|
+
requireMatrix(count, `Suite pattern matched no files: ${pattern}.`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const inputs = await Promise.all(
|
|
275
|
+
[...files]
|
|
276
|
+
.sort()
|
|
277
|
+
.map(async (file) => [
|
|
278
|
+
file,
|
|
279
|
+
await fileDigest(await contained(root, file)),
|
|
280
|
+
]),
|
|
281
|
+
)
|
|
282
|
+
return digest(canonical(inputs))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Audit all configured packages and prepare one immutable, portable archive bundle. */
|
|
286
|
+
export async function npmPlan(
|
|
287
|
+
config: NpmMatrixConfig,
|
|
288
|
+
options: NpmMatrixLocation = {},
|
|
289
|
+
): Promise<NpmPlan> {
|
|
290
|
+
npmMatrixConfigSchema.assert(config)
|
|
291
|
+
const root = path.resolve(options.rootDirectory ?? process.cwd())
|
|
292
|
+
const output = options.bundleDirectory ?? `.breakage/npm-plan`
|
|
293
|
+
const bundle = await contained(
|
|
294
|
+
root,
|
|
295
|
+
relative(root, path.resolve(root, output)),
|
|
296
|
+
)
|
|
297
|
+
await ignoredOutput(root, path.join(bundle, `plan.json`))
|
|
298
|
+
await mkdir(bundle, { recursive: true })
|
|
299
|
+
return await withDirectoryLock(path.join(bundle, `.lock`), async () => {
|
|
300
|
+
// A failed re-plan must not leave yesterday's successful plan available.
|
|
301
|
+
await rm(path.join(bundle, `plan.json`), { force: true })
|
|
302
|
+
const commit = await sourceIdentity(root)
|
|
303
|
+
const patterns =
|
|
304
|
+
typeof config.configs === `string` ? [config.configs] : config.configs
|
|
305
|
+
const files = await discoverConfigs(root, patterns)
|
|
306
|
+
const packages: NpmPlan[`packages`] = []
|
|
307
|
+
const environments: NpmPlan[`environments`] = []
|
|
308
|
+
const requests = new Map<string, NpmArchiveRequest>()
|
|
309
|
+
for (const filename of files) {
|
|
310
|
+
const {
|
|
311
|
+
config: npm,
|
|
312
|
+
base,
|
|
313
|
+
baseDirectory,
|
|
314
|
+
} = await packageConfig(root, filename)
|
|
315
|
+
const manifest = await readManifest(path.join(base, `package.json`))
|
|
316
|
+
for (const peer of Object.keys(manifest.peerDependencies ?? {}))
|
|
317
|
+
requireMatrix(
|
|
318
|
+
npm.suites[peer] || manifest.peerDependenciesMeta?.[peer]?.optional,
|
|
319
|
+
`Missing relationship suite for ${manifest.name}: ${peer}.`,
|
|
320
|
+
)
|
|
321
|
+
const releases: NpmPlan[`packages`][number][`releases`] = []
|
|
322
|
+
const effective: NpmConfig = {
|
|
323
|
+
...npm,
|
|
324
|
+
...(npm.release
|
|
325
|
+
? {
|
|
326
|
+
release: {
|
|
327
|
+
...npm.release,
|
|
328
|
+
resolveDelta: async (context) => {
|
|
329
|
+
const transition = await resolveReleaseDelta(
|
|
330
|
+
npm.release!,
|
|
331
|
+
context,
|
|
332
|
+
)
|
|
333
|
+
releases.push({
|
|
334
|
+
context: portableContext(root, context),
|
|
335
|
+
...(transition ? { transition } : {}),
|
|
336
|
+
})
|
|
337
|
+
return transition
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
: {}),
|
|
342
|
+
}
|
|
343
|
+
const audit = await npmCheck(effective, {
|
|
344
|
+
baseDirectory,
|
|
345
|
+
coverageOnly: true,
|
|
346
|
+
})
|
|
347
|
+
requireMatrix(
|
|
348
|
+
audit.exitCode === 0,
|
|
349
|
+
`Cannot plan ${manifest.name}:\n${formatNpmReport(audit)}`,
|
|
350
|
+
)
|
|
351
|
+
for (const result of audit.representatives) {
|
|
352
|
+
requireMatrix(
|
|
353
|
+
result.status === `audited` && result.suites.length,
|
|
354
|
+
`Environment has no executable audited suites: ${manifest.name} / ${result.path}.`,
|
|
355
|
+
)
|
|
356
|
+
const environment = JSON.parse(
|
|
357
|
+
await readFile(path.join(base, result.path, `package.json`), `utf8`),
|
|
358
|
+
) as { description?: unknown }
|
|
359
|
+
const label =
|
|
360
|
+
config.label?.({
|
|
361
|
+
packageName: manifest.name,
|
|
362
|
+
representative: result.path,
|
|
363
|
+
peers: result.peers,
|
|
364
|
+
...(typeof environment.description === `string`
|
|
365
|
+
? { description: environment.description }
|
|
366
|
+
: {}),
|
|
367
|
+
}) ?? `${manifest.name}: ${result.path}`
|
|
368
|
+
requireMatrix(
|
|
369
|
+
typeof label === `string` && label.trim(),
|
|
370
|
+
`Invalid label for ${manifest.name} / ${result.path}.`,
|
|
371
|
+
)
|
|
372
|
+
environments.push({
|
|
373
|
+
id: digest(`${filename}\0${result.path}`).slice(0, 24),
|
|
374
|
+
label,
|
|
375
|
+
packageName: manifest.name,
|
|
376
|
+
representative: result.path,
|
|
377
|
+
suites: result.suites.map((s) => s.peer).sort(),
|
|
378
|
+
peers: peerIdentities(result.peers),
|
|
379
|
+
})
|
|
380
|
+
// Store portable identities; execution paths belong to each receipt's runner.
|
|
381
|
+
for (const peer of Object.values(result.peers))
|
|
382
|
+
peer.path = `(runner-local)`
|
|
383
|
+
}
|
|
384
|
+
packages.push({
|
|
385
|
+
config: filename,
|
|
386
|
+
directory: relative(root, base),
|
|
387
|
+
name: manifest.name,
|
|
388
|
+
configDigest: configDigest(npm, root, base),
|
|
389
|
+
inputDigest: await inputDigest(root, filename, npm, base),
|
|
390
|
+
audit,
|
|
391
|
+
releases: releases.sort((a, b) =>
|
|
392
|
+
canonical(a.context).localeCompare(canonical(b.context)),
|
|
393
|
+
),
|
|
394
|
+
})
|
|
395
|
+
for (const request of await archiveRequests(root, npm, base)) {
|
|
396
|
+
const previous = requests.get(request.artifact)
|
|
397
|
+
requireMatrix(
|
|
398
|
+
!previous || canonical(previous) === canonical(request),
|
|
399
|
+
`Conflicting archive requests: ${request.artifact}.`,
|
|
400
|
+
)
|
|
401
|
+
requests.set(request.artifact, request)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const archives = [...requests.values()].sort((a, b) =>
|
|
405
|
+
a.artifact.localeCompare(b.artifact),
|
|
406
|
+
)
|
|
407
|
+
for (const request of archives) {
|
|
408
|
+
await contained(root, request.directory)
|
|
409
|
+
await contained(root, request.artifact)
|
|
410
|
+
await ignoredOutput(root, path.resolve(root, request.artifact))
|
|
411
|
+
}
|
|
412
|
+
if (typeof config.prepare === `string`) await prepare(config.prepare, root)
|
|
413
|
+
else await config.prepare({ rootDirectory: root, archives })
|
|
414
|
+
const packed: NpmPlan[`archives`] = []
|
|
415
|
+
for (const archive of archives) {
|
|
416
|
+
const filename = await contained(root, archive.artifact)
|
|
417
|
+
const sha256 = await fileDigest(filename)
|
|
418
|
+
const file = `archives/${sha256}.tgz`
|
|
419
|
+
await mkdir(path.join(bundle, `archives`), { recursive: true })
|
|
420
|
+
await copyFile(filename, await contained(bundle, file))
|
|
421
|
+
packed.push({ path: archive.artifact, file, sha256 })
|
|
422
|
+
}
|
|
423
|
+
requireMatrix(
|
|
424
|
+
(await sourceIdentity(root)) === commit,
|
|
425
|
+
`Source changed while preparing the matrix.`,
|
|
426
|
+
)
|
|
427
|
+
const payload = {
|
|
428
|
+
formatVersion: 1 as const,
|
|
429
|
+
runId: randomUUID(),
|
|
430
|
+
commit,
|
|
431
|
+
configs: patterns,
|
|
432
|
+
packages,
|
|
433
|
+
environments,
|
|
434
|
+
archives: packed,
|
|
435
|
+
}
|
|
436
|
+
const plan = validateNpmPlan({ ...payload, id: planDigest(payload) })
|
|
437
|
+
await validateWorkspace(plan, {
|
|
438
|
+
rootDirectory: root,
|
|
439
|
+
bundleDirectory: bundle,
|
|
440
|
+
})
|
|
441
|
+
await writeMatrixJson(path.join(bundle, `plan.json`), plan)
|
|
442
|
+
return plan
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function validateWorkspace(plan: NpmPlan, options: NpmMatrixLocation) {
|
|
447
|
+
const root = path.resolve(options.rootDirectory ?? process.cwd())
|
|
448
|
+
const bundle = path.resolve(
|
|
449
|
+
root,
|
|
450
|
+
options.bundleDirectory ?? `.breakage/npm-plan`,
|
|
451
|
+
)
|
|
452
|
+
requireMatrix(
|
|
453
|
+
(await sourceIdentity(root)) === plan.commit,
|
|
454
|
+
`Plan belongs to a different source commit.`,
|
|
455
|
+
)
|
|
456
|
+
requireMatrix(
|
|
457
|
+
canonical(await discoverConfigs(root, plan.configs)) ===
|
|
458
|
+
canonical(plan.packages.map((p) => p.config).sort()),
|
|
459
|
+
`Plan no longer enumerates every configured npm package.`,
|
|
460
|
+
)
|
|
461
|
+
const configs = new Map<string, Awaited<ReturnType<typeof packageConfig>>>()
|
|
462
|
+
const requiredArchives = new Set<string>()
|
|
463
|
+
for (const pkg of plan.packages) {
|
|
464
|
+
const loaded = await packageConfig(root, pkg.config)
|
|
465
|
+
const { config, base } = loaded
|
|
466
|
+
for (const request of await archiveRequests(root, config, base))
|
|
467
|
+
requiredArchives.add(request.artifact)
|
|
468
|
+
requireMatrix(
|
|
469
|
+
relative(root, base) === pkg.directory &&
|
|
470
|
+
configDigest(config, root, base) === pkg.configDigest,
|
|
471
|
+
`Configuration changed for ${pkg.name}.`,
|
|
472
|
+
)
|
|
473
|
+
requireMatrix(
|
|
474
|
+
(await inputDigest(root, pkg.config, config, base)) === pkg.inputDigest,
|
|
475
|
+
`Contract, manifest, or lockfile inputs changed for ${pkg.name}.`,
|
|
476
|
+
)
|
|
477
|
+
const representatives = (await discover(config, base))
|
|
478
|
+
.map((d) => relative(base, d))
|
|
479
|
+
.sort()
|
|
480
|
+
requireMatrix(
|
|
481
|
+
canonical(representatives) ===
|
|
482
|
+
canonical(pkg.audit.representatives.map((r) => r.path).sort()),
|
|
483
|
+
`Representative discovery changed for ${pkg.name}.`,
|
|
484
|
+
)
|
|
485
|
+
for (const release of pkg.releases) {
|
|
486
|
+
requireMatrix(
|
|
487
|
+
config.release,
|
|
488
|
+
`Release configuration disappeared for ${pkg.name}.`,
|
|
489
|
+
)
|
|
490
|
+
const actual = await resolveReleaseDelta(config.release, {
|
|
491
|
+
...release.context,
|
|
492
|
+
packageDirectory: await contained(
|
|
493
|
+
root,
|
|
494
|
+
release.context.packageDirectory,
|
|
495
|
+
),
|
|
496
|
+
})
|
|
497
|
+
requireMatrix(
|
|
498
|
+
canonical(actual) === canonical(release.transition),
|
|
499
|
+
`Release evidence changed for ${pkg.name} / ${release.context.packageName}.`,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
if (pkg.audit.support.release) {
|
|
503
|
+
const expected = pkg.audit.support.release
|
|
504
|
+
requireMatrix(
|
|
505
|
+
canonical(
|
|
506
|
+
versionDelta(
|
|
507
|
+
expected.previousVersion,
|
|
508
|
+
expected.version,
|
|
509
|
+
config.release?.policy,
|
|
510
|
+
),
|
|
511
|
+
) === canonical(expected),
|
|
512
|
+
`Release policy changed for ${pkg.name}.`,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
configs.set(pkg.name, loaded)
|
|
516
|
+
}
|
|
517
|
+
requireMatrix(
|
|
518
|
+
canonical([...requiredArchives].sort()) ===
|
|
519
|
+
canonical(plan.archives.map((a) => a.path).sort()),
|
|
520
|
+
`Planned archives do not match the configured packages and local peers.`,
|
|
521
|
+
)
|
|
522
|
+
for (const archive of plan.archives) {
|
|
523
|
+
portablePath(archive.path)
|
|
524
|
+
requireMatrix(
|
|
525
|
+
archive.file === `archives/${archive.sha256}.tgz`,
|
|
526
|
+
`Invalid bundled archive path: ${archive.file}.`,
|
|
527
|
+
)
|
|
528
|
+
requireMatrix(
|
|
529
|
+
(await fileDigest(await contained(bundle, archive.file))) ===
|
|
530
|
+
archive.sha256,
|
|
531
|
+
`Candidate archive changed: ${archive.path}.`,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
return { root, bundle, configs }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Execute exactly one audited environment, producing failure evidence whenever the plan is valid. */
|
|
538
|
+
export async function npmExecute(
|
|
539
|
+
value: unknown,
|
|
540
|
+
id: string,
|
|
541
|
+
options: NpmMatrixLocation = {},
|
|
542
|
+
): Promise<NpmReceipt> {
|
|
543
|
+
const plan = validateNpmPlan(value)
|
|
544
|
+
const receipt: NpmReceipt = {
|
|
545
|
+
formatVersion: 1,
|
|
546
|
+
planId: plan.id,
|
|
547
|
+
id,
|
|
548
|
+
exitCode: 2,
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const env = plan.environments.find((e) => e.id === id)
|
|
552
|
+
requireMatrix(env, `Unknown planned environment: ${id}.`)
|
|
553
|
+
const { root, bundle, configs } = await validateWorkspace(plan, options)
|
|
554
|
+
const { config, baseDirectory } = configs.get(env.packageName)!
|
|
555
|
+
// Use the immutable bundle directly: no archive installation into the checkout.
|
|
556
|
+
const byPath = new Map(
|
|
557
|
+
plan.archives.map((a) => [a.path, path.join(bundle, a.file)]),
|
|
558
|
+
)
|
|
559
|
+
const base = path.resolve(baseDirectory, config.packageDirectory ?? `.`)
|
|
560
|
+
const artifactFor = (filename: string): string => {
|
|
561
|
+
const archive = byPath.get(relative(root, path.resolve(base, filename)))
|
|
562
|
+
requireMatrix(archive, `Plan is missing archive: ${filename}.`)
|
|
563
|
+
return archive
|
|
564
|
+
}
|
|
565
|
+
const effective: NpmConfig = {
|
|
566
|
+
...config,
|
|
567
|
+
artifact: artifactFor(config.artifact),
|
|
568
|
+
localPeers: Object.fromEntries(
|
|
569
|
+
Object.entries(config.localPeers ?? {}).map(([name, peer]) => [
|
|
570
|
+
name,
|
|
571
|
+
{
|
|
572
|
+
...peer,
|
|
573
|
+
artifact:
|
|
574
|
+
byPath.get(relative(root, path.resolve(base, peer.artifact))) ??
|
|
575
|
+
peer.artifact,
|
|
576
|
+
},
|
|
577
|
+
]),
|
|
578
|
+
),
|
|
579
|
+
}
|
|
580
|
+
receipt.report = await npmTestPlanned(
|
|
581
|
+
effective,
|
|
582
|
+
{ representative: env.representative, suites: env.suites },
|
|
583
|
+
{ baseDirectory },
|
|
584
|
+
)
|
|
585
|
+
receipt.exitCode = receipt.report.exitCode
|
|
586
|
+
// Tests/preparation cannot rewrite inputs or bundled artifacts and still attest success.
|
|
587
|
+
await validateWorkspace(plan, options)
|
|
588
|
+
if (receipt.exitCode === 0) {
|
|
589
|
+
const result = receipt.report.representatives.find(
|
|
590
|
+
(r) => r.path === env.representative,
|
|
591
|
+
)
|
|
592
|
+
requireMatrix(
|
|
593
|
+
result?.status === `passed` &&
|
|
594
|
+
canonical(result.suites.map((s) => s.peer).sort()) ===
|
|
595
|
+
canonical(env.suites) &&
|
|
596
|
+
result.suites.every((s) => s.status === `passed`) &&
|
|
597
|
+
canonical(peerIdentities(result.peers)) === canonical(env.peers),
|
|
598
|
+
`Execution differs from the planned suites or peer identities: ${env.label}.`,
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
receipt.exitCode = 2
|
|
603
|
+
receipt.error = errorMessage(error)
|
|
604
|
+
}
|
|
605
|
+
return receipt
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Verify source/config/archive identities and then require all planned execution receipts. */
|
|
609
|
+
export async function npmVerify(
|
|
610
|
+
value: unknown,
|
|
611
|
+
receipts: readonly unknown[],
|
|
612
|
+
options: NpmMatrixLocation = {},
|
|
613
|
+
): Promise<NpmVerification> {
|
|
614
|
+
try {
|
|
615
|
+
const plan = validateNpmPlan(value)
|
|
616
|
+
await validateWorkspace(plan, options)
|
|
617
|
+
return verifyNpmReceipts(plan, receipts)
|
|
618
|
+
} catch (error) {
|
|
619
|
+
return { exitCode: 2, errors: [errorMessage(error)], environments: 0 }
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function npmMatrix(value: unknown): {
|
|
624
|
+
include: {
|
|
625
|
+
id: string
|
|
626
|
+
label: string
|
|
627
|
+
packageName: string
|
|
628
|
+
representative: string
|
|
629
|
+
}[]
|
|
630
|
+
} {
|
|
631
|
+
return {
|
|
632
|
+
include: validateNpmPlan(value).environments.map(
|
|
633
|
+
({ id, label, packageName, representative }) => ({
|
|
634
|
+
id,
|
|
635
|
+
label,
|
|
636
|
+
packageName,
|
|
637
|
+
representative,
|
|
638
|
+
}),
|
|
639
|
+
),
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function writeMatrixJson(
|
|
644
|
+
filename: string,
|
|
645
|
+
value: unknown,
|
|
646
|
+
): Promise<void> {
|
|
647
|
+
await mkdir(path.dirname(filename), { recursive: true })
|
|
648
|
+
const temporary = `${filename}.${randomUUID()}.tmp`
|
|
649
|
+
try {
|
|
650
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
651
|
+
flag: `wx`,
|
|
652
|
+
})
|
|
653
|
+
await rename(temporary, filename)
|
|
654
|
+
} finally {
|
|
655
|
+
await rm(temporary, { force: true })
|
|
656
|
+
}
|
|
657
|
+
}
|