형식 관문 여덟이 확신 승격·수치 조작·화살표 뒤집기·필수 칸 삭제를 하나도 못 막는 것이 재현됐다. 그 가운데 결정적으로 판정 가능한 것을 코드로 옮긴다. check-required-content.py — 종류가 요구하는 칸이 없거나 비었는지 본다. audit-records.py 는 평문 칸 안에 마크업이 있는지만 보고 칸이 있는지는 안 센다. 고정 목차·자료 개수·답은 강제하지 않는다. 답이 없는 QUESTION 은 정상이고 「다음 검증」이 빈 것만 결함이다. 대상이 성립하지 않으면(프로젝트 없음·계약 없음) exit 2 로 막고, 계약은 있고 기록이 0건이면 통과시키되 초록으로 두지 않는다. 「봤고 괜찮다」와 「볼 것이 없어서 통과」는 다르다. check-preservation.py — 윤문 전후를 견준다. 지금 관문 가운데 편집 전후를 보는 것이 하나도 없어 수치를 바꾸거나 유보를 지운 편집이 그대로 통과했다. 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸 값일 수 있다. 유보 표현이 줄면 내되 옳은지는 판정하지 않는다. 늘어난 것은 세지 않는다. review-package.py — 아무것도 판정하지 않는다. 판정할 사람이 받을 것을 모은다. 해시·검사기 버전·여기서 실제로 돌린 관문·주장 후보·판정 기준. 종료 코드로 안 걸리는 것은 warnings 로 따로 올린다 — 확신 승격이 딱 그 모양이라 안 실으면 아무도 못 본다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
305 lines
14 KiB
Python
305 lines
14 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']}회",
|
|
"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": [
|
|
"이 파일의 어느 값도 판정이 아니다. 관문의 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())
|