/** * canonical 계약(studio-v1, public-v1)을 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 { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { argv, env, exit } from "node:process"; const CANONICAL_ROOT = env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package"; /** * 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증 * 없는 조회 표면이다. 각자 자기 canonical yaml에서 나오고 자기 digest를 들고 * 다니므로, 한쪽이 갱신돼도 다른 쪽 drift 게이트는 조용하다. */ type ContractTarget = Readonly<{ name: string; packageId: string; canonicalYaml: string; vendorYaml: string; generated: string; sourceRecord: string; }>; const CONTRACTS: readonly ContractTarget[] = Object.freeze([ Object.freeze({ name: "studio", packageId: "@tech-log/studio-contract", canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`, vendorYaml: "src/features/tech-log/contracts/studio/studio-api.openapi.yaml", generated: "src/features/tech-log/contracts/studio/generated.ts", sourceRecord: "src/features/tech-log/contracts/studio/canonical-source.json", }), Object.freeze({ name: "public", packageId: "@tech-log/public-contract", canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/public-v1.yaml`, vendorYaml: "src/features/tech-log/contracts/public/public-api.openapi.yaml", generated: "src/features/tech-log/contracts/public/generated.ts", sourceRecord: "src/features/tech-log/contracts/public/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 problems: string[] = []; const summaries: string[] = []; for (const target of CONTRACTS) { const vendored = readFileSync(target.vendorYaml, "utf8"); const generated = readFileSync(target.generated, "utf8"); const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord; if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) { problems.push(`${target.vendorYaml} does not hash to the recorded digest`); } if (operationIdsOf(vendored).join(" ") !== [...record.operationIds].join(" ")) { problems.push(`${target.sourceRecord} operationIds differ from ${target.vendorYaml}`); } if (specVersionOf(vendored) !== record.version) { problems.push(`${target.sourceRecord} version differs from ${target.vendorYaml}`); } if (record.packageId !== target.packageId) { problems.push(`${target.sourceRecord} packageId is not ${target.packageId}`); } // 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다. for (const operationId of record.operationIds) { if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) { problems.push(`${target.generated} is missing operation ${operationId}`); } } summaries.push( `${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations`, ); } if (problems.length > 0) fail(problems); console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`); exit(0); } const sourceRevision = execFileSync( "git", ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], { encoding: "utf8" }, ).trim(); for (const target of CONTRACTS) { const canonicalBytes = readFileSync(target.canonicalYaml); const canonicalText = canonicalBytes.toString("utf8"); const record: CanonicalRecord = { packageId: target.packageId, version: specVersionOf(canonicalText), digest: digestOf(canonicalBytes), sourceRevision, operationIds: operationIdsOf(canonicalText), }; // 격리 실행. 저장소의 node_modules와 lockfile은 그대로다. const generated = execFileSync( "corepack", [ "pnpm", "dlx", "--package", GENERATOR_TYPESCRIPT, "--package", OPENAPI_TYPESCRIPT, "openapi-typescript", target.canonicalYaml, ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, ); mkdirSync(dirname(target.vendorYaml), { recursive: true }); writeFileSync(target.vendorYaml, canonicalText); writeFileSync(target.generated, generated); writeFileSync(target.sourceRecord, `${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` 게이트가 잡는다.