사람이 채택한 편집 100쌍 중 9건을 막고 있었다. 막은 것이 전부 자료가 뒷받침하지 않는 덧붙인 이득과 되풀이를 지운 편집이고, 지운 문장 안에 숫자나 인용부호가 있었다는 이유로 막혔다. 반대로 「확인하지 못한 것」 절을 통째로 지운 편집은 통과했다. 부수 문장 삭제와 조건 삭제는 같은 연산이다. 지운 문장에 숫자가 있었는지로는 안 갈린다. 그래서 사라진 것은 warning 으로 내리고 새로 생긴 것만 error 로 둔다. 없던 수치·인용·코드를 더한 것은 날조이고 그것은 코드가 판정할 수 있다. 경계도 고쳤다. `(?![\w.-])` 의 `\w` 가 한글도 낱말 문자로 세어 `500행`·`5개다` 처럼 조사나 명사가 붙으면 보호가 통째로 풀렸다. 한국어에서 숫자는 거의 항상 뭔가가 바로 붙으므로 보호가 가장 필요한 자리에서 가장 안 걸렸다. 경고마다 그 값이 있던 문장을 함께 낸다. 기제와 수치까지만 있으면 검토자가 다시 찾아야 한다. review-package 의 reviewerNotes 맨 위에 읽는 계약을 넣었다 — gates 가 전부 0 이어도 그것만 으로 통과가 아니고, warnings 가 비어 있어야 자동 통과다. 막은 쌍 9 → 1. 남은 하나는 편집이 인라인 코드를 새로 넣은 쌍이라 규칙이 제대로 도는 것이다. 0 으로 만들려면 그 규칙을 풀어야 하고 그러면 지어낸 식별자가 함께 통과한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
316 lines
15 KiB
Python
316 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}
|
|
if preservation.get("available"):
|
|
for h in preservation["hedgesDropped"]:
|
|
warnings.append({
|
|
"id": "유보 감소",
|
|
"detail": f"{h['word']} {h['before']}회 → {h['after']}회",
|
|
"sentence": h.get("sentence", ""),
|
|
"note": "종료 코드로는 안 걸린다. 확신이 올라간 것인지 그 자리에서 "
|
|
"덜어 낼 만했던 것인지는 근거를 읽어야 안다",
|
|
})
|
|
for f in preservation.get("warnings", []):
|
|
warnings.append({
|
|
"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())
|