Files
clean-architecture-frontend…/scripts/check-adapter-inventory.ts
T
DongHyeonkaandClaude Opus 5 5a76f95291 fix: bound resumable teardown, image concurrency and delivery leases
TR-RR-06. dispose() now bounds its drain with a cleanupDeadlineMs from policy
and returns the result, so a non-cooperative mutation lock or provider can no
longer make teardown unbounded and an unproved drain is reported as still
CLOSING instead of closed over. The checkpoint store stays open in that case,
because something can still write to it. An abort is admitted physical work
like an upload, so it joins the tracked set rather than being stepped over.

TR-RR-07. The verification slot belongs to the raw verifier, not the wrapper.
Releasing it when the caller's wait expired let an abandoned verification keep
running while a new one was admitted, so repeated aborts produced more
concurrent physical work than the configured cap allows. The slot is now
released only once the raw tasks settle.

TR-RR-04. A presigned byte source owns a fetch reader and a capability lease and
its port requires close(); the delivery consumer never called it. The closeable
subtype is lost in the FileByteSource projection, so a holder keeps it from the
moment the lease exists and the outermost finally closes it exactly once — on
success, validation failure, writer failure and abort alike.

check:adapter-inventory now also fails if the shared abortable-operation
primitive has no production importers. It was safe to add only once the
presigned subsystems actually migrated onto it; a gate that fails CI for a
documented, unfixed defect reports the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:22:45 +09:00

162 lines
5.6 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",
);
}
// TR-RR-05. The shared abort/deadline primitive is only shared if production
// code imports it. A helper with zero importers is a second implementation
// waiting to happen, which is exactly how the four hand-written copies of
// these mechanics diverged in the first place.
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"));
if (importers.length === 0) {
problems.push(
"abortable-operation: the shared primitive has no production importers",
);
}
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();