`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>
142 lines
5.7 KiB
TypeScript
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` 게이트가 잡는다.
|