712 -> 612줄. 연결 수명주기와 트랜잭션 상태기계가 커널로 갔고 CP의 손수 abort 리스너가 0이 됐다. 그래서 abort 래칫을 24에서 23으로 조인다. 보존한 동작 다섯 가지: - 값 없는 완료는 CORRUPT_DATA/RECONCILE (translate에 NO_VALUE_PRODUCED를 명시 매핑). 빠뜨리면 UNAVAILABLE로 바뀐다 - durability는 undefined를 명시적으로 넘긴다. 커널 기본이나 strict를 쓰면 체크포인트 쓰기가 조용히 느려진다 - nativeFailure 6곳 전부 context.fail. requestFailed로 바꾸면 요청이 실패했는데도 뒤 요청이 커밋된다 - abort()가 throw하면 caller-abort 주장을 철회한다 (커널이 이미 구현) - blocked 타이머는 주입형 timers 사양이 빠뜨린 함정 하나를 추가로 막았다. bindScope는 succeed()에 해당하는 것이 없어 그대로 옮기면 정상 바인딩이 NO_VALUE_PRODUCED로 보고된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
9.7 KiB
TypeScript
246 lines
9.7 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 리스너를 지웠다.
|
|
const HAND_ROLLED_ABORT_CEILING = 23;
|
|
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();
|