부채 ④. 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
350 lines
17 KiB
Python
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())
|