From 6f1f8a44624eb7f5ffb20e5cf11e459df02864ea Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 27 Jul 2026 15:12:04 -0700 Subject: [PATCH] fix: check package-lock.json in the workspace version gate The gate compared only the three package.json manifests, so it passed while package-lock.json still recorded a stale workspace version. npm ci does not reject that either (checked on npm 10.9.2, 11.12.1, 11.16.0, 11.17.0), so nothing caught the exact drift the script exists to catch. Reproduced with a fixture whose manifests all read 0.9.20 while the lockfile recorded packages/core at 0.9.19 and a "*" CLI dependency: the gate exited 0. It now exits 1. --- scripts/check-workspace-versions.mjs | 42 +++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/check-workspace-versions.mjs b/scripts/check-workspace-versions.mjs index 3e0de75..9be1cd8 100644 --- a/scripts/check-workspace-versions.mjs +++ b/scripts/check-workspace-versions.mjs @@ -2,6 +2,12 @@ // Fails when the monorepo root, the CLI, and @codeburn/core drift apart. // The CLI must depend on the exact core version it ships with, otherwise a // published CLI can resolve a core it was never tested against. +// +// package-lock.json is checked too. Comparing only the manifests leaves the +// gate blind to the case it exists for: all three manifests can agree while the +// lockfile still records a stale workspace version, and `npm ci` does not +// reject that (verified on npm 10.9.2, 11.12.1, 11.16.0, 11.17.0). A gate that +// cannot catch its own failure mode is decoration. import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' @@ -20,6 +26,7 @@ const read = (relative) => { const root = read('package.json') const cli = read('packages/cli/package.json') const core = read('packages/core/package.json') +const lock = read('package-lock.json') const coreDep = cli.dependencies?.['@codeburn/core'] @@ -37,6 +44,39 @@ if (coreDep !== core.version) { ) } +// --- lockfile agreement ----------------------------------------------------- + +const lockEntry = (key) => lock.packages?.[key] + +if (!lock.packages) { + problems.push('package-lock.json has no "packages" map; expected lockfileVersion 2 or 3') +} else { + const expected = [ + ['', root.version, 'root'], + ['packages/cli', cli.version, 'packages/cli'], + ['packages/core', core.version, 'packages/core'], + ] + for (const [key, manifestVersion, label] of expected) { + const entry = lockEntry(key) + if (!entry) { + problems.push(`package-lock.json is missing an entry for ${label}`) + continue + } + if (entry.version !== manifestVersion) { + problems.push( + `package-lock.json records ${label} at ${entry.version}, but its manifest says ${manifestVersion}`, + ) + } + } + + const lockedDep = lockEntry('packages/cli')?.dependencies?.['@codeburn/core'] + if (lockedDep !== undefined && lockedDep !== coreDep) { + problems.push( + `package-lock.json records the CLI's core dependency as ${JSON.stringify(lockedDep)}, but packages/cli/package.json says ${JSON.stringify(coreDep)}`, + ) + } +} + if (problems.length > 0) { console.error('workspace version check failed:') for (const problem of problems) console.error(` - ${problem}`) @@ -46,4 +86,4 @@ if (problems.length > 0) { process.exit(1) } -console.log(`workspace versions agree at ${root.version}`) +console.log(`workspace versions and package-lock.json agree at ${root.version}`)