import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { buildContractSet } from "../generate-contract-set.ts"; import { contractSetSchema, type ContractSet } from "../../src/contracts/contract-set.ts"; /** * `corepack pnpm dev` never runs the build pipeline: plain `vite` serves * `public/release-manifest.json` verbatim. That fixture therefore has to carry * the same `contractSet` block `scripts/generate-build-manifest.ts` writes into * `dist/release-manifest.json`, because `verifyContractSet` runs unconditionally * at boot — in the default `MOCK` mode as much as in `HTTP` mode. When the two * disagree the app renders the fail-closed boot screen instead of the shell. * * Both halves of the answer live here so they cannot disagree: the generation * step rewrites the block, and the CI gate compares it. */ export const DEV_RELEASE_MANIFEST_PATH = "public/release-manifest.json"; export type DevReleaseManifestContractSet = ContractSet; function resolveManifestPath(root: string): string { return path.join(root, DEV_RELEASE_MANIFEST_PATH); } /** * The contract set the build would compile, derived from * `EXPECTED_CONTRACT_SET_PACKAGES` exactly as `generate-build-manifest.ts` * derives it. */ export function expectedDevReleaseManifestContractSet(): DevReleaseManifestContractSet { return contractSetSchema.parse( JSON.parse(JSON.stringify(buildContractSet())), ); } /** * Compares one manifest `contractSet` block against the compiled expectation. * Returns one human-readable line per disagreement; an empty array means the * fixture would boot. */ export function compareDevReleaseManifestContractSet( actual: unknown, expected: DevReleaseManifestContractSet, ): string[] { const parsed = contractSetSchema.safeParse(actual); if (!parsed.success) { return [ `${DEV_RELEASE_MANIFEST_PATH} contractSet is missing or not a valid contract set: ${parsed.error.issues .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) .join("; ")}`, ]; } const manifest = parsed.data; const failures: string[] = []; if (manifest.setAlgorithm !== expected.setAlgorithm) { failures.push( `setAlgorithm drift: manifest ${manifest.setAlgorithm}, build ${expected.setAlgorithm}`, ); } if (manifest.setDigest !== expected.setDigest) { failures.push( `setDigest drift: manifest ${manifest.setDigest}, build ${expected.setDigest}`, ); } const manifestById = new Map( manifest.packages.map((entry) => [entry.packageId, entry] as const), ); const expectedById = new Map( expected.packages.map((entry) => [entry.packageId, entry] as const), ); for (const [packageId, entry] of expectedById) { const declared = manifestById.get(packageId); if (!declared) { failures.push( `package missing from the manifest (boot fails with CONTRACT_SET_PACKAGE_MISSING): ${packageId}@${entry.version}`, ); continue; } if (JSON.stringify(declared) !== JSON.stringify(entry)) { failures.push( `package drift for ${packageId}: manifest ${JSON.stringify(declared)}, build ${JSON.stringify(entry)}`, ); } } for (const packageId of manifestById.keys()) { if (!expectedById.has(packageId)) { failures.push( `package declared by the manifest but not compiled into the build (boot fails with CONTRACT_SET_PACKAGE_UNEXPECTED): ${packageId}`, ); } } return failures; } export async function readDevReleaseManifest( root: string = process.cwd(), ): Promise> { const raw = await readFile(resolveManifestPath(root), "utf8"); const document: unknown = JSON.parse(raw); if (!document || typeof document !== "object" || Array.isArray(document)) { throw new TypeError(`${DEV_RELEASE_MANIFEST_PATH} must be a JSON object`); } return document as Record; } export async function checkDevReleaseManifestContractSet( root: string = process.cwd(), ): Promise { const document = await readDevReleaseManifest(root); return compareDevReleaseManifestContractSet( document["contractSet"], expectedDevReleaseManifestContractSet(), ); } /** * Rewrites only the `contractSet` block. Every other field of the hand * maintained fixture is preserved byte for byte by round-tripping the same * two-space JSON encoding the file already uses. */ export async function refreshDevReleaseManifestContractSet( root: string = process.cwd(), ): Promise> { const target = resolveManifestPath(root); const document = await readDevReleaseManifest(root); const contractSet = expectedDevReleaseManifestContractSet(); const previous = JSON.stringify(document["contractSet"]); document["contractSet"] = contractSet; const next = `${JSON.stringify(document, null, 2)}\n`; const changed = previous !== JSON.stringify(contractSet); if (changed) await writeFile(target, next, "utf8"); return Object.freeze({ changed, contractSet }); }