fix(#3902): packaging guard resolves npm 12's pack --json shape — stays armed on Node 26 (#4064)

* test(#3902): packListToPathSet must resolve npm 12's object-keyed pack --json shape (failing first)

* fix(#3902): resolve both npm pack --json shapes — the packaging guard stays armed on Node 26

npm 12 (bundled with Node 26) emits an object keyed by package name
where npm <=11 emitted an array; parsed[0] was undefined, the before()
hook threw, and all 6 tests in the packaging guard — including the two
'does NOT ship' guards that stop repo-only CI tooling leaking into the
published package — went dark for contributors on Node 26. CONTRIBUTING
pins Node 24 as the floor but requires Node 26 compatibility for code
AND tests; this was the test's parse, not the code.

packListToPathSet now resolves either shape and fails loud on anything
else — a silently-empty set would be the exact dark-guard failure mode
this guard exists to prevent.

* fix(#3902): review fold-in — a multi-key object fails loud

Single-package assumption documented and enforced: npm 12 emits exactly
one key for this repo (no workspaces — verified); if workspaces were
ever adopted, first-key selection would silently validate one package's
file list and recreate the exact silent-disarm this fix kills.

* chore(#3902): changeset fragment (pr number backfilled after PR creation)

* chore(#3902): backfill changeset PR number (4064)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 18:17:24 -04:00
committed by GitHub
parent 686c05a740
commit 73919526e9
2 changed files with 69 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4064
---
**The packaging guard stays armed on npm 12 (Node 26)** — npm 12 emits pack --json as an object keyed by package name, so parsed[0] was undefined, the before() hook threw, and all 6 packaging-guard tests (including both does-NOT-ship gates) went dark for Node 26 contributors (#3902)

View File

@@ -46,7 +46,37 @@ function resolveTarballFiles() {
timeout: 120_000, timeout: 120_000,
}); });
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return new Set(parsed[0].files.map((f) => f.path.replace(/\\/g, '/'))); return packListToPathSet(parsed);
}
/**
* Pure projection from `npm pack --json` output to the tarball path set —
* extracted so the SHAPE contract is unit-testable without running npm.
*/
function packListToPathSet(parsed) {
// #3902: npm <=11 emits an ARRAY of pack results; npm 12 (bundled with
// Node 26) emits an OBJECT keyed by package name. parsed[0] is undefined on
// the object shape — which threw in this file's before() hook and silently
// disabled the whole packaging guard, including both `does NOT ship`
// assertions. Resolve either shape; anything else fails loud.
// Single-package assumption: this repo has no workspaces, so npm 12 emits
// exactly ONE key. If workspaces are ever adopted, first-key would silently
// validate only one package — recreate the silent-disarm this fix kills —
// so a multi-key object fails loud instead.
let entry;
if (Array.isArray(parsed)) {
entry = parsed[0];
} else {
const keys = Object.keys(parsed);
if (keys.length !== 1) {
throw new Error(`npm pack --json emitted ${keys.length} package keys; expected exactly 1 for this single-package repo`);
}
entry = parsed[keys[0]];
}
if (!entry || !Array.isArray(entry.files)) {
throw new Error(`npm pack --json returned an unrecognized shape: ${Object.prototype.toString.call(parsed)}`);
}
return new Set(entry.files.map((f) => f.path.replace(/\\/g, '/')));
} }
/** /**
@@ -198,3 +228,36 @@ describe('#2858 — shipped scripts require only shipped paths', () => {
`a sibling require within scripts/ must classify as 'shipped'; got '${classification}'`); `a sibling require within scripts/ must classify as 'shipped'; got '${classification}'`);
}); });
}); });
// ─── #3902: npm 12's pack --json shape must resolve like npm <=11's ──────────
describe('#3902 packListToPathSet resolves both npm pack --json shapes', () => {
const FILES = [{ path: 'gsd-core/bin/gsd-tools.cjs' }, { path: 'lib/Backslash\\Case.cjs' }];
test('npm <=11 array shape', () => {
const set = packListToPathSet([{ files: FILES }]);
assert.ok(set.has('gsd-core/bin/gsd-tools.cjs'));
assert.ok(set.has('lib/Backslash/Case.cjs'), 'windows separators normalized');
});
test('npm 12 (Node 26) object-keyed shape', () => {
// npm 12 emits { "<pkg-name>": { files: [...] } } — parsed[0] is undefined
// there, which used to throw in the before() hook and silently disable
// the whole packaging guard, including both `does NOT ship` assertions.
const set = packListToPathSet({ '@opengsd/gsd-core': { files: FILES } });
assert.ok(set.has('gsd-core/bin/gsd-tools.cjs'));
assert.ok(set.has('lib/Backslash/Case.cjs'));
});
test('an unrecognized shape fails loud, never silently-empty', () => {
assert.throws(() => packListToPathSet({ weird: true }));
});
test('a multi-key object (workspaces) fails loud — first-key would silently validate one package', () => {
assert.throws(() => packListToPathSet({
'pkg-a': { files: FILES },
'pkg-b': { files: FILES },
}), /exactly 1/);
});
});