Files
DongHyeonkaandClaude Opus 5 b75c9d0956 fix: declare the compiled contract set in the dev release manifest
`corepack pnpm dev` did not boot. Plain `vite` serves
`public/release-manifest.json` verbatim, and that hand-maintained fixture still
declared `"packages": []` after the first real contract contribution was
registered. `verifyContractSet` runs unconditionally at boot — before any
adapter is chosen, so in the default `MOCK` mode as much as in `HTTP` — saw the
build had compiled `@tech-log/studio-contract` and failed closed with
`CONTRACT_SET_PACKAGE_MISSING`. Production builds were never affected:
`scripts/generate-build-manifest.ts` derives `dist/release-manifest.json`'s
block from the same composed set.

Editing the fixture by hand is not the fix — it had already gone stale twice,
once when the package first appeared and once when the contract moved 2.0.0 ->
3.0.0, because every regeneration changes the package digest. So
`generate:tech-log-contract` now refreshes the block itself, as its last step
and through a dynamic import so it reads the canonical source it just wrote.
`generate:dev-release-manifest` does the same refresh on its own.

The composed set can also change without the contract being regenerated — a
contribution added to or removed from `installed-contract-contributions.ts`
moves it. Tying the refresh to contract generation is therefore necessary but
not sufficient; the CI gate that follows is what closes that half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:07:26 +09:00

136 lines
5.0 KiB
TypeScript

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<Record<string, unknown>> {
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<string, unknown>;
}
export async function checkDevReleaseManifestContractSet(
root: string = process.cwd(),
): Promise<readonly string[]> {
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<Readonly<{ changed: boolean; contractSet: DevReleaseManifestContractSet }>> {
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 });
}