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:
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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user