Files
tech-log-frontend/scripts/generate-tech-log-contract.ts
T
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

142 lines
5.7 KiB
TypeScript

/**
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
*
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
* 있고(VD-01), TS7 루트는 compiler API를 노출하지 않는다. 격리된 `pnpm dlx`
* 환경에서 실행하면 lockfile과 peer 계약을 건드리지 않고 같은 산출물을 얻는다.
*
* `--check`는 canonical 저장소도 생성기도 없이 동작한다. vendor된 계약이
* 기록된 digest와 일치하는지, 기록된 operationId가 생성물에 모두 존재하는지만
* 본다. 손으로 yaml이나 generated.ts를 고치면 여기서 걸린다.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { argv, env, exit } from "node:process";
const CANONICAL_ROOT =
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
const check = argv.includes("--check");
function digestOf(bytes: Buffer | string): string {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function operationIdsOf(yaml: string): string[] {
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!);
}
function specVersionOf(yaml: string): string {
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
if (!match) throw new Error("canonical yaml has no info.version");
return match[1]!;
}
type CanonicalRecord = Readonly<{
packageId: string;
version: string;
digest: string;
sourceRevision: string;
operationIds: readonly string[];
}>;
function fail(problems: readonly string[]): never {
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
console.error("Run: corepack pnpm generate:tech-log-contract");
exit(1);
}
if (check) {
const vendored = readFileSync(VENDOR_YAML, "utf8");
const generated = readFileSync(GENERATED, "utf8");
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
const problems: string[] = [];
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
}
const vendoredOperations = operationIdsOf(vendored);
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
}
if (specVersionOf(vendored) !== record.version) {
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
}
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
for (const operationId of record.operationIds) {
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
problems.push(`${GENERATED} is missing operation ${operationId}`);
}
}
if (problems.length > 0) fail(problems);
console.log(
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
exit(0);
}
const canonicalBytes = readFileSync(CANONICAL_YAML);
const canonicalText = canonicalBytes.toString("utf8");
const record: CanonicalRecord = {
packageId: "@tech-log/studio-contract",
version: specVersionOf(canonicalText),
digest: digestOf(canonicalBytes),
sourceRevision: execFileSync(
"git",
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
{ encoding: "utf8" },
).trim(),
operationIds: operationIdsOf(canonicalText),
};
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
const generated = execFileSync(
"corepack",
[
"pnpm",
"dlx",
"--package",
GENERATOR_TYPESCRIPT,
"--package",
OPENAPI_TYPESCRIPT,
"openapi-typescript",
CANONICAL_YAML,
],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
);
writeFileSync(VENDOR_YAML, canonicalText);
writeFileSync(GENERATED, generated);
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
console.log(
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
// 선언해야 하고(§5.5, `verifyContractSet`는 MOCK 모드에서도 무조건 돈다),
// 그러지 않으면 dev 부팅이 CONTRACT_SET_PACKAGE_MISSING으로 닫힌다.
// 방금 쓴 canonical-source.json을 읽어야 하므로 정적 import가 아닌 동적
// import로 불러온다.
const { refreshDevReleaseManifestContractSet, DEV_RELEASE_MANIFEST_PATH } =
await import("./lib/dev-release-manifest.ts");
const refreshed = await refreshDevReleaseManifestContractSet();
console.log(
`${refreshed.changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` +
`(${refreshed.contractSet.packages.length} package(s), ${refreshed.contractSet.setDigest})`,
);
// contract를 다시 만들지 않아도 구성된 set은 바뀔 수 있다
// (`installed-contract-contributions.ts`에 기여가 추가/제거되는 경우).
// 그 경로는 여기서 못 잡으므로 `check:dev-release-manifest` 게이트가 잡는다.