A 가 B-011 운영 실행에서 봤다. PROJECT_DECISION 은 보낸 uuid5 여섯이 글자 그대로 돌아온다. QUESTION 은 서버가 uuid4 로 다시 발급한다(C · V-009 열 번째). 둘 다 OrderedText 배열인데 서버가 다르게 다룬다. 한 벌로 묶어 빼면 DECISION 에서 볼 수 있는 것을 안 보게 된다 — 서버가 언젠가 DECISION 도 재발급하기 시작해도 아무도 모른다. 그래서 종류로 갈랐다. QUESTION 다시 매긴다 → id 를 뺀다 (쟀다) PROJECT_DECISION 다시 안 매긴다 → 그대로 본다 (쟀다) 그 밖 안 쟀다 → 빼지 않고 그대로 보고, 안 쟀다는 것을 값에 적는다 안 잰 채로 빼면 「안 봐도 되는 것」으로 굳는다. 「못 보는 것」과 「안 봐도 되는 것」은 다르다 — itemIdBehaviourUnmeasured 가 어느 칸을 왜 그대로 견줬는지 적는다. python3 -m unittest discover -s scripts/tests — Ran 286 · OK (skipped=13) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
1164 lines
64 KiB
Python
1164 lines
64 KiB
Python
#!/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 <review-package.json>
|
|
python3 scripts/studio-save.py --record <기록.md> --package <pkg.json> -o <plan.json>
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import uuid
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# 서버에 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"},
|
|
}
|
|
BODY_START, BODY_END = "<!-- 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) -> str:
|
|
"""기록 경로에서 유도한 안정 키.
|
|
|
|
프런트는 호출마다 `crypto.randomUUID()` 로 새 키를 만든다(`local-id.ts:8`). 그래서
|
|
「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고
|
|
만들어진 상황을 키 정책이 빠져나간다.
|
|
|
|
- **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다.
|
|
- **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를
|
|
재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로
|
|
조용히 재생돼 버린다.
|
|
|
|
**이 설계가 막는 것이 따로 있다 — 운영에서 확인했다.**
|
|
|
|
만들기 키가 경로만으로 만들어지므로 **같은 경로의 만들기는 서버에서 영원히 같은 키다.**
|
|
그래서 「재시도가 문서를 둘 만들지 않는다」는 지켜지지만, **문서를 지워도 키는 서버에
|
|
남아** 같은 기록으로 다시 만들려 하면 `422 IDEMPOTENT_REQUEST_MISMATCH` 가 난다.
|
|
첫 만들기가 본문 문제로 실패한 뒤 본문을 고쳐 다시 보내는 것도 같다.
|
|
|
|
두 번째부터 `PUT` 인 정상 편집 흐름은 영향이 없다. 키 정책을 고치는 것은 **여기서 하지
|
|
않는다** — 본문 해시를 그냥 넣으면 「재시도가 문서를 둘 만들지 않는다」가 깨진다.
|
|
「같은 시도인가」와 「새 생성인가」를 가르는 표식이 필요하고 그것은 설계 결정이다.
|
|
|
|
키가 언제 사라지는지는 `IDEMPOTENCY_TTL_CONTRACT` 를 본다 — **운영 인스턴스의 실제
|
|
값은 미확인이다.**
|
|
"""
|
|
base = _sha256_text(record_rel)[:32]
|
|
key = f"studio-{op}-{base}"
|
|
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
|
|
|
|
|
|
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("<!--"):
|
|
return para
|
|
return ""
|
|
|
|
|
|
def build_input(record_path: str) -> dict:
|
|
"""기록 `.md` 를 `WorkingCopyInput` 으로. 없는 칸을 지어내지 않는다."""
|
|
text = open(record_path, encoding="utf-8").read()
|
|
fm = _front_matter(text)
|
|
kind = (fm.get("kind") or "").upper()
|
|
if kind not in FIELD_MAP:
|
|
raise Refused(f"모르는 kind: {fm.get('kind')!r}")
|
|
found = _sections(text)
|
|
doc: dict = {
|
|
"kind": kind,
|
|
"title": fm.get("title", ""),
|
|
"slug": fm.get("slug", ""),
|
|
"summary": _summary(text),
|
|
"topicId": None, # 이름→uuid 해석은 catalog 조회가 필요하다. 지어내지 않는다
|
|
"projectId": None,
|
|
# **빈 배열이지 `None` 이 아니다.** `relations` 는 스키마의 `required` 목록에 있고
|
|
# `nullable: true` 가 없다(`studio-management-v1.yaml:6150,6186`). 빠지거나 `null` 이면
|
|
# 만들기가 `422 REQUEST_VALIDATION_FAILED` 로 거절된다 — 기본값을 `None` 으로 바꾸지 않는다
|
|
"relations": [],
|
|
}
|
|
for section, field in FIELD_MAP[kind].items():
|
|
if section in found:
|
|
doc[field] = found[section]
|
|
if kind == "CASE":
|
|
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
|
|
if kind == "QUESTION":
|
|
doc = _question_shape(doc, fm)
|
|
if kind == "CONCEPT":
|
|
doc = _concept_shape(doc, fm)
|
|
if kind == "REFERENCE":
|
|
doc = _reference_shape(doc, fm)
|
|
if kind == "PROJECT_DECISION":
|
|
doc = _decision_shape(doc, fm)
|
|
return doc
|
|
|
|
|
|
VERDICTS = ("PASS", "FAIL", "UNKNOWN")
|
|
|
|
# 사람이 눈으로 가릴 수 있게 시험 초안에 붙이는 접두사
|
|
HARNESS_PREFIX = "[HARNESS-TEST]"
|
|
# **제목만 바꾸면 저장이 안 된다.** `slug` 에 유일성 제약이 있어 원본 기록과 같은 slug 로
|
|
# 만들면 `409 DB_UNIQUE_VIOLATION` 이다 (C 가 V-009 에서 `P-CASE-01` 로 맞았다).
|
|
# 시험 초안은 **반드시** 원본과 같은 기록에서 나오므로 이 충돌은 우연이 아니라 필연이다.
|
|
HARNESS_SLUG_PREFIX = "harness-test-"
|
|
|
|
|
|
def as_harness_test(doc: dict) -> dict:
|
|
"""시험 초안으로 만든다. 제목과 `slug` 를 **함께** 바꾼다.
|
|
|
|
제목만 바꾸면 사람 눈에는 시험용인데 서버에는 원본과 같은 문서다. C 가 `slug` 앞에
|
|
`harness-test-` 를 붙여 같은 본문을 다시 걸었더니 201 이었다 — 원인이 확정된 자리다.
|
|
"""
|
|
out = dict(doc)
|
|
title = (doc.get("title") or "").strip()
|
|
if not title.startswith(HARNESS_PREFIX): # 두 번 걸어도 하나만 붙는다
|
|
out["title"] = f"{HARNESS_PREFIX} {title}".strip()
|
|
slug = (doc.get("slug") or "").strip()
|
|
if not slug:
|
|
raise Refused("slug 가 없는 기록으로 시험 초안을 만들지 않는다 — "
|
|
"무엇을 지워야 하는지 알 수 없다")
|
|
if not slug.startswith(HARNESS_SLUG_PREFIX):
|
|
out["slug"] = HARNESS_SLUG_PREFIX + slug
|
|
# `title` 은 120자, `slug` 는 100자가 상한이다 (`studio-v1.yaml:915-919`).
|
|
# 접두사가 상한을 넘기면 저장이 거절되므로 여기서 먼저 막는다 — 조용히 자르지 않는다
|
|
if len(out["title"]) > 120:
|
|
raise Refused(f"시험 초안 제목이 {len(out['title'])}자다. 상한 120자 — "
|
|
f"{HARNESS_PREFIX} 를 붙일 자리가 없다")
|
|
if len(out["slug"]) > 100:
|
|
raise Refused(f"시험 초안 slug 가 {len(out['slug'])}자다. 상한 100자 — "
|
|
f"{HARNESS_SLUG_PREFIX} 를 붙일 자리가 없다")
|
|
return out
|
|
|
|
# 종류마다 삭제 경로가 다르다. **서버가 종류를 조회 조건에 넣는다** —
|
|
# 「그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다」
|
|
# (`DeleteDocumentDraftUseCase` · `tech-log-backend` @ `a000f87`).
|
|
# 경로를 종류와 안 맞추면 `DOCUMENT_NOT_FOUND` 가 나고 **초안이 남는다.**
|
|
DELETE_PATHS = {
|
|
"CASE": "/api/v1/studio/cases/{id}",
|
|
"REFERENCE": "/api/v1/studio/references/{id}",
|
|
"CONCEPT": "/api/v1/studio/concepts/{id}",
|
|
"SETUP": "/api/v1/studio/setups/{id}",
|
|
"QUESTION": "/api/v1/studio/questions/{id}",
|
|
# Decision 만 프로젝트 아래에 있다. 지우려면 프로젝트 id 도 있어야 한다
|
|
"PROJECT_DECISION": "/api/v1/studio/projects/{projectId}/decisions/{id}",
|
|
}
|
|
|
|
|
|
def published_marks(record_path: str) -> list[str]:
|
|
"""이 기록이 게시된 것으로 보이는 표시. 비면 저장 대상이다.
|
|
|
|
**한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다.** 되돌릴 수 없는
|
|
자리라 「건드리지 않겠다」는 문장이 아니라 코드가 막는다 — 설계가 프롬프트 통제를
|
|
인정하지 않는 이유가 그대로 적용된다.
|
|
|
|
저장소의 기록 275건에서 `public:` 이 찬 것과 `status: 게시 중` 인 것이 **정확히 같은
|
|
17건**이다. 둘을 함께 보되 하나만 차 있어도 게시로 본다 — 한쪽이 뒤늦게 채워지는
|
|
경우에 놓치지 않으려는 것이다.
|
|
|
|
**이것은 저장소가 아는 것이지 서버가 아는 것이 아니다.** 계획의 첫 단계가 `GET` 으로
|
|
`currentPublication` 을 읽게 되어 있고, 그것이 최종 판정이다.
|
|
"""
|
|
text = open(record_path, encoding="utf-8").read()
|
|
fm = "\n".join(f"{k}: {v}" for k, v in _front_matter(text).items())
|
|
marks = []
|
|
m = re.search(r'^public:\s*"?(\S+?)"?\s*$', fm, re.M)
|
|
if m and m.group(1) not in ("", '""'):
|
|
marks.append(f"public: {m.group(1)}")
|
|
m = re.search(r"^status:\s*(.+?)\s*$", fm, re.M)
|
|
if m and "게시 중" in m.group(1):
|
|
marks.append(f"status: {m.group(1)}")
|
|
return marks
|
|
|
|
|
|
def _figure_kind_coverage() -> tuple[int, int]:
|
|
"""(저장소의 그림 수, 종류 표시가 없는 그림 수).
|
|
|
|
세는 쪽 경고를 **조용히 빼지 않기 위해** 낸다. 저장을 막지 않는다는 것과 안 보인다는 것은
|
|
다르다 — 안 보이면 그것이 또 「본 것만 같다」다.
|
|
"""
|
|
total = unmarked = 0
|
|
for path in glob.glob(os.path.join(ROOT, "docs/*/final/assets/**/*.svg"),
|
|
recursive=True):
|
|
total += 1
|
|
try:
|
|
if not FIGURE_KIND.search(open(path, encoding="utf-8", errors="replace").read()):
|
|
unmarked += 1
|
|
except OSError:
|
|
unmarked += 1
|
|
return total, unmarked
|
|
|
|
|
|
def _zero_note(pkg: dict) -> str:
|
|
"""경고 0 건이 「봤는데 없었다」인지 「볼 것이 아직 없었다」인지 한 줄로 가른다."""
|
|
pres = pkg.get("preservation") or {}
|
|
return "(봤고 없었다)" if pres.get("available") else "(사정권 밖 — 편집 전후 비교를 안 돌렸다)"
|
|
|
|
|
|
def _review_gate(warnings: list[dict], verdict_path: str | None,
|
|
package_path: str, preservation: dict | None = None) -> list[dict]:
|
|
"""경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다.
|
|
|
|
검토로 라우팅한다는 것은 **저장이 막힌다**는 뜻이어야 한다. 막지 않으면 라우팅이 아니라
|
|
주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」.
|
|
|
|
이 함수가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다. 경고는 실렸는데 읽는 쪽이
|
|
없었다 — **싣는 것과 막는 것은 다른 일이다.**
|
|
|
|
경고가 없으면 판정 파일이 없어도 그대로 나간다. 정상은 이 게이트에 안 걸린다.
|
|
|
|
**막는 것과 세는 것을 가른다.** `scope: "repository"` 인 경고는 이 기록에 대한 발견이
|
|
아니라 저장소 전체의 미비다 — 그림 종류 표시가 그렇다. 저장소의 그림 어느 것에도 그
|
|
표시가 없어서 그림 붙은 기록이면 무조건 걸린다. **모든 기록에 걸리는 경고는 어느 기록에
|
|
대해서도 아무 말을 하지 않는다.** 그것으로 막으면 게이트의 첫 실사용에서 그림 붙은
|
|
기록이 전부 막히고, 그러면 사람이 게이트를 우회하기 시작한다.
|
|
|
|
세는 쪽도 **조용히 빼지 않는다.** 몇 건인지 함께 낸다.
|
|
|
|
**경고 0 건에는 두 가지가 있다.** 「봤는데 없었다」와 「볼 것이 아직 없었다」다. 이 묶음의
|
|
막는 경고는 전부 편집 전후 보존 비교에서 나오고(`review-package.py` 의 `_warn` 자리 둘이
|
|
모두 `if preservation.get("available")` 안이다), 그 비교는 `--before` 를 줘야 돈다.
|
|
빼면 이 게이트가 **아무 줄도 안 남기고 통과한다.** 종료 코드는 양쪽 다 0 이다 — 새
|
|
관문이 아니라 **읽는 계약**이라, 갈리는 것은 찍는 문구다.
|
|
"""
|
|
blocking = [w for w in warnings if w.get("scope") != "repository"]
|
|
counted = [w for w in warnings if w.get("scope") == "repository"]
|
|
if counted:
|
|
print(f"세는 경고 {len(counted)}건 — 저장소 전체의 미비라 저장을 막지 않는다: "
|
|
+ " · ".join(sorted({w["id"] for w in counted})), file=sys.stderr)
|
|
if any(w["id"] == "그림 종류가 표시돼 있지 않다" for w in counted):
|
|
total, unmarked = _figure_kind_coverage()
|
|
print(f" 이 저장소에 아직 표시 안 된 그림 {unmarked}장이 있다 (그림 {total}장 중). "
|
|
f"조용히 빼지 않고 센다", file=sys.stderr)
|
|
warnings = blocking
|
|
if not warnings:
|
|
pres = preservation or {}
|
|
if pres.get("available"):
|
|
print("검토 관문 — 경고 0건. 편집 전후 비교를 돌렸고 걸린 것이 없다",
|
|
file=sys.stderr)
|
|
else:
|
|
why = {"NO_BEFORE": "--before 를 안 줘서 편집 전후 비교를 안 돌렸다",
|
|
"CHECKER_UNREADABLE": "check-preservation.py 를 못 읽었다"}.get(
|
|
pres.get("reason"), "묶음이 왜 비었는지 적지 않았다")
|
|
print(f"검토 관문 — 경고 0건이지만 **본 것이 없다**: {why}.\n"
|
|
" 이 묶음의 막는 경고는 전부 편집 전후 비교에서 나온다. "
|
|
"초록이 아니라 사정권 밖이다", file=sys.stderr)
|
|
return []
|
|
if not verdict_path:
|
|
raise Refused(
|
|
f"이 묶음에 검토가 필요한 것이 {len(warnings)}건 있다. --verdicts 로 판정 파일을 준다\n "
|
|
+ "\n ".join(f"[{w.get('key')}] {w['id']} — {str(w.get('detail'))[:70]}"
|
|
for w in warnings)
|
|
+ "\n검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다")
|
|
if not os.path.isfile(verdict_path):
|
|
raise Refused(f"그런 판정 파일이 없다: {verdict_path}")
|
|
try:
|
|
book = json.load(open(verdict_path, encoding="utf-8"))
|
|
except (OSError, ValueError) as e:
|
|
raise Refused(f"판정 파일을 읽지 못했다: {e}")
|
|
|
|
want = book.get("packageSha256")
|
|
now = _sha256_file(package_path)
|
|
if want and want != now:
|
|
raise Refused(
|
|
"판정이 다른 묶음에 붙어 있다 — 그 판정을 이 묶음에 쓸 수 없다\n"
|
|
f" 판정이 본 묶음 {want}\n 지금 묶음 {now}")
|
|
|
|
by_key = {v.get("key"): v for v in book.get("verdicts") or []}
|
|
missing, failed, unknown, passed = [], [], [], []
|
|
for w in warnings:
|
|
v = by_key.get(w.get("key"))
|
|
if v is None:
|
|
missing.append(w)
|
|
continue
|
|
verdict = str(v.get("verdict", "")).upper()
|
|
if verdict not in VERDICTS:
|
|
raise Refused(f"판정은 {' · '.join(VERDICTS)} 중 하나다: {verdict!r}")
|
|
{"PASS": passed, "FAIL": failed, "UNKNOWN": unknown}[verdict].append((w, v))
|
|
|
|
if missing:
|
|
raise Refused(
|
|
"판정이 안 붙은 경고가 있다 — 빠뜨린 것과 통과시킨 것은 다르다\n "
|
|
+ "\n ".join(f"[{w.get('key')}] {w['id']}" for w in missing))
|
|
if failed:
|
|
raise Refused(
|
|
"검토가 **근거를 읽고 틀렸다고 봤다.** 고치기 전에는 저장하지 않는다\n "
|
|
+ "\n ".join(f"[{w.get('key')}] {w['id']} — {v.get('why', '')[:70]}"
|
|
for w, v in failed))
|
|
if unknown:
|
|
raise Refused(
|
|
"검토가 **근거가 모자라 판정을 못 했다.** UNKNOWN 은 통과가 아니다 — "
|
|
"근거를 채우고 다시 본다\n "
|
|
+ "\n ".join(f"[{w.get('key')}] {w['id']} — {v.get('why', '')[:70]}"
|
|
for w, v in unknown))
|
|
return [{"key": w.get("key"), "id": w["id"], "verdict": "PASS",
|
|
"why": v.get("why", ""), "reviewer": book.get("reviewer")}
|
|
for w, v in passed]
|
|
|
|
|
|
def approved(record_path: str, package_path: str) -> dict:
|
|
"""검토를 지난 최종본만 통과시킨다.
|
|
|
|
묶음의 `target.sha256` 은 검토가 본 파일의 해시다. 지금 디스크의 파일이 그것과 다르면
|
|
**검토 뒤에 바뀐 것**이라 그 판정을 이 파일에 붙일 수 없다.
|
|
"""
|
|
pkg = json.load(open(package_path, encoding="utf-8"))
|
|
now = _sha256_file(record_path)
|
|
want = (pkg.get("target") or {}).get("sha256")
|
|
if not want:
|
|
raise Refused(f"묶음에 target.sha256 이 없다: {package_path}")
|
|
if want != now:
|
|
raise Refused(
|
|
"검토가 본 파일과 지금 파일이 다르다 — 그 판정을 이 파일에 붙일 수 없다\n"
|
|
f" 검토 시점 {want}\n 지금 {now}")
|
|
failed = [g["cmd"] for g in pkg.get("gates", []) if g.get("exit") not in (0, "0")]
|
|
if failed:
|
|
raise Refused("관문이 통과하지 못한 묶음이다:\n " + "\n ".join(failed))
|
|
return pkg
|
|
|
|
|
|
def _reject_forbidden_paths(plan: list[dict]) -> None:
|
|
"""게시 경로가 계획에 들어 있으면 멈춘다.
|
|
|
|
한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. 되돌릴 수 없는
|
|
동작을 프롬프트가 아니라 코드가 막는다.
|
|
"""
|
|
for step in plan:
|
|
if FORBIDDEN_PATH.search(step["path"]):
|
|
raise Refused(f"게시 경로는 이 어댑터가 만들지 않는다: {step['path']}")
|
|
|
|
|
|
def plan_requests(record_path: str, doc: dict, document_id: str | None,
|
|
expected_version: int | None,
|
|
harness_test: bool = False) -> list[dict]:
|
|
"""보낼 요청을 그대로 적어 낸다. 보내지 않는다."""
|
|
rel = os.path.relpath(os.path.abspath(record_path), ROOT)
|
|
steps: list[dict] = []
|
|
if document_id:
|
|
# **저장 전에 게시 상태를 서버에 묻는다.** 저장소의 frontmatter 는 저장소가 아는
|
|
# 것이지 서버가 아는 것이 아니다. 이 단계가 최종 판정이고, 여기서 PUBLISHED 가
|
|
# 나오면 저장 단계로 넘어가지 않는다
|
|
steps.append({
|
|
"op": "publication-precheck", "method": "GET",
|
|
"path": f"{BASE_PATH}/documents/{document_id}",
|
|
"headers": {}, "cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
|
|
"body": None,
|
|
"expect": {"status": 200,
|
|
"stopIf": "currentPublication.status == PUBLISHED — 저장하지 않는다",
|
|
"why": "게시한 문서는 삭제가 409 로 거절된다"},
|
|
})
|
|
if not document_id:
|
|
steps.append({
|
|
"op": "create", "method": "POST", "path": f"{BASE_PATH}/documents",
|
|
"headers": {IDEMPOTENCY_HEADER: idempotency_key("create", rel),
|
|
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
|
|
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
|
|
"body": doc,
|
|
"expect": {
|
|
"status": 201, "readHeader": REPLAYED_HEADER,
|
|
# C 가 운영에서 이것을 맞았다. 코드만 보면 본문이 틀린 줄 안다
|
|
"onIdempotencyMismatch": _expect_error("IDEMPOTENT_REQUEST_MISMATCH"),
|
|
"onValidationFailed": _expect_error("REQUEST_VALIDATION_FAILED"),
|
|
},
|
|
})
|
|
else:
|
|
if expected_version is None:
|
|
raise Refused(
|
|
"expectedVersion 을 모른 채 저장하지 않는다. 먼저 GET 으로 현재 version 을 "
|
|
"읽는다 — 안 보내면 서버가 0 으로 채워 사실상 항상 충돌한다"
|
|
" (StudioDocumentController.java:196)")
|
|
if expected_version < 1:
|
|
raise Refused(f"expectedVersion 은 1 이상이어야 한다 (스키마 minimum 1): {expected_version}")
|
|
body = {"expectedVersion": expected_version, "document": doc}
|
|
steps.append({
|
|
"op": "save", "method": "PUT", "path": f"{BASE_PATH}/documents/{document_id}",
|
|
"headers": {IDEMPOTENCY_HEADER: idempotency_key("save", rel, body),
|
|
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
|
|
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
|
|
"body": body,
|
|
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
|
|
"onConflict": _expect_error("VERSION_CONFLICT")},
|
|
})
|
|
if harness_test and not document_id:
|
|
# 새로 만든 뒤 한 번 더 저장해 낙관적 락을 실제로 태운다. `expectedVersion` 은
|
|
# **만들기 응답이 준 version** 이다 — 모른 채 보내면 서버가 0 으로 채워 늘 충돌한다.
|
|
# 이 단계가 없으면 이 런에서 `expectedVersion`·`VERSION_CONFLICT` 경로가 안 돈다
|
|
body = {"expectedVersion": "<create 응답의 document.version>", "document": doc}
|
|
steps.append({
|
|
"op": "save-after-create", "method": "PUT",
|
|
"path": f"{BASE_PATH}/documents/<생성된 id>",
|
|
"headers": {IDEMPOTENCY_HEADER: "<이 본문으로 다시 만든 save 키>",
|
|
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
|
|
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
|
|
"body": body,
|
|
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
|
|
"onConflict": _expect_error("VERSION_CONFLICT"),
|
|
"note": "멱등 키는 보낼 내용이 정해진 뒤에 만든다. "
|
|
"scripts/studio-save.py 의 idempotency_key('save', 경로, 본문) 이다"},
|
|
})
|
|
steps.append({
|
|
"op": "verify", "method": "GET",
|
|
"path": f"{BASE_PATH}/documents/{document_id or '<생성된 id>'}",
|
|
"headers": {}, "cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
|
|
"body": None,
|
|
"expect": {"status": 200,
|
|
"compare": "정규화한 본문·칸·자료를 보낸 것과 견준다",
|
|
"private": "currentPublication 이 null 이거나 status != PUBLISHED",
|
|
# 정리 경로가 이 값을 쓴다. 되읽은 값이 보낸 값과 다르면 **지우려는 곳이
|
|
# 문서가 있는 곳이 아니다** — 그 상태로 DELETE 하면 404 가 나고 초안이 남는다
|
|
**({"projectId": f"보낸 값 {doc.get('projectId')} 와 같아야 한다 — "
|
|
"정리 경로가 이 값을 쓴다"}
|
|
if doc.get("projectId") else {})},
|
|
})
|
|
if harness_test:
|
|
# 시험 초안은 만든 자리에서 지운다. **`--harness-test` 일 때만** 이 단계를 낸다 —
|
|
# 진짜 기록에는 삭제 요청을 만들지 않는다.
|
|
#
|
|
# `DELETE /api/v1/studio/cases/{id}` 는 실재한다
|
|
# (`ManagementDocumentController.java:72` · `studio-management-v1.yaml:126,246`).
|
|
#
|
|
# **시험 초안을 CASE 로 고른 이유는 삭제 경로 때문이 아니다.** 처음에 「Decision 은
|
|
# 계약에 삭제 경로가 없다」고 적었는데 틀렸다 — `ManagementDocumentController.java:123`
|
|
# 에 `DELETE /v1/studio/projects/{id}/decisions/{decisionId}` 가 있다. 스킬의 문장을
|
|
# 확인 없이 옮겼다. **고른 이유는 그 기록이 이 배치가 만든 것이라 남의 것이 아니어서다.**
|
|
kind = (doc.get("kind") or "").upper()
|
|
template = DELETE_PATHS.get(kind)
|
|
if template is None:
|
|
raise Refused(
|
|
f"이 종류의 삭제 경로를 모른다: {kind!r}. 지울 수 없는 것을 만들지 않는다\n"
|
|
f" 아는 종류: {' · '.join(sorted(DELETE_PATHS))}")
|
|
project_id = doc.get("projectId")
|
|
if "{projectId}" in template and not project_id:
|
|
# **우회하지 않는다.** 프로젝트 id 없이 만들면 그 초안을 못 지운다.
|
|
# `projectId` 는 `[string, "null"]` 이고 계약이 「게시 시점에 non-null,
|
|
# 저장 시점에는 강제하지 않는다」라고 적어 두었다(`studio-v1.yaml:932-934`) —
|
|
# 그러니 null 로도 만들어지고, 만들어지면 지울 수 없다.
|
|
raise Refused(
|
|
f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다.\n"
|
|
" `--project-id <uuid>` 로 준다. 값은 사람이 Studio 목록에서 읽은 것이어야 "
|
|
"한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다.\n"
|
|
" 없으면 시험 초안을 만들지 않는다. 지울 수 없는 것을 운영에 만들지 않는다")
|
|
path = template.format(id=document_id or "<생성된 id>", projectId=project_id)
|
|
steps.append({
|
|
"op": "cleanup", "method": "DELETE",
|
|
"path": path,
|
|
"headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
|
|
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
|
|
# **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가
|
|
# `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 초안이 남는다.
|
|
# **계약은 400 을 적었지만 운영에서 온 것은 `422 REQUEST_VALIDATION_FAILED` 다**
|
|
# (C 가 V-008 에서 직접 걸었다). 계약이 적은 값이 아니라 관측한 값을 적는다.
|
|
# 값은 **`verify` 가 읽은 version** 이다 — 만들 때 version 이 아니다.
|
|
# 만든 뒤 한 번 더 저장하므로 그 사이에 올라가 있다
|
|
"body": {"expectedVersion": "<verify 가 읽은 document.version>"},
|
|
"expect": {"status": 204,
|
|
"onConflict": "409 DOCUMENT_PUBLISHED(공개된 기록은 삭제할 수 없습니다) "
|
|
"또는 DOCUMENT_IN_USE(참조하는 곳이 있어 삭제할 수 없습니다) "
|
|
"— 둘 다 사람이 화면에서 처리한다",
|
|
"onNotFound": _expect_error("DOCUMENT_NOT_FOUND"),
|
|
"onMissingBody": _expect_error("REQUEST_VALIDATION_FAILED"),
|
|
"csrfHeader": "X-CSRF-TOKEN 이다. X-XSRF-TOKEN 이면 403 이 난다",
|
|
# C 가 운영에서 본 것: 종류를 어긋나게 보내면 DELETE 가 404 인데
|
|
# **그 뒤 GET 이 200 이다.** 404 는 지워졌다는 뜻이 아니다
|
|
"then": "GET 이 404 여야 지워진 것이다. DELETE 의 204 만 보고 끝내지 않는다",
|
|
"why": "시험 초안을 남기지 않는다"},
|
|
})
|
|
_reject_forbidden_paths(steps)
|
|
return steps
|
|
|
|
|
|
# 이 어댑터가 값을 대조하지 않는 칸. 서버가 이름을 uuid 로 바꿔 돌려주거나 목록을 다시
|
|
# 정렬하는 자리라 문자열로 견줄 수 없다. **빼먹은 것이 아니라 못 보는 것**이므로
|
|
# 보고서에 그대로 적는다. 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다
|
|
NOT_COMPARED = {
|
|
"topicId": "이름→uuid 해석을 이 어댑터가 하지 않는다",
|
|
"projectId": "같음",
|
|
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
|
|
}
|
|
|
|
# 항목 배열의 `id` 를 서버가 다시 매기는지는 **종류마다 다르고, 잰 종류만 안다.**
|
|
#
|
|
# QUESTION 다시 매긴다 — C 가 V-009 열 번째에서 봤다. 보낸 uuid5 와 받은 uuid4 가
|
|
# 다르고 `text`·`order` 는 같았다
|
|
# PROJECT_DECISION **다시 안 매긴다** — A 가 B-011 운영 실행에서 봤다. 보낸 uuid5 여섯이
|
|
# 글자 그대로 돌아왔다
|
|
#
|
|
# 둘 다 `OrderedText` 배열인데 서버가 다르게 다룬다. **그러니 한 벌로 묶어 빼면 안 된다.**
|
|
# QUESTION 에서는 뺄 수밖에 없는 칸이지만 **DECISION 에서는 볼 수 있는 칸이고, 보면
|
|
# 「안 매겨졌다」는 사실이 드러난다.** 빼 버리면 서버가 언젠가 DECISION 도 재발급하기
|
|
# 시작해도 아무도 모른다.
|
|
#
|
|
# **안 잰 종류는 뺀 것이 아니라 그대로 본다.** 「못 보는 것」과 「안 봐도 되는 것」은 다르다 —
|
|
# 안 잰 채로 빼면 「안 봐도 되는 것」으로 굳는다. 차이가 나면 그때 재서 이 표에 적는다.
|
|
ITEM_ID_REISSUED = {
|
|
"QUESTION": {"facts", "assumptions", "unknowns", "constraints", "options"},
|
|
"PROJECT_DECISION": set(), # 쟀다 — 서버가 보낸 id 를 그대로 쓴다
|
|
}
|
|
ITEM_ID_NOTE = ("항목 `id` 만 못 본다 — 보낸 것은 uuid5 이고 서버가 새 uuid4 를 발급한다. "
|
|
"`text`·`title`·`body`·`order` 는 그대로 대조한다 — 항목이 빠지거나 "
|
|
"순서가 바뀌는 것은 여전히 걸린다")
|
|
ITEM_ID_UNMEASURED = ("이 종류는 서버가 항목 `id` 를 다시 매기는지 **아직 안 쟀다.** "
|
|
"그래서 빼지 않고 그대로 견준다 — `id` 만 다른 차이가 나오면 그것은 "
|
|
"값이 사라진 것이 아니라 **재발급을 처음 본 것**일 수 있다. "
|
|
"재서 ITEM_ID_REISSUED 에 적는다")
|
|
|
|
|
|
def _is_empty(v) -> bool:
|
|
"""값이 「없음」인가. 서버가 빈 배열로, 어댑터가 `None` 으로 두는 자리를 같게 본다."""
|
|
return v is None or v == "" or v == [] or v == {}
|
|
|
|
|
|
def _without_item_ids(value):
|
|
"""항목 배열에서 `id` 만 뺀다. 나머지 칸은 그대로 둔다."""
|
|
if not isinstance(value, list):
|
|
return value
|
|
return [{k: v for k, v in x.items() if k != "id"} if isinstance(x, dict) else x
|
|
for x in value]
|
|
|
|
|
|
def normalize(value) -> str:
|
|
"""되읽어 견주기 전에 줄 끝 공백과 줄바꿈 표기만 맞춘다.
|
|
|
|
**정규화가 차이를 지울 수 있다.** 그래서 이 함수는 뜻이 바뀌지 않는 것만 건드리고,
|
|
`compare_saved` 는 정규화한 값과 **원문 둘 다** 견준다. 정규화 뒤에만 같아지는 차이는
|
|
통과가 아니라 `whitespace-only` 로 보고한다.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
text = str(value).replace("\r\n", "\n")
|
|
return "\n".join(line.rstrip() for line in text.split("\n")).strip()
|
|
|
|
|
|
def _compare_summary(sent: dict, empty_both: list, differences: list,
|
|
whitespace_only: list) -> str:
|
|
"""사람이 보는 한 줄. **그냥 「같음」이면 사정권 밖이 초록으로 돌아온다** —
|
|
`NO_BEFORE` 를 요약 줄에 적은 것과 같은 이유다."""
|
|
total = len([f for f in sent if f not in NOT_COMPARED])
|
|
seen = total - len(empty_both)
|
|
head = "같음" if not differences and not whitespace_only else (
|
|
f"다름 {len(differences)}칸" + (f" · 공백만 {len(whitespace_only)}칸"
|
|
if whitespace_only else ""))
|
|
tail = (f" — {total}칸 중 {seen}칸을 견줬다. "
|
|
f"{len(empty_both)}칸은 양쪽이 비어 견줄 것이 없었다"
|
|
if empty_both else f" — {total}칸을 전부 견줬다")
|
|
return head + tail
|
|
|
|
|
|
def compare_saved(sent: dict, fetched: dict) -> dict:
|
|
"""보낸 것과 되읽은 것을 견준다.
|
|
|
|
문자열 비교 하나로 「같다」를 판정하지 않는다. 세 가지를 나눠 낸다.
|
|
|
|
- `differences` — 정규화한 뒤에도 다르다. 값이 잘렸거나 바뀐 것이다
|
|
- `whitespaceOnly` — 정규화 뒤에만 같아진다. 통과로 세지 않는다
|
|
- `notCompared` · `unexpected` — 이 어댑터가 못 본 칸과 서버가 더 준 칸
|
|
|
|
**잘림은 길이로 한 번 더 본다.** Studio 는 제목 30/31자·본문 4157/4187자로 조용히 자른
|
|
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
|
|
"""
|
|
differences, whitespace_only, truncated = [], [], []
|
|
item_ids_skipped, empty_both = [], []
|
|
kind = (sent.get("kind") or "").upper()
|
|
reissued = ITEM_ID_REISSUED.get(kind)
|
|
array_fields = [f for f, v in sent.items()
|
|
if isinstance(v, list) and any(isinstance(x, dict) for x in v)]
|
|
for field, want in sent.items():
|
|
if field in NOT_COMPARED:
|
|
continue
|
|
got = fetched.get(field)
|
|
if _is_empty(want) and _is_empty(got):
|
|
# **견준 것이 아니라 견줄 것이 없었던 칸이다.** 이것을 「같다」에 섞으면
|
|
# 「칸이 다 맞았다」가 「찬 칸만 맞았다」를 가린다 — R14 와 같은 모양이다.
|
|
# C 가 V-009 에서 걸렸다: `P-QUESTION-01` 의 「되읽기 차이 0」이 배열 다섯이
|
|
# 전부 비어 **견줄 것이 없어서** 나온 값이었다
|
|
empty_both.append(field)
|
|
continue
|
|
if reissued and field in reissued and isinstance(want, list):
|
|
# **빼먹은 것이 아니라 못 보는 것**이므로 무엇을 왜 안 보는지 값에 적는다
|
|
item_ids_skipped.append({"field": field, "items": len(want),
|
|
"note": ITEM_ID_NOTE})
|
|
want, got = _without_item_ids(want), _without_item_ids(got)
|
|
nw, ng = normalize(want), normalize(got)
|
|
raw_same = str(want or "") == str(got or "")
|
|
if nw != ng:
|
|
entry = {"field": field, "sentChars": len(nw), "gotChars": len(ng),
|
|
"sent": nw[:120], "got": ng[:120]}
|
|
if ng and nw.startswith(ng):
|
|
entry["shape"] = "잘림 — 되읽은 값이 보낸 값의 앞부분이다"
|
|
truncated.append(entry)
|
|
differences.append(entry)
|
|
elif not raw_same:
|
|
whitespace_only.append({"field": field,
|
|
"note": "정규화 뒤에만 같다. 공백이나 줄바꿈이 달라졌다"})
|
|
unexpected = [k for k in fetched
|
|
if k not in sent and k not in NOT_COMPARED and k not in ("id", "version")]
|
|
return {
|
|
"same": not differences and not whitespace_only,
|
|
"differences": differences,
|
|
"truncated": truncated,
|
|
"whitespaceOnly": whitespace_only,
|
|
"notCompared": NOT_COMPARED,
|
|
"itemIdsNotCompared": item_ids_skipped,
|
|
# 안 잰 종류를 조용히 넘기지 않는다. 무엇을 왜 그대로 견줬는지 값에 적는다
|
|
**({"itemIdBehaviourUnmeasured": {"kind": kind, "fields": array_fields,
|
|
"note": ITEM_ID_UNMEASURED}}
|
|
if reissued is None and array_fields else {}),
|
|
"unexpected": unexpected,
|
|
# **실제로 견준 칸만 남는다.** 양쪽이 빈 칸은 `emptyBoth` 로 갈라 나간다.
|
|
# C 의 수정안은 이 칸을 개수로 바꿨는데 여기서는 목록을 유지하고 개수를 따로 둔다 —
|
|
# 이미 목록으로 읽는 회귀가 있고, 어느 칸을 못 봤는지는 이름이 있어야 안다
|
|
"comparedFields": [f for f in sent
|
|
if f not in NOT_COMPARED and f not in empty_both],
|
|
"comparedCount": len([f for f in sent
|
|
if f not in NOT_COMPARED and f not in empty_both]),
|
|
"emptyBoth": empty_both,
|
|
"summary": _compare_summary(sent, empty_both, differences, whitespace_only),
|
|
}
|
|
|
|
|
|
def is_private(detail: dict) -> tuple[bool, str]:
|
|
pub = detail.get("currentPublication")
|
|
if pub is None:
|
|
return True, "currentPublication 이 없다"
|
|
status = pub.get("status")
|
|
if status == "PUBLISHED":
|
|
return False, f"이미 게시된 문서다 — publicPath={pub.get('publicPath')}"
|
|
return True, f"게시 상태가 {status} 다"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="Studio 저장 어댑터. 게시하지 않는다.")
|
|
ap.add_argument("--record", required=True)
|
|
ap.add_argument("--package", required=True, help="review-package.py 가 만든 묶음")
|
|
ap.add_argument("--document-id", help="이미 있는 문서. 없으면 만들기 계획을 낸다")
|
|
ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version")
|
|
ap.add_argument("-o", "--out", help="계획을 적을 파일")
|
|
ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일")
|
|
ap.add_argument("--project-id",
|
|
help="PROJECT_DECISION 이 걸리는 프로젝트의 uuid. 사람이 Studio 목록에서 "
|
|
"읽은 값이어야 한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다. "
|
|
"삭제 경로가 이 값을 쓴다")
|
|
ap.add_argument("--harness-test", action="store_true",
|
|
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다")
|
|
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
|
|
args = ap.parse_args()
|
|
|
|
try:
|
|
for p in (args.record, args.package):
|
|
if not os.path.isfile(p):
|
|
raise Refused(f"그런 파일이 없다: {p}")
|
|
marks = published_marks(args.record)
|
|
if marks:
|
|
raise Refused(
|
|
"게시된 기록이다. 저장 대상에서 뺀다 — 한 번이라도 게시한 문서는 게시를 "
|
|
"취소해도 삭제가 409 로 거절된다\n " + "\n ".join(marks))
|
|
pkg = approved(args.record, args.package)
|
|
asset_errors, asset_warnings = inspect_assets(pkg)
|
|
if asset_errors:
|
|
raise Refused("그림 허용 정책에 걸린다 — 저장 단계에서 막는다\n " +
|
|
"\n ".join(f"{e['asset']}: {e['detail']}" for e in asset_errors))
|
|
all_warnings = list(pkg.get("warnings") or []) + asset_warnings
|
|
cleared = _review_gate(all_warnings, args.verdicts, args.package,
|
|
pkg.get("preservation"))
|
|
doc = build_input(args.record)
|
|
if args.project_id:
|
|
# **지어내지 않는다.** 사람이 Studio 목록에서 읽어 준 값만 들어온다
|
|
doc["projectId"] = args.project_id
|
|
if args.harness_test:
|
|
doc = as_harness_test(doc)
|
|
steps = plan_requests(args.record, doc, args.document_id, args.expected_version,
|
|
args.harness_test)
|
|
|
|
if args.send and not UNATTENDED_SAVE_ENABLED:
|
|
raise Refused("무인 저장이 꺼져 있다 — " + UNATTENDED_SAVE_BLOCKED_BY)
|
|
except Refused as e:
|
|
print(f"거절: {e}", file=sys.stderr)
|
|
return 3
|
|
|
|
plan = {
|
|
"schemaVersion": 1,
|
|
"mode": "plan",
|
|
"unattendedSaveEnabled": UNATTENDED_SAVE_ENABLED,
|
|
"unattendedSaveBlockedBy": UNATTENDED_SAVE_BLOCKED_BY,
|
|
"record": os.path.relpath(os.path.abspath(args.record), ROOT),
|
|
"recordSha256": _sha256_file(args.record),
|
|
"packageSha256": _sha256_file(args.package),
|
|
"approvedAgainst": pkg["target"]["sha256"],
|
|
"checkerVersions": pkg.get("checkerVersions"),
|
|
"requests": steps,
|
|
"warnings": all_warnings,
|
|
"reviewCleared": cleared,
|
|
"notes": [
|
|
"이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.",
|
|
"게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.",
|
|
"자격증명은 환경 변수 자리표시자로만 적혀 있다. 값은 이 저장소에 없다.",
|
|
"**warnings 가 비어 있어야 자동 통과다.** 관문이 전부 통과해도 그것만으로는 "
|
|
"아니다. 그림이 검토 뒤에 바뀌었다는 경고는 무엇이 바뀌었는지 말하지 못한다 — "
|
|
"화살표 방향과 주체는 사람이 그림을 열어 봐야 안다.",
|
|
],
|
|
}
|
|
text = json.dumps(plan, ensure_ascii=False, indent=2) + "\n"
|
|
if args.out:
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
|
open(args.out, "w", encoding="utf-8").write(text)
|
|
ops = " · ".join(f"{s['op']} {s['method']}" for s in steps)
|
|
print(f"{args.out} — {ops} · 그림 {len(pkg.get('assets') or [])}장 "
|
|
# **한 줄 요약도 「0건」을 두 가지로 찍는다.** 여기만 그냥 0 이면
|
|
# 사람이 보는 마지막 줄에서 사정권 밖이 초록으로 돌아온다
|
|
f"· 경고 {len(all_warnings)}건"
|
|
f"{'(검토 통과 %d)' % len(cleared) if all_warnings else _zero_note(pkg)} "
|
|
f"· 무인 저장 {'켬' if UNATTENDED_SAVE_ENABLED else '끔'}")
|
|
if cleared:
|
|
print(f"검토가 통과시킨 경고 {len(cleared)}건 — 판정 파일 {args.verdicts}",
|
|
file=sys.stderr)
|
|
else:
|
|
print(text, end="")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|