From 73919526e9570c43bb29e850b79e5ee43e84c808 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 29 Aug 2026 18:17:24 -0400 Subject: [PATCH] =?UTF-8?q?fix(#3902):=20packaging=20guard=20resolves=20np?= =?UTF-8?q?m=2012's=20pack=20--json=20shape=20=E2=80=94=20stays=20armed=20?= =?UTF-8?q?on=20Node=2026=20(#4064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/serene-goats-sprint.md | 5 ++ ...pped-scripts-require-only-shipped.test.cjs | 65 ++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 .changeset/serene-goats-sprint.md diff --git a/.changeset/serene-goats-sprint.md b/.changeset/serene-goats-sprint.md new file mode 100644 index 000000000..68e7e4a5f --- /dev/null +++ b/.changeset/serene-goats-sprint.md @@ -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) diff --git a/tests/packaging-shipped-scripts-require-only-shipped.test.cjs b/tests/packaging-shipped-scripts-require-only-shipped.test.cjs index fa329a6ad..b140ab8ec 100644 --- a/tests/packaging-shipped-scripts-require-only-shipped.test.cjs +++ b/tests/packaging-shipped-scripts-require-only-shipped.test.cjs @@ -46,7 +46,37 @@ function resolveTarballFiles() { timeout: 120_000, }); 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}'`); }); }); + + +// ─── #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 { "": { 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/); + }); +});