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 { 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`가 같은 이유로 지웠다. const HAND_ROLLED_ABORT_CEILING = 22; 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();