breakage 0.8.4 → 0.9.0
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 +164 -46
- package/dist/breakage-DIY49fzP.js +1349 -0
- package/dist/breakage-DIY49fzP.js.map +1 -0
- package/dist/breakage.d.ts +179 -11
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +3 -1
- package/dist/breakage.x.js +169 -67
- 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 +236 -0
- package/package.json +51 -11
- package/src/breakage.ts +136 -70
- package/src/breakage.x.ts +160 -112
- package/src/changesets.ts +51 -0
- package/src/config.ts +128 -0
- package/src/npm/check.ts +449 -0
- package/src/npm/cli.ts +130 -0
- package/src/npm/config.ts +118 -0
- package/src/npm/environment.ts +661 -0
- package/src/npm/manifest.ts +134 -0
- package/src/npm/release.ts +173 -0
- package/src/npm/types.ts +64 -0
- package/src/release.ts +153 -0
- package/src/task.ts +54 -0
- package/dist/breakage-Dc4IldeW.js +0 -435
- package/dist/breakage-Dc4IldeW.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.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,661 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import * as fs from "node:fs/promises"
|
|
3
|
+
import {
|
|
4
|
+
copyFile,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises"
|
|
13
|
+
import { createRequire } from "node:module"
|
|
14
|
+
import path from "node:path"
|
|
15
|
+
|
|
16
|
+
import semver from "semver"
|
|
17
|
+
import { simpleGit } from "simple-git"
|
|
18
|
+
|
|
19
|
+
import { prepare } from "../task.ts"
|
|
20
|
+
import { validatePeerRanges } from "./config.ts"
|
|
21
|
+
import type { Manifest } from "./manifest.ts"
|
|
22
|
+
import { matches, normalizeWorkspacePeers, readManifest } from "./manifest.ts"
|
|
23
|
+
import { releaseVersion } from "./release.ts"
|
|
24
|
+
import type { NpmConfig, NpmPeerEvidence, NpmSuite } from "./types.ts"
|
|
25
|
+
|
|
26
|
+
export type Environment = {
|
|
27
|
+
directory: string
|
|
28
|
+
stage: string
|
|
29
|
+
manifest: Manifest
|
|
30
|
+
peers: Record<string, NpmPeerEvidence>
|
|
31
|
+
candidates: Record<
|
|
32
|
+
string,
|
|
33
|
+
{
|
|
34
|
+
directory: string
|
|
35
|
+
artifact: string
|
|
36
|
+
currentVersion: string
|
|
37
|
+
version: string
|
|
38
|
+
manifest: Manifest
|
|
39
|
+
}
|
|
40
|
+
>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function within(parent: string, child: string): boolean {
|
|
44
|
+
const relative = path.relative(parent, child)
|
|
45
|
+
return (
|
|
46
|
+
relative === `` ||
|
|
47
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
48
|
+
relative !== `..` &&
|
|
49
|
+
!path.isAbsolute(relative))
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function exists(filename: string): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
await lstat(filename)
|
|
56
|
+
return true
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if ((error as NodeJS.ErrnoException).code === `ENOENT`) return false
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function discover(
|
|
64
|
+
config: NpmConfig,
|
|
65
|
+
base: string,
|
|
66
|
+
): Promise<string[]> {
|
|
67
|
+
const found = new Set<string>()
|
|
68
|
+
for await (const name of fs.glob(config.representatives, {
|
|
69
|
+
cwd: base,
|
|
70
|
+
exclude: [`**/node_modules/**`, `**/test/**`, `**/.git/**`],
|
|
71
|
+
})) {
|
|
72
|
+
const directory = path.resolve(base, name)
|
|
73
|
+
if (!(await lstat(directory)).isDirectory())
|
|
74
|
+
throw new Error(`Representative is not a directory: ${name}.`)
|
|
75
|
+
found.add(directory)
|
|
76
|
+
}
|
|
77
|
+
if (!found.size)
|
|
78
|
+
throw new Error(
|
|
79
|
+
`No representative directories matched the configured globs.`,
|
|
80
|
+
)
|
|
81
|
+
return [...found].sort()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A freshly created ignored stage is also the exclusive ownership lock. */
|
|
85
|
+
export async function withStage<T>(
|
|
86
|
+
directory: string,
|
|
87
|
+
action: (stage: string) => Promise<T>,
|
|
88
|
+
): Promise<T> {
|
|
89
|
+
const stage = path.join(directory, `test`)
|
|
90
|
+
const git = simpleGit(directory)
|
|
91
|
+
const root = await git.revparse([`--show-toplevel`])
|
|
92
|
+
if (!within(await realpath(root), await realpath(directory)))
|
|
93
|
+
throw new Error(`Representative escapes the repository: ${directory}.`)
|
|
94
|
+
if (
|
|
95
|
+
!(await git.raw([`check-ignore`, `--`, `${stage}/`])).trim() ||
|
|
96
|
+
(await git.raw([`ls-files`, `--`, stage])).trim()
|
|
97
|
+
) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Representative test/ must be gitignored and contain no tracked files: ${stage}.`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(stage)
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Cannot own ${stage}; another run or preexisting files may be present. Remove it only after checking ownership.`,
|
|
107
|
+
{ cause },
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return await action(stage)
|
|
112
|
+
} finally {
|
|
113
|
+
await rm(stage, { recursive: true, force: true })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function installed(
|
|
118
|
+
directory: string,
|
|
119
|
+
peer: string,
|
|
120
|
+
): Promise<NpmPeerEvidence> {
|
|
121
|
+
const location = path.join(directory, `node_modules`, peer)
|
|
122
|
+
const physical = await realpath(location)
|
|
123
|
+
// Support pnpm's store links, while rejecting arbitrary workspace source links.
|
|
124
|
+
if (!physical.split(path.sep).includes(`node_modules`))
|
|
125
|
+
throw new Error(`Peer ${peer} resolves to workspace source: ${physical}.`)
|
|
126
|
+
const manifest = await readManifest(path.join(physical, `package.json`))
|
|
127
|
+
if (manifest.name !== peer)
|
|
128
|
+
throw new Error(`Expected ${peer}, found ${manifest.name} in ${location}.`)
|
|
129
|
+
return { version: manifest.version, path: physical, source: `installed` }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function representativeDeclarations(
|
|
133
|
+
manifest: Manifest,
|
|
134
|
+
): Record<string, string> {
|
|
135
|
+
for (const field of [
|
|
136
|
+
`dependencies`,
|
|
137
|
+
`devDependencies`,
|
|
138
|
+
`optionalDependencies`,
|
|
139
|
+
`peerDependencies`,
|
|
140
|
+
] as const) {
|
|
141
|
+
for (const [name, specifier] of Object.entries(manifest[field] ?? {})) {
|
|
142
|
+
const version = semver.parse(specifier)
|
|
143
|
+
// Semver's normalized version omits build metadata; exact pins retain it.
|
|
144
|
+
const exact =
|
|
145
|
+
version &&
|
|
146
|
+
`${version.version}${version.build.length ? `+${version.build.join(`.`)}` : ``}`
|
|
147
|
+
if (!exact || specifier !== exact)
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Representative ${manifest.name} must pin ${field}.${name} to an exact version, such as 1.0.0; received ${JSON.stringify(specifier)}. Declare support ranges in the package under test, and use breakage.candidates for coordinated local releases.`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [peer, development] of Object.entries(
|
|
154
|
+
manifest.devDependencies ?? {},
|
|
155
|
+
)) {
|
|
156
|
+
for (const field of [`dependencies`, `optionalDependencies`] as const) {
|
|
157
|
+
const runtime = manifest[field]?.[peer]
|
|
158
|
+
if (runtime !== undefined && runtime !== development)
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Conflicting representative declarations for ${peer}: devDependencies has ${development}, but ${field} has ${runtime}. Use one consistent requirement.`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Optional runtime declarations retain npm's override of required ones.
|
|
165
|
+
return {
|
|
166
|
+
...manifest.devDependencies,
|
|
167
|
+
...manifest.dependencies,
|
|
168
|
+
...manifest.optionalDependencies,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Select exact pins without acquiring or preparing their stages. */
|
|
173
|
+
export async function maySelectEnvironment(
|
|
174
|
+
config: NpmConfig,
|
|
175
|
+
base: string,
|
|
176
|
+
directory: string,
|
|
177
|
+
packageManifest: Manifest,
|
|
178
|
+
selection: { peer: string; range: string },
|
|
179
|
+
): Promise<boolean> {
|
|
180
|
+
const manifest = await readManifest(path.join(directory, `package.json`))
|
|
181
|
+
const declarations = representativeDeclarations(manifest)
|
|
182
|
+
const { peer, range } = selection
|
|
183
|
+
if (peer === packageManifest.name)
|
|
184
|
+
return matches(packageManifest.version, range)
|
|
185
|
+
if (manifest.breakage?.candidates?.includes(peer)) {
|
|
186
|
+
const local = config.localPeers?.[peer]
|
|
187
|
+
if (!local)
|
|
188
|
+
throw new Error(`Candidate ${peer} has no localPeers configuration.`)
|
|
189
|
+
const packageDirectory = path.resolve(base, local.directory)
|
|
190
|
+
const current = await readManifest(
|
|
191
|
+
path.join(packageDirectory, `package.json`),
|
|
192
|
+
)
|
|
193
|
+
if (current.name !== peer)
|
|
194
|
+
throw new Error(`Local peer identity mismatch for ${peer}.`)
|
|
195
|
+
const release = await releaseVersion(config, {
|
|
196
|
+
packageName: peer,
|
|
197
|
+
packageDirectory,
|
|
198
|
+
currentVersion: current.version,
|
|
199
|
+
})
|
|
200
|
+
if (release === undefined)
|
|
201
|
+
throw new Error(`Candidate ${peer} has no release version.`)
|
|
202
|
+
return matches(release, range)
|
|
203
|
+
}
|
|
204
|
+
const declared = declarations[peer]
|
|
205
|
+
return declared !== undefined && matches(declared, range)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function prepareEnvironment(
|
|
209
|
+
config: NpmConfig,
|
|
210
|
+
base: string,
|
|
211
|
+
directory: string,
|
|
212
|
+
stage: string,
|
|
213
|
+
packageManifest: Manifest,
|
|
214
|
+
): Promise<Environment> {
|
|
215
|
+
const peerNames = Object.keys(packageManifest.peerDependencies ?? {})
|
|
216
|
+
const filename = path.join(directory, `package.json`)
|
|
217
|
+
const lockfile = path.join(directory, config.lockfile)
|
|
218
|
+
for (const input of [filename, lockfile]) {
|
|
219
|
+
if (
|
|
220
|
+
!(await lstat(input)).isFile() ||
|
|
221
|
+
!within(directory, await realpath(input))
|
|
222
|
+
)
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Installation inputs must be regular files inside the representative: ${input}.`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
const inputs = await Promise.all([readFile(filename), readFile(lockfile)])
|
|
228
|
+
const manifest = await readManifest(filename)
|
|
229
|
+
const declarations = representativeDeclarations(manifest)
|
|
230
|
+
const candidates: Environment[`candidates`] = {}
|
|
231
|
+
for (const peer of manifest.breakage?.candidates ?? []) {
|
|
232
|
+
if (!peerNames.includes(peer))
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Candidate ${peer} is not a declared peer of the package under test.`,
|
|
235
|
+
)
|
|
236
|
+
const local = config.localPeers?.[peer]
|
|
237
|
+
if (!local)
|
|
238
|
+
throw new Error(`Candidate ${peer} has no localPeers configuration.`)
|
|
239
|
+
const localDirectory = path.resolve(base, local.directory)
|
|
240
|
+
const localManifest = await normalizeWorkspacePeers(
|
|
241
|
+
await readManifest(path.join(localDirectory, `package.json`)),
|
|
242
|
+
)
|
|
243
|
+
if (localManifest.name !== peer)
|
|
244
|
+
throw new Error(`Local peer identity mismatch for ${peer}.`)
|
|
245
|
+
validatePeerRanges(localManifest.peerDependencies ?? {})
|
|
246
|
+
const evidence = await releaseVersion(config, {
|
|
247
|
+
packageName: peer,
|
|
248
|
+
packageDirectory: localDirectory,
|
|
249
|
+
currentVersion: localManifest.version,
|
|
250
|
+
})
|
|
251
|
+
if (evidence === undefined)
|
|
252
|
+
throw new Error(`Candidate ${peer} has no release version.`)
|
|
253
|
+
candidates[peer] = {
|
|
254
|
+
directory: localDirectory,
|
|
255
|
+
artifact: path.resolve(base, local.artifact),
|
|
256
|
+
currentVersion: localManifest.version,
|
|
257
|
+
version: evidence,
|
|
258
|
+
manifest: localManifest,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const relevant = [
|
|
262
|
+
...new Set([
|
|
263
|
+
...peerNames,
|
|
264
|
+
...Object.values(candidates).flatMap((candidate) =>
|
|
265
|
+
Object.keys(candidate.manifest.peerDependencies ?? {}),
|
|
266
|
+
),
|
|
267
|
+
]),
|
|
268
|
+
].filter(
|
|
269
|
+
(peer) =>
|
|
270
|
+
peer === packageManifest.name ||
|
|
271
|
+
declarations[peer] !== undefined ||
|
|
272
|
+
candidates[peer] !== undefined,
|
|
273
|
+
)
|
|
274
|
+
const before: Record<string, string> = {}
|
|
275
|
+
for (const peer of relevant) {
|
|
276
|
+
if (
|
|
277
|
+
peer !== packageManifest.name &&
|
|
278
|
+
!candidates[peer] &&
|
|
279
|
+
(await exists(path.join(directory, `node_modules`, peer)))
|
|
280
|
+
)
|
|
281
|
+
before[peer] = (await installed(directory, peer)).version
|
|
282
|
+
}
|
|
283
|
+
const preparation = await prepare(config.prepareCommand, directory).then(
|
|
284
|
+
() => ({ ok: true as const }),
|
|
285
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
286
|
+
)
|
|
287
|
+
const after = await Promise.all(
|
|
288
|
+
[filename, lockfile].map((input) =>
|
|
289
|
+
readFile(input).catch(() => Buffer.alloc(0)),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
if (inputs.some((input, index) => !input.equals(after[index]!))) {
|
|
293
|
+
await Promise.all(
|
|
294
|
+
[filename, lockfile].map(async (input, index) => {
|
|
295
|
+
// Recreate an input rather than writing through a link introduced by a command.
|
|
296
|
+
await rm(input, { force: true })
|
|
297
|
+
await writeFile(input, inputs[index]!, { flag: `wx` })
|
|
298
|
+
}),
|
|
299
|
+
)
|
|
300
|
+
throw new Error(
|
|
301
|
+
`Frozen preparation changed the manifest or lockfile in ${directory}; restored the original inputs.`,
|
|
302
|
+
{ cause: preparation.ok ? undefined : preparation.error },
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
if (!preparation.ok) throw preparation.error
|
|
306
|
+
const peers: Environment[`peers`] = {}
|
|
307
|
+
for (const peer of relevant) {
|
|
308
|
+
const candidate = candidates[peer]
|
|
309
|
+
const staged = candidate !== undefined || peer === packageManifest.name
|
|
310
|
+
if (
|
|
311
|
+
!staged &&
|
|
312
|
+
Object.hasOwn(manifest.optionalDependencies ?? {}, peer) &&
|
|
313
|
+
!(await exists(path.join(directory, `node_modules`, peer)))
|
|
314
|
+
)
|
|
315
|
+
continue
|
|
316
|
+
const evidence: NpmPeerEvidence = staged
|
|
317
|
+
? {
|
|
318
|
+
version: candidate?.version ?? packageManifest.version,
|
|
319
|
+
path: path.join(stage, `node_modules`, peer),
|
|
320
|
+
source: `candidate`,
|
|
321
|
+
}
|
|
322
|
+
: await installed(directory, peer)
|
|
323
|
+
const specifier = declarations[peer]
|
|
324
|
+
if (
|
|
325
|
+
!(staged && specifier === undefined) &&
|
|
326
|
+
evidence.version !== specifier
|
|
327
|
+
) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Representative ${peer}@${evidence.version} does not match its manifest pin ${specifier ?? `(missing)`}.`,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
if (before[peer] && before[peer] !== evidence.version)
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Stale installed ${peer}: frozen preparation changed ${before[peer]} to ${evidence.version}.`,
|
|
335
|
+
)
|
|
336
|
+
for (const [localPeer, local] of Object.entries(config.localPeers ?? {})) {
|
|
337
|
+
if (staged) continue
|
|
338
|
+
const localDirectory = path.resolve(base, local.directory)
|
|
339
|
+
// Historical-only registrations need not exist in today's checkout.
|
|
340
|
+
if (
|
|
341
|
+
!peerNames.includes(localPeer) &&
|
|
342
|
+
!relevant.includes(localPeer) &&
|
|
343
|
+
!(await exists(localDirectory))
|
|
344
|
+
)
|
|
345
|
+
continue
|
|
346
|
+
if (within(await realpath(localDirectory), evidence.path))
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Released peer ${peer} resolves into a local workspace package.`,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
peers[peer] = evidence
|
|
352
|
+
}
|
|
353
|
+
return { directory, stage, manifest, peers, candidates }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Validate the complete coordinated formulation before an audit or execution. */
|
|
357
|
+
export function checkCandidatePeers(
|
|
358
|
+
environment: Environment,
|
|
359
|
+
focused: boolean,
|
|
360
|
+
): string[] {
|
|
361
|
+
const conflicts: string[] = []
|
|
362
|
+
for (const [name, candidate] of Object.entries(environment.candidates)) {
|
|
363
|
+
for (const [peer, range] of Object.entries(
|
|
364
|
+
candidate.manifest.peerDependencies ?? {},
|
|
365
|
+
)) {
|
|
366
|
+
const evidence = environment.peers[peer]
|
|
367
|
+
if (!evidence) {
|
|
368
|
+
if (candidate.manifest.peerDependenciesMeta?.[peer]?.optional) continue
|
|
369
|
+
throw new Error(
|
|
370
|
+
`Complete representative is missing required peer ${peer}: ${range} of coordinated candidate ${name}@${candidate.version}.`,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
// This is declaration membership; exact-preview coverage is audited separately.
|
|
374
|
+
if (!semver.satisfies(evidence.version, range))
|
|
375
|
+
conflicts.push(
|
|
376
|
+
`Coordinated candidate ${name}@${candidate.version} requires ${peer}: ${range}, but the representative provides ${peer}@${evidence.version}.`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (conflicts.length && !focused) throw new Error(conflicts.join(`\n`))
|
|
381
|
+
return conflicts.map(
|
|
382
|
+
(conflict) =>
|
|
383
|
+
`${conflict} Running focused exploration outside declared support.`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function copySuite(
|
|
388
|
+
base: string,
|
|
389
|
+
stage: string,
|
|
390
|
+
suite: NpmSuite,
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
for (const pattern of suite.files) {
|
|
393
|
+
if (path.isAbsolute(pattern) || pattern.split(/[\\/]/).includes(`..`))
|
|
394
|
+
throw new Error(`Suite pattern must stay inside the package: ${pattern}.`)
|
|
395
|
+
let count = 0
|
|
396
|
+
for await (const filename of fs.glob(pattern, {
|
|
397
|
+
cwd: base,
|
|
398
|
+
exclude: [
|
|
399
|
+
`**/node_modules/**`,
|
|
400
|
+
`**/.git/**`,
|
|
401
|
+
path.relative(base, stage),
|
|
402
|
+
`${path.relative(base, stage)}/**`,
|
|
403
|
+
],
|
|
404
|
+
})) {
|
|
405
|
+
const source = path.resolve(base, filename)
|
|
406
|
+
const stat = await lstat(source)
|
|
407
|
+
if (stat.isDirectory()) continue
|
|
408
|
+
if (
|
|
409
|
+
!stat.isFile() ||
|
|
410
|
+
!within(await realpath(base), await realpath(source))
|
|
411
|
+
)
|
|
412
|
+
throw new Error(
|
|
413
|
+
`Suite inputs must be regular package files: ${filename}.`,
|
|
414
|
+
)
|
|
415
|
+
// Refuse a symlink anywhere in the source path, even if its target is internal.
|
|
416
|
+
let segment = base
|
|
417
|
+
for (const part of filename.split(path.sep)) {
|
|
418
|
+
segment = path.join(segment, part)
|
|
419
|
+
if ((await lstat(segment)).isSymbolicLink())
|
|
420
|
+
throw new Error(`Suite inputs cannot follow symlinks: ${filename}.`)
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
[
|
|
424
|
+
`package.json`,
|
|
425
|
+
`package-lock.json`,
|
|
426
|
+
`npm-shrinkwrap.json`,
|
|
427
|
+
`.npmrc`,
|
|
428
|
+
].includes(filename) ||
|
|
429
|
+
filename.split(path.sep).includes(`node_modules`)
|
|
430
|
+
) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Suite input collides with the consumer installation: ${filename}.`,
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
const destination = path.resolve(stage, filename)
|
|
436
|
+
if (!within(stage, destination))
|
|
437
|
+
throw new Error(`Suite path escapes its stage: ${filename}.`)
|
|
438
|
+
await mkdir(path.dirname(destination), { recursive: true })
|
|
439
|
+
await copyFile(source, destination)
|
|
440
|
+
count++
|
|
441
|
+
}
|
|
442
|
+
if (!count) throw new Error(`Suite pattern matched no files: ${pattern}.`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function findPackage(
|
|
447
|
+
from: string,
|
|
448
|
+
name: string,
|
|
449
|
+
installation: string,
|
|
450
|
+
): Promise<string | undefined> {
|
|
451
|
+
const origin = await realpath(from)
|
|
452
|
+
// A linked store package may resolve siblings in its own installation namespace.
|
|
453
|
+
// Never grant access to the store's or checkout's unrelated ancestor installs.
|
|
454
|
+
const parts = origin.split(path.sep)
|
|
455
|
+
const modules = parts.lastIndexOf(`node_modules`)
|
|
456
|
+
const boundary = within(installation, origin)
|
|
457
|
+
? installation
|
|
458
|
+
: modules < 0
|
|
459
|
+
? origin
|
|
460
|
+
: parts.slice(0, modules + 1).join(path.sep)
|
|
461
|
+
const lookup =
|
|
462
|
+
createRequire(path.join(origin, `__breakage_probe__.ts`)).resolve.paths(
|
|
463
|
+
name,
|
|
464
|
+
) ?? []
|
|
465
|
+
for (const directory of lookup) {
|
|
466
|
+
const location = path.join(directory, name)
|
|
467
|
+
// An installed but broken package must fail verification, not look absent.
|
|
468
|
+
if (await exists(location)) {
|
|
469
|
+
if (!within(installation, directory) && !within(boundary, directory))
|
|
470
|
+
throw new Error(
|
|
471
|
+
`Dependency ${name} resolves outside the representative installation: ${location}.`,
|
|
472
|
+
)
|
|
473
|
+
return await realpath(location)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return undefined
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function resolvePackage(
|
|
480
|
+
from: string,
|
|
481
|
+
name: string,
|
|
482
|
+
installation: string,
|
|
483
|
+
): Promise<string> {
|
|
484
|
+
const location = await findPackage(from, name, installation)
|
|
485
|
+
if (location !== undefined) return location
|
|
486
|
+
throw new Error(`Cannot resolve ${name} from ${from}.`)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function stageCandidate(
|
|
490
|
+
config: NpmConfig,
|
|
491
|
+
base: string,
|
|
492
|
+
manifest: Manifest,
|
|
493
|
+
environment: Environment,
|
|
494
|
+
suites: NpmSuite[],
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
const { stage, candidates } = environment
|
|
497
|
+
const artifacts = {
|
|
498
|
+
[manifest.name]: path.resolve(base, config.artifact),
|
|
499
|
+
...Object.fromEntries(
|
|
500
|
+
Object.entries(candidates).map(([name, entry]) => [name, entry.artifact]),
|
|
501
|
+
),
|
|
502
|
+
}
|
|
503
|
+
if (candidates[manifest.name])
|
|
504
|
+
throw new Error(
|
|
505
|
+
`The package under test cannot also be its own candidate peer.`,
|
|
506
|
+
)
|
|
507
|
+
for (const artifact of Object.values(artifacts)) {
|
|
508
|
+
if (!(await lstat(artifact)).isFile())
|
|
509
|
+
throw new Error(`Candidate artifact must be a packed file: ${artifact}.`)
|
|
510
|
+
}
|
|
511
|
+
await writeFile(
|
|
512
|
+
path.join(stage, `package.json`),
|
|
513
|
+
JSON.stringify({
|
|
514
|
+
name: `breakage-consumer`,
|
|
515
|
+
version: `0.0.0`,
|
|
516
|
+
private: true,
|
|
517
|
+
type: environment.manifest.type ?? `module`,
|
|
518
|
+
dependencies: Object.fromEntries(
|
|
519
|
+
Object.entries(artifacts).map(([name, artifact]) => [
|
|
520
|
+
name,
|
|
521
|
+
`file:${artifact}`,
|
|
522
|
+
]),
|
|
523
|
+
),
|
|
524
|
+
}),
|
|
525
|
+
)
|
|
526
|
+
await prepare(
|
|
527
|
+
config.installCommand ??
|
|
528
|
+
`npm install --prefix . --workspaces=false --ignore-scripts --legacy-peer-deps --package-lock=false --no-audit --no-fund`,
|
|
529
|
+
stage,
|
|
530
|
+
)
|
|
531
|
+
for (const [name] of Object.entries(artifacts)) {
|
|
532
|
+
const directory = path.join(stage, `node_modules`, name)
|
|
533
|
+
if (!within(stage, await realpath(directory)))
|
|
534
|
+
throw new Error(
|
|
535
|
+
`Candidate ${name} must be installed inside the test stage.`,
|
|
536
|
+
)
|
|
537
|
+
const filename = path.join(directory, `package.json`)
|
|
538
|
+
const packed = await readManifest(filename)
|
|
539
|
+
const candidate = candidates[name]
|
|
540
|
+
if (
|
|
541
|
+
packed.name !== name ||
|
|
542
|
+
(candidate
|
|
543
|
+
? ![candidate.currentVersion, candidate.version].includes(
|
|
544
|
+
packed.version,
|
|
545
|
+
)
|
|
546
|
+
: packed.version !== manifest.version)
|
|
547
|
+
) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`Candidate archive identity/version mismatch for ${name}@${packed.version}.`,
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
const expectedManifest = candidate?.manifest ?? manifest
|
|
553
|
+
if (
|
|
554
|
+
Object.keys({
|
|
555
|
+
...packed.peerDependencies,
|
|
556
|
+
...expectedManifest.peerDependencies,
|
|
557
|
+
}).some(
|
|
558
|
+
(peer) =>
|
|
559
|
+
packed.peerDependencies?.[peer] !==
|
|
560
|
+
expectedManifest.peerDependencies?.[peer] ||
|
|
561
|
+
Boolean(packed.peerDependenciesMeta?.[peer]?.optional) !==
|
|
562
|
+
Boolean(expectedManifest.peerDependenciesMeta?.[peer]?.optional),
|
|
563
|
+
)
|
|
564
|
+
) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`Candidate archive has stale peer declarations; rebuild and pack ${name}.`,
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
if (candidate) {
|
|
570
|
+
const evidence = environment.peers[name]
|
|
571
|
+
if (evidence) evidence.archiveVersion = packed.version
|
|
572
|
+
if (packed.version !== candidate.version) {
|
|
573
|
+
// Replace the entry: installed files may share an inode with a cache.
|
|
574
|
+
const raw = JSON.parse(await readFile(filename, `utf8`)) as Record<
|
|
575
|
+
string,
|
|
576
|
+
unknown
|
|
577
|
+
>
|
|
578
|
+
const temporary = path.join(
|
|
579
|
+
path.dirname(filename),
|
|
580
|
+
`.breakage-${randomUUID()}.json`,
|
|
581
|
+
)
|
|
582
|
+
await writeFile(
|
|
583
|
+
temporary,
|
|
584
|
+
JSON.stringify({ ...raw, version: candidate.version }),
|
|
585
|
+
{ flag: `wx` },
|
|
586
|
+
)
|
|
587
|
+
try {
|
|
588
|
+
await rename(temporary, filename)
|
|
589
|
+
} finally {
|
|
590
|
+
await rm(temporary, { force: true })
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
for (const suite of suites) await copySuite(base, stage, suite)
|
|
596
|
+
await verifyResolution(manifest, environment)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function verifyResolution(
|
|
600
|
+
manifest: Manifest,
|
|
601
|
+
environment: Environment,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
const { stage, peers } = environment
|
|
604
|
+
const installation = await realpath(environment.directory)
|
|
605
|
+
const packages = [manifest.name, ...Object.keys(environment.candidates)]
|
|
606
|
+
for (const [peer, evidence] of Object.entries(peers)) {
|
|
607
|
+
const expected = await realpath(evidence.path)
|
|
608
|
+
if ((await resolvePackage(stage, peer, installation)) !== expected)
|
|
609
|
+
throw new Error(`Tests resolve a shadow copy of ${peer}.`)
|
|
610
|
+
for (const name of packages) {
|
|
611
|
+
if (name === peer) continue
|
|
612
|
+
const origin = path.join(stage, `node_modules`, name)
|
|
613
|
+
if ((await resolvePackage(origin, peer, installation)) !== expected)
|
|
614
|
+
throw new Error(
|
|
615
|
+
`Candidate ${name} resolves a different instance of ${peer} than its tests.`,
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
const actual = await readManifest(path.join(expected, `package.json`))
|
|
619
|
+
if (actual.name !== peer || actual.version !== evidence.version)
|
|
620
|
+
throw new Error(`Peer identity changed during execution: ${peer}.`)
|
|
621
|
+
}
|
|
622
|
+
// Runtime dependency peers can be singletons too (for example a React helper).
|
|
623
|
+
const visited = new Set<string>()
|
|
624
|
+
const pending = packages.map((name) => path.join(stage, `node_modules`, name))
|
|
625
|
+
while (pending.length) {
|
|
626
|
+
const directory = await realpath(pending.pop()!)
|
|
627
|
+
if (visited.has(directory)) continue
|
|
628
|
+
if (!directory.split(path.sep).includes(`node_modules`))
|
|
629
|
+
throw new Error(
|
|
630
|
+
`Runtime dependency resolves to workspace source: ${directory}.`,
|
|
631
|
+
)
|
|
632
|
+
visited.add(directory)
|
|
633
|
+
const installedManifest = await readManifest(
|
|
634
|
+
path.join(directory, `package.json`),
|
|
635
|
+
)
|
|
636
|
+
for (const peer of Object.keys(installedManifest.peerDependencies ?? {})) {
|
|
637
|
+
const evidence = peers[peer]
|
|
638
|
+
if (
|
|
639
|
+
evidence &&
|
|
640
|
+
(await resolvePackage(directory, peer, installation)) !==
|
|
641
|
+
(await realpath(evidence.path))
|
|
642
|
+
)
|
|
643
|
+
throw new Error(
|
|
644
|
+
`Runtime dependency ${installedManifest.name} resolves a different instance of ${peer}.`,
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
const optional = installedManifest.optionalDependencies ?? {}
|
|
648
|
+
for (const dependency of Object.keys({
|
|
649
|
+
...installedManifest.dependencies,
|
|
650
|
+
...optional,
|
|
651
|
+
})) {
|
|
652
|
+
// npm lets optionalDependencies override dependencies of the same name.
|
|
653
|
+
if (Object.hasOwn(optional, dependency)) {
|
|
654
|
+
const location = await findPackage(directory, dependency, installation)
|
|
655
|
+
if (location !== undefined) pending.push(location)
|
|
656
|
+
} else {
|
|
657
|
+
pending.push(await resolvePackage(directory, dependency, installation))
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|