refactor: 리펙토링

This commit is contained in:
DongHyeonka
2026-08-01 19:39:59 +09:00
parent 9c959ea2a5
commit c6da03369c
171 changed files with 20329 additions and 782 deletions
+60
View File
@@ -0,0 +1,60 @@
import { spawnSync } from "node:child_process";
import { rm } from "node:fs/promises";
import process from "node:process";
import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtime-capabilities.ts";
/**
* §17.2.2. Deterministic two-pass build.
*
* The Service Worker needs the exact hashed asset list at compile time, so the
* order is fixed:
*
* 1. clean dist and .generated/frontend-runtime
* 2. generate contractSet and build-info source
* 3. Vite app build (emptyOutDir = true)
* 4. scan app dist and generate the static asset source
* 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
* 6. generate Release Manifest V2 and the build manifest
*
* Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
* and `null`: those modes never run an active worker build.
*/
const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker;
const buildsActiveWorker = selection?.mode === "ACTIVE";
function run(command: string, args: readonly string[]): void {
const result = spawnSync(command, [...args], {
stdio: "inherit",
env: process.env,
});
if (result.status !== 0) {
process.stderr.write(`build step failed: ${command} ${args.join(" ")}\n`);
process.exit(result.status ?? 1);
}
}
// 1. clean
await rm("dist", { recursive: true, force: true });
await rm(".generated/frontend-runtime", { recursive: true, force: true });
// 2. contract set + build info
run("node", ["scripts/generate-contract-set.ts"]);
// 3. app build
run("npx", ["vite", "build"]);
if (buildsActiveWorker) {
// 4. hashed asset inventory
run("node", ["scripts/generate-service-worker-assets.ts", "dist"]);
// 5. service worker build
run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]);
} else {
process.stdout.write(
`service worker mode ${selection?.mode ?? "null"}: skipping worker build\n`,
);
}
// 6. release + build manifest
run("node", ["scripts/generate-build-manifest.ts"]);
+48 -1
View File
@@ -71,6 +71,45 @@ for (const [gateId, gate] of Object.entries(document.gates)) {
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
}
for (const [index, step] of (gate.steps ?? []).entries()) {
if (!isRecord(step) || (step.expect !== "pass" && step.expect !== "fail")) {
failures.push(`${gateId}[${index}] has an invalid step expectation`);
continue;
}
if (step.expect === "pass") {
if (
step.expectedExitCode !== undefined ||
step.expectedDiagnosticId !== undefined
) {
failures.push(
`${gateId}[${index}] passing step declares a negative fixture identity`,
);
}
continue;
}
if (
typeof step.expectedExitCode !== "number" ||
!Number.isSafeInteger(step.expectedExitCode) ||
step.expectedExitCode < 1 ||
step.expectedExitCode > 255
) {
failures.push(`${gateId}[${index}] lacks an exact expected exit code`);
}
const diagnosticId = step.expectedDiagnosticId;
if (
typeof diagnosticId !== "string" ||
diagnosticId.trim().length === 0 ||
diagnosticId.length > 256 ||
["\r", "\n", "\0"].some(
(character) =>
typeof diagnosticId === "string" && diagnosticId.includes(character),
)
) {
failures.push(
`${gateId}[${index}] lacks a bounded expected diagnostic identity`,
);
}
}
}
const runbookGateEvidence = Object.freeze({
@@ -107,7 +146,6 @@ if (
const forbiddenWorkflowPatterns = [
/continue-on-error\s*:/,
/retention-days\s*:/,
/timeout-minutes\s*:/,
/allow_failure\s*:/,
];
for (const pattern of forbiddenWorkflowPatterns) {
@@ -115,6 +153,13 @@ for (const pattern of forbiddenWorkflowPatterns) {
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
}
}
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
if (jobTimeoutCount !== 5) {
failures.push("every CI gate job must declare timeout-minutes: 45");
}
if (/if-no-files-found:\s*warn/.test(workflow)) {
failures.push("CI evidence upload must fail when artifacts are absent");
}
for (const requiredToken of [
"merge_gate:",
"release_gate:",
@@ -144,6 +189,8 @@ for (const requiredToken of [
"SOURCE_DATE_EPOCH",
'"--format=%H%n%ct"',
"env: gateEnvironment",
"classifyGateStepResult",
"timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS",
]) {
if (!gateRunner.includes(requiredToken)) {
failures.push(`CI gate runner missing ${requiredToken}`);
+20 -1
View File
@@ -9,6 +9,7 @@ import {
scanProductionBundle,
validateRecipeCatalog,
} from "./lib/optional-recipes.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
type GateViolation = Readonly<{
ruleId: string;
@@ -46,7 +47,25 @@ const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : [];
const packageDocument = JSON.parse(
await readFile("package.json", "utf8"),
) as Record<string, unknown>;
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
const catalogSchemaViolations: string[] = [];
try {
assertMatchesJsonSchema(
JSON.parse(
await readFile(
"schemas/config/frontend-capability-recipes.schema.json",
"utf8",
),
),
catalog,
"optional recipe catalog",
);
} catch {
catalogSchemaViolations.push("CATALOG_JSON_SCHEMA_INVALID");
}
const catalogViolations = [
...catalogSchemaViolations,
...validateRecipeCatalog(catalog, packageDocument),
];
const runtimeSourceViolations = (
await Promise.all(
recipes.flatMap((recipe: unknown) => {
+17
View File
@@ -15,6 +15,7 @@ import {
validateBreakingEvidence,
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
type RegistryRow = Record<string, unknown>;
type RegistryRows = Record<string, RegistryRow>;
@@ -436,6 +437,22 @@ const report = {
failures,
registries: snapshots,
};
if (usesRepositoryBaseline && failures.length === 0) {
try {
assertMatchesJsonSchema(
JSON.parse(
await readFile(
"schemas/artifacts/registry-snapshot.schema.json",
"utf8",
),
),
report,
"registry snapshot",
);
} catch {
failures.push("registry snapshot JSON Schema mismatch");
}
}
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
+1
View File
@@ -0,0 +1 @@
export * from "../../src/contracts/release-artifacts.ts";
+16 -9
View File
@@ -7,7 +7,12 @@ import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.ts";
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.ts";
import {
buildManifestArtifactSchema,
releaseManifestV2ArtifactSchema,
runtimeConfigV2ArtifactSchema,
} from "../src/contracts/release-artifacts.ts";
import { buildContractSet } from "./generate-contract-set.ts";
import {
assertCiBuildEnvironment,
buildDate,
@@ -43,7 +48,7 @@ const assetManifestHash = createHash("sha256")
const moduleInventoryHash = createHash("sha256")
.update(moduleInventory)
.digest("hex");
const runtimeConfig = runtimeConfigSchema.parse(
const runtimeConfig = runtimeConfigV2ArtifactSchema.parse(
JSON.parse(await readFile("dist/config.json", "utf8")),
);
const routeChunks: Record<string, string> = {};
@@ -59,12 +64,12 @@ for (const definition of Object.values(ROUTE_REGISTRY)) {
}
routeChunks[definition.chunkId] = asset.file;
}
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema);
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigV2ArtifactSchema);
runtimeConfig.BUILD_ID = buildId;
runtimeConfig.RELEASE_ID = releaseId;
const manifest = {
const manifest = buildManifestArtifactSchema.parse({
schemaVersion: 1,
buildId,
commitSha,
@@ -84,20 +89,22 @@ const manifest = {
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
},
};
});
const releaseManifest = {
schemaVersion: 1,
// §5.2 / §24.6 RC-3. The writer emits V2 only; the scalar API contract
// version has no writer source left.
const releaseManifest = releaseManifestV2ArtifactSchema.parse({
schemaVersion: 2,
appVersion: packageJson.version,
buildId,
commitSha,
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
assetManifestHash,
releaseId,
builtAt,
routeChunks,
};
contractSet: buildContractSet(),
});
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/quality", { recursive: true });
+64
View File
@@ -0,0 +1,64 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import {
CONTRACT_SET_ALGORITHM,
computeContractSetDigestWith,
type ContractSetPackage,
} from "../src/contracts/contract-set-canonical.ts";
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
/**
* §5.4 / §17.2.2 step 2. Derives the build-time expected contract set from the
* installed contributions and emits it as generated source for the manifest
* writer. `TEMPLATE_FIXTURE` provenance never reaches this list, so a fixture
* cannot influence the release digest.
*/
const OUTPUT = ".generated/frontend-runtime/contract-set.ts";
function sha256(bytes: Uint8Array): Uint8Array {
return new Uint8Array(createHash("sha256").update(bytes).digest());
}
export function buildContractSet(
packages: readonly ContractSetPackage[] = EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[],
) {
return Object.freeze({
setAlgorithm: CONTRACT_SET_ALGORITHM,
setDigest: computeContractSetDigestWith(packages, sha256),
packages: Object.freeze(
[...packages]
.map((entry) => Object.freeze({ ...entry }))
.sort((left, right) => (left.packageId < right.packageId ? -1 : 1)),
),
});
}
async function main(): Promise<void> {
const contractSet = buildContractSet();
const source = [
"// Generated by scripts/generate-contract-set.ts. Do not edit.",
"// Regenerated from scratch on every build; never committed.",
"",
"import type { ContractSet } from \"../../src/contracts/contract-set.ts\";",
"",
`export const BUILD_CONTRACT_SET: ContractSet = ${JSON.stringify(
contractSet,
null,
2,
)} as const;`,
"",
].join("\n");
await mkdir(dirname(OUTPUT), { recursive: true });
await writeFile(OUTPUT, source, "utf8");
process.stdout.write(
`contract set: ${contractSet.packages.length} package(s) ${contractSet.setDigest}\n`,
);
}
if (process.argv[1]?.endsWith("generate-contract-set.ts")) {
await main();
}
+144
View File
@@ -0,0 +1,144 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
SERVICE_WORKER_BOUNDS,
SERVICE_WORKER_SCRIPT_PATH,
type StaticAssetManifestV1,
} from "../src/contracts/service-worker.ts";
/**
* §17.2.2 step 4. Scans the completed app `dist` and emits the exact hashed
* asset list the Service Worker will verify at install time.
*
* `service-worker.js` itself and `index.html` are excluded (§17.2.2), as are
* the runtime config and release manifest, which are network-only (§18.4).
*/
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",
});
const EXCLUDED_FILES: ReadonlySet<string> = new Set([
"index.html",
SERVICE_WORKER_SCRIPT_PATH,
"config.json",
"release-manifest.json",
"runtime-config.schema.json",
]);
/** Vite emits content-hashed names; only those may be treated as immutable. */
const HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/;
export async function collectStaticAssets(
distDirectory: string,
buildId: string,
releaseId: string,
): Promise<StaticAssetManifestV1> {
const files = await walk(distDirectory, distDirectory);
const assets: StaticAssetManifestV1["assets"][number][] = [];
for (const relative of files.sort()) {
const base = path.basename(relative);
if (EXCLUDED_FILES.has(base) || relative.startsWith(".vite/")) continue;
const contentType = CACHEABLE_EXTENSIONS[path.extname(base).toLowerCase()];
if (!contentType || !HASHED_NAME.test(base)) continue;
const absolute = path.join(distDirectory, relative);
const bytes = await readFile(absolute);
if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) {
throw new Error(`Static asset exceeds its byte bound: ${relative}`);
}
assets.push({
url: `/${relative.split(path.sep).join("/")}`,
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
bytes: bytes.byteLength,
contentType,
});
}
if (assets.length > SERVICE_WORKER_BOUNDS.assets) {
throw new Error("Static asset count exceeds its bound.");
}
const totalBytes = assets.reduce((sum, asset) => sum + asset.bytes, 0);
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
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));
}
return {
schemaVersion: 1,
buildId,
releaseId,
setDigest: `sha256:${hash.digest("hex")}`,
assets,
};
}
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 });
const files: string[] = [];
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(root, absolute)));
} else if ((await stat(absolute)).isFile()) {
files.push(path.relative(root, absolute));
}
}
return files;
}
async function main(): Promise<void> {
const distDirectory = process.argv[2] ?? "dist";
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const releaseId = process.env.RELEASE_ID ?? "local-release";
const manifest = await collectStaticAssets(distDirectory, buildId, releaseId);
const source = [
"// Generated by scripts/generate-service-worker-assets.ts. Do not edit.",
"",
'import type { StaticAssetManifestV1 } from "../../src/contracts/service-worker.ts";',
"",
`export const SERVICE_WORKER_ASSETS: StaticAssetManifestV1 = ${JSON.stringify(
manifest,
null,
2,
)} as const;`,
"",
].join("\n");
await mkdir(path.dirname(OUTPUT), { recursive: true });
await writeFile(OUTPUT, source, "utf8");
process.stdout.write(
`service worker assets: ${manifest.assets.length} file(s) ${manifest.setDigest}\n`,
);
}
if (process.argv[1]?.endsWith("generate-service-worker-assets.ts")) {
await main();
}
+82
View File
@@ -0,0 +1,82 @@
export type GateStepExpectation =
| Readonly<{ kind: "pass" }>
| Readonly<{
kind: "fail";
expectedExitCode: number;
expectedDiagnosticId: string;
}>;
export type GateProcessResult = Readonly<{
status: number | null;
signal: string | null;
stdout: string;
stderr: string;
error?: Readonly<{ code?: string }>;
}>;
export type GateStepClassification =
| Readonly<{
kind: "EXPECTED_PASS" | "EXPECTED_FAILURE";
expectationMet: true;
}>
| Readonly<{
kind: "UNEXPECTED_EXIT";
expectationMet: false;
}>
| Readonly<{
kind: "UNEXPECTED_DIAGNOSTIC";
expectationMet: false;
}>
| Readonly<{
kind: "INFRASTRUCTURE_FAILURE";
expectationMet: false;
detail: string;
}>;
/** A negative fixture passes only with its registered exit and diagnostic. */
export function classifyGateStepResult(
expectation: GateStepExpectation,
result: GateProcessResult,
): GateStepClassification {
const errorCode = result.error?.code;
const spawnFailed = result.error !== undefined;
if (spawnFailed || result.signal || result.status === null) {
return Object.freeze({
kind: "INFRASTRUCTURE_FAILURE" as const,
expectationMet: false as const,
detail:
errorCode ??
result.signal ??
(spawnFailed ? "SPAWN_ERROR" : "NO_EXIT_STATUS"),
});
}
if (expectation.kind === "pass" && result.status === 0) {
return Object.freeze({
kind: "EXPECTED_PASS" as const,
expectationMet: true as const,
});
}
if (expectation.kind === "fail") {
if (result.status !== expectation.expectedExitCode) {
return Object.freeze({
kind: "UNEXPECTED_EXIT" as const,
expectationMet: false as const,
});
}
const diagnosticOutput = `${result.stdout}\n${result.stderr}`;
if (!diagnosticOutput.includes(expectation.expectedDiagnosticId)) {
return Object.freeze({
kind: "UNEXPECTED_DIAGNOSTIC" as const,
expectationMet: false as const,
});
}
return Object.freeze({
kind: "EXPECTED_FAILURE" as const,
expectationMet: true as const,
});
}
return Object.freeze({
kind: "UNEXPECTED_EXIT" as const,
expectationMet: false as const,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
/**
* Compile a checked-in JSON Schema and assert a concrete artifact against it.
* Conversion failures are fatal too: an unsupported or malformed schema must
* not silently turn a release schema into documentation-only metadata.
*/
export function assertMatchesJsonSchema(
schemaDocument: unknown,
value: unknown,
label: string,
): void {
try {
const schema = z.fromJSONSchema(schemaDocument as never);
const result = schema.safeParse(value);
if (!result.success) {
throw new TypeError(z.prettifyError(result.error));
}
} catch (error) {
throw new TypeError(`${label} does not satisfy its checked-in JSON Schema.`, {
cause: error,
});
}
}
+101
View File
@@ -0,0 +1,101 @@
import type {
InstalledServiceWorkerSelection,
ServiceWorkerHandlerId,
StaticAssetManifestV1,
} from "../../src/contracts/service-worker.ts";
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
export type ServiceWorkerBuildInput = Readonly<{
assets: StaticAssetManifestV1;
handlers: readonly ServiceWorkerHandlerId[];
contractSetDigest: string;
releaseManifestUrl: string;
}>;
/**
* ACTIVE worker compilation is a release-integrity boundary. Missing generated
* modules, stale identities and placeholder digests are fatal build defects;
* they must never be converted into a worker that merely degrades at runtime.
*/
export function resolveServiceWorkerBuildInput(input: Readonly<{
selection: InstalledServiceWorkerSelection | null;
assets: unknown;
contractSet: unknown;
runtimeConfig: unknown;
buildId: string;
releaseId: string;
}>): ServiceWorkerBuildInput {
if (input.selection?.mode !== "ACTIVE") {
throw new TypeError(
"Service Worker build requires an ACTIVE static selection.",
);
}
if (!Array.isArray(input.selection.handlers)) {
throw new TypeError("Service Worker handlers must be an array.");
}
const handlers = new Set<ServiceWorkerHandlerId>();
for (const handler of input.selection.handlers) {
if (
handler !== "PWA_STATIC_ASSETS" &&
handler !== "OFFLINE_SYNC_WAKEUP" &&
handler !== "WEB_PUSH"
) {
throw new TypeError(`Unknown Service Worker handler: ${String(handler)}.`);
}
if (handlers.has(handler)) {
throw new TypeError(`Duplicate Service Worker handler: ${handler}.`);
}
handlers.add(handler);
}
if (handlers.has("WEB_PUSH")) {
throw new TypeError(
"WEB_PUSH requires an installed product-owned worker contribution.",
);
}
const assets = parseAssets(input.assets);
if (assets.buildId !== input.buildId || assets.releaseId !== input.releaseId) {
throw new TypeError("Generated Service Worker asset identity is stale.");
}
const contractSet = record(input.contractSet);
const contractSetDigest = contractSet?.setDigest;
if (typeof contractSetDigest !== "string" || !DIGEST.test(contractSetDigest)) {
throw new TypeError("Generated contract set digest is invalid.");
}
const runtimeConfig = record(input.runtimeConfig);
const releaseManifestUrl = runtimeConfig?.RELEASE_MANIFEST_URL;
if (
typeof releaseManifestUrl !== "string" ||
releaseManifestUrl.length === 0 ||
releaseManifestUrl.length > 2_048
) {
throw new TypeError("Runtime release manifest URL is invalid.");
}
return Object.freeze({
assets,
handlers: Object.freeze([...handlers]),
contractSetDigest,
releaseManifestUrl,
});
}
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.");
}
return candidate as unknown as StaticAssetManifestV1;
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
+104 -13
View File
@@ -8,12 +8,21 @@ import {
isValidCommitSha,
isValidSourceDateEpoch,
} from "./lib/build-environment.ts";
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
type GateStep = Readonly<{
type GateStepBase = Readonly<{
script: string;
args?: readonly string[];
expect: "pass" | "fail";
timeoutMs?: number;
}>;
type GateStep =
| (GateStepBase & Readonly<{ expect: "pass" }>)
| (GateStepBase &
Readonly<{
expect: "fail";
expectedExitCode: number;
expectedDiagnosticId: string;
}>);
type GateDefinition = Readonly<{
name: string;
steps: readonly GateStep[];
@@ -40,6 +49,8 @@ if (!gateId || !gate) {
const output: string[] = [];
let passed = true;
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000;
const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024;
const gateEnvironment = { ...process.env };
if (gateEnvironment.CI === "true") {
@@ -92,19 +103,49 @@ if (passed) {
const result = spawnSync(
"corepack",
["pnpm", step.script, ...(step.args ?? [])],
{ encoding: "utf8", env: gateEnvironment },
{
encoding: "utf8",
env: gateEnvironment,
timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
maxBuffer: MAX_STEP_OUTPUT_BYTES,
},
);
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
output.push(
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
result.stdout,
result.stderr,
stdout,
stderr,
);
const exitedSuccessfully = result.status === 0;
const expectationMet =
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
if (!expectationMet) {
const expectation =
step.expect === "pass"
? ({ kind: "pass" } as const)
: ({
kind: "fail",
expectedExitCode: step.expectedExitCode,
expectedDiagnosticId: step.expectedDiagnosticId,
} as const);
const classification = classifyGateStepResult(expectation, {
status: result.status,
signal: result.signal,
stdout,
stderr,
...(result.error
? { error: { code: (result.error as NodeJS.ErrnoException).code } }
: {}),
});
output.push(`classification: ${classification.kind}`);
if (!classification.expectationMet) {
output.push(
`expectation failed: expected ${step.expect}, exit=${result.status}`,
`expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`,
...(step.expect === "fail"
? [
`expected negative fixture identity: exit=${step.expectedExitCode}, diagnostic=${JSON.stringify(step.expectedDiagnosticId)}`,
]
: []),
...(classification.kind === "INFRASTRUCTURE_FAILURE"
? [`infrastructure failure: ${classification.detail}`]
: []),
);
passed = false;
break;
@@ -187,11 +228,61 @@ function parseGateSteps(value: unknown, gateId: string): GateStep[] {
const args =
candidate.args === undefined
? undefined
: parseStringArray(candidate.args, `${gateId}[${index}].args`);
return {
: parseStringArray(candidate.args, `${gateId}[${index}].args`);
const timeoutMs = candidate.timeoutMs;
if (
timeoutMs !== undefined &&
(typeof timeoutMs !== "number" ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1_000 ||
timeoutMs > 3_600_000)
) {
throw new TypeError(`Invalid CI gate step timeout: ${gateId}[${index}]`);
}
const base = {
script: candidate.script,
expect: candidate.expect,
...(args ? { args } : {}),
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
};
if (candidate.expect === "pass") {
if (
candidate.expectedExitCode !== undefined ||
candidate.expectedDiagnosticId !== undefined
) {
throw new TypeError(
`Passing CI gate step cannot declare failure identity: ${gateId}[${index}]`,
);
}
return { ...base, expect: "pass" as const };
}
if (
typeof candidate.expectedExitCode !== "number" ||
!Number.isSafeInteger(candidate.expectedExitCode) ||
candidate.expectedExitCode < 1 ||
candidate.expectedExitCode > 255
) {
throw new TypeError(
`Invalid expected failure exit code: ${gateId}[${index}]`,
);
}
const expectedDiagnosticId = candidate.expectedDiagnosticId;
if (
typeof expectedDiagnosticId !== "string" ||
expectedDiagnosticId.trim().length === 0 ||
expectedDiagnosticId.length > 256 ||
["\r", "\n", "\0"].some((character) =>
expectedDiagnosticId.includes(character),
)
) {
throw new TypeError(
`Invalid expected failure diagnostic: ${gateId}[${index}]`,
);
}
return {
...base,
expect: "fail" as const,
expectedExitCode: candidate.expectedExitCode,
expectedDiagnosticId,
};
});
}
@@ -47,6 +47,9 @@ const copyTargets = [
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"tsconfig.web-worker.json",
"tsconfig.service-worker.json",
"vite.service-worker.config.ts",
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
+3
View File
@@ -28,6 +28,9 @@ const copyTargets = [
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"tsconfig.web-worker.json",
"tsconfig.service-worker.json",
"vite.service-worker.config.ts",
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
+1 -7
View File
@@ -82,13 +82,7 @@ try {
}).observe({ type: "layout-shift", buffered: true });
});
await page.goto(baseUrl, { waitUntil: "networkidle" });
const performanceRoutes: ReadonlyArray<{
access: string;
navigationLabel: string | null;
}> = Object.values(ROUTE_REGISTRY);
const targetLabel = performanceRoutes.find(
(definition) => definition.access === "integration-defined",
)?.navigationLabel;
const targetLabel = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.navigationLabel;
if (!targetLabel) {
throw new Error("Performance route must be present in navigation.");
}
+3
View File
@@ -49,6 +49,9 @@ const copyTargets = [
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"tsconfig.web-worker.json",
"tsconfig.service-worker.json",
"vite.service-worker.config.ts",
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
+24
View File
@@ -39,6 +39,9 @@ const copyTargets = [
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"tsconfig.web-worker.json",
"tsconfig.service-worker.json",
"vite.service-worker.config.ts",
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
@@ -94,6 +97,23 @@ export function createInstalledFeatureInputs(_context: FeatureContext) {
}
`;
const emptyContractContributions = `import {
composeContractContributions,
type InstalledContractContribution,
type InstalledContractPackageIdentity,
} from "../contracts/external-contract-runtime.ts";
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([]);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
INSTALLED_CONTRACT_CONTRIBUTIONS,
);
export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] =
COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages;
`;
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({}),
"en-US": Object.freeze({}),
@@ -170,6 +190,10 @@ await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-messages.ts"),
emptyMessages,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-contract-contributions.ts"),
emptyContractContributions,
);
const vitestConfigFile = path.join(fixtureRoot, "vitest.config.ts");
const vitestConfig = await readFile(vitestConfigFile, "utf8");
+52 -63
View File
@@ -7,12 +7,23 @@ import {
} from "../src/application/policies/compatibility.ts";
import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.ts";
import {
parseBuildManifestArtifact,
parseReleaseArtifact,
parseRuntimeConfigArtifact,
projectReleaseTokens,
type BuildManifestArtifact,
type ReleaseArtifact,
type RuntimeConfigArtifact,
} from "../src/contracts/release-artifacts.ts";
import { verifyContractSet } from "../src/contracts/contract-set.ts";
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
type CoherenceFixture = Readonly<{
name: string;
@@ -20,23 +31,8 @@ type CoherenceFixture = Readonly<{
frontend: CompatibilityTuple;
runtime: CompatibilityTuple;
}>;
type ReleaseDocument = CompatibilityTuple &
Record<string, unknown> &
Readonly<{ releaseId: string; routeChunks: Readonly<Record<string, unknown>> }>;
type RuntimeConfigDocument = Readonly<{
BUILD_ID: string;
CONFIG_SCHEMA_VERSION: string;
API_CONTRACT_VERSION: string;
RELEASE_ID: string;
}>;
type BuildManifestDocument = Readonly<
Record<string, unknown> & {
outputs?: Readonly<{
runtimeConfigSchema?: unknown;
routeChunks?: Readonly<Record<string, unknown>>;
}>;
}
>;
type RuntimeConfigDocument = RuntimeConfigArtifact &
Readonly<{ BUILD_ID: string; RELEASE_ID: string }>;
type ViteManifestEntry = Readonly<{
file: string;
name?: string;
@@ -54,9 +50,17 @@ const release = parseReleaseDocument(
const runtimeConfig = parseRuntimeConfigDocument(
JSON.parse(await readFile("dist/config.json", "utf8")),
);
const buildManifest = parseBuildManifestDocument(
JSON.parse(await readFile("artifacts/release/build-manifest.json", "utf8")),
const buildManifestDocument: unknown = JSON.parse(
await readFile("artifacts/release/build-manifest.json", "utf8"),
);
assertMatchesJsonSchema(
JSON.parse(
await readFile("schemas/artifacts/build-manifest.schema.json", "utf8"),
),
buildManifestDocument,
"build manifest",
);
const buildManifest = parseBuildManifestDocument(buildManifestDocument);
const runtimeConfigJsonSchema = requireRecord(
JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")),
"runtime config JSON schema",
@@ -69,11 +73,20 @@ const actualAssetManifestHash = createHash("sha256")
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
const artifactMismatches: string[] = [...artifactComparison.mismatches];
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
if (typeof release[token] !== "string" || release[token].length === 0) {
for (const [token, value] of Object.entries(projectReleaseTokens(release))) {
if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) {
artifactMismatches.push(`releaseToken:${token}`);
}
}
if (release.schemaVersion === 2) {
const contractSetVerification = await verifyContractSet({
expected: EXPECTED_CONTRACT_SET_PACKAGES,
manifest: release.contractSet,
});
if (!contractSetVerification.ok) {
artifactMismatches.push(contractSetVerification.code);
}
}
if (
typeof release.builtAt !== "string" ||
!Number.isFinite(Date.parse(release.builtAt))
@@ -91,19 +104,19 @@ if (
artifactMismatches.push("runtimeConfigSchema");
}
if (
buildManifest.outputs?.runtimeConfigSchema !==
buildManifest.outputs.runtimeConfigSchema !==
"dist/runtime-config.schema.json"
) {
artifactMismatches.push("buildManifest:runtimeConfigSchema");
}
for (const [buildToken, releaseToken] of [
["buildId", "buildId"],
["commitSha", "commitSha"],
["releaseId", "releaseId"],
["generatedAt", "builtAt"],
]) {
if (buildManifest[buildToken] !== release[releaseToken]) {
artifactMismatches.push(`buildManifest:${buildToken}`);
for (const [token, buildValue, releaseValue] of [
["buildId", buildManifest.buildId, release.buildId],
["commitSha", buildManifest.commitSha, release.commitSha],
["releaseId", buildManifest.releaseId, release.releaseId],
["generatedAt", buildManifest.generatedAt, release.builtAt],
] as const) {
if (buildValue !== releaseValue) {
artifactMismatches.push(`buildManifest:${token}`);
}
}
@@ -134,7 +147,7 @@ for (const definition of Object.values(ROUTE_REGISTRY)) {
continue;
}
if (
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
buildManifest.outputs.routeChunks[definition.chunkId] !== routeAsset
) {
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
}
@@ -221,45 +234,21 @@ function parseFixturesDocument(value: unknown): Readonly<{
};
}
function parseReleaseDocument(value: unknown): ReleaseDocument {
const document = requireRecord(value, "release manifest");
const tuple = parseCompatibilityTuple(document, "release manifest");
const routeChunks = isRecord(document.routeChunks)
? document.routeChunks
: {};
return { ...document, ...tuple, routeChunks };
function parseReleaseDocument(value: unknown): ReleaseArtifact {
return parseReleaseArtifact(value);
}
function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument {
const document = requireRecord(value, "runtime config");
const document = parseRuntimeConfigArtifact(value);
return {
...document,
BUILD_ID: requireString(document.BUILD_ID, "runtime config BUILD_ID"),
CONFIG_SCHEMA_VERSION: requireString(
document.CONFIG_SCHEMA_VERSION,
"runtime config CONFIG_SCHEMA_VERSION",
),
API_CONTRACT_VERSION: requireString(
document.API_CONTRACT_VERSION,
"runtime config API_CONTRACT_VERSION",
),
RELEASE_ID: requireString(
document.RELEASE_ID,
"runtime config RELEASE_ID",
),
RELEASE_ID: requireString(document.RELEASE_ID, "runtime config RELEASE_ID"),
};
}
function parseBuildManifestDocument(value: unknown): BuildManifestDocument {
const document = requireRecord(value, "build manifest");
const outputs = isRecord(document.outputs)
? {
runtimeConfigSchema: document.outputs.runtimeConfigSchema,
routeChunks: isRecord(document.outputs.routeChunks)
? document.outputs.routeChunks
: undefined,
}
: undefined;
return { ...document, ...(outputs ? { outputs } : {}) };
function parseBuildManifestDocument(value: unknown): BuildManifestArtifact {
return parseBuildManifestArtifact(value);
}
function parseCompatibilityTuple(value: unknown, label: string): CompatibilityTuple {
+21 -1
View File
@@ -8,6 +8,7 @@ import {
supplyChainDigest,
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
type Document = Record<string, unknown>;
@@ -48,6 +49,25 @@ const provenance = await readDocument("artifacts/release/provenance.json");
const verification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
const artifactSchemaFailures: string[] = [];
for (const [schemaPath, artifact, label] of [
[
"schemas/artifacts/dependency-inventory.schema.json",
inventory,
"dependency inventory",
],
[
"schemas/artifacts/supply-chain-verification.schema.json",
verification,
"supply-chain verification",
],
] as const) {
try {
assertMatchesJsonSchema(await readDocument(schemaPath), artifact, label);
} catch {
artifactSchemaFailures.push(`${label} JSON Schema mismatch`);
}
}
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
@@ -69,7 +89,7 @@ const coherence = verifySupplyChainCoherence(
provenance,
distDigest,
);
const failures: string[] = [...coherence.failures];
const failures: string[] = [...artifactSchemaFailures, ...coherence.failures];
if (
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256