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.
Files changed (39) hide show
  1. package/README.md +164 -46
  2. package/dist/breakage-DIY49fzP.js +1349 -0
  3. package/dist/breakage-DIY49fzP.js.map +1 -0
  4. package/dist/breakage.d.ts +179 -11
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +169 -67
  8. package/dist/breakage.x.js.map +1 -1
  9. package/dist/changesets.d.ts +8 -0
  10. package/dist/changesets.d.ts.map +1 -0
  11. package/dist/changesets.js +27 -0
  12. package/dist/changesets.js.map +1 -0
  13. package/dist/release-BsdIDT4g.js +69 -0
  14. package/dist/release-BsdIDT4g.js.map +1 -0
  15. package/dist/release-D47BqRuB.d.ts +40 -0
  16. package/dist/release-D47BqRuB.d.ts.map +1 -0
  17. package/npm.md +236 -0
  18. package/package.json +51 -11
  19. package/src/breakage.ts +136 -70
  20. package/src/breakage.x.ts +160 -112
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +128 -0
  23. package/src/npm/check.ts +449 -0
  24. package/src/npm/cli.ts +130 -0
  25. package/src/npm/config.ts +118 -0
  26. package/src/npm/environment.ts +661 -0
  27. package/src/npm/manifest.ts +134 -0
  28. package/src/npm/release.ts +173 -0
  29. package/src/npm/types.ts +64 -0
  30. package/src/release.ts +153 -0
  31. package/src/task.ts +54 -0
  32. package/dist/breakage-Dc4IldeW.js +0 -435
  33. package/dist/breakage-Dc4IldeW.js.map +0 -1
  34. package/dist/breakage.$configPath.schema.json +0 -33
  35. package/dist/breakage.help.schema.json +0 -6
  36. package/dist/breakage.main.schema.json +0 -33
  37. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  38. package/dist/breakage.prelude.schema.json +0 -31
  39. 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
+ }