/** * 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` 게이트가 잡는다.