docs: generate the TechLog contract outside the repo toolchain

openapi-typescript needs the TypeScript 5 classic compiler API; this repo
pins typescript@7.0.2 (VD-01), whose root export has no compiler API.
Run the generator in an isolated pnpm dlx environment instead, so the
lockfile and peer contract are untouched.

Also make check:tech-log-contract work without the canonical repo or the
generator — it read an absolute path to a sibling repo that CI never has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 00:03:29 +09:00
co-authored by Claude Opus 5
parent 9f0599a389
commit ac555a85e8
@@ -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"
```