breakage 0.8.5 → 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 +163 -47
- package/dist/{breakage-B7dJsgcu.js → breakage-DJiIiLzL.js} +958 -221
- package/dist/breakage-DJiIiLzL.js.map +1 -0
- package/dist/breakage.d.ts +239 -71
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +3 -1
- package/dist/breakage.x.js +216 -79
- package/dist/breakage.x.js.map +1 -1
- package/dist/changesets.d.ts +8 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +27 -0
- package/dist/changesets.js.map +1 -0
- package/dist/release-BsdIDT4g.js +69 -0
- package/dist/release-BsdIDT4g.js.map +1 -0
- package/dist/release-D47BqRuB.d.ts +40 -0
- package/dist/release-D47BqRuB.d.ts.map +1 -0
- package/npm.md +198 -44
- package/package.json +45 -6
- package/src/breakage.ts +142 -73
- package/src/breakage.x.ts +174 -127
- package/src/changesets.ts +51 -0
- package/src/config.ts +131 -0
- package/src/npm/check.ts +70 -50
- package/src/npm/cli.ts +30 -22
- package/src/npm/config.ts +38 -6
- package/src/npm/environment.ts +10 -10
- package/src/npm/matrix-cli.ts +170 -0
- package/src/npm/matrix-model.ts +395 -0
- package/src/npm/matrix.ts +657 -0
- package/src/npm/release.ts +9 -25
- package/src/npm/types.ts +5 -45
- package/src/release.ts +153 -0
- package/src/{npm/command.ts → task.ts} +2 -0
- package/dist/breakage-B7dJsgcu.js.map +0 -1
- package/dist/breakage.$configPath.schema.json +0 -33
- package/dist/breakage.help.schema.json +0 -6
- package/dist/breakage.main.schema.json +0 -33
- package/dist/breakage.npm.check.schema.json +0 -16
- package/dist/breakage.npm.help.schema.json +0 -6
- package/dist/breakage.npm.schema.json +0 -6
- package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
- package/dist/breakage.npm.test.schema.json +0 -16
- package/dist/breakage.prelude.$configPath.schema.json +0 -31
- package/dist/breakage.prelude.schema.json +0 -31
- package/dist/breakage.schema.schema.json +0 -10
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { glob, readFile } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { type } from "arktype"
|
|
4
|
+
import { options } from "comline"
|
|
5
|
+
import { configOptions, defaultConfig, loadConfig } from "../config.ts"
|
|
6
|
+
import { errorMessage } from "../task.ts"
|
|
7
|
+
import { formatNpmReport } from "./check.ts"
|
|
8
|
+
import {
|
|
9
|
+
npmPlan,
|
|
10
|
+
npmExecute,
|
|
11
|
+
npmVerify,
|
|
12
|
+
npmMatrix,
|
|
13
|
+
writeMatrixJson,
|
|
14
|
+
} from "./matrix.ts"
|
|
15
|
+
|
|
16
|
+
const hiddenSchema = {
|
|
17
|
+
"release?": configOptions.optionsSchema.get(`release`),
|
|
18
|
+
"npm?": configOptions.optionsSchema.get(`npm`),
|
|
19
|
+
"npmMatrix?": configOptions.optionsSchema.get(`npmMatrix`),
|
|
20
|
+
}
|
|
21
|
+
const hidden = {
|
|
22
|
+
release: { configOnly: true },
|
|
23
|
+
npm: { configOnly: true },
|
|
24
|
+
npmMatrix: { configOnly: true },
|
|
25
|
+
} as const
|
|
26
|
+
const root = {
|
|
27
|
+
schema: type(`string`),
|
|
28
|
+
required: false,
|
|
29
|
+
description: `Source Git checkout root (defaults to the current directory).`,
|
|
30
|
+
example: `--root .`,
|
|
31
|
+
completion: { fileSystem: `directories` as const },
|
|
32
|
+
}
|
|
33
|
+
const plan = {
|
|
34
|
+
schema: type(`string`),
|
|
35
|
+
required: true,
|
|
36
|
+
description: `Prepared plan.json; archives are read from its bundle directory.`,
|
|
37
|
+
example: `--plan .breakage/npm-plan/plan.json`,
|
|
38
|
+
completion: { fileSystem: `files` as const },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const npmPlanManual = options(
|
|
42
|
+
`Audit the peer matrix and prepare a portable archive bundle; print matrix JSON.`,
|
|
43
|
+
type({
|
|
44
|
+
...hiddenSchema,
|
|
45
|
+
"config?": `string`,
|
|
46
|
+
"root?": `string`,
|
|
47
|
+
"out?": `string`,
|
|
48
|
+
}),
|
|
49
|
+
{
|
|
50
|
+
...hidden,
|
|
51
|
+
root,
|
|
52
|
+
config: {
|
|
53
|
+
schema: type(`string`),
|
|
54
|
+
required: false,
|
|
55
|
+
description: `TypeScript configuration containing npmMatrix.`,
|
|
56
|
+
example: `--config breakage.config.ts`,
|
|
57
|
+
completion: { fileSystem: `files` },
|
|
58
|
+
},
|
|
59
|
+
out: {
|
|
60
|
+
schema: type(`string`),
|
|
61
|
+
required: false,
|
|
62
|
+
description: `Bundle directory (defaults to .breakage/npm-plan).`,
|
|
63
|
+
example: `--out .breakage/peer-bundle`,
|
|
64
|
+
completion: { fileSystem: `directories` },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
export const npmExecuteManual = options(
|
|
69
|
+
`Execute one planned representative and write its receipt.`,
|
|
70
|
+
type({
|
|
71
|
+
...hiddenSchema,
|
|
72
|
+
plan: `string`,
|
|
73
|
+
receipt: `string`,
|
|
74
|
+
"root?": `string`,
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
...hidden,
|
|
78
|
+
root,
|
|
79
|
+
plan,
|
|
80
|
+
receipt: {
|
|
81
|
+
schema: type(`string`),
|
|
82
|
+
required: true,
|
|
83
|
+
description: `Output receipt filename.`,
|
|
84
|
+
example: `--receipt .breakage/receipts/environment.json`,
|
|
85
|
+
completion: { fileSystem: `files` },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
export const npmVerifyManual = options(
|
|
90
|
+
`Verify the complete planned matrix against collected execution receipts.`,
|
|
91
|
+
type({
|
|
92
|
+
...hiddenSchema,
|
|
93
|
+
plan: `string`,
|
|
94
|
+
receipts: `string`,
|
|
95
|
+
"root?": `string`,
|
|
96
|
+
}),
|
|
97
|
+
{
|
|
98
|
+
...hidden,
|
|
99
|
+
root,
|
|
100
|
+
plan,
|
|
101
|
+
receipts: {
|
|
102
|
+
schema: type(`string`),
|
|
103
|
+
required: true,
|
|
104
|
+
description: `Glob selecting all collected JSON receipts, relative to root.`,
|
|
105
|
+
example: `--receipts '.breakage/receipts/**/*.json'`,
|
|
106
|
+
completion: { fileSystem: `files` },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
export async function runNpmMatrixCli(
|
|
112
|
+
action: `plan` | `execute` | `verify`,
|
|
113
|
+
opts: {
|
|
114
|
+
root?: string
|
|
115
|
+
config?: string
|
|
116
|
+
out?: string
|
|
117
|
+
plan?: string
|
|
118
|
+
receipt?: string
|
|
119
|
+
receipts?: string
|
|
120
|
+
},
|
|
121
|
+
id?: string,
|
|
122
|
+
): Promise<number> {
|
|
123
|
+
try {
|
|
124
|
+
const rootDirectory = path.resolve(opts.root ?? process.cwd())
|
|
125
|
+
if (action === `plan`) {
|
|
126
|
+
const config = await loadConfig(
|
|
127
|
+
path.resolve(
|
|
128
|
+
rootDirectory,
|
|
129
|
+
opts.config ?? defaultConfig(rootDirectory),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
if (!config.npmMatrix)
|
|
133
|
+
throw new Error(`Configure npmMatrix.configs and npmMatrix.prepare.`)
|
|
134
|
+
const prepared = await npmPlan(config.npmMatrix, {
|
|
135
|
+
rootDirectory,
|
|
136
|
+
...(opts.out ? { bundleDirectory: opts.out } : {}),
|
|
137
|
+
})
|
|
138
|
+
process.stdout.write(`${JSON.stringify(npmMatrix(prepared))}\n`)
|
|
139
|
+
return 0
|
|
140
|
+
}
|
|
141
|
+
const filename = path.resolve(rootDirectory, opts.plan!)
|
|
142
|
+
const value: unknown = JSON.parse(await readFile(filename, `utf8`))
|
|
143
|
+
const location = { rootDirectory, bundleDirectory: path.dirname(filename) }
|
|
144
|
+
if (action === `execute`) {
|
|
145
|
+
const receipt = await npmExecute(value, id!, location)
|
|
146
|
+
await writeMatrixJson(path.resolve(rootDirectory, opts.receipt!), receipt)
|
|
147
|
+
if (receipt.report) process.stdout.write(formatNpmReport(receipt.report))
|
|
148
|
+
if (receipt.error) process.stderr.write(`${receipt.error}\n`)
|
|
149
|
+
return receipt.exitCode
|
|
150
|
+
}
|
|
151
|
+
const receipts: unknown[] = []
|
|
152
|
+
for await (const filename of glob(opts.receipts!, { cwd: rootDirectory }))
|
|
153
|
+
receipts.push(
|
|
154
|
+
JSON.parse(
|
|
155
|
+
await readFile(path.resolve(rootDirectory, filename), `utf8`),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
const result = await npmVerify(value, receipts, location)
|
|
159
|
+
if (result.errors.length)
|
|
160
|
+
process.stderr.write(`${result.errors.join(`\n`)}\n`)
|
|
161
|
+
else
|
|
162
|
+
process.stdout.write(
|
|
163
|
+
`Verified passing receipts for all ${result.environments} planned environments.\n`,
|
|
164
|
+
)
|
|
165
|
+
return result.exitCode
|
|
166
|
+
} catch (error) {
|
|
167
|
+
process.stderr.write(`npm matrix setup error: ${errorMessage(error)}\n`)
|
|
168
|
+
return 2
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// cspell:words premajor preminor prepatch
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { type } from "arktype"
|
|
4
|
+
import type { NpmPeerEvidence, NpmReport } from "./types.ts"
|
|
5
|
+
|
|
6
|
+
export type NpmArchiveRequest = {
|
|
7
|
+
packageName: string
|
|
8
|
+
directory: string
|
|
9
|
+
artifact: string
|
|
10
|
+
}
|
|
11
|
+
export type NpmPrepareContext = {
|
|
12
|
+
rootDirectory: string
|
|
13
|
+
archives: NpmArchiveRequest[]
|
|
14
|
+
}
|
|
15
|
+
export type NpmMatrixLabelContext = {
|
|
16
|
+
packageName: string
|
|
17
|
+
representative: string
|
|
18
|
+
description?: string
|
|
19
|
+
peers: Record<string, NpmPeerEvidence>
|
|
20
|
+
}
|
|
21
|
+
export const npmMatrixConfigSchema = type({
|
|
22
|
+
"+": `reject`,
|
|
23
|
+
configs: `string | string[]`,
|
|
24
|
+
prepare: type(`string`).or(
|
|
25
|
+
type(`Function`).as<(context: NpmPrepareContext) => void | Promise<void>>(),
|
|
26
|
+
),
|
|
27
|
+
"label?": type(`Function`).as<(context: NpmMatrixLabelContext) => string>(),
|
|
28
|
+
})
|
|
29
|
+
export type NpmMatrixConfig = typeof npmMatrixConfigSchema.infer
|
|
30
|
+
|
|
31
|
+
const transition = type({
|
|
32
|
+
previousVersion: `string`,
|
|
33
|
+
version: `string`,
|
|
34
|
+
delta: `'major' | 'premajor' | 'minor' | 'preminor' | 'patch' | 'prepatch' | 'prerelease' | 'release' | 'none'`,
|
|
35
|
+
})
|
|
36
|
+
const identity = type({
|
|
37
|
+
version: `string`,
|
|
38
|
+
source: `'installed' | 'candidate'`,
|
|
39
|
+
})
|
|
40
|
+
const reportSchema = type({
|
|
41
|
+
package: { name: `string`, version: `string` },
|
|
42
|
+
mode: `'check' | 'test' | 'coverage-only'`,
|
|
43
|
+
exitCode: `0 | 1 | 2`,
|
|
44
|
+
errors: `string[]`,
|
|
45
|
+
coverage: type({
|
|
46
|
+
peer: `string`,
|
|
47
|
+
era: `string`,
|
|
48
|
+
representatives: `string[]`,
|
|
49
|
+
}).array(),
|
|
50
|
+
support: {
|
|
51
|
+
status: `'unchanged' | 'breaking-release' | 'end-of-support' | 'first-release' | 'not-checked' | 'setup-error'`,
|
|
52
|
+
changes: type({
|
|
53
|
+
peer: `string`,
|
|
54
|
+
reason: `'narrowed-range' | 'required-peer'`,
|
|
55
|
+
previous: `string | null`,
|
|
56
|
+
current: `string | null`,
|
|
57
|
+
}).array(),
|
|
58
|
+
"previousVersion?": `string`,
|
|
59
|
+
"release?": transition.and({ breaking: `boolean` }),
|
|
60
|
+
"error?": `string`,
|
|
61
|
+
},
|
|
62
|
+
representatives: type({
|
|
63
|
+
path: `string`,
|
|
64
|
+
status: `'passed' | 'audited' | 'skipped' | 'compatibility-failure' | 'setup-error'`,
|
|
65
|
+
peers: {
|
|
66
|
+
"[string]": identity.and({ path: `string`, "archiveVersion?": `string` }),
|
|
67
|
+
},
|
|
68
|
+
suites: type({
|
|
69
|
+
peer: `string`,
|
|
70
|
+
status: `'passed' | 'compatibility-failure' | 'setup-error' | 'not-run'`,
|
|
71
|
+
"output?": `string`,
|
|
72
|
+
}).array(),
|
|
73
|
+
warnings: `string[]`,
|
|
74
|
+
"error?": `string`,
|
|
75
|
+
}).array(),
|
|
76
|
+
}).as<NpmReport>()
|
|
77
|
+
|
|
78
|
+
export const npmPlanSchema = type({
|
|
79
|
+
"+": `reject`,
|
|
80
|
+
formatVersion: `1`,
|
|
81
|
+
id: `string`,
|
|
82
|
+
runId: `string`,
|
|
83
|
+
commit: `string`,
|
|
84
|
+
configs: `string[]`,
|
|
85
|
+
packages: type({
|
|
86
|
+
config: `string`,
|
|
87
|
+
directory: `string`,
|
|
88
|
+
name: `string`,
|
|
89
|
+
configDigest: `string`,
|
|
90
|
+
inputDigest: `string`,
|
|
91
|
+
audit: reportSchema,
|
|
92
|
+
releases: type({
|
|
93
|
+
context: {
|
|
94
|
+
packageName: `string`,
|
|
95
|
+
packageDirectory: `string`,
|
|
96
|
+
currentVersion: `string`,
|
|
97
|
+
"baseline?": {
|
|
98
|
+
version: `string`,
|
|
99
|
+
"ref?": `string`,
|
|
100
|
+
"commit?": `string`,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"transition?": transition,
|
|
104
|
+
}).array(),
|
|
105
|
+
}).array(),
|
|
106
|
+
environments: type({
|
|
107
|
+
id: `string`,
|
|
108
|
+
label: `string`,
|
|
109
|
+
packageName: `string`,
|
|
110
|
+
representative: `string`,
|
|
111
|
+
suites: `string[]`,
|
|
112
|
+
peers: { "[string]": identity },
|
|
113
|
+
}).array(),
|
|
114
|
+
archives: type({ path: `string`, file: `string`, sha256: `string` }).array(),
|
|
115
|
+
})
|
|
116
|
+
export type NpmPlan = typeof npmPlanSchema.infer
|
|
117
|
+
export const npmReceiptSchema = type({
|
|
118
|
+
"+": `reject`,
|
|
119
|
+
formatVersion: `1`,
|
|
120
|
+
planId: `string`,
|
|
121
|
+
id: `string`,
|
|
122
|
+
exitCode: `0 | 1 | 2`,
|
|
123
|
+
"report?": reportSchema,
|
|
124
|
+
"error?": `string`,
|
|
125
|
+
})
|
|
126
|
+
export type NpmReceipt = typeof npmReceiptSchema.infer
|
|
127
|
+
export type NpmVerification = {
|
|
128
|
+
exitCode: 0 | 1 | 2
|
|
129
|
+
errors: string[]
|
|
130
|
+
environments: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Stable object ordering; arrays retain their semantic order. */
|
|
134
|
+
export function canonical(value: unknown): string {
|
|
135
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(`,`)}]`
|
|
136
|
+
if (value && typeof value === `object`)
|
|
137
|
+
return `{${Object.entries(value)
|
|
138
|
+
.filter(([, v]) => v !== undefined)
|
|
139
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
140
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
141
|
+
.join(`,`)}}`
|
|
142
|
+
return JSON.stringify(value) ?? `undefined`
|
|
143
|
+
}
|
|
144
|
+
export function digest(value: string | Uint8Array): string {
|
|
145
|
+
return createHash(`sha256`).update(value).digest(`hex`)
|
|
146
|
+
}
|
|
147
|
+
export function planDigest(plan: Omit<NpmPlan, `id`>): string {
|
|
148
|
+
return digest(canonical(plan))
|
|
149
|
+
}
|
|
150
|
+
export function peerIdentities(
|
|
151
|
+
peers: Record<string, NpmPeerEvidence>,
|
|
152
|
+
): Record<string, { version: string; source: `installed` | `candidate` }> {
|
|
153
|
+
return Object.fromEntries(
|
|
154
|
+
Object.entries(peers).map(([name, { version, source }]) => [
|
|
155
|
+
name,
|
|
156
|
+
{ version, source },
|
|
157
|
+
]),
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
export function requireMatrix(
|
|
161
|
+
condition: unknown,
|
|
162
|
+
message: string,
|
|
163
|
+
): asserts condition {
|
|
164
|
+
if (!condition) throw new Error(message)
|
|
165
|
+
}
|
|
166
|
+
function sameNames(
|
|
167
|
+
actual: string[],
|
|
168
|
+
expected: string[],
|
|
169
|
+
message: string,
|
|
170
|
+
): void {
|
|
171
|
+
const counts = (names: string[]): Map<string, number> => {
|
|
172
|
+
const result = new Map<string, number>()
|
|
173
|
+
for (const name of names) result.set(name, (result.get(name) ?? 0) + 1)
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
const actualCounts = counts(actual)
|
|
177
|
+
const expectedCounts = counts(expected)
|
|
178
|
+
const missing = [...expectedCounts]
|
|
179
|
+
.filter(([name, count]) => (actualCounts.get(name) ?? 0) < count)
|
|
180
|
+
.map(([name]) => name)
|
|
181
|
+
const extra = [...actualCounts]
|
|
182
|
+
.filter(([name, count]) => count > (expectedCounts.get(name) ?? 0))
|
|
183
|
+
.map(([name]) => name)
|
|
184
|
+
requireMatrix(
|
|
185
|
+
!missing.length && !extra.length,
|
|
186
|
+
`${message}${missing.length ? ` Missing: ${missing.join(`, `)}.` : ``}${extra.length ? ` Unexpected or duplicated: ${extra.join(`, `)}.` : ``}`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Validate the audit as well as the plan envelope; empty or partial plans never pass. */
|
|
191
|
+
export function validateNpmPlan(value: unknown): NpmPlan {
|
|
192
|
+
const plan = npmPlanSchema.assert(value)
|
|
193
|
+
const { id, ...payload } = plan
|
|
194
|
+
requireMatrix(
|
|
195
|
+
id === planDigest(payload),
|
|
196
|
+
`Plan digest does not match its contents.`,
|
|
197
|
+
)
|
|
198
|
+
requireMatrix(
|
|
199
|
+
plan.runId.length > 0 && /^[a-f0-9]{40,64}$/.test(plan.commit),
|
|
200
|
+
`Invalid plan source identity.`,
|
|
201
|
+
)
|
|
202
|
+
requireMatrix(
|
|
203
|
+
plan.configs.length &&
|
|
204
|
+
plan.packages.length &&
|
|
205
|
+
plan.environments.length &&
|
|
206
|
+
plan.archives.length,
|
|
207
|
+
`Plan must contain packages, environments, and archives.`,
|
|
208
|
+
)
|
|
209
|
+
for (const [name, values] of [
|
|
210
|
+
[`package`, plan.packages.map((p) => p.name)],
|
|
211
|
+
[`configuration`, plan.packages.map((p) => p.config)],
|
|
212
|
+
[`environment`, plan.environments.map((e) => e.id)],
|
|
213
|
+
[`archive`, plan.archives.map((a) => a.path)],
|
|
214
|
+
] as const)
|
|
215
|
+
requireMatrix(
|
|
216
|
+
new Set(values).size === values.length,
|
|
217
|
+
`Duplicate ${name} identities in plan.`,
|
|
218
|
+
)
|
|
219
|
+
for (const archive of plan.archives)
|
|
220
|
+
requireMatrix(
|
|
221
|
+
/^[a-f0-9]{64}$/.test(archive.sha256) &&
|
|
222
|
+
archive.file === `archives/${archive.sha256}.tgz`,
|
|
223
|
+
`Invalid archive digest or bundle path: ${archive.path}.`,
|
|
224
|
+
)
|
|
225
|
+
sameNames(
|
|
226
|
+
[...new Set(plan.environments.map((e) => e.packageName))],
|
|
227
|
+
plan.packages.map((p) => p.name),
|
|
228
|
+
`Plan environments do not match package audits.`,
|
|
229
|
+
)
|
|
230
|
+
for (const pkg of plan.packages) {
|
|
231
|
+
const audit = pkg.audit
|
|
232
|
+
requireMatrix(
|
|
233
|
+
audit.package.name === pkg.name &&
|
|
234
|
+
audit.mode === `coverage-only` &&
|
|
235
|
+
audit.exitCode === 0 &&
|
|
236
|
+
audit.errors.length === 0 &&
|
|
237
|
+
!audit.support.error,
|
|
238
|
+
`Coverage audit failed for ${pkg.name}.`,
|
|
239
|
+
)
|
|
240
|
+
requireMatrix(
|
|
241
|
+
[`unchanged`, `breaking-release`, `first-release`].includes(
|
|
242
|
+
audit.support.status,
|
|
243
|
+
),
|
|
244
|
+
`Support audit did not pass for ${pkg.name}.`,
|
|
245
|
+
)
|
|
246
|
+
requireMatrix(
|
|
247
|
+
audit.coverage.length &&
|
|
248
|
+
audit.coverage.every((c) => c.representatives.length),
|
|
249
|
+
`Uncovered peer era for ${pkg.name}.`,
|
|
250
|
+
)
|
|
251
|
+
const expected = plan.environments.filter((e) => e.packageName === pkg.name)
|
|
252
|
+
requireMatrix(
|
|
253
|
+
new Set(expected.map((e) => e.representative)).size === expected.length,
|
|
254
|
+
`Duplicate representative paths for ${pkg.name}.`,
|
|
255
|
+
)
|
|
256
|
+
sameNames(
|
|
257
|
+
audit.representatives.map((r) => r.path),
|
|
258
|
+
expected.map((e) => e.representative),
|
|
259
|
+
`Missing or duplicate audited environments for ${pkg.name}.`,
|
|
260
|
+
)
|
|
261
|
+
for (const env of expected) {
|
|
262
|
+
const result = audit.representatives.find(
|
|
263
|
+
(r) => r.path === env.representative,
|
|
264
|
+
)!
|
|
265
|
+
requireMatrix(
|
|
266
|
+
result.status === `audited` && !result.error && env.suites.length,
|
|
267
|
+
`Environment was not audited: ${pkg.name} / ${env.representative}.`,
|
|
268
|
+
)
|
|
269
|
+
sameNames(
|
|
270
|
+
result.suites.map((s) => s.peer),
|
|
271
|
+
env.suites,
|
|
272
|
+
`Audited suites differ: ${env.id}.`,
|
|
273
|
+
)
|
|
274
|
+
requireMatrix(
|
|
275
|
+
new Set(env.suites).size === env.suites.length &&
|
|
276
|
+
result.suites.every((s) => s.status === `not-run`),
|
|
277
|
+
`Invalid audited suites: ${env.id}.`,
|
|
278
|
+
)
|
|
279
|
+
requireMatrix(
|
|
280
|
+
canonical(peerIdentities(result.peers)) === canonical(env.peers),
|
|
281
|
+
`Audited peer identities differ: ${env.id}.`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
for (const coverage of audit.coverage)
|
|
285
|
+
for (const name of coverage.representatives)
|
|
286
|
+
requireMatrix(
|
|
287
|
+
expected.some(
|
|
288
|
+
(e) =>
|
|
289
|
+
e.representative === name && e.suites.includes(coverage.peer),
|
|
290
|
+
),
|
|
291
|
+
`Coverage references an unplanned suite: ${pkg.name} / ${name} / ${coverage.peer}.`,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
return plan
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Pure receipt verification; runner-local installation paths are diagnostic only. */
|
|
298
|
+
export function verifyNpmReceipts(
|
|
299
|
+
value: unknown,
|
|
300
|
+
receipts: readonly unknown[],
|
|
301
|
+
): NpmVerification {
|
|
302
|
+
const errors: string[] = []
|
|
303
|
+
let environments = 0
|
|
304
|
+
try {
|
|
305
|
+
const plan = validateNpmPlan(value)
|
|
306
|
+
environments = plan.environments.length
|
|
307
|
+
const parsed = receipts.map((r, i) => {
|
|
308
|
+
try {
|
|
309
|
+
return npmReceiptSchema.assert(r)
|
|
310
|
+
} catch (cause) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Malformed receipt ${i + 1}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
313
|
+
{ cause },
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
const environmentName = (id: string): string => {
|
|
318
|
+
const env = plan.environments.find((e) => e.id === id)
|
|
319
|
+
return env ? `${env.packageName} / ${env.representative} (${id})` : id
|
|
320
|
+
}
|
|
321
|
+
sameNames(
|
|
322
|
+
parsed.map((r) => environmentName(r.id)),
|
|
323
|
+
plan.environments.map((e) => environmentName(e.id)),
|
|
324
|
+
`Missing, duplicate, or unexpected environment receipts.`,
|
|
325
|
+
)
|
|
326
|
+
for (const env of plan.environments) {
|
|
327
|
+
try {
|
|
328
|
+
const receipt = parsed.find((r) => r.id === env.id)!
|
|
329
|
+
requireMatrix(
|
|
330
|
+
receipt.planId === plan.id,
|
|
331
|
+
`Receipt belongs to a different plan.`,
|
|
332
|
+
)
|
|
333
|
+
requireMatrix(
|
|
334
|
+
receipt.exitCode === 0 && !receipt.error && receipt.report,
|
|
335
|
+
receipt.error ?? `Environment execution failed.`,
|
|
336
|
+
)
|
|
337
|
+
const report = receipt.report
|
|
338
|
+
const pkg = plan.packages.find((p) => p.name === env.packageName)!
|
|
339
|
+
requireMatrix(
|
|
340
|
+
canonical(report.package) === canonical(pkg.audit.package),
|
|
341
|
+
`Wrong package identity.`,
|
|
342
|
+
)
|
|
343
|
+
requireMatrix(
|
|
344
|
+
report.mode === `test` &&
|
|
345
|
+
report.exitCode === 0 &&
|
|
346
|
+
report.errors.length === 0 &&
|
|
347
|
+
report.support.status === `not-checked` &&
|
|
348
|
+
!report.support.error,
|
|
349
|
+
`Receipt does not contain a successful execution report.`,
|
|
350
|
+
)
|
|
351
|
+
sameNames(
|
|
352
|
+
report.representatives.map((r) => r.path),
|
|
353
|
+
pkg.audit.representatives.map((r) => r.path),
|
|
354
|
+
`Missing or duplicate representatives in execution report.`,
|
|
355
|
+
)
|
|
356
|
+
for (const result of report.representatives) {
|
|
357
|
+
if (result.path !== env.representative) {
|
|
358
|
+
requireMatrix(
|
|
359
|
+
result.status === `skipped` &&
|
|
360
|
+
result.suites.length === 0 &&
|
|
361
|
+
!result.error,
|
|
362
|
+
`Unexpected execution: ${result.path}.`,
|
|
363
|
+
)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
requireMatrix(
|
|
367
|
+
result.status === `passed` && !result.error,
|
|
368
|
+
`Environment did not execute successfully.`,
|
|
369
|
+
)
|
|
370
|
+
sameNames(
|
|
371
|
+
result.suites.map((s) => s.peer),
|
|
372
|
+
env.suites,
|
|
373
|
+
`Missing, duplicate, or unexpected relationship suites.`,
|
|
374
|
+
)
|
|
375
|
+
for (const suite of result.suites)
|
|
376
|
+
requireMatrix(
|
|
377
|
+
suite.status === `passed`,
|
|
378
|
+
`Suite did not pass: ${suite.peer}.`,
|
|
379
|
+
)
|
|
380
|
+
requireMatrix(
|
|
381
|
+
canonical(peerIdentities(result.peers)) === canonical(env.peers),
|
|
382
|
+
`Installed peer identities differ from the audit.`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
errors.push(
|
|
387
|
+
`${env.packageName} / ${env.representative}: ${error instanceof Error ? error.message : String(error)}`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
errors.push(error instanceof Error ? error.message : String(error))
|
|
393
|
+
}
|
|
394
|
+
return { exitCode: errors.length ? 2 : 0, errors, environments }
|
|
395
|
+
}
|