Files
clean-architecture-frontend…/scripts/check-adapter-inventory.ts
T
DongHyeonkaandClaude Opus 5 a91e78f035 refactor: move the IndexedDB runtime onto the kernel
2902 -> 2744줄 (코드 2807 -> 2563, 주석 12 -> 111). 네 번째이자 마지막
사본이다. 손수 abort 리스너가 0이 되어 래칫을 22에서 21로 조인다.

RT-1을 보존했다: 번역기가 CLOSED -> unavailable을 명시 매핑한다. 빠뜨리면
close() 중 진행 중이던 open이 ABORTED로 보고된다.

RT-2는 보존하지 못했다. 사양은 "의미 동일"이라고 썼지만 실제로는 blocked
데드라인 이후 재시도가 새 factory.open()을 띄운다(이행 전에는 안 띄웠다).
커널에 "settle 이후에도 살아 있는 요청"을 알려줄 훅이 없어서 커널을 고치지
않고는 불가능하다. 연결 누수나 데이터 위험은 없고 낭비되는 요청만 는다.
다음 커밋에서 커널에 onSettled를 추가해 복원한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:17:19 +09:00

249 lines
9.9 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.
//
// 이 목록은 재검토가 이름으로 지목한 네 파일만 덮는다. 같은 두 그룹의
// `image-cdn-runtime.ts`와 `resumable-upload-runtime.ts`는 여기 없고 지금도
// 자기 abort 사본을 들고 있다. 어댑터 전체로는 23개 파일이 그렇다. 그 전수
// 이행은 abort 의미론을 바꾸는 별도 작업이라 이 목록으로 강제하지 않고,
// 아래 래칫이 개수가 늘어나는 것만 막는다.
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",
);
const PRIMITIVE_SPECIFIER = "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`,
);
}
}
// 손수 짠 abort 배선은 줄어들기만 해야 한다.
//
// 커널 `platform/abortable-operation.ts`가 있는데도 어댑터 23개 파일이
// `addEventListener("abort")`로 같은 race/cleanup을 각자 짠다. 그 전수 이행은
// 동작이 바뀌는 큰 작업이라 한 번에 하지 않는다. 대신 개수를 여기 고정해
// 되돌아가지 못하게 한다. 이행으로 숫자가 내려가면 이 상수도 같이 내린다.
// `platform/`은 커널 자신이므로 세지 않는다.
//
// 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
// 23 → 22: `storage/indexeddb/indexeddb-maintenance.ts`가 같은 이유로 지웠다.
// 22 → 21: `storage/indexeddb/indexeddb-runtime.ts`가 같은 이유로 지웠다.
// 이것으로 IndexedDB 어댑터 4벌이 모두 커널을 쓴다.
const HAND_ROLLED_ABORT_CEILING = 21;
const handRolledScan = spawnSync(
"git",
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
{ encoding: "utf8" },
);
const handRolledFiles = (
handRolledScan.status === 0 ? handRolledScan.stdout : ""
)
.split("\n")
.filter(Boolean)
.filter((file) => !file.startsWith("src/adapters/platform/"))
.filter((file) => !readFileSync(file, "utf8").includes(PRIMITIVE_SPECIFIER))
.sort();
if (handRolledFiles.length > HAND_ROLLED_ABORT_CEILING) {
problems.push(
`abortable-operation: hand-rolled abort wiring grew to ` +
`${handRolledFiles.length} files (ceiling ${HAND_ROLLED_ABORT_CEILING}). ` +
`Use platform/abortable-operation.ts instead of a new listener pair. ` +
`Current offenders (the new one is whichever this change added): ` +
`${handRolledFiles.join(", ")}`,
);
}
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; ` +
`hand-rolled abort wiring: ${handRolledFiles.length}/` +
`${HAND_ROLLED_ABORT_CEILING} files (ratchet) PASS`,
);
}
await main();