#!/usr/bin/env python3 """Studio 저장 어댑터. 승인된 최종본만 넣고, 저장한 뒤 되읽어 대조한다. **게시하지 않는다.** 이 파일에 게시 요청을 만드는 코드가 없고, 만들어 낸 요청이 게시 경로를 가리키면 `_reject_forbidden_paths` 가 막는다. 스킬 문서의 「게시 버튼을 누르지 않는다」는 문장 하나로 막던 것을 코드로 옮긴 것이다. **무인 저장은 꺼져 있다.** `UNATTENDED_SAVE_ENABLED` 가 `False` 다. 서버 권한이 `studio:read`·`studio:write` 둘뿐이라 저장할 수 있는 계정은 게시도 할 수 있고, 그래서 사람이 안 보는 사이에 저장을 돌리지 않는다. 이 값을 `True` 로 바꾸는 것은 서버에 `studio:publish` 가 갈라져 들어온 뒤의 일이다. **자격증명을 이 저장소에 적지 않는다.** 세션 쿠키와 CSRF 토큰은 환경 변수로만 받는다. 서버 계약 (tech-log-backend @ a000f87 의 src/config/openapi/studio-v1.yaml 과 소스에서 읽었다) | 무엇 | 값 | |---|---| | 만들기 | `POST /api/v1/studio/documents` → 201 | | 읽기 | `GET /api/v1/studio/documents/{id}` | | 저장 | `PUT /api/v1/studio/documents/{id}` → 200 | | 멱등 키 | `Idempotency-Key` 헤더 **필수**, 200자 이하 (`StudioIdempotency.java:34,77-88`) | | 재생 여부 | 응답 헤더 `Idempotency-Replayed` | | 낙관적 락 | `SaveDocumentCommand.expectedVersion` **필수 · minimum 1** | | 충돌 | `VERSION_CONFLICT` 409, `details.latestDocument` 에 현재 문서 전체 | | 인증 | 쿠키 `TECHLOG_SESSION` + 변경 요청에 `X-CSRF-TOKEN` | python3 scripts/studio-save.py --record <기록.md> --package python3 scripts/studio-save.py --record <기록.md> --package -o """ from __future__ import annotations import argparse import glob import hashlib import json import os import re import subprocess import sys import uuid ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "scripts")) import techlog # noqa: E402 # 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다. # 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001). UNATTENDED_SAVE_ENABLED = False UNATTENDED_SAVE_BLOCKED_BY = ( "CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. " "studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다" ) BASE_PATH = "/api/v1/studio" FORBIDDEN_PATH = re.compile(r"/(publish|unpublish)(/|$)") MAX_KEY_LENGTH = 200 # StudioIdempotency.java:34 IDEMPOTENCY_HEADER = "Idempotency-Key" REPLAYED_HEADER = "Idempotency-Replayed" # 기록의 `##` 이름 → Studio 입력 칸. studio-form-map.md 가 정본이다 FIELD_MAP = { "CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment", "재현 조건": "reproduction", "본문": "bodyMarkdown"}, # **`basisVersion` 은 절이 아니라 frontmatter 에 있다.** `required` 인데 어댑터가 # 절만 봐서 빠졌다 — `422 /basisVersion must not be null` (C 가 V-009 의 # `P-CONCEPT-01`·`02` 에서 맞았다). `_concept_shape` 가 frontmatter 에서 가져온다 "CONCEPT": {"본문": "bodyMarkdown"}, # **REFERENCE 는 셋이 한꺼번에 틀려 있었다** (`studio-v1.yaml:955-963`). # 이름 — `appliesWhen` 이 아니라 **`applyWhen`** 이다. nextValidation 과 같은 자리다 # 모양 — `rules` 는 `ReferenceRule` 배열, `applyWhen`·`exceptions`·`examples` 는 # `OrderedText` 배열이다. 문자열로 보내면 MismatchedInputException 이다 # 없는 칸 — `verifiedOn` 이 required 인데 아예 없었다 "REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "applyWhen", "예외": "exceptions", "예시": "examples"}, # **QUESTION 의 칸은 문자열이 아니다.** `facts`·`assumptions`·`unknowns`·`constraints` 는 # `OrderedText` 배열이고 `options` 는 `QuestionOption` 배열이며, 마지막 칸의 이름은 # `nextValidation` 이다 — `nextVerification` 이 아니다 (`studio-v1.yaml:1013-1040`). # 문자열로 보내면 `400 VALIDATION_FAILED · MismatchedInputException` 이다. # C 가 V-009 의 `P-QUESTION-02` 에서 맞았다. 여기서는 이름만 잇고 모양은 `_question_shape` 가 만든다 "QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns", "제약": "constraints", "선택지": "options", "다음 검증": "nextValidation"}, # **`근거` 절은 Studio 로 안 보낸다.** `ProjectDecisionInput` 에 그 칸이 없고 # `unevaluatedProperties: false` 라 보내면 거절된다(`studio-v1.yaml:1046`). # 기록의 `## 근거` 절은 그대로 둔다 — 저장소가 아는 것과 서버가 받는 것은 다르다. # `결정문`→`statement` · `영향`→`consequences`(OrderedText 배열) 이고 # `decisionStatus`·`decidedOn` 은 frontmatter 에 있다 (`_decision_shape`) "PROJECT_DECISION": {"결정문": "statement", "판단 이유": "rationale", "영향": "consequences"}, # **SETUP 은 절 이름을 강제하지 않는다.** 「실행 절차·구성 값·확인 방법을 `##` 절로 적는다. # 절 이름을 강제하지 않는다 — 프로젝트마다 셋업의 모양이 다르다」(`SetupInput.bodyMarkdown`). # 그 절들은 전부 본문 안이라 `_sections` 가 세지 않는다. 본문 밖의 칸 `pinnedVersions` 는 # Concept 의 `basisVersion` 과 같이 **절이 아니라 frontmatter 에 있다** — `_setup_shape` # 가 가져온다. 절만 보면 빠지고, required 라 빠지면 422 다 "SETUP": {"본문": "bodyMarkdown"}, } BODY_START, BODY_END = "", "" # SVG 에서 **문자열로 판정되는 것**. 사람 판단이 필요 없으므로 error 로 막는다. # `plan/01` §8 이 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고 # 적었는데 그 정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만 # 스크립트가 들어 있는지는 안 그렇다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이다 SVG_FORBIDDEN = ( (re.compile(r"<\s*script\b", re.I), "스크립트가 들어 있다"), (re.compile(r"<\s*foreignObject\b", re.I), "foreignObject 는 임의의 HTML 을 싣는다"), (re.compile(r"\son[a-z]+\s*=", re.I), "이벤트 처리기 속성이 있다"), # 따옴표는 \x22 \x27 로 적는다. 정규식 안에 두 종류를 함께 쓰면 파이썬 문자열이 먼저 닫힌다 (re.compile(r"(?:xlink:)?href\s*=\s*[\x22\x27](?:https?:)?//", re.I), "바깥 주소를 가리킨다"), (re.compile(r"<\s*(?:image|use)\b[^>]*(?:xlink:)?href\s*=\s*[\x22\x27](?!#|data:)", re.I), "바깥 리소스를 불러온다"), (re.compile(r"@import\b", re.I), "바깥 스타일을 불러온다"), ) # 그림이 **실제 흐름**을 그린 것인지 **개념 설명**인지. `plan/01` §8 이 표시하라고 적었다. # 없으면 error 가 아니라 warning 이다 — 지금 저장소의 그림 272장에 이 표시가 없고, # 없다고 전부 막으면 정상을 막는 쪽으로 넘어간다 FIGURE_KIND = re.compile( r"data-figure-kind\s*=\s*[\x22\x27](evidence|illustrative)[\x22\x27]", re.I) class Refused(Exception): """어댑터가 스스로 거절한 것. 서버 오류가 아니다.""" def inspect_svg(path: str) -> tuple[list[str], str | None]: """(막아야 할 것, 그림 종류). 종류를 못 읽으면 None.""" try: text = open(path, encoding="utf-8", errors="replace").read() except OSError as e: return [f"열지 못했다: {e}"], None hits = [why for pat, why in SVG_FORBIDDEN if pat.search(text)] m = FIGURE_KIND.search(text) return hits, (m.group(1).lower() if m else None) def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]: """그림을 본다. 막는 것과 읽어야 할 것을 나눠 낸다. **바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다.** 화살표를 뒤집어도 `gates` 는 전부 0 이고 `warnings` 도 비어 자동 통과했다. 문장 쪽에는 다리를 놨는데 그림 쪽에는 안 놨던 자리다. """ errors, warnings = [], [] for asset in package.get("assets") or []: rel = asset.get("path", "") full = os.path.join(ROOT, rel) if not asset.get("exists") or not rel.endswith(".svg"): continue hits, kind = inspect_svg(full) for why in hits: errors.append({"id": "그림 허용 정책", "asset": rel, "detail": why}) now = _sha256_file(full) if asset.get("sha256") and now != asset["sha256"]: warnings.append({ "id": "그림이 검토 뒤에 바뀌었다", "asset": rel, "detail": f"검토 시점 {asset['sha256'][:12]} → 지금 {now[:12]}", "note": "무엇이 바뀌었는지는 이 어댑터가 말하지 못한다. " "화살표 방향·주체는 사람이 그림을 열어 봐야 안다", }) if kind is None: warnings.append({ "id": "그림 종류가 표시돼 있지 않다", "asset": rel, "scope": "repository", "detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다', "note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. " "이 기록의 결함이 아니라 저장소 전체의 미비라 저장을 막지 않는다 — " "모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지 않는다", }) elif kind == "illustrative": warnings.append({ "id": "설명용 그림", "asset": rel, "detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다", "note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다", }) for entry in warnings: # 묶음의 경고와 같은 방식으로 키를 붙인다. 저장 게이트가 판정을 이 키로 찾는다 seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset")) entry["key"] = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12] return errors, warnings def _sha256_file(path: str) -> str: with open(path, "rb") as fh: return hashlib.sha256(fh.read()).hexdigest() def _sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def idempotency_key(op: str, record_rel: str, payload: dict | None = None, run_id: str | None = None) -> str: """기록 경로에서 유도한 안정 키. 프런트는 호출마다 `crypto.randomUUID()` 로 새 키를 만든다(`local-id.ts:8`). 그래서 「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고 만들어진 상황을 키 정책이 빠져나간다. - **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다. - **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를 재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로 조용히 재생돼 버린다. **이 설계가 막는 것이 따로 있다 — 운영에서 확인했다.** 만들기 키가 경로만으로 만들어지므로 **같은 경로의 만들기는 서버에서 영원히 같은 키다.** 그래서 「재시도가 문서를 둘 만들지 않는다」는 지켜지지만, **문서를 지워도 키는 서버에 남아** 같은 기록으로 다시 만들려 하면 `422 IDEMPOTENT_REQUEST_MISMATCH` 가 난다. 첫 만들기가 본문 문제로 실패한 뒤 본문을 고쳐 다시 보내는 것도 같다. 두 번째부터 `PUT` 인 정상 편집 흐름은 영향이 없다. 키 정책을 고치는 것은 **여기서 하지 않는다** — 본문 해시를 그냥 넣으면 「재시도가 문서를 둘 만들지 않는다」가 깨진다. 「같은 시도인가」와 「새 생성인가」를 가르는 표식이 필요하고 그것은 설계 결정이다. 키가 언제 사라지는지는 `IDEMPOTENCY_TTL_CONTRACT` 를 본다 — **운영 인스턴스의 실제 값은 미확인이다.** **`run_id` 가 그 막힘을 푼다.** 런 식별자를 **계획 파일에 적고** 키에 섞는다. - 같은 계획을 두 번 보내면 **같은 키**다. 계획 안에 적힌 값이라 보낼 때마다 안 바뀐다 - 계획을 새로 만들면 **다른 키**다. 그래서 지웠던 기록을 다시 걸 수 있다 **보낼 때 키를 만들어 내지 않는다.** 그러면 재시도가 곧 중복 생성이 된다 — 이 어댑터가 처음부터 막으려던 것이 그것이다. 만드는 자리는 계획을 짜는 순간 하나뿐이다. """ base = _sha256_text(record_rel)[:32] key = f"studio-{op}-{base}" if run_id: key += "-" + _sha256_text(run_id)[:12] if payload is not None: key += "-" + _sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))[:32] # **지금 키 정책에서는 여기 안 온다.** 키가 `studio-{op}-{32자}`(+`-{32자}`)라 길이가 # 사실상 고정이고 저장 키가 77자다. 죽은 코드가 아니라 **키 정책이 바뀌면 그때 살아나는 # 방어선**이다 — 경로를 키에 그대로 넣는 식으로 바꾸면 200자를 넘길 수 있다. 지우지 않는다. if len(key) > MAX_KEY_LENGTH: # 서버가 200자 초과를 거절한다 raise Refused(f"멱등 키가 {len(key)}자다. 서버 상한은 {MAX_KEY_LENGTH}자") return key # 서버가 돌려주는 코드를 사람 말로 옮긴다. **코드만으로는 무엇을 하라는 것인지 모른다.** # 서버가 주는 `client_safe_message` 는 무슨 일이 있었는지만 말하고 무엇을 해야 하는지는 # 말하지 않는다 — 그 줄이 이 표에 있는 이유다. # # 출처는 `tech-log-backend @ a000f87` 이다. 옮겨 적은 것이 아니라 읽고 확인한 것이다. SERVER_ERRORS = { "IDEMPOTENT_REQUEST_MISMATCH": { "status": 422, "server": "Idempotency key reused with different request body", "means": "이 기록은 전에 **다른 내용으로** 만들어졌다. 같은 멱등 키에 다른 본문이 " "오면 서버는 재생하지 않고 거절한다", "likely": ["만들기가 아니라 저장이어야 한다 — 문서가 이미 서버에 있다", "같은 경로로 지웠다 다시 만드는 중이다. **문서를 지워도 키는 남는다**", "첫 만들기가 본문 문제로 실패한 뒤 본문만 고쳐 다시 보냈다"], "notBody": "본문이 스키마에 안 맞는다는 뜻이 **아니다**. 그쪽은 " "`REQUEST_VALIDATION_FAILED` 다", "source": "error-codes.yaml:389 · OperationalError.java:48 · " "IdempotencyRequestMismatchException.java:3-7", }, "REQUEST_VALIDATION_FAILED": { "status": 422, "means": "본문이 스키마에 안 맞는다. **검증이 멱등 처리보다 앞이라 이 요청은 " "지문을 남기지 않는다** — 본문을 고쳐 같은 키로 다시 보낼 수 있다", "likely": ["`relations` 가 빠졌거나 `null` 이다 (required · nullable 아님)", "정리 단계 DELETE 에 본문이 없다 (`requestBody: required: true`)"], "source": "studio-management-v1.yaml:6150,6186 · 577-582", }, "DOCUMENT_NOT_FOUND": { "status": 404, "means": "그 경로의 **종류**로는 그 id 의 문서가 없다. id 가 없는 것일 수도, " "경로의 종류가 문서의 종류와 어긋난 것일 수도 있다", "warning": "**404 를 받았다고 지워진 것이 아니다.** 종류가 어긋난 DELETE 는 404 를 " "내고 문서는 그대로 남는다 — 지워졌는지는 GET 으로 확인한다", "source": "DeleteDocumentDraftUseCase:20 — 종류를 조회 조건에 넣지 않으면 " "Case 경로로 Reference 를 지울 수 있게 된다", }, "VERSION_CONFLICT": { "status": 409, "means": "내가 읽은 뒤에 남이 고쳤다. `details.latestDocument` 에 현재 문서가 들어 있다", "likely": ["`expectedVersion` 을 안 보내 서버가 0 으로 채웠다", "되읽기와 저장 사이에 다른 사람이 저장했다"], "source": "StudioDocumentController.java:196", }, "DOCUMENT_PUBLISHED": { "status": 409, "means": "한 번이라도 게시한 문서다. 게시를 취소해도 삭제는 거절된다", "source": "publishing-tech-log-to-studio/SKILL.md", }, } # **멱등 키가 얼마나 남아 있나.** 지운 문서를 같은 기록으로 다시 만들려면 키가 사라져야 한다. # # 계약에서 읽은 것 — `APP_IDEMPOTENCY_TTL` 기본 **24h**, 상한 72h(`IdempotencyProperties` 가 # 검증), 청소 주기 `reaper-interval: 10m` (`application.yml:473-477` · # `env-keys.yaml:1668-1680`). # # **미확인 — 이 운영 인스턴스의 실제 값은 모른다.** `application.yml` 이 `${APP_IDEMPOTENCY_TTL}` # 을 기본값 없이 쓰므로 값은 그 서버의 환경 변수에 있고, 이 어댑터는 그것을 읽을 수 없다. # C 가 관측한 것은 「V-007 이후 몇 시간 뒤에도 키가 남아 있었다」까지다. 24h 로 단정하지 않는다. IDEMPOTENCY_TTL_CONTRACT = "기본 24h · 상한 72h · reaper 10m (운영 인스턴스의 실제 값은 미확인)" def explain_error(code: str) -> dict | None: """서버 코드를 사람 말로. 모르는 코드는 지어내지 않고 None 을 돌려준다.""" return SERVER_ERRORS.get((code or "").upper()) def _expect_error(code: str) -> dict: """계획에 실을 형태. 계획을 읽는 사람이 코드를 다시 찾아보지 않아도 되게 한다.""" info = explain_error(code) if info is None: # 표에 없는 코드를 계획에 적지 않는다 raise Refused(f"모르는 서버 오류 코드다: {code!r}") return {"code": code, **info} def _front_matter(text: str) -> dict: if not text.startswith("---"): return {} end = text.find("\n---", 3) out: dict[str, str] = {} for line in text[3:end].splitlines(): m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line) if m: out[m.group(1)] = m.group(2).strip().strip('"') return out def _sections(text: str) -> dict[str, str]: a, b = text.find(BODY_START), text.find(BODY_END) marks = [] for m in re.finditer(r"^##\s+(.+)$", text, re.M): if a >= 0 <= b and a < m.start() < b: continue marks.append((m.group(1).strip(), m.end())) out: dict[str, str] = {} for i, (name, start) in enumerate(marks): stop = marks[i + 1][1] - len(f"## {marks[i + 1][0]}") if i + 1 < len(marks) else len(text) chunk = text[start:stop] if name == "본문": chunk = chunk.replace(BODY_START, "").replace(BODY_END, "") out[name] = chunk.strip() return out # `OrderedText`·`QuestionOption` 은 `id` 가 required 이고 `format: uuid` 다 # (`studio-v1.yaml:871-896`). 새 초안에는 서버가 준 id 가 없으므로 이쪽에서 만든다. # **난수로 만들지 않는다** — 같은 기록을 다시 계획하면 같은 본문이어야 저장 멱등 키가 # 안정적이다. 기록 slug·칸 이름·순번으로 uuid5 를 만든다. _ORDERED_NS = uuid.UUID("6f6b2f7a-5a1e-5a3f-9c4d-0a1b2c3d4e5f") def _ordered_id(slug: str, field: str, order: int) -> str: return str(uuid.uuid5(_ORDERED_NS, f"{slug}|{field}|{order}")) def _bullets(chunk: str) -> list[str]: """`- ` 로 시작하는 항목만 고른다. 이어지는 들여쓴 줄은 같은 항목에 붙인다.""" items: list[list[str]] = [] for line in chunk.split("\n"): if re.match(r"^\s*-\s+", line): items.append([re.sub(r"^\s*-\s+", "", line).rstrip()]) elif items and line.strip(): items[-1].append(line.strip()) return ["\n".join(x).strip() for x in items if "\n".join(x).strip()] # 제목이 붙은 항목의 모양이 둘이다. `### N. 제목` 도 있고 **굵은 한 줄**도 있다. # 둘 다 `{title, description}` 에 그대로 맞는다 — 굵은 줄이 제목이고 다음 문단이 몸통이다. BOLD_TITLE = re.compile(r"^\*\*(.+?)\*\*\s*$", re.M) HASH_TITLE = re.compile(r"^###\s+(.+)$", re.M) def _question_options(chunk: str) -> list[dict]: """제목이 붙은 항목을 `{title, description}` 으로. **모양 하나만 읽으면 다른 모양으로 쓴 절이 조용히 `[]` 가 된다.** `_ordered` 에서 문단형을 못 읽던 것과 같은 자리인데, 이쪽은 `_ordered` 를 안 거쳐서 **거절도 안 걸렸다** — 읽지도 않고 막지도 않으니 그게 제일 나쁜 상태다. """ for pattern in (HASH_TITLE, BOLD_TITLE): parts = pattern.split(chunk) if len(parts) < 3: # 그 모양이 아니다 — 다음 모양을 본다 continue out: list[dict] = [] for i in range(1, len(parts), 2): title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip()) out.append({"title": title[:120], "description": parts[i + 1].strip()}) return out # 세 번째 모양 — 표시 없이 **첫 줄이 제목이고 다음 줄들이 몸통**이다. 빈 줄로 나뉜다. # 이 저장소의 `openquestion-*` 여섯이 이 모양이라 앞 둘만 읽으면 그 여섯이 막힌다 out = [] for block in re.split(r"\n\s*\n", chunk): lines = [x for x in block.strip().split("\n") if x.strip()] if not lines: continue title = re.sub(r"^\d+[.)]\s*", "", lines[0].strip()) out.append({"title": title[:120], "description": "\n".join(lines[1:]).strip()}) return out def _ordered(slug: str, field: str, raw) -> list[dict]: """절을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다. **항목의 모양이 절마다 다르다.** `사실`·`가정` 은 `- ` 목록이고 DECISION 의 `영향` 은 빈 줄로 나뉜 문단이다. 목록만 읽으면 문단으로 쓴 절이 **조용히 `[]` 가 된다** — 스키마에 `minItems` 가 없어 그대로 저장되고 내용만 사라진다. 그래서 목록을 먼저 보고, 없으면 문단으로 나눈다. **글이 있는데 항목이 0 개면 거절한다** — 비어 있는 것과 못 읽은 것은 다른 일이다. """ if not isinstance(raw, str) or not raw.strip(): return [] # 절이 비어 있다 — 정상이다 items = _bullets(raw) if not items: items = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()] if not items: raise Refused(f"{field} 절에 글이 있는데 항목을 하나도 못 읽었다. " "빈 칸으로 보내지 않는다 — 무엇이 사라졌는지 보고 고친다") return [{"id": _ordered_id(slug, field, i), "text": t, "order": i} for i, t in enumerate(items)] def _titled(slug: str, field: str, raw) -> list[dict]: """`### N. 제목` 과 그 아래 문단을 `{제목, 본문}` 쌍으로.""" out: list[dict] = [] parts = re.split(r"^###\s+(.+)$", raw, flags=re.M) if isinstance(raw, str) else [] for i in range(1, len(parts), 2): title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip()) out.append({"id": _ordered_id(slug, field, len(out)), "title": title[:120], "body": parts[i + 1].strip(), "order": len(out)}) return out # `PinnedVersion` 은 `{name, version}` 둘뿐이다. `OrderedText` 와 달리 `id` 도 `order` 도 # 없고 `additionalProperties: false` 라 더 넣으면 거절된다 (`studio-api.openapi.yaml:964-973`). PINNED_MAX_ITEMS = 30 # `SetupInput.pinnedVersions.maxItems` PINNED_NAME_MAX, PINNED_VERSION_MAX = 60, 40 # 이름·버전을 따옴표나 백틱으로 감싸 적는 것이 자연스럽다. 값에는 넣지 않는다 — # 화면이 표의 칸으로 그리므로 따옴표가 글자 그대로 남는다 _PINNED_TRIM = " \t`\"'\u201c\u201d\u2018\u2019" def _pinned_versions(block) -> list[dict]: """frontmatter 의 `pinnedVersions:` 블록을 `PinnedVersion` 배열로. pinnedVersions: - name: Keycloak version: 26.7.0 비어 있는 것은 정상이다 — 「비우면 화면에 표를 그리지 않습니다」. 그러나 **글이 있는데 항목이 0 개면 거절한다.** `_ordered` 와 같은 자리다: 비어 있는 것과 못 읽은 것은 다른 일이다. """ if not isinstance(block, str) or not block.strip(): return [] out: list[dict] = [] for line in block.splitlines(): item = line.strip() if not item or item.startswith("#"): continue m = re.match(r"^-\s*name:\s*(.+)$", item) if m: out.append({"name": m.group(1).strip().strip(_PINNED_TRIM)}) continue m = re.match(r"^version:\s*(.+)$", item) if m and out and "version" not in out[-1]: out[-1]["version"] = m.group(1).strip().strip(_PINNED_TRIM) continue raise Refused(f"고정한 버전을 읽지 못했다: {item!r} — frontmatter 에 " "`- name: <이름>` 과 ` version: <버전>` 두 줄씩 적는다") if not out: raise Refused("pinnedVersions 에 글이 있는데 항목을 하나도 못 읽었다. " "빈 칸으로 보내지 않는다 — 무엇이 사라졌는지 보고 고친다") for item in out: name, version = item.get("name", ""), item.get("version", "") # **조용히 자르거나 채우지 않는다.** `minLength: 1` 이라 빈 쪽이 있으면 서버가 거절한다 if not name or not version: raise Refused(f"고정한 버전의 한쪽이 비었다: {item} — " "이름과 버전 둘 다 있어야 한다 (minLength 1)") if len(name) > PINNED_NAME_MAX or len(version) > PINNED_VERSION_MAX: raise Refused(f"고정한 버전이 상한을 넘는다: 이름 {len(name)}자 " f"(상한 {PINNED_NAME_MAX}) · 버전 {len(version)}자 " f"(상한 {PINNED_VERSION_MAX}) — 기록에서 줄인다") if len(out) > PINNED_MAX_ITEMS: raise Refused(f"고정한 버전이 {len(out)}개다. 상한 {PINNED_MAX_ITEMS}개 " "(studio-api.openapi.yaml:1011) — 기록에서 줄인다") return out def _setup_shape(doc: dict, fm: dict) -> dict: """SETUP 의 본문 밖 칸은 `pinnedVersions` 하나다. **이 종류에는 검증일 칸이 없다.** `lastVerifiedOn` 도 `verifiedOn` 도 계약에 없고, 「낡음은 검증일이 아니라 `pinnedVersions` 가 말한다 — 어느 버전 위에서 이 절차가 성립했는지가 유효 범위다」(`SetupDetailResponse`). 그러니 다른 종류를 보고 검증일을 넣지 않는다 — `unevaluatedProperties: false` 라 보내면 거절된다. """ doc["pinnedVersions"] = _pinned_versions(fm.get("pinnedVersions")) return doc def _case_shape(doc: dict, fm: dict) -> dict: """CASE 의 `lastVerifiedOn` 은 절이 아니라 frontmatter 에 있다. 없으면 null 이다.""" doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None return doc def _concept_shape(doc: dict, fm: dict) -> dict: """`basisVersion` 은 frontmatter 에 있다. 절만 보면 빠진다.""" basis = (fm.get("basisVersion") or "").strip() if len(basis) > 120: # **조용히 자르지 않는다.** 무엇을 보고 쓴 글인지가 잘리면 읽는 사람이 모른다 raise Refused(f"basisVersion 이 {len(basis)}자다. 상한 120자 " f"(studio-v1.yaml:990) — 기록에서 줄인다") doc["basisVersion"] = basis # 비워도 되지만 **빠지면 안 된다** return doc def _reference_shape(doc: dict, fm: dict) -> dict: """REFERENCE 의 칸을 서버 스키마의 모양으로. `purpose` 만 문자열이다.""" slug = doc.get("slug") or "" doc["rules"] = _titled(slug, "rules", doc.get("rules")) for field in ("applyWhen", "exceptions", "examples"): doc[field] = _ordered(slug, field, doc.get(field)) if not isinstance(doc.get("purpose"), str): doc["purpose"] = "" # required 이고 `[string, "null"]` 이다. **기록이 안 적었으면 지어내지 않는다** on = (fm.get("verifiedOn") or fm.get("lastVerifiedOn") or "").strip() doc["verifiedOn"] = on or None return doc DECISION_STATUS = ("PROPOSED", "ADOPTED") def _decision_shape(doc: dict, fm: dict) -> dict: """PROJECT_DECISION 의 칸을 계약의 모양으로 (`studio-v1.yaml:1050-1063`).""" slug = doc.get("slug") or "" doc["consequences"] = _ordered(slug, "consequences", doc.get("consequences")) for field in ("statement", "rationale"): if not isinstance(doc.get(field), str): doc[field] = "" status = (fm.get("decisionStatus") or "").strip().upper() if status and status not in DECISION_STATUS: # **지어내지 않는다.** enum 이 셋뿐이라 다른 값은 서버가 거절한다 — # 여기서 막는 편이 운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다 raise Refused(f"decisionStatus 가 {status!r} 다. 계약은 " f"{' · '.join(DECISION_STATUS)} 와 null 뿐이다 (studio-v1.yaml:1055)") doc["decisionStatus"] = status or None doc["decidedOn"] = (fm.get("decidedOn") or "").strip() or None return doc def _question_shape(doc: dict, fm: dict) -> dict: """QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다. 값이 아니라 타입이 문제였다. 문자열로 보내면 `MismatchedInputException` 이다. """ slug = doc.get("slug") or "" for field in ("facts", "assumptions", "unknowns", "constraints"): # **빈 배열이지 빠뜨리는 것이 아니다.** 넷 다 required 다 (`studio-v1.yaml:1023`) doc[field] = _ordered(slug, field, doc.get(field)) raw = doc.get("options") opts = _question_options(raw) if isinstance(raw, str) else [] if isinstance(raw, str) and raw.strip() and not opts: # **`_ordered` 의 거절이 여기서는 안 걸린다.** 이 칸은 그 함수를 안 거친다 — # 같은 규칙을 여기에도 둔다. 비어 있는 것과 못 읽은 것은 다른 일이다. # # **지금은 여기 안 온다.** 세 번째 모양(빈 줄로 나눈 문단)이 글이 있으면 언제나 # 항목을 하나는 만든다. `MAX_KEY_LENGTH` 와 같은 자리다 — 죽은 코드가 아니라 # **모양을 더하거나 고칠 때 살아나는 방어선**이다. 지우지 않는다 raise Refused("선택지 절에 글이 있는데 항목을 하나도 못 읽었다. " "빈 칸으로 보내지 않는다 — `### N. 제목` 이나 `**굵은 한 줄**` 로 " "제목을 붙이거나, 무엇이 사라졌는지 보고 고친다") doc["options"] = [{"id": _ordered_id(slug, "options", i), "title": o["title"], "description": o["description"], "order": i} for i, o in enumerate(opts)] if not isinstance(doc.get("nextValidation"), str): doc["nextValidation"] = "" # required 이고 `null` 도 받는다. 기록이 안 적었으면 지어내지 않고 null 로 둔다 status = (fm.get("questionStatus") or "").strip().upper() doc["questionStatus"] = status if status in ("OPEN", "RESOLVED") else None return doc def _summary(text: str) -> str: m = re.search(r"^#\s+.+$", text, re.M) if not m: return "" after = re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.M)[0] for para in (p.strip() for p in after.split("\n\n")): if para and not para.startswith("