From 288adbcfd621a53edc0115653a8d81836a24c750 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 22:31:05 -0400 Subject: [PATCH] chore(deps): enrol installs by package.json, and fail on a missing lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifteenth pass of #1962. `installDirs` filtered on the presence of a lockfile, so a missing one silently removed that install from the comparison rather than failing. For the root — the install every shared source resolves from — that meant the guard could report success from client locks alone, which is the deny-by-default contract inverted. Enrolment is now by `package.json` (an install we are meant to compare), with the root always enrolled, and a missing lockfile is a named, loud failure. A `clients/` directory with no `package.json` is not an install and is skipped, so a stray scratch dir isn't asked for a lockfile it should never have. Three regression cases: a missing client lockfile in a fixture that IS skewed (so failing open would show as a pass), a missing ROOT lockfile, and a stray `clients/` dir that must not be enrolled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 41 +++++++++++++++++++++++ scripts/verify-dep-lockstep.mjs | 34 +++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 50db6b99..c00379d7 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -228,6 +228,47 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", () ); }); +test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", () => { + // Enrolment is by `package.json`, so a missing lockfile is loud rather than a + // silently absent row. The fixture IS skewed, so dropping the install instead + // would leave the remaining holder unopposed and report aligned. + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + rmSync(path.join(dir, "clients", "web", "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 when the ROOT lockfile is missing", () => { + // The worst variant: the root is the install every shared source resolves + // from, so omitting it could let the guard pass on client locks alone. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + rmSync(path.join(dir, "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /^\s+\.\/package-lock\.json$/m); + }); +}); + +test("main: a clients/ dir with no package.json is not an install", () => { + // Enrolling a stray directory would demand a lockfile it should never have. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true }); + writeFileSync( + path.join(dir, "clients", "scratch", "notes.md"), + "scratch\n", + ); + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + }); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 55553375..27b5812c 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -377,9 +377,18 @@ function installDirs() { .map((e) => `clients/${e.name}`) .sort() : []; - return ["."] - .concat(clients) - .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); + // Enrolment is by `package.json` — an install we are meant to compare — + // NOT by the presence of a lockfile (Copilot, #1962). Filtering on the + // lockfile made a missing one silently drop that install from the + // comparison; for the root, the install every shared source resolves from, + // that meant the guard could report success from client locks alone. A + // missing lockfile is now a loud failure in `main`, not an absent row. The + // root is always enrolled: it is this repo, so its manifest is a given. + return ["."].concat( + clients.filter((dir) => + existsSync(path.join(repoRoot, dir, "package.json")), + ), + ); } /** @@ -427,6 +436,25 @@ export function main() { } const dirs = installDirs(); + + // A missing lockfile is a failure, not a skipped install: dropping one would + // remove its versions from the comparison and could report a real skew as + // aligned. + const missing = dirs.filter( + (dir) => !existsSync(path.join(repoRoot, dir, "package-lock.json")), + ); + if (missing.length > 0) { + console.error( + `verify:dep-lockstep — ${missing.length} install(s) have a package.json but no lockfile:\n`, + ); + for (const dir of missing) console.error(` ${dir}/package-lock.json`); + console.error( + "\nEvery install must be compared; skipping one could report a real skew as aligned." + + "\nRun `npm install` there, or remove the install if it is no longer part of the repo.", + ); + process.exit(1); + } + const locks = dirs.map((dir) => { const file = path.join(repoRoot, dir, "package-lock.json"); let lock;