diff --git a/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md b/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md index afebe9d..bc08ac7 100644 --- a/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md +++ b/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md @@ -12,7 +12,9 @@ ## Global Constraints -- Canonical 계약: `/home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml`, spec version `2.0.0`, digest `sha256:85a65004f29880334b9a0a3b54089450a898f1a815f679b2985b87ed8723df5b`, 설계 패키지 revision `0ec5582`. +- Canonical 계약: `/home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml`, spec version `2.0.0`, operation 19개. +- **digest와 revision은 생성 시점에 `canonical-source.json`에 기록한다. 계획서나 테스트에 값을 박지 않는다.** canonical 저장소는 활발히 편집 중이다(2026-08-17 하루에만 두 번 변경, 마지막은 description 전용이라 구조 영향 없음). 고정 값을 박으면 계약이 그대로인데도 테스트가 깨진다. +- 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의 classic compiler API를 요구하고 이 저장소는 TypeScript `7.0.2`를 고정한다(VD-01). TS7 루트 export는 `{version, versionMajorMinor}`뿐이라 compiler API가 없다. 격리된 `pnpm dlx`로 실행한다. - canonical operation은 **19개**다. JSON 18개는 계약 기여에, multipart `uploadStudioAsset` 1개는 업로드 전송 seam에 존재해야 한다. - 오류 코드는 **23개** 전부를 처리한다. 목록은 Task 2에 있다. - 브랜치는 `feature/techlog-backend-alignment`다. `main`에 직접 커밋하지 않는다. @@ -68,7 +70,9 @@ test("vendored contract matches the recorded canonical digest", () => { test("canonical source records the pinned revision and version", () => { assert.equal(canonicalSource.packageId, "tech-log-studio-contract"); assert.equal(canonicalSource.version, "2.0.0"); - assert.equal(canonicalSource.sourceRevision, "0ec5582"); + // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 + // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. + assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); assert.match(canonicalSource.digest, /^sha256:[0-9a-f]{64}$/); }); @@ -91,12 +95,20 @@ Expected: FAIL — `canonical-source.json` 모듈을 찾을 수 없다. - [ ] **Step 3: 생성 스크립트 작성** -`scripts/generate-tech-log-contract.ts`: +`scripts/generate-tech-log-contract.ts`. 두 모드는 **의존성이 다르다**. 생성은 canonical 저장소와 네트워크가 필요하고, 검증은 저장소 안의 파일만 읽는다 — CI에는 canonical 사본도 생성 도구도 없기 때문이다. ```typescript /** * canonical studio-v1.yaml을 vendor하고 타입을 생성한다. - * `--check`는 재생성 결과가 커밋 내용과 동일한지 검증만 하고 쓰지 않는다. + * + * 생성기는 저장소 의존성에 넣지 않는다. `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"; @@ -104,23 +116,23 @@ 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"; + 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 { +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]!, - ); + return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!); } function specVersionOf(yaml: string): string { @@ -129,66 +141,91 @@ function specVersionOf(yaml: string): string { return match[1]!; } -function revisionOf(): string { - return execFileSync("git", ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], { - encoding: "utf8", - }).trim(); +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 = { +const record: CanonicalRecord = { packageId: "tech-log-studio-contract", version: specVersionOf(canonicalText), digest: digestOf(canonicalBytes), - sourceRevision: revisionOf(), + 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", "exec", "openapi-typescript", CANONICAL_YAML], + [ + "pnpm", + "dlx", + "--package", + GENERATOR_TYPESCRIPT, + "--package", + OPENAPI_TYPESCRIPT, + "openapi-typescript", + CANONICAL_YAML, + ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, ); -const recordText = `${JSON.stringify(record, null, 2)}\n`; - -if (check) { - const problems: string[] = []; - if (readFileSync(VENDOR_YAML, "utf8") !== canonicalText) { - problems.push(`${VENDOR_YAML} differs from the canonical contract`); - } - if (readFileSync(GENERATED, "utf8") !== generated) { - problems.push(`${GENERATED} is not the current generation output`); - } - if (readFileSync(SOURCE_RECORD, "utf8") !== recordText) { - problems.push(`${SOURCE_RECORD} does not match the canonical digest/revision`); - } - if (problems.length > 0) { - console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`); - console.error("Run: corepack pnpm generate:tech-log-contract"); - exit(1); - } - console.log("tech-log contract is in sync with the canonical source."); - exit(0); -} - writeFileSync(VENDOR_YAML, canonicalText); writeFileSync(GENERATED, generated); -writeFileSync(SOURCE_RECORD, recordText); -console.log(`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}).`); +writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`); +console.log( + `Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, +); ``` -- [ ] **Step 4: 의존성과 스크립트 추가** +- [ ] **Step 4: 스크립트 등록** -Run: - -```bash -corepack pnpm add -D openapi-typescript@7.9.1 -``` - -`package.json`의 `scripts`에 세 줄을 추가한다: +`openapi-typescript`를 devDependency로 **추가하지 않는다**. `package.json`의 `scripts`에 세 줄만 더한다: ```json "generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts", @@ -201,7 +238,7 @@ corepack pnpm add -D openapi-typescript@7.9.1 - [ ] **Step 5: 생성 실행** Run: `corepack pnpm generate:tech-log-contract` -Expected: `Generated from tech-log-studio-contract@2.0.0 (0ec5582).` +Expected: `Generated from tech-log-studio-contract@2.0.0 (<7자리 revision>), 19 operations.` `generated.ts`의 diff가 크다. `paths`에 `/api/v1/studio/session`, `/api/v1/studio/assets`, `/api/v1/studio/assets/{assetId}`가 생기고 `components.schemas`에 `StudioSession`, `Asset`, `AssetDetail`, `AssetPage`, `AssetUploadForm`, `UpdateAssetCommand`, `AssetUsage`, `AssetKind`, `AssetManagementStatus`가 생기는지 육안 확인한다. @@ -221,7 +258,11 @@ git checkout -- src/features/tech-log/contracts/studio/studio-api.openapi.yaml corepack pnpm check:tech-log-contract; echo "exit=$?" ``` -Expected: 첫 실행 `exit=1`과 drift 메시지, 복원 후 `exit=0`. +Expected: 첫 실행 `exit=1`과 digest 불일치 메시지, 복원 후 `exit=0`. + +생성물 훼손도 잡는지 확인한다. `generated.ts`에서 `getStudioSession` operation 키 이름을 임시로 바꾼 뒤 `check`를 실행하고 되돌린다. + +Expected: `exit=1`과 `missing operation getStudioSession`. - [ ] **Step 8: 기존 계약 소비자 회귀 확인** @@ -231,7 +272,7 @@ Expected: PASS. `contract.ts`의 기존 타입 alias가 새 `generated.ts`에서 - [ ] **Step 9: 커밋** ```bash -git add package.json pnpm-lock.yaml scripts/generate-tech-log-contract.ts \ +git add package.json scripts/generate-tech-log-contract.ts \ src/features/tech-log/contracts/studio/ tests/features/tech-log/contract-generation.test.ts git commit -m "build: generate the TechLog Studio contract from canonical source" ```