Four fixtures linked the installed dependencies into a throwaway root with a single directory symlink at <fixture>/node_modules, then ran pnpm inside that root. pnpm does not recognise the modules directory it finds there and purges it; with CI=true it does so without a prompt. The purge followed the symlink and deleted the repository's own node_modules mid-run, so a test suite uninstalled the workspace it was running in. That is what produced the cascading, file-unrelated failures a full test:unit run reported, and it happened twice while running the suites for the adapter re-review. scripts/lib/fixture-node-modules.ts replaces all four sites: node_modules is a real directory whose entries are individual symlinks, so a recursive delete unlinks the fixture's own links instead of walking through one link into the shared tree. Resolution is unchanged. tests/unit/fixture-node-modules.test.ts performs the exact recursive delete pnpm performs and asserts the source tree survives, and check:adapter-inventory now fails on any reintroduction of the directory-symlink form — verified by putting the old line back and watching the gate reject it. A full tests/unit + tests/integration run now leaves the dependencies intact. removal-fixture, supply-chain and security-followup-archive, the three suites that had to be excluded before, pass in that run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
import { CACHEABLE_ASSET_CONTENT_TYPES } from "../src/contracts/service-worker-static-manifest.ts";
|
|
|
|
/**
|
|
* GOV-01 / SW-RR-03. Structural gates for facts that a hand-maintained document
|
|
* cannot keep true.
|
|
*
|
|
* The adapter review inventory claimed 118/118 while the tree held 119 files,
|
|
* so a whole adapter was outside every review's coverage without anything
|
|
* failing. And the Service Worker asset generator and the shared manifest
|
|
* decoder each carried their own extension table, so a build could emit an
|
|
* asset the runtime contract then refused. Both are now equalities this script
|
|
* checks rather than numbers someone has to remember to update.
|
|
*/
|
|
|
|
const INVENTORY_PATH = "docs/reviews/adapters/INVENTORY.md";
|
|
const GENERATOR_PATH = "scripts/generate-service-worker-assets.ts";
|
|
|
|
function trackedAdapterFiles(): readonly string[] {
|
|
const listed = spawnSync("git", ["ls-files", "src/adapters"], {
|
|
encoding: "utf8",
|
|
});
|
|
if (listed.status !== 0) {
|
|
throw new Error(`git ls-files failed: ${listed.stderr}`);
|
|
}
|
|
return listed.stdout.split("\n").filter(Boolean).sort();
|
|
}
|
|
|
|
function inventoryRows(markdown: string): readonly string[] {
|
|
const rows: string[] = [];
|
|
for (const line of markdown.split("\n")) {
|
|
const match = /^\|\s*\d+\s*\|\s*`([^`]+)`\s*\|/u.exec(line);
|
|
if (match?.[1]) rows.push(match[1]);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function reportDifference(
|
|
label: string,
|
|
expected: readonly string[],
|
|
actual: readonly string[],
|
|
): readonly string[] {
|
|
const missing = expected.filter((value) => !actual.includes(value));
|
|
const extra = actual.filter((value) => !expected.includes(value));
|
|
const problems: string[] = [];
|
|
for (const value of missing) problems.push(`${label}: missing ${value}`);
|
|
for (const value of extra) problems.push(`${label}: unexpected ${value}`);
|
|
return problems;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const problems: string[] = [];
|
|
|
|
const tracked = trackedAdapterFiles();
|
|
const markdown = await readFile(INVENTORY_PATH, "utf8");
|
|
const listed = inventoryRows(markdown);
|
|
problems.push(...reportDifference("adapter inventory", tracked, listed));
|
|
if (listed.length !== new Set(listed).size) {
|
|
problems.push("adapter inventory: duplicate row");
|
|
}
|
|
const total = /합계: \*\*(\d+)\/(\d+)\*\*/u.exec(markdown);
|
|
if (
|
|
!total ||
|
|
Number(total[1]) !== tracked.length ||
|
|
Number(total[2]) !== tracked.length
|
|
) {
|
|
problems.push(
|
|
`adapter inventory: total does not equal ${tracked.length} tracked files`,
|
|
);
|
|
}
|
|
|
|
// SW-RR-03. The generator must read the shared table rather than declare one.
|
|
const generator = await readFile(GENERATOR_PATH, "utf8");
|
|
if (!generator.includes("CACHEABLE_ASSET_CONTENT_TYPES")) {
|
|
problems.push(
|
|
"service worker assets: generator does not use the shared extension table",
|
|
);
|
|
}
|
|
if (/const CACHEABLE_EXTENSIONS[^=]*=\s*Object\.freeze\(\{/u.test(generator)) {
|
|
problems.push(
|
|
"service worker assets: generator declares its own extension table",
|
|
);
|
|
}
|
|
for (const [extension, contentType] of Object.entries(
|
|
CACHEABLE_ASSET_CONTENT_TYPES,
|
|
)) {
|
|
if (!extension.startsWith(".") || contentType.length === 0) {
|
|
problems.push(`service worker assets: invalid table row ${extension}`);
|
|
}
|
|
}
|
|
|
|
// A fixture that links the repository's node_modules with a single directory
|
|
// symlink is destructive: pnpm running inside that fixture purges the modules
|
|
// directory it does not recognise, follows the link, and deletes the real
|
|
// dependencies mid-run. `linkFixtureNodeModules` is the only sanctioned form.
|
|
const sources = spawnSync(
|
|
"git",
|
|
["grep", "-n", "-e", 'symlink(', "--", "scripts", "tests"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (sources.status === 0) {
|
|
for (const line of sources.stdout.split("\n").filter(Boolean)) {
|
|
if (!line.includes("node_modules")) continue;
|
|
if (line.startsWith("scripts/lib/fixture-node-modules.ts:")) continue;
|
|
problems.push(
|
|
`fixture node_modules: use linkFixtureNodeModules instead — ${line}`,
|
|
);
|
|
}
|
|
}
|
|
const linkedFixtures = spawnSync(
|
|
"git",
|
|
["grep", "-l", "linkFixtureNodeModules", "--", "scripts", "tests"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (
|
|
linkedFixtures.status !== 0 ||
|
|
linkedFixtures.stdout.split("\n").filter(Boolean).length < 2
|
|
) {
|
|
problems.push(
|
|
"fixture node_modules: the shared linker has no callers, so it is not the sanctioned path",
|
|
);
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
for (const problem of problems) console.error(problem);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(
|
|
`Adapter inventory: ${tracked.length} files PASS; ` +
|
|
`service worker asset table: ${
|
|
Object.keys(CACHEABLE_ASSET_CONTENT_TYPES).length
|
|
} shared extensions PASS; ` +
|
|
`fixture node_modules linking PASS`,
|
|
);
|
|
}
|
|
|
|
await main();
|