Files
document-haness/scripts/review-package.py
T
DongHyeonkaandClaude Opus 5 0f753c3be5 fix: TechLog 리비전 셋을 매니페스트에 맞추고, 「경고 0건」을 두 가지로 가른다
부채 ④. TechLog 의 tech-log-design-package 가 ca1bbfe·1aae8dc·ffa088b 를 적고 있었는데
매니페스트는 그 뒤 다시 만들어져 세 파일 모두를 tech-log-frontend @ 0d4d1e5 로 적는다.
옛 셋은 tech-log-frontend 에 없다 — 옛 매니페스트가 안 남아 그 사이 계약 내용이 바뀌었는지는
대조할 수 없고, 그 사실을 verified 에 적었다. 기록의 「검증 환경」이 적은 리비전은 그때 잰
조건이라 고치지 않는다. 파일 sha256 셋을 함께 적어 리비전 문자열이 아니라 내용에 못박는다.

  check_evidence --repo TechLog     exit 0 「문제 없음」 (3 → 0)
  review-package.py TechLog         exit 0 · 관문 10 전부 exit 0

R14. 이 묶음의 막는 경고는 전부 편집 전후 보존 비교에서 나오고(review-package.py 의
_warn 자리 둘이 모두 if preservation.get("available") 안이다) 그 비교는 --before 를 줘야
돈다. 빼면 검토 관문이 아무 줄도 안 남기고 통과한다.

- --before 를 필수로 만들지 않는다. 새로 쓴 기록엔 윤문 전 사본이 없어 첫 기록이 막힌다.
  없다는 것을 적고 출력한다
- preservation.reason 으로 두 원인을 가른다 — NO_BEFORE · CHECKER_UNREADABLE
- 묶음이 자기 입력을 적는다. 줬으면 before·beforeSha256, 안 줬으면 명시적 null.
  빠진 칸과 null 은 다르다
- 종료 코드는 양쪽 다 0 이다. 새 관문이 아니라 읽는 계약이라 갈리는 것은 문구다.
  _review_gate 의 stderr 와 계획 한 줄 요약 둘 다 가른다
- B-B004c-plans/verdict.json 을 지웠다. 옛 묶음 53ce6a5b… 에 묶여 있는데 지금 계획은
  0beb5420… 이라 읽히지 않는다 — 검토를 받은 것처럼 보이는 파일이 남는다

python3 -m unittest discover -s scripts/tests — Ran 266 · OK (skipped=13)

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

350 lines
17 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, "reason": "CHECKER_UNREADABLE",
"detail": f"check-preservation.py 를 못 읽었다: {path}"}
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]),
# 핵심이 측정을 주장하는데 근거 목록이 비었나. 관문이라 exit≠0 이면
# `studio-save.py` 의 `approved()` 가 이 묶음을 거절한다 —
# **경고를 만드는 것과 그것으로 막는 것은 둘 다 있어야 한 쌍이다**
_run(["python3", "scripts/check-core-support.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] = []
# **빠진 칸과 null 은 다르다.** `--before` 를 줬으면 경로와 해시를, 안 줬으면 `None` 을
# 명시적으로 적는다. 안 적으면 「비교가 없다」가 「비교할 것이 없었다」와 구별되지 않는다
preservation = (_preservation(before, rec_abs) if before else
{"available": False, "reason": "NO_BEFORE",
"detail": "--before 를 안 줬다. 편집 전후 비교를 돌리지 않았다",
"before": None, "beforeSha256": None})
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 는 따로 읽는다.",
"**경고 0 건이 「봤는데 없었다」는 뜻이 아니다.** 이 묶음의 막는 경고는 전부 "
"편집 전후 보존 비교에서 나오고, 그 비교는 `--before` 를 줘야 돈다. "
"`preservation.available` 이 false 면 경고가 0 건인 것은 **사정권 밖**이지 "
"초록이 아니다. `preservation.reason` 이 왜 안 돌았는지 적는다 — "
"`NO_BEFORE`(안 줬다) · `CHECKER_UNREADABLE`(비교기를 못 읽었다).",
"이 묶음이 못 보는 것이 있다. 수치도 인용도 없이 더한 산문 — 자료에 없는 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]
pres = pkg["preservation"]
# **`--before` 를 필수로 만들지 않는다** — 새로 쓴 기록에는 윤문 전 사본이 없어서
# 첫 기록이 저장을 못 하게 된다. 없다는 것을 적고 출력하는 것으로 가른다
note = ("경고 %d건" % len(pkg["warnings"]) if pres.get("available") else
"경고 0건 — 편집 전후 비교를 안 돌렸다(%s). 사정권 밖이지 초록이 아니다"
% pres.get("reason", "?"))
print(f"{args.out} — 증거 {len(pkg['evidence'])} · 주장 후보 "
f"{len(pkg['claimCandidates'])} · 관문 {len(pkg['gates'])}"
f" (exit≠0 {len(failed)}건) · {note}")
else:
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())