Files
document-haness/scripts/review-package.py
T
DongHyeonkaandClaude Opus 5 20c8535169 feat(studio-save): 검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다
시도 1 이 「8건이 PASS 로 나가지 않는다」를 세웠는데 코드 3건에 대해서만 성립했다. 경고는
실렸는데 그것을 읽고 막는 코드가 없었다. 검토로 라우팅한다는 것이 실제로는 경고를 붙여서
통과시키는 것이었다. 막지 않으면 라우팅이 아니라 주석이다.

경고가 있으면 항목마다 판정을 받고 전부 PASS 일 때만 저장이 나간다. 판정 파일이 없으면
거절한다 — 검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다. 판정이 안 붙은
경고가 있어도 거절한다 — 빠뜨린 것과 통과시킨 것은 다르다.

FAIL 과 UNKNOWN 은 둘 다 막되 문구가 갈린다. 근거가 모자라 판정을 못 한 것과 근거를 읽고
틀렸다고 본 것은 다음에 할 일이 다르다.

판정이 어느 경고에 붙은 것인지 정하려고 경고에 키를 붙였고, 그 키를 읽는 코드를 같은
변경에 넣었다. 내용이 바뀌면 키도 바뀌어 옛 판정이 다른 경고에 붙지 않는다.

경고가 없으면 판정 파일 없이 그대로 나간다. 정상은 이 게이트에 안 걸린다 — 채택 편집
100쌍에서 91쌍이 판정 없이 나가고 9쌍이 판정을 받아야 한다. 판정을 받아야 한다는 것은
막힌다는 뜻이 아니다.

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

327 lines
15 KiB
Python

#!/usr/bin/env python3
"""의미 검토가 받을 입력을 한 파일로 묶는다.
설계가 정한 분업이다 — 「결정적으로 판정 가능한 조건만 코드로 검사한다. 의미·인과·가독성은
근거를 받은 별도 검토 컨텍스트가 판단하고 확신이 없으면 보류한다」.
이 도구는 **판단하지 않는다.** 판단할 사람이 받을 것을 모은다.
담는 것 다섯.
1. **대상과 해시** — 기록·SSOT·증거·그림의 sha256. 검토가 끝난 뒤 파일이 바뀌면 그 판정은
이 해시에 안 맞는다
2. **검사기 버전** — 스킬의 `metadata.version` 과 검사기 파일의 내용 해시
3. **관문 결과** — 여기서 **실제로 돌려** 종료 코드를 적는다. 받아 적지 않는다
4. **주장 후보** — 본문에서 검증 가능한 문장을 기계로 뽑는다. 사람이 적은 주장 목록과
견주면 「본문에 있으나 주장 목록에는 빠진 것」이 보인다
5. **판정 기준** — 주장 종류마다 무엇이 있어야 하는지 (`quality-policy@1` §3)
python3 scripts/review-package.py <프로젝트> --record <기록.md> -o <출력.json>
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import re
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# quality-policy@1 §3 — 주장 종류마다 필요한 근거와 허용되는 표현
CLAIM_KINDS = {
"코드 구조·설정": {"근거": "저장소 ID, 커밋, 파일, 심볼 또는 범위",
"허용": "지정된 버전에 이 구현·설정이 존재함"},
"실제 실행 동작": {"근거": "실행 ID, 환경·입력·명령·종료 코드, 원본 출력",
"허용": "기록된 조건에서 관찰된 결과"},
"성능 비교": {"근거": "실험 코드·데이터·부하·반복 횟수·원시 측정치·집계 방식",
"허용": "측정한 조건과 변동 범위 안의 비교"},
"개념·제품 동작": {"근거": "해당 버전의 공식 문서 또는 명세", "허용": "출처가 설명하는 적용 범위"},
"선택 이유·개인 경험": {"근거": "작성자 기록·ADR·승인된 메모", "허용": "기록에 있는 이유와 실제 수행한 일"},
"해석·가설": {"근거": "해석의 전제가 되는 근거와 아직 확인하지 못한 부분",
"허용": "가능성·추정임을 명시한 설명"},
}
# 검증 가능한 문장을 고르는 표지. 뜻을 보지 않고 표면만 본다
CLAIM_MARKS = (
(re.compile(r"\d"), "수치"),
(re.compile(r"exit\s*=?\s*\d|종료 코드"), "종료 코드"),
(re.compile(r"`[^`]+`"), "식별자"),
(re.compile(r"(더|덜|보다|만큼|배|비해)\s"), "비교"),
(re.compile(r"(때문|므로|따라서|그래서|원인)"), "인과"),
(re.compile(r"(항상|절대|전부|모두|하나도|없다|never|always)"), "전칭"),
)
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
def _sha256(path: str) -> str | None:
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
except OSError:
return None
def _front_matter_block(text: str) -> str:
if not text.startswith("---"):
return ""
end = text.find("\n---", 3)
return text[3:end] if end > 0 else ""
def _listed(fm: str, key: str) -> list[str]:
"""`key:` 아래의 `- 값` 목록. `- key: x` 짝은 `file:` 쪽을 쓴다."""
out = []
grab = False
for line in fm.splitlines():
if re.match(rf"^{key}:\s*$", line):
grab = True
continue
if grab:
if re.match(r"^\S", line):
break
m = re.match(r"^\s+-\s+(\S.*)$", line) or re.match(r"^\s+file:\s*(\S+)$", line)
if m and not m.group(1).startswith("key:"):
out.append(m.group(1).strip())
return out
def _scalar(fm: str, key: str) -> str | None:
m = re.search(rf"^{key}:\s*(.*)$", fm, re.M)
return m.group(1).strip().strip('"') or None if m else None
def _claim_candidates(text: str) -> list[dict]:
a, b = text.find(BODY_START), text.find(BODY_END)
region = text[a:b] if a >= 0 <= b else text
offset = text[:a].count("\n") + 1 if a >= 0 else 0
out = []
in_fence = False
for i, line in enumerate(region.splitlines()):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence or not line.strip() or line.lstrip().startswith(("#", "|", "<!--")):
continue
for sentence in re.split(r"(?<=[.!?다])\s+", line.strip()):
marks = [name for pat, name in CLAIM_MARKS if pat.search(sentence)]
if len(sentence) > 12 and marks:
out.append({"line": offset + i + 1, "marks": marks, "text": sentence.strip()})
return out
def _run(cmd: list[str]) -> dict:
try:
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
tail = (p.stdout + p.stderr).strip().splitlines()
return {"cmd": " ".join(cmd), "exit": p.returncode,
"tail": tail[-3:] if tail else []}
except (OSError, subprocess.SubprocessError) as e:
return {"cmd": " ".join(cmd), "exit": None, "tail": [f"실행 실패: {e}"]}
def _checker_versions() -> dict:
path = os.path.join(ROOT, "scripts", "skill-versions.py")
spec = importlib.util.spec_from_file_location("skill_versions", path)
if spec is None or spec.loader is None:
return {}
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m.collect()
def _render_figures(assets: list[dict], out_dir: str) -> None:
"""그림을 PNG 로 떠서 검토가 **눈으로 볼** 수 있게 한다.
`check-figure-text.py` 는 `<text>` 가 이름인지 보고 `check-figure-overlap.py` 는 상자와
라벨이 겹치는지 본다. 둘 다 좌표와 문자열만 본다 — 그림이 말하는 것이 본문과 같은지는
사람이 봐야 안다. 렌더가 실패하면 실패했다고 적는다. 안 본 것을 본 것으로 만들지 않는다.
"""
for a in assets:
a["preview"] = None
if not a.get("exists") or not a["path"].endswith(".svg"):
continue
r = _run(["python3", "scripts/preview-figure.py",
"--file", a["path"], "-o", out_dir])
a["preview"] = {"exit": r["exit"],
"png": r["tail"][-1] if r["exit"] == 0 and r["tail"] else None,
"note": "검사기는 좌표와 문자열만 본다. 그림이 본문과 같은 것을 말하는지는 눈으로 본다"}
def _preservation(before: str, record: str) -> dict:
"""윤문 전후 비교의 결과를 통째로 싣는다.
`check-preservation.py` 는 유보 표현이 줄어든 것을 **경고로만** 낸다 — 종료 코드가 0 이라
관문으로는 안 걸린다. 확신 승격(가설→확인, 로컬→운영)이 딱 그 모양이라, 실어 보내지
않으면 그 편집은 아무도 못 본다. 그래서 `gates` 가 아니라 `warnings` 로 올린다.
"""
path = os.path.join(ROOT, "scripts", "check-preservation.py")
spec = importlib.util.spec_from_file_location("check_preservation", path)
if spec is None or spec.loader is None:
return {"available": False}
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
res = m.compare(open(before, encoding="utf-8").read(),
open(record, encoding="utf-8").read())
res["before"] = os.path.relpath(os.path.abspath(before), ROOT)
res["beforeSha256"] = _sha256(before)
res["available"] = True
return res
def build(project: str, record: str, figures_dir: str | None = None,
before: str | None = None) -> dict:
rec_abs = os.path.abspath(record)
text = open(rec_abs, encoding="utf-8").read()
fm = _front_matter_block(text)
rec_dir = os.path.dirname(rec_abs)
base = os.path.join(ROOT, "docs", project)
ssot = os.path.join(base, "final", "document.md")
def _ref(rel: str) -> dict:
p = rel if os.path.isabs(rel) else os.path.normpath(os.path.join(rec_dir, rel))
return {"path": os.path.relpath(p, ROOT), "sha256": _sha256(p),
"exists": os.path.exists(p)}
evidence = [_ref(r) for r in _listed(fm, "evidence")]
for e in evidence:
meta = os.path.join(base, "final", "evidence", "meta",
os.path.basename(e["path"]).rsplit(".", 1)[0] + ".json")
if os.path.exists(meta):
with open(meta, encoding="utf-8") as fh:
m = json.load(fh)
e["meta"] = {k: m.get(k) for k in
("command", "cwd", "exitCode", "sourceRevision", "sourceDirty",
"executedAt", "proves", "doesNotProve", "sha256")}
else:
e["meta"] = None
assets = [_ref(r) for r in _listed(fm, "assets")]
if figures_dir:
_render_figures(assets, figures_dir)
gates = [
_run(["python3", "scripts/verify-tech-log-tree.py", project]),
_run(["python3", "scripts/verify-project-layout.py", project]),
_run(["python3", "scripts/audit-records.py", project]),
_run(["python3", "scripts/check-required-content.py", project]),
_run(["node", ".agents/skills/writing-tech-log-records/scripts/check_evidence.mjs",
project, "--repo"]),
_run(["node", ".agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs",
"--warn", os.path.relpath(rec_abs, ROOT)]),
_run(["node", ".agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs",
os.path.relpath(rec_abs, ROOT)]),
]
if any(a["path"].endswith(".svg") for a in assets):
gates.append(_run(["python3", "scripts/check-figure-text.py", project]))
gates.append(_run(["python3", "scripts/check-figure-overlap.py", project]))
warnings: list[dict] = []
preservation = _preservation(before, rec_abs) if before else {"available": False}
def _warn(entry: dict) -> None:
"""경고에 안정 키를 붙여 담는다.
판정이 **어느 경고에 붙은 것인지** 정해져야 저장 게이트가 대조할 수 있다.
내용이 바뀌면 키도 바뀐다 — 다른 경고에 옛 판정이 붙지 않는다.
읽는 쪽은 `scripts/studio-save.py` 의 `_review_gate` 다.
"""
seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset"))
entry["key"] = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
warnings.append(entry)
if preservation.get("available"):
for h in preservation["hedgesDropped"]:
_warn({
"id": "유보 감소",
"detail": f"{h['word']} {h['before']}회 → {h['after']}회",
"sentence": h.get("sentence", ""),
"note": "종료 코드로는 안 걸린다. 확신이 올라간 것인지 그 자리에서 "
"덜어 낼 만했던 것인지는 근거를 읽어야 안다",
})
for f in preservation.get("warnings", []):
_warn({
"id": f"보호 구간 {f['change']}",
"detail": f"{f['kind']}{f['value'][:80]}",
"sentence": f.get("sentence", ""),
"note": f.get("note", ""),
})
return {
"schemaVersion": 2,
"project": project,
"policyVersion": "quality-policy@1",
"target": {
"record": os.path.relpath(rec_abs, ROOT),
"sha256": _sha256(rec_abs),
"kind": _scalar(fm, "kind"),
"slug": _scalar(fm, "slug"),
"title": _scalar(fm, "title"),
"sourceRevision": _scalar(fm, "sourceRevision"),
},
"ssot": {"path": os.path.relpath(ssot, ROOT), "sha256": _sha256(ssot)},
"sourceAnchors": _listed(fm, "source"),
"evidence": evidence,
"assets": assets,
"checkerVersions": _checker_versions(),
"gates": gates,
"preservation": preservation,
"warnings": warnings,
"claimCandidates": _claim_candidates(text),
"judgmentCriteria": CLAIM_KINDS,
"reviewerNotes": [
"**gates 가 전부 exit 0 이어도 그것만으로 통과가 아니다.** warnings 가 비어 있어야 "
"자동 통과다. warnings 가 있으면 근거를 읽는 검토가 항목마다 PASS·FAIL·UNKNOWN 을 "
"낸다. UNKNOWN 은 통과가 아니다.",
"이 파일의 어느 값도 판정이 아니다. 관문의 exit 는 형식 검사의 결과일 뿐이다.",
"claimCandidates 는 표면 표지로 뽑은 것이라 주장이 아닌 문장이 섞인다. "
"반대로 표지가 없는 주장은 빠진다 — 본문을 읽고 빠진 것을 찾는 것이 검토의 일이다.",
"판정을 낼 때 target.sha256 과 evidence[].sha256 을 함께 적는다. "
"그 값이 바뀌면 판정은 다른 파일에 대한 것이 된다.",
"assets[].preview.png 가 있으면 열어서 본다. 그림 검사기는 좌표와 문자열만 보므로 "
"그림이 본문과 다른 것을 말해도 통과한다.",
"warnings 는 관문이 아니다. 종료 코드로 안 걸리는 것만 여기 올라온다 — "
"유보 표현이 줄어든 자리가 그것이고, 확신 승격이 딱 그 모양이다. "
"gates 가 전부 0 이어도 warnings 는 따로 읽는다.",
"이 묶음이 못 보는 것이 있다. 수치도 인용도 없이 더한 산문 — 자료에 없는 1인칭 "
"경험이나 선택 이유 — 은 보호 구간 비교로 원리적으로 안 보이고 gates 도 warnings 도 "
"비어 있다. 본문을 읽는 것 말고는 방법이 없다.",
],
}
def main() -> int:
ap = argparse.ArgumentParser(description="의미 검토가 받을 입력을 한 파일로 묶는다.")
ap.add_argument("project")
ap.add_argument("--record", required=True)
ap.add_argument("-o", "--out")
ap.add_argument("--figures", help="그림을 PNG 로 떠서 둘 폴더. 검토가 눈으로 보는 자리다")
ap.add_argument("--before", help="윤문 전 사본. 주면 편집 전후 비교를 실어 보낸다")
args = ap.parse_args()
if not os.path.isdir(os.path.join(ROOT, "docs", args.project)):
print(f"그런 프로젝트가 없다: {args.project}", file=sys.stderr)
return 2
if not os.path.isfile(args.record):
print(f"그런 기록이 없다: {args.record}", file=sys.stderr)
return 2
pkg = build(args.project, args.record, args.figures, args.before)
text = json.dumps(pkg, ensure_ascii=False, indent=2) + "\n"
if args.out:
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(text)
failed = [g for g in pkg["gates"] if g["exit"] != 0]
print(f"{args.out} — 증거 {len(pkg['evidence'])} · 주장 후보 "
f"{len(pkg['claimCandidates'])} · 관문 {len(pkg['gates'])}"
f" (exit≠0 {len(failed)}건) · 경고 {len(pkg['warnings'])}건")
else:
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())