Merge branch 'harness/B-implementation' into harness/A-integration

This commit is contained in:
DongHyeonka
2026-09-10 11:07:35 +09:00
61 changed files with 3870 additions and 4 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""명령을 실제로 돌려 원문과 실행 메타를 함께 적립한다.
`final/evidence/raw/` 는 정본이고 `final/evidence/meta/` 는 그 실행의
command·cwd·executedAt·exitCode·revision 이다. 둘을 사람이 따로 적으면 갈라진다 —
`verify-project-layout.py` 가 「raw 는 있는데 meta 가 없다」로 세는 자리가 그것이다.
이 도구는 **종료 코드를 손으로 적을 수 없게 만든다.** 명령을 여기서 돌리고, 그 프로세스의
반환값을 그대로 meta 에 적는다. 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다.
기존 경로를 지우지 않는다 — 손으로 만든 raw/meta 도 그대로 유효하고, 이 도구는 선택적으로 쓴다.
python3 scripts/capture-evidence.py <프로젝트> <증거 id> -- <명령...>
python3 scripts/capture-evidence.py <프로젝트> <증거 id> --cwd <경로> \
--proves "<이 출력이 뒷받침하는 것>" --does-not-prove "<뒷받침하지 못하는 것>" -- <명령...>
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _revision(cwd: str) -> str | None:
"""그 작업 디렉터리 저장소의 HEAD. 저장소가 아니면 None 이다."""
try:
out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd,
capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return None
return out.stdout.strip() if out.returncode == 0 else None
def _dirty(cwd: str) -> bool | None:
"""작업 트리에 커밋 안 된 변경이 있나. 있으면 revision 이 출력을 설명하지 못한다."""
try:
out = subprocess.run(["git", "status", "--porcelain"], cwd=cwd,
capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return None
return bool(out.stdout.strip()) if out.returncode == 0 else None
def capture(project: str, eid: str, command: list[str], cwd: str,
proves: str, does_not_prove: str, kind: str,
timeout: int, subdir: str) -> int:
base = os.path.join(ROOT, "docs", project, "final", "evidence")
raw_dir = os.path.join(base, "raw", subdir) if subdir else os.path.join(base, "raw")
meta_dir = os.path.join(base, "meta")
os.makedirs(raw_dir, exist_ok=True)
os.makedirs(meta_dir, exist_ok=True)
started = datetime.datetime.now().astimezone()
try:
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
except subprocess.TimeoutExpired as e:
exit_code = 124
out = (e.stdout or "") + (e.stderr or "") + f"\n[timeout {timeout}s]\n"
except OSError as e:
print(f"명령을 실행하지 못했다: {e}", file=sys.stderr)
return 2
raw_rel = os.path.join("raw", subdir, f"{eid}.txt") if subdir else os.path.join("raw", f"{eid}.txt")
raw_path = os.path.join(base, raw_rel)
with open(raw_path, "w", encoding="utf-8") as fh:
fh.write(out)
meta = {
"id": eid,
"kind": kind,
"sourceRevision": _revision(cwd),
"sourceDirty": _dirty(cwd),
"executedAt": started.isoformat(timespec="seconds"),
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": " ".join(command),
"cwd": os.path.relpath(cwd, ROOT) if cwd.startswith(ROOT) else cwd,
"exitCode": exit_code,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": f"evidence/{raw_rel}",
"presentationPath": None,
"proves": proves,
"doesNotProve": does_not_prove,
"sha256": hashlib.sha256(out.encode("utf-8")).hexdigest(),
"bytes": len(out.encode("utf-8")),
}
meta_path = os.path.join(meta_dir, f"{eid}.json")
tmp = meta_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, meta_path)
print(f"{os.path.relpath(raw_path, ROOT)} exit={exit_code} {meta['bytes']}B")
print(f"{os.path.relpath(meta_path, ROOT)}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description="명령을 돌려 raw 원문과 실행 메타를 함께 적립한다.")
ap.add_argument("project")
ap.add_argument("evidence_id")
ap.add_argument("--cwd", default=ROOT)
ap.add_argument("--subdir", default="", help="raw/ 아래 하위 폴더")
ap.add_argument("--kind", default="terminal",
choices=["terminal", "browser", "query-plan", "benchmark", "other"])
ap.add_argument("--proves", default="", help="이 출력이 뒷받침하는 것 (경계까지)")
ap.add_argument("--does-not-prove", default="", help="이 출력이 뒷받침하지 못하는 것")
ap.add_argument("--timeout", type=int, default=600)
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
ap.parse_args(["--help"])
if "--" not in argv:
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
cut = argv.index("--")
args = ap.parse_args(argv[:cut])
command = argv[cut + 1:]
if not command:
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
return capture(args.project, args.evidence_id, command,
os.path.abspath(args.cwd), args.proves, args.does_not_prove,
args.kind, args.timeout, args.subdir)
if __name__ == "__main__":
raise SystemExit(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""문장을 고치기 전과 후에 보호 구간이 그대로인지 본다.
윤문(S5·S6)은 뜻을 바꾸지 않고 문장만 고치는 단계다. 그런데 지금 관문 가운데 **편집 전후를
견주는 것이 하나도 없다.** `check_prose` 는 고친 뒤 파일만 보고, `check_evidence` 는 인용이
SSOT 에 있는지만 본다. 그래서 수치를 바꾸거나 유보를 지운 편집이 그대로 통과한다.
보는 것은 둘이다.
**1. 보호 구간** — 수치·날짜·버전·단위·코드·명령어·URL·직접 인용은 한 글자도 달라지면 안 된다
(CLAUDE.md 「작업 규칙」). 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸
값일 수 있어서 사라진 것과 같은 무게로 본다.
**2. 유보 표현의 수** — 「추정」·「보인다」·「확인하지 못했다」 같은 말이 편집으로 줄면
확신이 올라간 것이다. **이 검사기는 그것이 옳은지 모른다.** 줄었다는 사실만 내고 판단은
근거를 받은 검토가 한다. 늘어난 것은 세지 않는다 — 유보를 더하는 것은 이 규범에서 안전한 쪽이다.
python3 scripts/check-preservation.py <편집 전.md> <편집 후.md>
python3 scripts/check-preservation.py --json <before> <after>
"""
from __future__ import annotations
import argparse
import collections
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 보호 구간. CLAUDE.md 「수치, 날짜, 버전, 단위, 코드, 명령어, URL, 직접 인용, 공식 명칭」
EXTRACTORS: dict[str, re.Pattern[str]] = {
"코드블록": re.compile(r"```[^\n]*\n(.*?)```", re.S),
"인라인코드": re.compile(r"`([^`\n]+)`"),
"URL": re.compile(r"(https?://[^\s`)\"'\]]+)"),
"직접인용": re.compile(r"「([^」]+)」"),
# 수치 — 소수·천단위 구분·단위·백분율·시각까지 한 덩어리로 잡는다.
# 앞뒤가 한글이면 낱말의 일부일 수 있어 낱말 경계를 요구한다
"수치": re.compile(r"(?<![\w.-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)(?![\w.-])"),
}
# 유보 표현. 늘어난 것은 세지 않고 줄어든 것만 낸다
HEDGES = (
"추정", "가능성", "보인다", "보였다", "아마", "", "일 수 있다", "일지도",
"확인하지 못했다", "확인하지 않았다", "미확인", "안 봤다", "못 봤다",
"모른다", "정하지 않았다", "재지 않았다", "돌리지 않았다", "열지 않았다",
"로컬", "이 환경에서", "이번에는", "한정", "범위 안",
)
def _counts(text: str) -> dict[str, collections.Counter]:
out = {}
for name, pat in EXTRACTORS.items():
out[name] = collections.Counter(m.strip() for m in pat.findall(text))
return out
def _hedges(text: str) -> collections.Counter:
return collections.Counter({h: text.count(h) for h in HEDGES if text.count(h)})
def compare(before: str, after: str) -> dict:
b, a = _counts(before), _counts(after)
findings = []
for name in EXTRACTORS:
lost = b[name] - a[name]
gained = a[name] - b[name]
for value, n in sorted(lost.items()):
findings.append({"kind": name, "change": "사라짐", "count": n, "value": value})
for value, n in sorted(gained.items()):
findings.append({"kind": name, "change": "새로생김", "count": n, "value": value})
hb, ha = _hedges(before), _hedges(after)
dropped = hb - ha
hedge = [{"word": w, "before": hb[w], "after": ha[w]} for w in sorted(dropped)]
return {"findings": findings, "hedgesDropped": hedge,
"hedgeTotalBefore": sum(hb.values()), "hedgeTotalAfter": sum(ha.values())}
def main() -> int:
ap = argparse.ArgumentParser(description="편집 전후 보호 구간이 그대로인지 본다.")
ap.add_argument("before")
ap.add_argument("after")
ap.add_argument("--json", action="store_true")
ap.add_argument("--samples", type=int, default=5)
args = ap.parse_args()
for p in (args.before, args.after):
if not os.path.isfile(p):
print(f"그런 파일이 없다: {p}", file=sys.stderr)
return 2
before = open(args.before, encoding="utf-8").read()
after = open(args.after, encoding="utf-8").read()
res = compare(before, after)
if args.json:
print(json.dumps(res, ensure_ascii=False, indent=2))
return 1 if res["findings"] else 0
print(f"\n편집 전 {os.path.relpath(args.before, ROOT)}"
f"\n편집 후 {os.path.relpath(args.after, ROOT)}")
grouped = collections.defaultdict(list)
for f in res["findings"]:
grouped[(f["kind"], f["change"])].append(f)
for (kind, change), items in sorted(grouped.items()):
print(f"{kind} {change} {len(items):>3}")
for f in items[:args.samples]:
v = f["value"].replace("\n", "")
print(f" · {v[:96]}")
if len(items) > args.samples:
print(f" … 외 {len(items) - args.samples}")
if res["hedgesDropped"]:
print(f" ! 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}"
f" → 편집 후 {res['hedgeTotalAfter']}")
for h in res["hedgesDropped"][:args.samples]:
print(f" · {h['word']} {h['before']}회 → {h['after']}")
print(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다")
n = len(res["findings"])
print(f"\nPRESERVATION: {'FAIL' if n else 'PASS'} — 보호 구간 변화 {n}"
f" · 유보 감소 {len(res['hedgesDropped'])}")
return 1 if n else 0
if __name__ == "__main__":
raise SystemExit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""종류가 요구하는 내용이 실제로 채워져 있는지 본다.
`audit-records.py` 는 평문 칸 **안에 마크업이 있는지**만 본다. 칸이 아예 없거나 제목만 있고
비어 있는 것은 세지 않는다. Studio 는 빈 칸도 받아 주므로 그대로 저장되고, 화면에서는
제목만 남은 칸으로 보인다.
**결정적으로 판정 가능한 것만 본다.** 칸이 있는가, 비어 있지 않은가, 계약에 없는 `##` 이
있는가(화면에 자리가 없어 통째로 사라진다), 종류가 요구하는 근거의 자리가 채워졌는가.
내용이 옳은지·인과가 맞는지는 보지 않는다 — 그것은 근거를 받은 검토 컨텍스트의 몫이다.
**강제하지 않는 것 셋.**
- 고정 목차. 본문(`## 본문`) 안의 절 구성은 글마다 다르다
- 자료 개수. 그림 몇 장·증거 몇 건을 요구하지 않는다
- 답. 답이 없는 QUESTION 은 정상이다. 물음과 확인된 사실과 답을 구할 방법만 요구한다
python3 scripts/check-required-content.py <프로젝트>
python3 scripts/check-required-content.py --file <기록.md>
"""
from __future__ import annotations
import argparse
import collections
import glob
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import techlog # noqa: E402
# 종류마다의 `##` 칸. 정본은
# .agents/skills/publishing-tech-log-to-studio/references/studio-form-map.md 다.
# 계약에 없는 `##` 는 화면에 자리가 없어 통째로 사라진다
SECTIONS: dict[str, dict[str, tuple[str, ...]]] = {
"case": {"required": ("관계", "문제", "결론", "검증 환경", "재현 조건", "본문"),
"optional": ()},
"concept": {"required": ("관계", "본문"), "optional": ()},
"reference": {"required": ("관계", "목적", "규칙", "적용 조건", "예외"),
"optional": ("예시",)},
"question": {"required": ("관계", "사실", "미지수", "다음 검증"),
"optional": ("가정", "제약", "선택지")},
# Decision 의 틀에는 `관계` 가 없다 (templates/decision.md). 있으면 받되 요구하지 않는다
"decision": {"required": ("근거", "결정문", "판단 이유", "영향"),
"optional": ("관계",)},
}
BODY_KINDS = {"case", "concept"}
# frontmatter 의 `kind` 는 Studio 가 쓰는 값이다. 폴더 이름과 하나가 다르다 —
# decision/ 폴더의 기록은 `kind: PROJECT_DECISION` 이다 (templates/decision.md:3)
KIND_ALIASES = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
"QUESTION": "question", "PROJECT_DECISION": "decision"}
# 종류가 요구하는 근거의 자리. 값이 옳은지가 아니라 **자리가 채워졌는지**만 본다
FRONTMATTER: dict[str, tuple[str, ...]] = {
"case": ("sourceRevision",),
"concept": ("basisVersion",),
"reference": ("sourceRevision",),
"question": ("questionStatus",),
"decision": ("decisionStatus",),
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
def _front_matter(text: str) -> tuple[dict, int]:
"""frontmatter 와 그것이 끝나는 줄 번호."""
if not text.startswith("---"):
return {}, 0
end = text.find("\n---", 3)
if end < 0:
return {}, 0
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out, text[:end].count("\n") + 2
def _sections(text: str) -> dict[str, str]:
"""`## 이름` → 그 아래 내용. 본문 구간 안의 `##` 은 세지 않는다."""
body_a = text.find(BODY_START)
body_b = text.find(BODY_END)
out: dict[str, str] = {}
order: list[tuple[str, int]] = []
for m in re.finditer(r"^##\s+(.+)$", text, re.M):
if body_a >= 0 <= body_b and body_a < m.start() < body_b:
continue # 본문 안의 절은 글마다 다르다. 강제하지 않는다
order.append((m.group(1).strip(), m.end()))
for i, (name, start) in enumerate(order):
stop = order[i + 1][1] - len(f"## {order[i + 1][0]}") if i + 1 < len(order) else len(text)
chunk = text[start:stop]
if name == "본문":
chunk = chunk.replace(BODY_START, "").replace(BODY_END, "")
out[name] = chunk.strip()
return out
def _summary(text: str, fm_end: int) -> str:
"""제목 바로 아래 첫 문단. Studio 의 `요약` 칸이다."""
rest = text.split("\n", fm_end)[-1] if fm_end else text
m = re.search(r"^#\s+.+$", rest, re.M)
if not m:
return ""
after = rest[m.end():]
after = re.split(r"^##\s", after, maxsplit=1, flags=re.M)[0]
for para in (p.strip() for p in after.split("\n\n")):
if para and not para.startswith("<!--"):
return para
return ""
def check_record(path: str, rep: techlog.Report) -> None:
rel = os.path.relpath(path, ROOT)
text = open(path, encoding="utf-8").read()
fm, fm_end = _front_matter(text)
kind = KIND_ALIASES.get((fm.get("kind") or "").upper(),
(fm.get("kind") or "").lower())
if kind not in SECTIONS:
rep.error("kind 를 모르겠다", f"{rel} — kind={fm.get('kind')!r}")
return
spec = SECTIONS[kind]
found = _sections(text)
for name in spec["required"]:
if name not in found:
rep.error(f"{kind.upper()} 에 `{name}` 칸이 없다", rel)
elif not found[name]:
rep.error(f"{kind.upper()} 의 `{name}` 칸이 비었다",
f"{rel} — 제목만 있고 내용이 없다")
known = set(spec["required"]) | set(spec["optional"])
for name in found:
if name not in known:
rep.error("계약에 없는 칸 — 화면에 자리가 없어 사라진다",
f"{rel} — ## {name}")
if not _summary(text, fm_end):
rep.error("요약이 없다", f"{rel} — 제목 바로 아래 첫 문단이 `요약` 칸이다")
for key in FRONTMATTER.get(kind, ()):
if not fm.get(key):
rep.error(f"{kind.upper()} 에 `{key}` 가 없다", rel)
# 본문이 있는 종류만 본문 마커를 갖는다. 없는 종류에 있으면 평문으로 새어 나간다
has_body = BODY_START in text and BODY_END in text
if kind in BODY_KINDS and not has_body:
rep.error(f"{kind.upper()} 에 본문 마커가 없다", rel)
if kind not in BODY_KINDS and has_body:
rep.error(f"{kind.upper()} 은 본문이 없는 종류인데 본문 마커가 있다", rel)
# 근거의 자리 — CASE 는 관찰을 뒷받침할 것이 있어야 한다.
# 무엇을 가리키는지는 check_evidence 가 보고, 여기서는 자리가 비었는지만 본다
if kind == "case" and "evidence:" not in text and "source:" not in text:
rep.error("CASE 에 근거 목록이 없다", f"{rel} — evidence: 도 source: 도 없다")
# 답이 없는 QUESTION 은 정상이다. 답을 구할 방법이 없는 것이 결함이다
if kind == "question" and found.get("다음 검증", "").strip() in ("", "-"):
rep.error("QUESTION 에 답을 구할 방법이 없다",
f"{rel} — 답이 없는 것은 결함이 아니지만 방법이 없는 것은 결함이다")
def verify(project: str) -> tuple[techlog.Report, str | None]:
"""(보고, 대상이 성립하지 않는 사유). 사유가 있으면 검사한 것이 하나도 없다.
「봤고 괜찮다」와 「볼 것이 없어서 통과」를 가른다. 셋으로 나뉜다.
| 상태 | 종료 코드 | 문구 |
|---|---|---|
| 봤고 괜찮다 | 0 | `error 0` |
| 대상이 성립하지 않는다 (프로젝트 없음 · 계약 없음) | 2 | `대상이 성립하지 않는다 — <이유>` |
| 볼 것이 아직 없다 (계약은 있고 기록 0건) | 0 | `기록 0건 — 계약의 글감 N개가 아직 안 쓰였다` |
가운데는 결함이다. 아래는 결함이 아니다 — 아직 안 쓴 것은 잘못이 아니다. 다만 초록으로
보이면 안 된다.
"""
rep = techlog.Report(project)
studio = os.path.join(ROOT, "docs", project, "tech-log-studio")
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
return rep, "tech-log-tree.json 이 없다"
records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md"))
if not f.split(os.sep)[-3].startswith("_")]
rep.facts["records"] = len(records)
planned = len(list(techlog.nodes(index)))
if not records:
rep.facts["미작성"] = f"계약의 글감 {planned}개가 아직 안 쓰였다"
kinds = collections.Counter()
for f in records:
kinds[os.path.basename(os.path.dirname(f))] += 1
check_record(f, rep)
rep.facts["kinds"] = dict(kinds)
return rep, None
def render(rep: techlog.Report, samples: int) -> None:
facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items())
print(f"\n[{rep.project}] {facts}")
for label, bucket, mark in (("error", rep.errors, ""), ("warn", rep.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for d in details[:samples]:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="종류가 요구하는 내용이 채워져 있는지 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--file", action="append", default=[], help="기록 .md 를 직접 준다")
ap.add_argument("--samples", type=int, default=3)
args = ap.parse_args()
if args.file:
rep = techlog.Report("파일")
rep.facts["records"] = len(args.file)
for f in args.file:
check_record(os.path.abspath(f), rep)
reports = [rep]
else:
projects = args.projects or sorted(
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
if not os.path.basename(os.path.dirname(p)).startswith("_"))
if not projects:
print("볼 프로젝트가 없다", file=sys.stderr)
return 2
missing = [p for p in projects
if not os.path.isdir(os.path.join(ROOT, "docs", p))]
if missing:
print("대상이 성립하지 않는다 — 그런 프로젝트가 없다: "
f"{', '.join(missing)}", file=sys.stderr)
return 2
pairs = [verify(p) for p in projects]
ungrounded = [(p, why) for (r, why), p in zip(pairs, projects) if why]
if ungrounded:
for p, why in ungrounded:
print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr)
return 2
reports = [r for r, _ in pairs]
for r in reports:
render(r, args.samples)
e = sum(r.error_count for r in reports)
total = sum(r.facts.get("records", 0) for r in reports)
unwritten = [f"{r.project}: {r.facts['미작성']}" for r in reports if "미작성" in r.facts]
for line in unwritten:
print(f" · {line}")
print(f"\nREQUIRED CONTENT: {'FAIL' if e else 'PASS'}"
f" — 기록 {total} · error {e}"
+ (f" · 아직 안 쓴 프로젝트 {len(unwritten)}" if unwritten else ""))
return 1 if e else 0
if __name__ == "__main__":
raise SystemExit(main())
+304
View File
@@ -0,0 +1,304 @@
#!/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())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""스킬과 검사기의 버전을 한 장으로 뽑는다.
통과 판정은 「어느 문서를 어느 검사기로 봤는가」에 묶여야 한다. 스킬은 `SKILL.md` 의
`metadata.version` 이 그 값이고, 스크립트 검사기는 버전 칸이 없으므로 파일 내용의 sha256
앞 12자를 쓴다. 버전을 올리는 것을 잊어도 sha 는 따라 움직인다.
python3 scripts/skill-versions.py # 사람이 읽는 표
python3 scripts/skill-versions.py --json # 판정에 붙일 값
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GATE_SCRIPTS = [
"scripts/verify-tech-log-tree.py", "scripts/verify-project-layout.py",
"scripts/verify-pipeline-run.py", "scripts/verify-pipeline.py",
"scripts/audit-records.py", "scripts/check-figure-text.py",
"scripts/check-figure-overlap.py", "scripts/build-tech-log-tree.py",
"scripts/studio-body.py", "scripts/capture-evidence.py",
]
def _sha12(path: str) -> str | None:
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()[:12]
except OSError:
return None
def collect() -> dict:
skills = {}
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/SKILL.md"))):
name = os.path.basename(os.path.dirname(path))
text = open(path, encoding="utf-8").read()
m = re.search(r"^metadata:\n(?: .*\n)*? version:\s*\"?([^\"\n]+)\"?",
text, re.M)
skills[name] = {"version": m.group(1).strip() if m else None,
"sha12": _sha12(path)}
checkers = {}
for rel in GATE_SCRIPTS:
checkers[rel] = {"version": None, "sha12": _sha12(os.path.join(ROOT, rel))}
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/scripts/*.mjs"))):
rel = os.path.relpath(path, ROOT)
checkers[rel] = {"version": None, "sha12": _sha12(path)}
return {"skills": skills, "checkers": checkers}
def main() -> int:
ap = argparse.ArgumentParser(description="스킬·검사기의 버전과 내용 해시를 뽑는다.")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
data = collect()
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2))
return 0
missing = 0
print("스킬")
for name, v in data["skills"].items():
mark = " " if v["version"] else ""
if not v["version"]:
missing += 1
print(f" {mark} {name:<40} {v['version'] or '버전 없음':<10} {v['sha12']}")
print("\n검사기 — 버전 칸이 없어 내용 해시로 묶는다")
for name, v in data["checkers"].items():
print(f" {name:<70} {v['sha12']}")
print(f"\nSKILL VERSIONS: {'FAIL' if missing else 'PASS'}"
f"스킬 {len(data['skills'])} · 버전 없음 {missing} · 검사기 {len(data['checkers'])}")
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())
+24 -4
View File
@@ -9,13 +9,33 @@ from pathlib import Path
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"),
(re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"),
# 줄 맨 앞에 앵커를 두면 `curl -H "Authorization: Bearer ..."` 를 놓친다.
# 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 그 명령줄이다.
# 값은 따옴표와 줄바꿈 전까지 먹는다 — 헤더 한 줄이면 줄 끝까지, 인용부호 안이면 닫는
# 따옴표 앞까지다. 따옴표를 넘겨 먹으면 명령의 나머지가 통째로 가려진다
(re.compile(r"(?i)(\b(?:proxy-)?authorization\s*:\s*(?:bearer|basic)\s+)[^\"'\r\n]*"),
r"\1[REDACTED]"),
(re.compile(r"(?i)(\b(?:set-cookie|cookie)\s*:\s*)[^\"'\r\n]*"), r"\1[REDACTED]"),
# `curl -u user:pw` · `--user user:pw`. 사용자 이름은 남긴다
(re.compile(r"(?i)((?:^|\s)(?:-u|--user)[=\s]+)([^\s:\"']+):([^\s\"']+)"),
r"\1\2:[REDACTED]"),
# JWT 자체. `eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다
(re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+"),
"[REDACTED]"),
# 접속 문자열의 자격증명 — postgresql://app:<암호>@db:5432/app.
# 사용자 이름은 남긴다. 어느 계정으로 붙었는지가 증거의 일부다
(
re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)([^:/?#\s@]+):([^@\s/]+)@"),
r"\1\2:[REDACTED]@",
),
# 값의 끝을 **따옴표 앞에서** 막는다. `[^\s,;]+` 로 두면 닫는 따옴표까지 먹어
# `-H "X-Api-Key: [REDACTED] https://...` 가 되고, 증거에 실린 명령이 실제로 돌린
# 명령과 달라진다. 감싼 따옴표가 있으면 그대로 되돌려 놓는다
(
re.compile(
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)"
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([\"']?)([^\s,;\"'\r\n]+)([\"']?)"
),
r"\1[REDACTED]",
r"\1\2[REDACTED]\4",
),
(
re.compile(
@@ -43,6 +43,97 @@ class RenderTerminalTest(unittest.TestCase):
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_credentials_inside_a_command_line_are_redacted(self):
"""줄 맨 앞이 아니라 명령 인자 안에 있는 자격증명.
터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 `curl -H` 의 인자다.
값은 닫는 따옴표 앞까지만 먹는다 — 넘겨 먹으면 명령의 나머지가 통째로 가려진다.
아래 값은 전부 합성이고 실제 비밀값이 아니다.
"""
cases = {
'curl -H "Authorization: Bearer TESTONLY-aaa.bbb.ccc" https://example.test/api':
'curl -H "Authorization: Bearer [REDACTED]" https://example.test/api',
"curl -H 'Authorization: Bearer TESTONLY-xyz' -sS https://example.test":
"curl -H 'Authorization: Bearer [REDACTED]' -sS https://example.test",
'curl -H "Cookie: SESSION=TESTONLY-sess" https://example.test/api':
'curl -H "Cookie: [REDACTED]" https://example.test/api',
"Set-Cookie: SESSION=TESTONLY-x; HttpOnly":
"Set-Cookie: [REDACTED]",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_connection_string_password_is_redacted_and_user_is_kept(self):
"""scheme://user:pw@host 의 암호만 가린다.
어느 계정으로 붙었는지는 증거의 일부라 사용자 이름을 남긴다.
"""
cases = {
"psql postgresql://app:TESTONLY-pw@db:5432/app":
"psql postgresql://app:[REDACTED]@db:5432/app",
"DATABASE_URL=mysql://root:TESTONLY-pw@127.0.0.1:3306/app":
"DATABASE_URL=mysql://root:[REDACTED]@127.0.0.1:3306/app",
"redis://default:TESTONLY-pw@cache:6379/0":
"redis://default:[REDACTED]@cache:6379/0",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_masking_does_not_eat_the_closing_quote(self):
"""가려졌다는 것과 명령이 그대로라는 것은 다르다.
값의 끝을 공백까지로 두면 닫는 따옴표까지 먹어 증거에 실린 명령이 실제로 돌린
명령과 달라진다. 가려진 것만 보고 지나치지 않도록 따옴표 수를 함께 센다.
아래 값은 전부 합성이고 실제 비밀값이 아니다.
"""
lines = (
'curl -H "X-Api-Key: TESTONLY-i-apikey-header" https://example.invalid/d',
'curl -H "X-Token: TESTONLY-x" -H "Accept: application/json" https://example.invalid/d',
'export TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig"',
"curl -H 'X-Api-Key: TESTONLY-single' https://example.invalid/d",
)
for raw in lines:
with self.subTest(raw=raw):
out = redact_line(raw)
self.assertIn("[REDACTED]", out)
self.assertNotIn("TESTONLY", out)
self.assertEqual(raw.count('"'), out.count('"'), out)
self.assertEqual(raw.count("'"), out.count("'"), out)
def test_basic_auth_shapes_are_redacted(self):
"""Bearer 말고도 자격증명이 실리는 자리가 있다."""
cases = {
'curl -H "Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
'curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d',
'curl -H "Proxy-Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
'curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d',
"curl -u admin:TESTONLY-basic-pw https://example.invalid/d":
"curl -u admin:[REDACTED] https://example.invalid/d",
"curl --user admin:TESTONLY-basic-pw https://example.invalid/d":
"curl --user admin:[REDACTED] https://example.invalid/d",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_a_bare_jwt_is_redacted(self):
"""`eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다."""
raw = "Set token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig now"
out = redact_line(raw)
self.assertEqual("Set token [REDACTED] now", out)
def test_ordinary_urls_are_not_touched(self):
"""자격증명이 없는 주소는 그대로 둔다. 과하게 가리면 증거를 못 읽는다."""
for line in (
"https://example.test/api?x=1",
"git clone https://github.com/org/repo.git",
"GET https://example.test/studio/documents/abc-123/edit -> 200",
):
with self.subTest(line=line):
self.assertEqual(line, redact_line(line))
def test_normal_output_is_not_changed_by_redaction(self):
line = "GET /api/me -> 200 in 14ms"
self.assertEqual(line, redact_line(line))
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 가운데 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
이 값은 로컬에서 잰 것이고 운영에서 같은지는 확인하지 못했다.
```
build-tech-log-tree.py exit=1
```
문서가 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적어 두었다.
자세한 것은 https://example.test/docs 에 있다.
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 중 셋이 `exit 1` 이었다. 응답 시간은 4ms 였다.
이 값은 운영에서 확인한 값이다.
```
build-tech-log-tree.py exit=0
```
문서는 「관문은 종료 코드가 0 이면 지난 것이다」 라고 적었다.
자세한 것은 https://example.test/doc 에 있다.
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 중 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
이 값은 로컬에서 잰 것이라 운영에서 같은지는 확인하지 못했다.
```
build-tech-log-tree.py exit=1
```
문서는 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적었다.
자세한 것은 https://example.test/docs 에 있다.
+46
View File
@@ -0,0 +1,46 @@
---
kind: CASE
slug: fixture-case
title: 고정 사례 케이스
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
source:
- final/document.md#s1
evidence:
- ../../../final/evidence/raw/x.txt
---
# 고정 사례 케이스
## 관계
- **고정 사례 개념**
이 사건을 읽으려면 그 개념이 먼저 필요하다.
## 문제
관측한 현상을 적는다. 범위도 함께 적는다.
## 결론
## 검증 환경
python 3.12.3 · 리비전 0000000
## 재현 조건
1. 이 순서로 돌린다.
2. 값이 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 무엇이 있었나
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
<!-- body:end -->
@@ -0,0 +1,28 @@
---
kind: CONCEPT
slug: fixture-concept
title: 고정 사례 개념
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 개념
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건이 이 개념 위에서 벌어진다.
## 본문
<!-- body:start -->
## 정의
적용 범위까지 함께 적는다.
<!-- body:end -->
@@ -0,0 +1,26 @@
---
kind: PROJECT_DECISION
slug: fixture-decision
title: 고정 사례 결정
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
decisionStatus: PROPOSED
---
# 고정 사례 결정
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
## 근거
기록에 있는 근거만 적는다.
## 결정문
실제로 고른 것을 적는다.
## 영향
감수한 비용과 재검토 조건을 적는다.
@@ -0,0 +1,30 @@
---
kind: QUESTION
slug: fixture-question
title: 고정 사례 물음
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
questionStatus: OPEN
---
# 고정 사례 물음
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
## 관계
- **고정 사례 케이스**
그 사건이 이 물음을 열었다.
## 사실
- 확인된 사실을 적는다.
## 미지수
- 아직 모르는 것을 적는다.
## 다음 검증
@@ -0,0 +1,31 @@
---
kind: REFERENCE
slug: fixture-reference
title: 고정 사례 참조
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 참조
무엇을 참고하는 기준인지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건에서 이 기준이 쓰였다.
## 목적
이 기준을 쓰는 이유를 적는다.
## 규칙
1. 판단 기준을 적는다
근거와 함께 적는다.
## 예외
적용되지 않는 조건을 적는다.
+49
View File
@@ -0,0 +1,49 @@
---
kind: CASE
slug: fixture-case
title: 고정 사례 케이스
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
source:
- final/document.md#s1
evidence:
- ../../../final/evidence/raw/x.txt
---
# 고정 사례 케이스
한 문장으로 무엇이 있었는지 적는다. 이 문단이 Studio 의 요약 칸이 된다.
## 관계
- **고정 사례 개념**
이 사건을 읽으려면 그 개념이 먼저 필요하다.
## 문제
관측한 현상을 적는다. 범위도 함께 적는다.
## 결론
근거가 뒷받침하는 만큼만 적는다.
## 검증 환경
python 3.12.3 · 리비전 0000000
## 재현 조건
1. 이 순서로 돌린다.
2. 값이 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 무엇이 있었나
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
<!-- body:end -->
+29
View File
@@ -0,0 +1,29 @@
---
kind: CONCEPT
slug: fixture-concept
title: 고정 사례 개념
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
basisVersion: 예시 명세 1.0
---
# 고정 사례 개념
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건이 이 개념 위에서 벌어진다.
## 본문
<!-- body:start -->
## 정의
적용 범위까지 함께 적는다.
<!-- body:end -->
+30
View File
@@ -0,0 +1,30 @@
---
kind: PROJECT_DECISION
slug: fixture-decision
title: 고정 사례 결정
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
decisionStatus: PROPOSED
---
# 고정 사례 결정
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
## 근거
기록에 있는 근거만 적는다.
## 결정문
실제로 고른 것을 적는다.
## 판단 이유
확인된 대안과 그것을 고르지 않은 이유를 적는다.
## 영향
감수한 비용과 재검토 조건을 적는다.
+33
View File
@@ -0,0 +1,33 @@
---
kind: QUESTION
slug: fixture-question
title: 고정 사례 물음
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
questionStatus: OPEN
---
# 고정 사례 물음
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
## 관계
- **고정 사례 케이스**
그 사건이 이 물음을 열었다.
## 사실
- 확인된 사실을 적는다.
## 미지수
- 아직 모르는 것을 적는다.
## 다음 검증
1. 답을 구할 방법을 적는다.
닫는 조건 : 어떤 결과가 나오면 닫는지 적는다.
+35
View File
@@ -0,0 +1,35 @@
---
kind: REFERENCE
slug: fixture-reference
title: 고정 사례 참조
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 참조
무엇을 참고하는 기준인지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건에서 이 기준이 쓰였다.
## 목적
이 기준을 쓰는 이유를 적는다.
## 규칙
1. 판단 기준을 적는다
근거와 함께 적는다.
## 적용 조건
어느 버전·어느 전제에서 쓰는지 적는다.
## 예외
적용되지 않는 조건을 적는다.
+78
View File
@@ -0,0 +1,78 @@
"""윤문 전후에 보호 구간이 그대로인지 보는 검사기.
정상 윤문은 통과하고, 수치·코드·인용·URL 을 건드린 편집은 걸린다.
유보 표현이 줄어든 것은 걸러 내되 판정하지 않는다 — 판단은 근거를 읽는 검토가 한다.
"""
import importlib.util
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
F = os.path.join(ROOT, "scripts", "tests", "fixtures", "preservation")
_spec = importlib.util.spec_from_file_location(
"check_preservation", os.path.join(ROOT, "scripts", "check-preservation.py"))
cp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cp)
def _read(name):
return open(os.path.join(F, name), encoding="utf-8").read()
class PreservationTest(unittest.TestCase):
def setUp(self):
self.before = _read("before.md")
def test_an_ordinary_rewrite_passes(self):
res = cp.compare(self.before, _read("after-ok.md"))
self.assertEqual([], res["findings"])
self.assertEqual([], res["hedgesDropped"])
def test_a_number_changed_in_prose_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
values = {f["value"] for f in res["findings"] if f["kind"] == "수치"}
self.assertIn("14ms", values)
self.assertIn("4ms", values)
def test_a_number_changed_inside_a_code_block_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
blocks = {f["value"] for f in res["findings"] if f["kind"] == "코드블록"}
self.assertTrue(any("exit=1" in b for b in blocks))
self.assertTrue(any("exit=0" in b for b in blocks))
def test_a_changed_direct_quotation_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("직접인용", kinds)
def test_a_changed_url_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("URL", kinds)
def test_dropped_hedges_are_surfaced_without_a_verdict(self):
"""유보가 줄면 낸다. 옳은지 그른지는 말하지 않는다."""
res = cp.compare(self.before, _read("after-tampered.md"))
dropped = {h["word"] for h in res["hedgesDropped"]}
self.assertIn("확인하지 못했다", dropped)
self.assertIn("로컬", dropped)
self.assertEqual(0, res["hedgeTotalAfter"])
def test_adding_a_hedge_is_not_reported(self):
"""유보를 더하는 것은 이 규범에서 안전한 쪽이다."""
after = self.before.replace("14ms 였다", "14ms 였다. 다만 한 번만 쟀다")
res = cp.compare(self.before, after)
self.assertEqual([], res["hedgesDropped"])
def test_a_missing_file_is_not_reported_as_clean(self):
import subprocess
p = subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-preservation.py"),
os.path.join(F, "before.md"), os.path.join(F, "nope.md")],
cwd=ROOT, capture_output=True, text=True)
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
고정 사례는 다섯 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
"""
import importlib.util
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FIXTURES = os.path.join(ROOT, "scripts", "tests", "fixtures", "required-content")
_spec = importlib.util.spec_from_file_location(
"check_required_content", os.path.join(ROOT, "scripts", "check-required-content.py"))
crc = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(crc)
KINDS = ("case", "concept", "reference", "question", "decision")
def _report(sub, name):
rep = crc.techlog.Report("fixture")
crc.check_record(os.path.join(FIXTURES, sub, f"{name}.md"), rep)
return rep
class RequiredContentTest(unittest.TestCase):
def test_every_kind_passes_when_filled(self):
for kind in KINDS:
with self.subTest(kind=kind):
rep = _report("ok", kind)
self.assertEqual(0, rep.error_count,
f"{kind}: {dict(rep.errors)}")
def test_every_kind_fails_when_a_required_part_is_missing(self):
for kind in KINDS:
with self.subTest(kind=kind):
rep = _report("missing", kind)
self.assertGreater(rep.error_count, 0,
f"{kind} 의 누락 사례가 통과했다")
def test_the_missing_part_is_named(self):
"""무엇이 빠졌는지 말한다. 「어딘가 잘못됐다」로 끝나지 않는다."""
expected = {
"case": "결론",
"concept": "basisVersion",
"reference": "적용 조건",
"question": "다음 검증",
"decision": "판단 이유",
}
for kind, part in expected.items():
with self.subTest(kind=kind):
rules = " / ".join(_report("missing", kind).errors)
self.assertIn(part, rules)
def test_decision_kind_is_project_decision_in_frontmatter(self):
"""decision/ 폴더의 기록은 kind: PROJECT_DECISION 이다 (templates/decision.md)."""
self.assertEqual("decision", crc.KIND_ALIASES["PROJECT_DECISION"])
rep = _report("ok", "decision")
self.assertNotIn("kind 를 모르겠다", rep.errors)
def test_an_unanswered_question_is_not_an_error(self):
"""답이 없는 QUESTION 자체는 결함이 아니다. 답을 구할 방법이 없는 것이 결함이다."""
rep = _report("ok", "question")
self.assertEqual(0, rep.error_count)
def test_body_markers_belong_only_to_case_and_concept(self):
self.assertEqual({"case", "concept"}, crc.BODY_KINDS)
def _cli(self, *args):
import subprocess
return subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-required-content.py"), *args],
cwd=ROOT, capture_output=True, text=True)
def test_a_missing_project_is_not_reported_as_clean(self):
"""대상이 없으면 통과가 아니다. 오타 하나로 관문이 무효가 되면 안 된다."""
p = self._cli("nonexistent-project")
self.assertEqual(2, p.returncode)
self.assertIn("대상이 성립하지 않는다", p.stderr)
def test_a_project_without_a_contract_does_not_come_back_green(self):
"""계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다."""
p = self._cli("ca-tmpl")
self.assertEqual(2, p.returncode)
self.assertIn("tech-log-tree.json 이 없다", p.stderr)
def test_a_contract_with_no_records_yet_is_not_an_error(self):
"""아직 안 쓴 것은 결함이 아니다. 다만 초록으로 보이면 안 된다."""
p = self._cli("keycloak-session-store")
self.assertEqual(0, p.returncode)
self.assertIn("아직 안 쓰였다", p.stdout)
def test_one_ungrounded_target_stops_the_whole_run(self):
"""성립하는 것과 안 하는 것을 함께 주면 통과로 뭉개지 않는다.
성립하는 쪽으로 `keycloak` 을 쓴다. `verify-pipeline.py` 의 계약이 scripts/ 안에
저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은
프로젝트를 테스트에 적으면 계약 검사가 깨진다.
"""
p = self._cli("keycloak", "ca-tmpl")
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()