@cyd-prc/dsh-audit-chain 0.1.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.
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release verification: the layer the test suite cannot see.
4
+ *
5
+ * The suite runs against the source tree. It says nothing about what a package
6
+ * manager actually puts in a tarball, whether a published artifact still matches
7
+ * the commit it claims to be, or whether the metadata inside the package points
8
+ * at files the package does not carry. Both failure modes have happened here:
9
+ *
10
+ * - 0.2.0 published an artifact that predated its own commit — caught only by
11
+ * comparing the tarball to the tree, file by file;
12
+ * - 0.2.2 declared `dsh.bundle.patch` while `files` omitted that file, so a
13
+ * registry install resolved to a package with no row to apply, and only a
14
+ * local-path install worked.
15
+ *
16
+ * This tool is those two checks, made recomputable. Run it from a checkout.
17
+ *
18
+ * Usage:
19
+ * node tools/verify-release.mjs # published name@version vs this tree
20
+ * node tools/verify-release.mjs --spec pkg@1.2.3 # a different version of *this* package
21
+ * node tools/verify-release.mjs --pack # pre-publish: pack this tree and check it
22
+ * node tools/verify-release.mjs --run-tests # also run the suite inside the artifact
23
+ * node tools/verify-release.mjs --packer "<cmd>" # packer command to use with --pack
24
+ * # (the tool appends `pack`)
25
+ * node tools/verify-release.mjs --wait <seconds> # poll the registry until a fresh
26
+ * # publish settles (packument and
27
+ * # tarball propagate independently)
28
+ * node tools/verify-release.mjs --json
29
+ *
30
+ * Exit code 0 = verified, 1 = discrepancies (or a usage/network failure).
31
+ *
32
+ * Two deliberate choices, both learned from auditing this package:
33
+ * - No external tools. The tarball is gunzipped with `zlib` and read with an
34
+ * in-process tar reader, so the check runs wherever Node does.
35
+ * - `.json` files are compared *semantically* when their bytes differ, because
36
+ * the published `package.json` is the tree's minus the packer's trailing
37
+ * newline. A checker that cried wolf on that would be ignored, and an ignored
38
+ * checker is worse than none. The difference is still reported, never hidden.
39
+ *
40
+ * And two guardrails learned the same way (0.3.2):
41
+ * - The tree anchor is this file's own location, so running *this* copy against
42
+ * *another* package compares the wrong trees and reports every file as
43
+ * different (defect 17). A `--spec` naming a different package is refused.
44
+ * - Metadata references are not the only ring: shipped code that reads a file
45
+ * the artifact does not carry fails for every installer (the sibling guard's
46
+ * `verify-invariants.mjs` shipped that way). The artifact's `.js`/`.mjs` are
47
+ * scanned for file literals that resolve nowhere in the artifact.
48
+ */
49
+ import { createHash } from 'node:crypto';
50
+ import { spawnSync } from 'node:child_process';
51
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
52
+ import { tmpdir } from 'node:os';
53
+ import { dirname, join, relative, resolve, sep } from 'node:path';
54
+ import { fileURLToPath } from 'node:url';
55
+ import { gunzipSync } from 'node:zlib';
56
+
57
+ const HERE = dirname(fileURLToPath(import.meta.url));
58
+ const ROOT = dirname(HERE);
59
+
60
+ /** Directories that are never part of an artifact. */
61
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
62
+
63
+ /**
64
+ * Could these bytes be a text file? No NUL and valid UTF-8. This — not the
65
+ * file name — decides the line-ending equivalence class: `LICENSE` has no
66
+ * extension, and an `.svg` is text, while a `.png` in the tree never wants the
67
+ * comparison at all.
68
+ */
69
+ const TEXT_DECODER = new TextDecoder('utf-8', { fatal: true });
70
+
71
+ /**
72
+ * @param buffer - raw file bytes.
73
+ * @returns whether the bytes are text for comparison purposes.
74
+ */
75
+ export function looksLikeText(buffer) {
76
+ if (buffer.includes(0)) return false;
77
+ try {
78
+ TEXT_DECODER.decode(buffer);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
86
+ const sha1 = (buffer) => createHash('sha1').update(buffer).digest('hex');
87
+
88
+ /**
89
+ * Canonical JSON: sorted keys, so two texts are compared by value and not by the
90
+ * order a packer happened to write them in.
91
+ * @param value - any JSON-safe value.
92
+ * @returns the canonical string.
93
+ */
94
+ export function canonicalJson(value) {
95
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
96
+ if (value !== null && typeof value === 'object') {
97
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
98
+ }
99
+ return JSON.stringify(value ?? null) ?? 'null';
100
+ }
101
+
102
+ /**
103
+ * Are two texts the same JSON document, whatever their bytes?
104
+ * @param a - first text.
105
+ * @param b - second text.
106
+ * @returns true when both parse and their canonical forms are equal.
107
+ */
108
+ export function jsonEquivalent(a, b) {
109
+ try {
110
+ return canonicalJson(JSON.parse(a)) === canonicalJson(JSON.parse(b));
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Are two texts equal once CRLF is normalised to LF?
118
+ *
119
+ * `core.autocrlf=true` is the default on Windows, so a fresh checkout there
120
+ * holds CRLF bytes where the artifact holds LF. Comparing raw bytes then turns
121
+ * every text file into a false DIFF — measured by the test side as 17 phantom
122
+ * mismatches on a good release, nearly declared bad (defect 20).
123
+ * @param a - first text.
124
+ * @param b - second text.
125
+ * @returns true when the texts agree modulo line endings.
126
+ */
127
+ export function sameTextModuloLineEndings(a, b) {
128
+ return a.replace(/\r\n/g, '\n') === b.replace(/\r\n/g, '\n');
129
+ }
130
+
131
+ /**
132
+ * The tree's HEAD commit, so a reading names what it compared against — the
133
+ * first question a mismatch report has to answer is "which tree was this?".
134
+ * @param root - the tree root.
135
+ * @returns the short hash, or null outside a git checkout.
136
+ */
137
+ export function treeHeadOf(root) {
138
+ try {
139
+ const result = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'], { cwd: root, encoding: 'utf8' });
140
+ const head = result.status === 0 ? result.stdout.trim() : '';
141
+ return /^[0-9a-f]{7,40}$/.test(head) ? head : null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Does the artifact carry what a metadata reference points at?
149
+ *
150
+ * A reference may be a file, a directory (`files: ["lib"]` ships a tree) or a
151
+ * glob (`exports: { "./locale/*.json": … }`). Treating a glob as a literal
152
+ * filename is a false positive this tool's own audit produced once, which is why
153
+ * the glob case is a unit test here rather than a comment.
154
+ * @param reference - the path as written in package.json.
155
+ * @param paths - the artifact's file paths.
156
+ * @returns true when something in the artifact satisfies the reference.
157
+ */
158
+ export function matchesReference(reference, paths) {
159
+ const clean = String(reference).replace(/^\.\//, '');
160
+ if (clean.length === 0) return true;
161
+ if (clean.includes('*')) {
162
+ const pattern = new RegExp(`^${clean.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
163
+ return paths.some((path) => pattern.test(path));
164
+ }
165
+ return paths.some((path) => path === clean || path.startsWith(`${clean}/`));
166
+ }
167
+
168
+ /**
169
+ * Read a `.tar.gz` in process: no `tar`, no temporary directory.
170
+ * @param buffer - the tarball bytes.
171
+ * @returns a map of artifact-relative path to file bytes.
172
+ */
173
+ export function readTarGz(buffer) {
174
+ const tar = gunzipSync(buffer);
175
+ const files = new Map();
176
+ const cstr = (slice) => {
177
+ const end = slice.indexOf(0);
178
+ return slice.subarray(0, end === -1 ? slice.length : end).toString('utf8');
179
+ };
180
+ let offset = 0;
181
+ while (offset + 512 <= tar.length) {
182
+ const header = tar.subarray(offset, offset + 512);
183
+ if (header.every((byte) => byte === 0)) break;
184
+ const name = cstr(header.subarray(0, 100));
185
+ const prefix = cstr(header.subarray(345, 500));
186
+ const sizeText = cstr(header.subarray(124, 136)).trim();
187
+ const size = sizeText.length === 0 ? 0 : Number.parseInt(sizeText, 8) || 0;
188
+ const type = String.fromCharCode(header[156] === 0 ? 48 : header[156]);
189
+ const full = prefix.length > 0 ? `${prefix}/${name}` : name;
190
+ // npm tarballs wrap everything in `package/`; that prefix is not part of the
191
+ // package's own layout.
192
+ const key = full.startsWith('package/') ? full.slice('package/'.length) : full;
193
+ if (type === '0' && size > 0) {
194
+ files.set(key, Buffer.from(tar.subarray(offset + 512, offset + 512 + size)));
195
+ }
196
+ offset += 512 + Math.ceil(size / 512) * 512;
197
+ }
198
+ return files;
199
+ }
200
+
201
+ /** Walk the working tree, skipping the directories an artifact never carries. */
202
+ function walkTree(root) {
203
+ const files = new Map();
204
+ const visit = (dir) => {
205
+ for (const name of readdirSync(dir)) {
206
+ if (SKIP_DIRS.has(name)) continue;
207
+ const full = join(dir, name);
208
+ const info = statSync(full);
209
+ if (info.isDirectory()) visit(full);
210
+ else if (info.isFile()) files.set(relative(root, full).split(sep).join('/'), sha256(readFileSync(full)));
211
+ }
212
+ };
213
+ visit(root);
214
+ return files;
215
+ }
216
+
217
+ /** Compare an artifact against a tree: bytes first, JSON semantics second. */
218
+ function compareArtifact(artifact, tree) {
219
+ const rows = [];
220
+ let mismatches = 0;
221
+ for (const [path, bytes] of [...artifact.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
222
+ const local = tree.get(path);
223
+ if (local === undefined) {
224
+ rows.push({ path, verdict: 'IN-ARTIFACT-ONLY' });
225
+ mismatches += 1;
226
+ continue;
227
+ }
228
+ const digest = sha256(bytes);
229
+ if (digest === local) {
230
+ rows.push({ path, verdict: 'match' });
231
+ continue;
232
+ }
233
+ if (path.endsWith('.json') && jsonEquivalent(readFileSync(join(ROOT, path), 'utf8'), bytes.toString('utf8'))) {
234
+ rows.push({ path, verdict: 'match (json-equivalent; bytes differ by the packer)' });
235
+ continue;
236
+ }
237
+ // A checkout may legitimately hold CRLF where the artifact holds LF: text
238
+ // files compare modulo line endings, and the report says so (defect 20).
239
+ // Text-ness is sniffed from the bytes, not the name, so `LICENSE` (no
240
+ // extension) is covered and a `.png` never enters the comparison.
241
+ const localBytes = readFileSync(join(ROOT, path));
242
+ if (looksLikeText(localBytes) && looksLikeText(bytes)
243
+ && sameTextModuloLineEndings(localBytes.toString('utf8'), bytes.toString('utf8'))) {
244
+ rows.push({ path, verdict: 'match (line endings normalized by the checkout)' });
245
+ continue;
246
+ }
247
+ rows.push({ path, verdict: 'DIFF' });
248
+ mismatches += 1;
249
+ }
250
+ return { rows, mismatches };
251
+ }
252
+
253
+ /** Check every path package.json references against the artifact's file list. */
254
+ function checkReferences(manifestText, artifact) {
255
+ const manifest = JSON.parse(manifestText);
256
+ const paths = [...artifact.keys()];
257
+ const checked = [];
258
+ let missing = 0;
259
+ const add = (label, reference) => {
260
+ const present = matchesReference(reference, paths);
261
+ if (!present) missing += 1;
262
+ checked.push({ label, reference, present });
263
+ };
264
+ if (manifest.icon) add('icon', manifest.icon);
265
+ for (const [key, value] of Object.entries(manifest.exports ?? {})) {
266
+ if (typeof value === 'string') add(`exports[${key}]`, value);
267
+ }
268
+ if (manifest.dsh?.bundle?.patch) add('dsh.bundle.patch', manifest.dsh.bundle.patch);
269
+ for (const entry of manifest.files ?? []) add(`files:${entry}`, entry);
270
+ // The other direction: something `files` promises that the artifact lacks.
271
+ const promisedButAbsent = [];
272
+ for (const entry of manifest.files ?? []) {
273
+ const clean = String(entry).replace(/^\.\//, '');
274
+ const listed = paths.some((path) => path === clean || path.startsWith(`${clean}/`));
275
+ if (!listed && !clean.includes('*')) promisedButAbsent.push(clean);
276
+ }
277
+ return { checked, missing, promisedButAbsent };
278
+ }
279
+
280
+ /** File-looking string literals in a source text. */
281
+ const CODE_REFERENCE = /['"]([A-Za-z0-9._-]+\.(?:md|json|ya?ml|txt|js|mjs))['"]/g;
282
+
283
+ /**
284
+ * Does the artifact carry everything its shipped *code* reads?
285
+ *
286
+ * `checkReferences` covers the paths `package.json` points at; this covers the
287
+ * next ring out — a shipped tool that does `readFileSync(join(root, 'X'))` for
288
+ * an `X` the artifact does not carry fails for every user who installs the
289
+ * package. Found on the sibling guard: its shipped invariant harness read
290
+ * `DESIGN-v1.md` and the external negative anchor, neither of which shipped.
291
+ *
292
+ * A basename match counts as carried: a `join(dir, name)` literal and a nested
293
+ * copy both satisfy it. That makes this a heuristic whose only job is to flag
294
+ * references that resolve *nowhere* in the artifact. Two scoping decisions keep
295
+ * it quiet enough to be trusted:
296
+ *
297
+ * - `test/` is not scanned: fixture names in tests are data, not reads
298
+ * (a `file_path` fixture value never opens the file it names), and a test
299
+ * that reads an unshipped fixture already fails the `--run-tests` ring.
300
+ * - a literal without an extension never matches, so prose examples survive.
301
+ *
302
+ * @param artifact - the readTarGz map of artifact-relative path to bytes.
303
+ * @returns `{ scanned, unresolved: [{file, reference}] }`.
304
+ */
305
+ export function scanShippedReferences(artifact) {
306
+ const paths = [...artifact.keys()];
307
+ const basenames = new Set(paths.map((path) => path.split('/').at(-1)));
308
+ const unresolved = [];
309
+ let scanned = 0;
310
+ for (const [path, bytes] of artifact) {
311
+ if (!/\.(?:js|mjs)$/.test(path)) continue;
312
+ if (path.startsWith('test/')) continue;
313
+ scanned += 1;
314
+ const text = bytes.toString('utf8');
315
+ for (const match of text.matchAll(CODE_REFERENCE)) {
316
+ const reference = match[1];
317
+ if (basenames.has(reference)) continue;
318
+ unresolved.push({ file: path, reference });
319
+ }
320
+ }
321
+ return { scanned, unresolved };
322
+ }
323
+
324
+ /** The package name part of a spec like `@scope/pkg@1.2.3` (or `pkg`). */
325
+ function nameOfSpec(spec) {
326
+ const slash = spec.startsWith('@') ? spec.indexOf('/') : -1;
327
+ const at = spec.lastIndexOf('@');
328
+ return at > slash ? spec.slice(0, at) : spec;
329
+ }
330
+
331
+ async function fetchArtifact(spec, waitSeconds = 0) {
332
+ const name = nameOfSpec(spec);
333
+ const slash = spec.startsWith('@') ? spec.indexOf('/') : -1;
334
+ const at = spec.lastIndexOf('@');
335
+ const version = at > slash ? spec.slice(at + 1) : 'latest';
336
+ const base = `https://registry.npmjs.org/${name.replace('/', '%2F')}`;
337
+ const deadline = Date.now() + waitSeconds * 1000;
338
+ const sleep = (ms) => new Promise((resolveSleep) => { setTimeout(resolveSleep, ms); });
339
+ // Registry reads settle eventually: the packument and the tarball propagate
340
+ // independently, and this project has been bitten in *both* orders (a tarball
341
+ // already 200 while the packument still 404s, and the reverse). Neither is an
342
+ // immediate verdict — with --wait, poll until the deadline.
343
+ for (;;) {
344
+ let lastProblem;
345
+ try {
346
+ const metaResponse = await fetch(`${base}/${version}`);
347
+ const meta = metaResponse.ok ? await metaResponse.json() : null;
348
+ const tarball = meta?.dist?.tarball;
349
+ if (tarball === undefined) {
350
+ lastProblem = `${name}@${version} is not in the registry yet`;
351
+ } else {
352
+ const response = await fetch(tarball);
353
+ if (response.ok) {
354
+ const bytes = Buffer.from(await response.arrayBuffer());
355
+ const integrityOk = meta.dist.shasum ? sha1(bytes) === meta.dist.shasum : null;
356
+ return { name, version: meta.version, bytes, meta, integrityOk };
357
+ }
358
+ lastProblem = `the tarball is not fetchable yet (HTTP ${response.status})`;
359
+ }
360
+ } catch (error) {
361
+ lastProblem = `registry read failed (${error?.message ?? error})`;
362
+ }
363
+ if (Date.now() >= deadline) {
364
+ // The usual cause is the one this tool exists for: the working copy is
365
+ // ahead of the registry, because the version in package.json is not
366
+ // published yet.
367
+ const packument = await fetch(base).then((r) => r.json()).catch(() => null);
368
+ const published = packument?.versions === undefined ? '(unknown)' : Object.keys(packument.versions).join(', ');
369
+ throw new Error(
370
+ `${name}@${version} could not be fetched (${lastProblem}; published: ${published}). `
371
+ + 'If this is a pre-release checkout that is expected: use --pack to verify the local tarball, '
372
+ + '--wait <seconds> to let a fresh publish settle, '
373
+ + 'or --spec to verify an artifact that is already published.',
374
+ );
375
+ }
376
+ await sleep(2000);
377
+ }
378
+ }
379
+
380
+ function packTree(packer) {
381
+ const candidates = packer === null
382
+ ? [['npm'], ['pnpm'], ['npx', ['--yes', 'pnpm']]]
383
+ : [packer.split(' ').filter((part) => part.length > 0)];
384
+ const destination = mkdtempSync(join(tmpdir(), 'verify-release-pack-'));
385
+ const attempts = [];
386
+ try {
387
+ for (const candidate of candidates) {
388
+ const [command, ...args] = candidate;
389
+ if (command === undefined) continue;
390
+ // `--packer` names the packer *command* (with any of its own flags); the
391
+ // `pack` subcommand and the destination are appended here, so a packer that
392
+ // is not on PATH — `--packer "node /path/to/pnpm.mjs"` — works the same way.
393
+ const result = spawnSync(command, [...args, 'pack', '--pack-destination', destination], {
394
+ cwd: ROOT,
395
+ encoding: 'utf8',
396
+ // An explicitly given packer is spawned as written; the bare command names
397
+ // need a shell on Windows to reach their `.cmd` shims.
398
+ shell: packer === null && process.platform === 'win32',
399
+ });
400
+ if (result.status === 0) {
401
+ const tarball = readdirSync(destination).find((name) => name.endsWith('.tgz'));
402
+ if (tarball !== undefined) {
403
+ return { bytes: readFileSync(join(destination, tarball)), packer: `${command} ${args.join(' ')}`, attempts };
404
+ }
405
+ attempts.push(`${command}: exited 0 but wrote no tarball`);
406
+ } else {
407
+ const detail = `${result.stderr ?? result.error?.message ?? ''}`.trim().split('\n')[0];
408
+ attempts.push(`${command}: ${detail.length > 0 ? detail : `exit ${result.status}`}`);
409
+ }
410
+ }
411
+ return { bytes: null, attempts };
412
+ } finally {
413
+ rmSync(destination, { recursive: true, force: true });
414
+ }
415
+ }
416
+
417
+ function runTestsInside(artifact) {
418
+ const dir = mkdtempSync(join(tmpdir(), 'verify-release-run-'));
419
+ try {
420
+ for (const [path, bytes] of artifact) {
421
+ const full = join(dir, path);
422
+ mkdirSync(dirname(full), { recursive: true });
423
+ writeFileSync(full, bytes);
424
+ }
425
+ const testFile = [...artifact.keys()].find((path) => /^test\/.+\.test\.mjs$/.test(path));
426
+ if (testFile === undefined) return { ran: false, reason: 'the artifact carries no test suite' };
427
+ const result = spawnSync(process.execPath, ['--test', testFile], { cwd: dir, stdio: 'inherit' });
428
+ return { ran: true, status: result.status, testFile };
429
+ } finally {
430
+ rmSync(dir, { recursive: true, force: true });
431
+ }
432
+ }
433
+
434
+ function main(argv) {
435
+ const options = { spec: null, pack: false, runTests: false, json: false, packer: null, wait: 0 };
436
+ for (let index = 0; index < argv.length; index += 1) {
437
+ const arg = argv[index];
438
+ if (arg === '--spec') options.spec = argv[++index];
439
+ else if (arg === '--packer') options.packer = argv[++index];
440
+ else if (arg === '--wait') {
441
+ const seconds = Number(argv[++index]);
442
+ if (!Number.isFinite(seconds) || seconds < 0) {
443
+ process.stderr.write(`--wait wants a non-negative number of seconds, got: ${argv[index]}\n`);
444
+ return 1;
445
+ }
446
+ options.wait = seconds;
447
+ } else if (arg === '--pack') options.pack = true;
448
+ else if (arg === '--run-tests') options.runTests = true;
449
+ else if (arg === '--json') options.json = true;
450
+ else if (arg === '--help' || arg === '-h') {
451
+ process.stdout.write('usage: verify-release.mjs [--spec name@version] [--pack] [--run-tests] [--packer cmd] [--wait seconds] [--json]\n');
452
+ return 0;
453
+ } else {
454
+ process.stderr.write(`unknown argument: ${arg}\n`);
455
+ return 1;
456
+ }
457
+ }
458
+
459
+ const manifestText = readFileSync(join(ROOT, 'package.json'), 'utf8');
460
+ const manifest = JSON.parse(manifestText);
461
+ const spec = options.spec ?? `${manifest.name}@${manifest.version}`;
462
+ const treeHead = treeHeadOf(ROOT);
463
+ // The tree anchor is this file's own location. Running this copy against a
464
+ // *different* package compares the wrong trees and reports every file as a
465
+ // difference (defect 17): refuse the mismatch rather than print 14 false DIFFs.
466
+ if (nameOfSpec(spec) !== manifest.name) {
467
+ process.stderr.write(
468
+ `verify-release: --spec names "${nameOfSpec(spec)}" but this copy ships with "${manifest.name}". `
469
+ + 'The tree anchor is the tool\'s own location — run the copy inside the package you are verifying.\n',
470
+ );
471
+ return 1;
472
+ }
473
+
474
+ Promise.resolve()
475
+ .then(async () => {
476
+ let bytes; let source;
477
+ if (options.pack) {
478
+ const packed = packTree(options.packer);
479
+ if (packed.bytes === null) {
480
+ process.stderr.write(`no packer available; pass --packer "<command>". tried: ${packed.attempts.join(' | ')}\n`);
481
+ return 1;
482
+ }
483
+ bytes = packed.bytes;
484
+ source = `packed locally with ${packed.packer}`;
485
+ } else {
486
+ const fetched = await fetchArtifact(spec, options.wait);
487
+ bytes = fetched.bytes;
488
+ source = `registry ${fetched.name}@${fetched.version}`;
489
+ if (fetched.integrityOk === false) {
490
+ process.stdout.write(`container integrity: MISMATCH (dist.shasum ${fetched.meta.dist.shasum})\n`);
491
+ }
492
+ }
493
+ const artifact = readTarGz(bytes);
494
+ const tree = walkTree(ROOT);
495
+ const comparison = compareArtifact(artifact, tree);
496
+ // The artifact's *own* metadata is what has to be self-consistent: a package
497
+ // whose manifest points at something it does not carry is broken wherever it
498
+ // is installed from, which is exactly what shipped in 0.2.2.
499
+ const innerManifest = artifact.get('package.json');
500
+ const references = checkReferences(
501
+ innerManifest === undefined ? manifestText : innerManifest.toString('utf8'),
502
+ artifact,
503
+ );
504
+ // The next ring out: shipped code reading files the artifact does not carry.
505
+ const codeReferences = scanShippedReferences(artifact);
506
+ const localOnly = [...tree.keys()].filter((path) => !artifact.has(path)).sort();
507
+ let tests = null;
508
+ if (options.runTests) tests = runTestsInside(artifact);
509
+
510
+ if (options.json) {
511
+ process.stdout.write(`${JSON.stringify({
512
+ source, spec, treeHead, files: artifact.size, comparison, references, codeReferences, localOnly, tests,
513
+ }, null, 2)}\n`);
514
+ } else {
515
+ process.stdout.write(`verify-release: ${source} (${artifact.size} files) · tree ${treeHead ?? '(unknown HEAD)'}\n`);
516
+ for (const row of comparison.rows) {
517
+ if (row.verdict !== 'match') process.stdout.write(` ${row.path.padEnd(28)} ${row.verdict}\n`);
518
+ }
519
+ process.stdout.write(` compared: ${comparison.rows.length} mismatches: ${comparison.mismatches}\n`);
520
+ process.stdout.write(` metadata references checked: ${references.checked.length} missing: ${references.missing}\n`);
521
+ for (const entry of references.checked) {
522
+ if (!entry.present) process.stdout.write(` MISSING ${entry.label} -> ${entry.reference}\n`);
523
+ }
524
+ if (references.promisedButAbsent.length > 0) {
525
+ process.stdout.write(` promised by files but absent: ${references.promisedButAbsent.join(', ')}\n`);
526
+ }
527
+ process.stdout.write(` code references scanned: ${codeReferences.scanned} unresolved: ${codeReferences.unresolved.length}\n`);
528
+ for (const entry of codeReferences.unresolved) {
529
+ process.stdout.write(` UNRESOLVED ${entry.file} -> ${entry.reference}\n`);
530
+ }
531
+ process.stdout.write(` in the tree but not in the artifact: ${localOnly.length === 0 ? '(none)' : localOnly.join(', ')}\n`);
532
+ if (tests !== null) {
533
+ process.stdout.write(` suite inside the artifact: ${tests.ran ? `ran ${tests.testFile}, exit ${tests.status}` : tests.reason}\n`);
534
+ }
535
+ }
536
+ const failed = comparison.mismatches > 0
537
+ || references.missing > 0
538
+ || references.promisedButAbsent.length > 0
539
+ || codeReferences.unresolved.length > 0
540
+ || (tests !== null && tests.ran && tests.status !== 0);
541
+ return failed ? 1 : 0;
542
+ })
543
+ .then((code) => { process.exitCode = code; })
544
+ .catch((error) => {
545
+ process.stderr.write(`verify-release failed: ${error?.message ?? error}\n`);
546
+ process.exitCode = 1;
547
+ });
548
+ return null;
549
+ }
550
+
551
+ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
552
+ const code = main(process.argv.slice(2));
553
+ if (typeof code === 'number') process.exitCode = code;
554
+ }