Files
document-haness/scripts/studio-save.py
T
DongHyeonkaandClaude Opus 5 16cbe141a0 fix: 고친 자리에 같은 버그를 다시 넣었고, 게이트가 관측 창을 덮었다
한글 수사의 뒤 경계가 세는 말 다음의 조사를 낱말의 일부로 봤다. 「다섯 개다」·「다섯 건을」·
「여섯 장이」가 전부 안 걸린다. 아라비아 숫자 쪽에서 같은 이유로 「500행」·「5개다」를
놓쳤던 것을 고쳤는데, 그 고침을 한글로 옮기면서 다시 넣었다. 회귀가 초록이었던 건 시험
문구가 전부 조사 없이 끝나서다.

뒤 경계를 풀었더니 채택된 편집 둘이 새로 막혔다. 「여덟 자리 → 여덟 곳」이다. 숫자가 안
바뀌었고 세는 말이 바뀌었다 — spatial-metaphor 를 고치는 정상 편집이다. 그래서 잡는 것을
수사로 좁히고 세는 말은 문맥으로만 본다. 대안도 긴 것부터로 정렬했다.

대조군을 주변이 바뀌는 쌍으로 다시 짰다. 같은 문자열은 어떤 검사기든 조용해서 대조가 되지
않는다. 그 대조군이 없었으면 위 오탐을 못 봤다.

그리고 저장 게이트가 그림 붙은 기록을 전부 막고 있었다. 저장소의 그림에 종류 표시가 하나도
없어서 그림이 붙으면 무조건 경고가 하나 붙는다. 게이트가 틀린 게 아니라 그 경고가 어느
기록에 대한 정보도 아니다 — 모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지
않는다. 저장소 전체의 미비는 세고 보고하되 저장을 막지 않는다. 조용히 빼지도 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 16:15:04 +09:00

579 lines
28 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
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"},
"CONCEPT": {"본문": "bodyMarkdown"},
"REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen",
"예외": "exceptions", "예시": "examples"},
"QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns",
"제약": "constraints", "선택지": "options", "다음 검증": "nextVerification"},
"PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale",
"영향": "impact"},
}
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`). 그래서
「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고
만들어진 상황을 키 정책이 빠져나간다.
- **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다.
- **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를
재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로
조용히 재생돼 버린다.
"""
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]
if len(key) > MAX_KEY_LENGTH: # 서버가 200자 초과를 거절한다
raise Refused(f"멱등 키가 {len(key)}자다. 서버 상한은 {MAX_KEY_LENGTH}자")
return key
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
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,
"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
return doc
VERDICTS = ("PASS", "FAIL", "UNKNOWN")
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 _review_gate(warnings: list[dict], verdict_path: str | None,
package_path: str) -> list[dict]:
"""경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다.
검토로 라우팅한다는 것은 **저장이 막힌다**는 뜻이어야 한다. 막지 않으면 라우팅이 아니라
주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」.
이 함수가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다. 경고는 실렸는데 읽는 쪽이
없었다 — **싣는 것과 막는 것은 다른 일이다.**
경고가 없으면 판정 파일이 없어도 그대로 나간다. 정상은 이 게이트에 안 걸린다.
**막는 것과 세는 것을 가른다.** `scope: "repository"` 인 경고는 이 기록에 대한 발견이
아니라 저장소 전체의 미비다 — 그림 종류 표시가 그렇다. 저장소의 그림 어느 것에도 그
표시가 없어서 그림 붙은 기록이면 무조건 걸린다. **모든 기록에 걸리는 경고는 어느 기록에
대해서도 아무 말을 하지 않는다.** 그것으로 막으면 게이트의 첫 실사용에서 그림 붙은
기록이 전부 막히고, 그러면 사람이 게이트를 우회하기 시작한다.
세는 쪽도 **조용히 빼지 않는다.** 몇 건인지 함께 낸다.
"""
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:
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) -> list[dict]:
"""보낼 요청을 그대로 적어 낸다. 보내지 않는다."""
rel = os.path.relpath(os.path.abspath(record_path), ROOT)
steps: list[dict] = []
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},
})
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": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"},
})
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"},
})
_reject_forbidden_paths(steps)
return steps
# 이 어댑터가 값을 대조하지 않는 칸. 서버가 이름을 uuid 로 바꿔 돌려주거나 목록을 다시
# 정렬하는 자리라 문자열로 견줄 수 없다. **빼먹은 것이 아니라 못 보는 것**이므로
# 보고서에 그대로 적는다. 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다
NOT_COMPARED = {
"topicId": "이름→uuid 해석을 이 어댑터가 하지 않는다",
"projectId": "같음",
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
}
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_saved(sent: dict, fetched: dict) -> dict:
"""보낸 것과 되읽은 것을 견준다.
문자열 비교 하나로 「같다」를 판정하지 않는다. 세 가지를 나눠 낸다.
- `differences` — 정규화한 뒤에도 다르다. 값이 잘렸거나 바뀐 것이다
- `whitespaceOnly` — 정규화 뒤에만 같아진다. 통과로 세지 않는다
- `notCompared` · `unexpected` — 이 어댑터가 못 본 칸과 서버가 더 준 칸
**잘림은 길이로 한 번 더 본다.** Studio 는 제목 30/31자·본문 4157/4187자로 조용히 자른
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
"""
differences, whitespace_only, truncated = [], [], []
for field, want in sent.items():
if field in NOT_COMPARED:
continue
got = fetched.get(field)
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,
"unexpected": unexpected,
"comparedFields": [f for f in sent if f not in NOT_COMPARED],
}
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("--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}")
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)
doc = build_input(args.record)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version)
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 [])}장 "
f"· 경고 {len(all_warnings)}건(검토 통과 {len(cleared)}) "
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())