chore: sync the frontend template from 4dc033c to 8157ad4
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -0,0 +1,202 @@
|
||||
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();
|
||||
@@ -0,0 +1,165 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
|
||||
/**
|
||||
* GOV-03. Joins the machine-readable remediation dispositions against the prose
|
||||
* ledger.
|
||||
*
|
||||
* The previous ledger declared "All 38 are now FIXED" while six of those rows
|
||||
* were reproducibly partial. A sentence is cheap and a reviewer reads it as
|
||||
* evidence, so this gate makes the claim derivable rather than authored: every
|
||||
* disposition must name a test path that exists, the prose must carry the same
|
||||
* verdict for the same id, and a blanket closure sentence is only allowed when
|
||||
* nothing is still open.
|
||||
*/
|
||||
|
||||
type Disposition = Readonly<{
|
||||
id: string;
|
||||
previous: string;
|
||||
disposition: string;
|
||||
summary: string;
|
||||
evidence: readonly string[];
|
||||
/**
|
||||
* Labels the evidence files actually carry. Defaults to the finding id; a row
|
||||
* declares its own when the review labelled the work differently, as the
|
||||
* cross-audit ids did.
|
||||
*/
|
||||
markers?: readonly string[];
|
||||
}>;
|
||||
|
||||
const DISPOSITIONS_PATH = "docs/operations/adapter-remediation-dispositions.json";
|
||||
const LEDGER_PATH = "docs/operations/adapter-remediation-ledger.md";
|
||||
/**
|
||||
* Finding ids are only unique within a review pass — the second pass also used
|
||||
* `SW-01` — so rows are matched inside this pass's section rather than anywhere
|
||||
* in the document.
|
||||
*/
|
||||
const SECTION_HEADING = "## Third re-review (2026-08-14)";
|
||||
const CLOSED_DISPOSITIONS: ReadonlySet<string> = new Set(["FIXED"]);
|
||||
const RECIPES_PATH = "config/recipes/frontend-capability-recipes.json";
|
||||
/** The table is `| id | prior verdict | disposition | evidence |`. */
|
||||
const DISPOSITION_OFFSET_FROM_ID = 2;
|
||||
/** Sentences that assert everything is done, and therefore need proof. */
|
||||
const BLANKET_CLOSURE = /All\s+(?:\d+|findings|rows)[^.\n]*\b(?:FIXED|closed)\b/giu;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const problems: string[] = [];
|
||||
const raw: unknown = JSON.parse(await readFile(DISPOSITIONS_PATH, "utf8"));
|
||||
if (
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
!Array.isArray((raw as { dispositions?: unknown }).dispositions)
|
||||
) {
|
||||
console.error(`${DISPOSITIONS_PATH}: dispositions array is required`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const dispositions = (raw as { dispositions: Disposition[] }).dispositions;
|
||||
const document = await readFile(LEDGER_PATH, "utf8");
|
||||
const sectionStart = document.indexOf(SECTION_HEADING);
|
||||
if (sectionStart < 0) {
|
||||
console.error(`${LEDGER_PATH}: missing section "${SECTION_HEADING}"`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ledger = document.slice(sectionStart);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const row of dispositions) {
|
||||
if (typeof row.id !== "string" || row.id.length === 0) {
|
||||
problems.push("a disposition row has no id");
|
||||
continue;
|
||||
}
|
||||
if (seen.has(row.id)) problems.push(`duplicate disposition id: ${row.id}`);
|
||||
seen.add(row.id);
|
||||
if (typeof row.disposition !== "string" || row.disposition.length === 0) {
|
||||
problems.push(`${row.id}: disposition is required`);
|
||||
}
|
||||
if (!Array.isArray(row.evidence) || row.evidence.length === 0) {
|
||||
problems.push(`${row.id}: at least one evidence path is required`);
|
||||
continue;
|
||||
}
|
||||
const markers = row.markers ?? [row.id];
|
||||
for (const path of row.evidence) {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await readFile(path, "utf8");
|
||||
} catch {
|
||||
problems.push(`${row.id}: evidence path does not exist: ${path}`);
|
||||
continue;
|
||||
}
|
||||
// A path that exists proves nothing on its own. The file has to name the
|
||||
// finding it is evidence for, so a row cannot point at an unrelated suite
|
||||
// and look substantiated.
|
||||
if (markers.length > 0 && !markers.some((mark) => contents.includes(mark))) {
|
||||
problems.push(
|
||||
`${row.id}: ${path} does not mention ${markers.join(" or ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The prose must carry the same verdict for the same id, so a reader of the
|
||||
// document and a reader of the receipt cannot reach different conclusions.
|
||||
const line = ledger
|
||||
.split("\n")
|
||||
.find((candidate) => candidate.includes(`| ${row.id} |`));
|
||||
if (!line) {
|
||||
problems.push(`${row.id}: no row in ${LEDGER_PATH}`);
|
||||
continue;
|
||||
}
|
||||
// The row carries the prior verdict as well, so the disposition is read
|
||||
// from its own column. Matching anywhere in the line let the "previous"
|
||||
// cell satisfy the check and hid a disagreement between the two records.
|
||||
const cells = line.split("|").map((cell) => cell.trim());
|
||||
const recorded = cells[cells.indexOf(row.id) + DISPOSITION_OFFSET_FROM_ID];
|
||||
if (recorded !== `\`${row.disposition}\``) {
|
||||
problems.push(
|
||||
`${row.id}: ${LEDGER_PATH} records ${
|
||||
recorded ?? "nothing"
|
||||
} where the receipt says \`${row.disposition}\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const open = dispositions.filter(
|
||||
(row) => !CLOSED_DISPOSITIONS.has(row.disposition),
|
||||
);
|
||||
const blanketClaims = [...document.matchAll(BLANKET_CLOSURE)];
|
||||
if (open.length > 0 && blanketClaims.length > 0) {
|
||||
problems.push(
|
||||
`${LEDGER_PATH} declares a blanket closure (${blanketClaims
|
||||
.map((match) => `"${match[0]}"`)
|
||||
.join(", ")}) while ${open.length} finding(s) are still open: ${open
|
||||
.map((row) => `${row.id}=${row.disposition}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// GOV-05 / X-AUDIT-04. The most drift-prone evidence in this document is a
|
||||
// number someone typed. The one number that gates a release is checked
|
||||
// against its source of truth rather than trusted.
|
||||
const recipes: unknown = JSON.parse(await readFile(RECIPES_PATH, "utf8"));
|
||||
const fileTransfer = (
|
||||
(recipes as { recipes?: readonly Record<string, unknown>[] }).recipes ?? []
|
||||
).find((recipe) => recipe["id"] === "file-transfer");
|
||||
const budget = fileTransfer?.["bundleBudgetGzipBytes"];
|
||||
if (typeof budget !== "number") {
|
||||
problems.push(`${RECIPES_PATH}: file-transfer has no bundle budget`);
|
||||
} else if (!ledger.includes(budget.toLocaleString("en-US"))) {
|
||||
problems.push(
|
||||
`${LEDGER_PATH} does not state the configured file-transfer budget of ${budget.toLocaleString(
|
||||
"en-US",
|
||||
)} gzip bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(problem);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Remediation ledger: ${dispositions.length} dispositions joined to ` +
|
||||
`${LEDGER_PATH}; ${open.length} open; evidence paths verified`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import { linkFixtureNodeModules } from "./lib/fixture-node-modules.ts";
|
||||
|
||||
const repositoryRoot = process.cwd();
|
||||
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-"));
|
||||
@@ -59,7 +59,7 @@ try {
|
||||
recursive: true,
|
||||
});
|
||||
await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true });
|
||||
await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
||||
await linkFixtureNodeModules(fixtureRoot, repositoryRoot);
|
||||
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
CACHEABLE_ASSET_CONTENT_TYPES,
|
||||
canonicalStaticManifestBytes,
|
||||
decodeStaticAssetManifest,
|
||||
isCanonicalStaticAssetUrl,
|
||||
} from "../src/contracts/service-worker-static-manifest.ts";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -18,15 +25,9 @@ import {
|
||||
|
||||
const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts";
|
||||
|
||||
const CACHEABLE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
});
|
||||
// SW-RR-03. The generator and the shared decoder read the same table, so a
|
||||
// manifest this script produces can never be one the runtime contract refuses.
|
||||
const CACHEABLE_EXTENSIONS = CACHEABLE_ASSET_CONTENT_TYPES;
|
||||
|
||||
const EXCLUDED_FILES: ReadonlySet<string> = new Set([
|
||||
"index.html",
|
||||
@@ -58,8 +59,17 @@ export async function collectStaticAssets(
|
||||
if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) {
|
||||
throw new Error(`Static asset exceeds its byte bound: ${relative}`);
|
||||
}
|
||||
// SW-02. The URL is checked against the same predicate the runtime decoder
|
||||
// applies. Emitting a path the decoder will refuse turned a correct build
|
||||
// into a runtime contract failure discovered only at install time.
|
||||
const url = `/${relative.split(path.sep).join("/")}`;
|
||||
if (!isCanonicalStaticAssetUrl(url)) {
|
||||
throw new Error(
|
||||
`Static asset path is not canonical for the service worker manifest: ${relative}`,
|
||||
);
|
||||
}
|
||||
assets.push({
|
||||
url: `/${relative.split(path.sep).join("/")}`,
|
||||
url,
|
||||
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
||||
bytes: bytes.byteLength,
|
||||
contentType,
|
||||
@@ -74,32 +84,31 @@ export async function collectStaticAssets(
|
||||
throw new Error("Static asset set exceeds its byte bound.");
|
||||
}
|
||||
|
||||
// The set digest is a length-prefixed hash over the sorted asset identities,
|
||||
// so a reordered directory listing cannot change it.
|
||||
const hash = createHash("sha256");
|
||||
hash.update("CA_STATIC_ASSET_SET_V1\0");
|
||||
for (const asset of assets) {
|
||||
hash.update(lengthPrefixed(asset.url));
|
||||
hash.update(lengthPrefixed(asset.sha256));
|
||||
hash.update(lengthPrefixed(String(asset.bytes)));
|
||||
hash.update(lengthPrefixed(asset.contentType));
|
||||
}
|
||||
// SW-05. The canonical byte serialization lives in the shared runtime-neutral
|
||||
// codec so the worker can recompute the identical digest with WebCrypto.
|
||||
const setDigest: `sha256:${string}` = `sha256:${createHash("sha256")
|
||||
.update(canonicalStaticManifestBytes(assets))
|
||||
.digest("hex")}`;
|
||||
|
||||
return {
|
||||
const manifest: StaticAssetManifestV1 = {
|
||||
schemaVersion: 1,
|
||||
buildId,
|
||||
releaseId,
|
||||
setDigest: `sha256:${hash.digest("hex")}`,
|
||||
setDigest,
|
||||
assets,
|
||||
};
|
||||
// SW-02. Every manifest this generator returns has already passed the exact
|
||||
// decoder the runtime will apply to it, so the build stops here rather than
|
||||
// at install time.
|
||||
const decoded = decodeStaticAssetManifest(manifest);
|
||||
if (!decoded.ok) {
|
||||
throw new Error(
|
||||
`Generated service worker manifest is not decodable: ${decoded.error.reason}`,
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function lengthPrefixed(value: string): Buffer {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
const prefix = Buffer.alloc(4);
|
||||
prefix.writeUInt32BE(bytes.byteLength, 0);
|
||||
return Buffer.concat([prefix, bytes]);
|
||||
}
|
||||
|
||||
async function walk(root: string, current: string): Promise<string[]> {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { lstat, mkdir, readdir, symlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Links the repository's installed dependencies into a throwaway fixture root.
|
||||
*
|
||||
* The obvious form — one directory symlink at `<fixture>/node_modules` — is
|
||||
* destructive. A fixture runs `pnpm` inside itself, pnpm does not recognise the
|
||||
* modules directory it finds there, and its purge follows the symlink and
|
||||
* deletes the *repository's* real dependencies mid-run. With `CI=true` that
|
||||
* happens without a prompt, so a test suite silently uninstalls the workspace
|
||||
* it is running in.
|
||||
*
|
||||
* `node_modules` is therefore a real directory here, and every entry inside it
|
||||
* is an individual symlink. Package resolution is unchanged, but a recursive
|
||||
* delete unlinks the fixture's own symlinks instead of walking through one link
|
||||
* into the shared tree.
|
||||
*/
|
||||
export async function linkFixtureNodeModules(
|
||||
fixtureRoot: string,
|
||||
sourceRoot: string = process.cwd(),
|
||||
): Promise<void> {
|
||||
const source = path.join(sourceRoot, "node_modules");
|
||||
const target = path.join(fixtureRoot, "node_modules");
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const entry of await readdir(source)) {
|
||||
const from = path.join(source, entry);
|
||||
const stats = await lstat(from);
|
||||
await symlink(from, path.join(target, entry), stats.isDirectory() ? "dir" : "file");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
loadCiGateContract,
|
||||
parseCiGateContract,
|
||||
} from "../contracts/ci-gates.ts";
|
||||
import { linkFixtureNodeModules } from "./fixture-node-modules.ts";
|
||||
import { generateCiWorkflow } from "../generate-ci-workflow.ts";
|
||||
|
||||
export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([
|
||||
@@ -42,7 +42,7 @@ export async function prepareRemovalFixture(
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(root, target), { recursive: true });
|
||||
}
|
||||
await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir");
|
||||
await linkFixtureNodeModules(root);
|
||||
}
|
||||
|
||||
export function runRemovalFixturePnpm(
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
canonicalStaticManifestBytes,
|
||||
decodeStaticAssetManifest,
|
||||
} from "../../src/contracts/service-worker-static-manifest.ts";
|
||||
|
||||
import type {
|
||||
InstalledServiceWorkerSelection,
|
||||
ServiceWorkerHandlerId,
|
||||
@@ -79,19 +86,28 @@ export function resolveServiceWorkerBuildInput(input: Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-05. The build gate no longer type-casts the manifest. It decodes every row
|
||||
* through the shared runtime-neutral codec and recomputes the set digest from
|
||||
* the same canonical bytes the generator hashed, so a tampered row, a reordered
|
||||
* set or a stale digest fails admission instead of shipping.
|
||||
*/
|
||||
function parseAssets(value: unknown): StaticAssetManifestV1 {
|
||||
const candidate = record(value);
|
||||
if (
|
||||
candidate?.schemaVersion !== 1 ||
|
||||
typeof candidate.buildId !== "string" ||
|
||||
typeof candidate.releaseId !== "string" ||
|
||||
typeof candidate.setDigest !== "string" ||
|
||||
!DIGEST.test(candidate.setDigest) ||
|
||||
!Array.isArray(candidate.assets)
|
||||
) {
|
||||
throw new TypeError("Generated Service Worker asset manifest is invalid.");
|
||||
const decoded = decodeStaticAssetManifest(value);
|
||||
if (!decoded.ok) {
|
||||
throw new TypeError(
|
||||
`Generated Service Worker asset manifest is invalid: ${decoded.error.reason}`,
|
||||
);
|
||||
}
|
||||
return candidate as unknown as StaticAssetManifestV1;
|
||||
const expected = `sha256:${createHash("sha256")
|
||||
.update(canonicalStaticManifestBytes(decoded.manifest.assets))
|
||||
.digest("hex")}`;
|
||||
if (expected !== decoded.manifest.setDigest) {
|
||||
throw new TypeError(
|
||||
"Generated Service Worker asset manifest set digest does not match its assets.",
|
||||
);
|
||||
}
|
||||
return decoded.manifest as unknown as StaticAssetManifestV1;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
Reference in New Issue
Block a user