The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
7.5 KiB
TypeScript
203 lines
7.5 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
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",
|
|
);
|
|
}
|
|
|
|
// TR-RR-05 / GOV-04. Every consumer the re-review named must use the shared
|
|
// primitive, not merely one file somewhere. Checking `importers.length > 0`
|
|
// let an unrelated production import satisfy the gate while Image and
|
|
// Resumable kept their own diverging copies of the same mechanics — which is
|
|
// exactly how the four hand-written versions drifted apart in the first place.
|
|
const REQUIRED_ABORT_CONSUMERS: readonly string[] = [
|
|
"src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts",
|
|
"src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts",
|
|
"src/adapters/browser-transfer/image-cdn/browser-image-probe.ts",
|
|
"src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts",
|
|
];
|
|
const primitiveImporters = spawnSync(
|
|
"git",
|
|
["grep", "-l", "platform/abortable-operation.ts", "--", "src"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
const importers = (
|
|
primitiveImporters.status === 0 ? primitiveImporters.stdout : ""
|
|
)
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.filter((file) => !file.endsWith("platform/abortable-operation.ts"))
|
|
.sort();
|
|
const importerSet = new Set(importers);
|
|
const missingConsumers = REQUIRED_ABORT_CONSUMERS.filter(
|
|
(consumer) => !importerSet.has(consumer),
|
|
);
|
|
if (missingConsumers.length > 0) {
|
|
problems.push(
|
|
`abortable-operation: required consumers do not import the shared primitive: ${missingConsumers.join(
|
|
", ",
|
|
)}`,
|
|
);
|
|
}
|
|
// The importer must reach the primitive by a specifier that resolves to the
|
|
// primitive itself, so a same-named local helper cannot satisfy the gate.
|
|
const PRIMITIVE_PATH = path.resolve(
|
|
"src/adapters/platform/abortable-operation.ts",
|
|
);
|
|
for (const consumer of REQUIRED_ABORT_CONSUMERS) {
|
|
if (!importerSet.has(consumer)) continue;
|
|
const source = readFileSync(consumer, "utf8");
|
|
const specifiers = [
|
|
...source.matchAll(/from\s+"([^"]*platform\/abortable-operation\.ts)"/gu),
|
|
].map((match) => match[1] ?? "");
|
|
const resolved = specifiers.some(
|
|
(specifier) =>
|
|
path.resolve(path.dirname(consumer), specifier) === PRIMITIVE_PATH,
|
|
);
|
|
if (!resolved) {
|
|
problems.push(
|
|
`abortable-operation: ${consumer} does not resolve its import to the shared primitive`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
for (const problem of problems) console.error(problem);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// GOV-04. The exact importer set is part of the receipt, so a reviewer can
|
|
// see which consumers the gate actually verified rather than a bare count.
|
|
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; ` +
|
|
`shared abort primitive: ${importers.length} importers ` +
|
|
`(${importers.join(", ")}) PASS`,
|
|
);
|
|
}
|
|
|
|
await main();
|