morphit/scripts/lockfile-sync-smoke.ts

250 lines
9.8 KiB
TypeScript

/**
* lockfile-sync smoke (cp144).
*
* Catches the cp140 → cp143 latent bug: when a new workspace
* (`apps/mcp-server`) was added to the root `package.json`,
* `package-lock.json` was not regenerated. `npm ci` requires
* the two to be in sync — so every CI run after cp140 failed
* with EUSAGE at the install step, gating ALL downstream jobs
* (typecheck, smokes, svelte-check, build). The repo looked
* green locally because `npm install` (the dev command) silently
* heals the lockfile, while `npm ci` (the CI command) refuses
* to. The failure mode was invisible until Ken happened to
* look at the actual Forgejo runner logs.
*
* This smoke runs the canonical `npm ci --dry-run` against the
* working tree and asserts exit-zero. Three scenarios:
*
* 1. `npm ci --dry-run` succeeds → lockfile is in sync.
* 2. Exit code is non-zero AND stderr mentions lockfile drift
* → known class-of-bug. Smoke names it specifically and
* points at `npm install` as the fix.
* 3. Exit code is non-zero for OTHER reasons (network down,
* registry hiccup) → smoke reports the failure verbatim
* so the operator can diagnose.
*
* Runtime: ~4 seconds when the lockfile IS in sync (npm ci
* dry-run + tree walk). Well under the cp143 240s ceiling.
*
* To tamper-test: revert any workspace dep in package-lock.json
* (e.g. delete the "node_modules/morphit-mcp" entry) and re-run.
* Smoke should fail with the class-of-bug branch.
*
* NOTE: this smoke uses `npm` from PATH. In CI the smokes job
* has npm available because the setup-node action ensures it.
* On developer machines anyone running smokes locally also has
* npm installed (you can't develop Morphit without it). No
* fallback needed.
*/
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
const ANSI_GREEN = '\x1b[32m';
const ANSI_RED = '\x1b[31m';
const ANSI_RESET = '\x1b[0m';
const REPO_ROOT = resolvePath(new URL('..', import.meta.url).pathname);
interface Result {
name: string;
passed: boolean;
detail?: string;
}
const results: Result[] = [];
function pass(name: string) {
results.push({ name, passed: true });
}
function fail(name: string, detail: string) {
results.push({ name, passed: false, detail });
}
// Markers that the npm ci EUSAGE failure mode uses. These are
// stable across at least npm 10.x — verified against the 2026-05-27
// cp140-stale-lockfile Forgejo CI log Ken caught.
const STALE_LOCKFILE_MARKERS = [
'EUSAGE',
'package.json and package-lock.json',
'package-lock.json or npm-shrinkwrap.json',
'lock file',
'Missing:'
];
console.log('── lockfile-sync smoke (cp144) ─────────────────────────');
const result = spawnSync(
'npm',
['ci', '--dry-run', '--no-audit', '--no-fund', '--prefer-offline'],
{
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
// Avoid blocking on network in pathological cases. npm ci
// with --prefer-offline will use cache when available; if it
// genuinely needs network the smoke takes longer but won't
// hang past the runner's 240s ceiling.
timeout: 120_000
}
);
const combinedOutput = `${result.stdout || ''}\n${result.stderr || ''}`;
const stale = STALE_LOCKFILE_MARKERS.some((m) => combinedOutput.includes(m));
// Scenario 1 — npm ci passed dry-run.
if (result.status === 0) {
pass('npm ci --dry-run succeeds (lockfile in sync with package.json)');
} else if (stale && result.status !== null) {
// Scenario 2 — npm ci failed AND the failure mode is the
// known stale-lockfile EUSAGE. Class-of-bug message.
const missing = combinedOutput
.split('\n')
.filter((l) => l.includes('Missing:'))
.slice(0, 6)
.map((l) => l.replace(/^.*Missing:\s*/, '').trim())
.filter(Boolean);
const missingDetail =
missing.length > 0
? `missing from lockfile (first ${missing.length}): ${missing.join(', ')}`
: 'lockfile is stale';
fail(
'npm ci --dry-run succeeds (lockfile in sync with package.json)',
`${missingDetail}. Fix: run \`npm install --package-lock-only\` from repo root, commit the updated package-lock.json, and push. This is the cp140 → cp144 class of bug — adding a workspace requires regenerating the lockfile or CI's \`npm ci\` will reject the install before any job (typecheck, smokes, svelte-check) gets a chance to run.`
);
} else {
// Scenario 3 — failed for some OTHER reason. Surface verbatim.
const head = combinedOutput
.split('\n')
.filter((l) => l.trim())
.slice(0, 8)
.join('\n ');
fail(
'npm ci --dry-run succeeds (lockfile in sync with package.json)',
`npm ci exited ${result.status} with non-lockfile error:\n ${head}`
);
}
// Scenario 2 (always-run sanity) — root package-lock.json must
// exist and be readable JSON. This is the precondition for
// scenario 1 even making sense. Catches accidental deletion or
// JSON corruption.
const lockPath = resolvePath(REPO_ROOT, 'package-lock.json');
if (!existsSync(lockPath)) {
fail(
'package-lock.json exists at repo root',
`expected file at ${lockPath} — required for reproducible CI installs (\`npm ci\` only works against a committed lockfile)`
);
} else {
try {
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
if (typeof lock !== 'object' || !lock.lockfileVersion) {
throw new Error('lockfile JSON is not the expected npm schema');
}
pass(
`package-lock.json exists and parses as valid npm schema (lockfileVersion=${lock.lockfileVersion})`
);
} catch (err) {
fail(
'package-lock.json exists at repo root',
`failed to parse: ${err instanceof Error ? err.message : String(err)}`
);
}
}
// Scenario 3 — every workspace declared in root package.json
// must have a corresponding entry in package-lock.json's
// `packages` map. This is the FAST offline check that catches
// the cp140 specific bug class even if `npm ci` is somehow
// unavailable. Authoritative for the workspace-add forgot-the-lockfile
// path; not authoritative for transitive-dep drift (that's what
// scenario 1 covers).
try {
const root = JSON.parse(
readFileSync(resolvePath(REPO_ROOT, 'package.json'), 'utf8')
) as { workspaces?: string[] };
const lock = JSON.parse(readFileSync(lockPath, 'utf8')) as {
packages?: Record<string, unknown>;
};
const declared = root.workspaces ?? [];
const lockKeys = new Set(Object.keys(lock.packages ?? {}));
const missing = declared.filter((w) => !lockKeys.has(w));
if (missing.length === 0) {
pass(
`every declared workspace (${declared.length}) appears in package-lock.json's packages map`
);
} else {
fail(
'every declared workspace appears in package-lock.json',
`workspaces in package.json but not in lockfile: ${missing.join(', ')}. This is the cp140 specific failure: the workspace was added to package.json but \`npm install\` was never re-run to populate the lockfile.`
);
}
} catch (err) {
fail(
'every declared workspace appears in package-lock.json',
`could not cross-check package.json vs package-lock.json: ${err instanceof Error ? err.message : String(err)}`
);
}
let failed = 0;
// ─── Workspace VERSION parity (cp472) ──────────────────────────────
// This smoke only ever checked that each workspace is PRESENT in the
// lockfile (the cp140 `npm ci` EUSAGE bug). It never checked the version
// each entry reports — so the lockfile silently sat at 1.4.12 through the
// whole of v1.5.0 while every package.json said 1.5.0, and CI stayed green.
// `npm install` heals it locally, which is exactly why nobody noticed.
// Ken: "i do not like anything to go stale, so fix where necessary, always."
//
// Version-only drift doesn't break `npm ci`, but it makes the lockfile lie
// about what the repo is: anything reading it (supply-chain tooling, an
// operator diffing a tarball against a tag, `npm ls`) gets the wrong answer.
// Regenerate with: npm install --package-lock-only
{
const name = 'lockfile workspace versions match their package.json';
try {
const lock = JSON.parse(readFileSync(resolvePath(REPO_ROOT, 'package-lock.json'), 'utf8')) as {
packages?: Record<string, { version?: string }>;
};
const pkgs = lock.packages ?? {};
const drift: string[] = [];
// '' is the root workspace entry; the rest are keyed by their path.
for (const [key, entry] of Object.entries(pkgs)) {
if (key.startsWith('node_modules/')) continue;
const manifestPath = resolvePath(REPO_ROOT, key === '' ? 'package.json' : `${key}/package.json`);
if (!existsSync(manifestPath)) continue;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: string };
if (manifest.version === undefined) continue;
if (entry.version !== manifest.version) {
drift.push(`${key === '' ? '(root)' : key}: lockfile ${entry.version ?? '<none>'} vs package.json ${manifest.version}`);
}
}
if (drift.length === 0) {
pass(name);
} else {
fail(
name,
`${drift.length} stale version(s) — regenerate with \`npm install --package-lock-only\` (version-only; it must not touch resolved/integrity):\n ${drift.join('\n ')}`
);
}
} catch (err) {
fail(name, `could not compare lockfile versions: ${String(err)}`);
}
}
for (const r of results) {
if (r.passed) {
console.log(` ${ANSI_GREEN}${ANSI_RESET} ${r.name}`);
} else {
console.log(` ${ANSI_RED}${ANSI_RESET} ${r.name}`);
if (r.detail) console.log(` ${r.detail}`);
failed++;
}
}
console.log();
console.log('──────────────────────────────────────────────────────');
if (failed > 0) {
console.log(`${failed} of ${results.length} scenarios failed`);
process.exit(1);
} else {
console.log(`✓ all ${results.length} scenarios passed`);
}