chore(deps): reject an unreadable lockfile instead of failing open

From Copilot's eleventh pass of #1962, and the most consequential kind of bug
for a gate: it failed *open*.

`topLevelLockVersions` returns an empty map for anything without a v2+
`packages` table — a lockfileVersion 1 file, or a malformed one. `main()` fed
that straight into the comparison, so such an install contributed no holders
at all, and a genuine skew among the remaining installs was reported as
aligned. The guard would have said OK to exactly what it exists to catch.

`hasReadableLockShape` now gates every discovered lockfile before any
comparison, requiring a `packages` object carrying the `""` root entry npm
always writes — "has a packages key" is not enough. An unreadable one is named
and exits 1, telling you to regenerate it. A JSON parse failure likewise
reports the file rather than dying on a bare SyntaxError with no path in it.

The empty-map behavior of the pure helper is kept and its test now says why it
is safe: the shape check rejects those inputs before they can reach
`findSkew`.

Three regression cases: a v1 lockfile in a fixture that IS skewed (so failing
open would show as a pass), a `packages` table with no root entry, and the
unit table for the predicate. Fixtures now write the `""` root entry, matching
real npm output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
This commit is contained in:
cliffhall 2026-08-10 22:02:56 -04:00
commit af63a07817

View file

@ -209,6 +209,25 @@ export function topLevelLockVersions(lock) {
return versions;
}
/**
* Whether a parsed lockfile has the shape this guard can read: a
* `lockfileVersion` 2+ `packages` table, keyed by install path with `""` for
* the root project.
*
* This is checked rather than tolerated because the gate is deny-by-default and
* `topLevelLockVersions` returns an empty map for anything else. An unreadable
* lockfile would otherwise contribute no holders, and a real skew among the
* remaining installs would be reported as aligned — the gate failing *open*,
* which is the one way it must never fail (Copilot, #1962). A v1 lockfile
* (`dependencies` only, no `packages`) lands here too, correctly: this guard
* cannot read it, so it must say so rather than skip the install.
*/
export function hasReadableLockShape(lock) {
const packages = lock?.packages;
if (typeof packages !== "object" || packages === null) return false;
return Object.prototype.hasOwnProperty.call(packages, "");
}
/**
* Find candidate packages that resolve to more than one version across the
* installs. `installs` is an array of `{ dir, versions }`. Returns one entry per
@ -375,13 +394,42 @@ export function main() {
}
const dirs = installDirs();
const installs = dirs.map((dir) => ({
const locks = dirs.map((dir) => {
const file = path.join(repoRoot, dir, "package-lock.json");
let lock;
try {
lock = JSON.parse(readFileSync(file, "utf8"));
} catch (cause) {
// Unparseable is the same failure as unreadable — say which file, rather
// than dying on a raw SyntaxError with no path in it.
throw new Error(
`verify:dep-lockstep — could not parse ${dir}/package-lock.json.`,
{ cause },
);
}
return { dir, lock };
});
// Refuse to compare against a lockfile whose shape we can't read, instead of
// treating it as an install that holds nothing — see `hasReadableLockShape`.
const unreadable = locks.filter(({ lock }) => !hasReadableLockShape(lock));
if (unreadable.length > 0) {
console.error(
`verify:dep-lockstep — ${unreadable.length} lockfile(s) are not in a readable format:\n`,
);
for (const { dir } of unreadable)
console.error(` ${dir}/package-lock.json`);
console.error(
"\nThis guard reads the `packages` table of a lockfileVersion 2+ lockfile. Without it the install" +
"\ncontributes no versions, so a real skew among the others would be reported as aligned — the gate" +
"\nfailing open. Regenerate the lockfile with a current npm (`npm install`).",
);
process.exit(1);
}
const installs = locks.map(({ dir, lock }) => ({
dir,
versions: topLevelLockVersions(
JSON.parse(
readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"),
),
),
versions: topLevelLockVersions(lock),
}));
const { failures, ignored } = partitionSkew(findSkew(candidates, installs));