Merge branch 'harness/B-implementation' into harness/A-integration
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
# 측정 결과
|
||||
|
||||
리비전 `43e1aad` 에서 열넷 가운데 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
|
||||
이 값은 로컬에서 잰 것이고 운영에서 같은지는 확인하지 못했다.
|
||||
|
||||
```
|
||||
build-tech-log-tree.py exit=1
|
||||
```
|
||||
|
||||
문서가 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적어 두었다.
|
||||
자세한 것은 https://example.test/docs 에 있다.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 측정 결과
|
||||
|
||||
리비전 `43e1aad` 에서 열넷 중 셋이 `exit 1` 이었다. 응답 시간은 4ms 였다.
|
||||
이 값은 운영에서 확인한 값이다.
|
||||
|
||||
```
|
||||
build-tech-log-tree.py exit=0
|
||||
```
|
||||
|
||||
문서는 「관문은 종료 코드가 0 이면 지난 것이다」 라고 적었다.
|
||||
자세한 것은 https://example.test/doc 에 있다.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# 측정 결과
|
||||
|
||||
리비전 `43e1aad` 에서 열넷 중 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
|
||||
이 값은 로컬에서 잰 것이라 운영에서 같은지는 확인하지 못했다.
|
||||
|
||||
```
|
||||
build-tech-log-tree.py exit=1
|
||||
```
|
||||
|
||||
문서는 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적었다.
|
||||
자세한 것은 https://example.test/docs 에 있다.
|
||||
@@ -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. 판단 기준을 적는다
|
||||
근거와 함께 적는다.
|
||||
|
||||
## 예외
|
||||
|
||||
적용되지 않는 조건을 적는다.
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: fixture-decision
|
||||
title: 고정 사례 결정
|
||||
topic: fixture-topic
|
||||
project: fixture
|
||||
status: 게시 전
|
||||
sourceRevision: 0000000000000000000000000000000000000000
|
||||
decisionStatus: PROPOSED
|
||||
---
|
||||
|
||||
# 고정 사례 결정
|
||||
|
||||
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
|
||||
|
||||
## 근거
|
||||
|
||||
기록에 있는 근거만 적는다.
|
||||
|
||||
## 결정문
|
||||
|
||||
실제로 고른 것을 적는다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
확인된 대안과 그것을 고르지 않은 이유를 적는다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수한 비용과 재검토 조건을 적는다.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: fixture-question
|
||||
title: 고정 사례 물음
|
||||
topic: fixture-topic
|
||||
project: fixture
|
||||
status: 게시 전
|
||||
sourceRevision: 0000000000000000000000000000000000000000
|
||||
questionStatus: OPEN
|
||||
---
|
||||
|
||||
# 고정 사례 물음
|
||||
|
||||
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **고정 사례 케이스**
|
||||
그 사건이 이 물음을 열었다.
|
||||
|
||||
## 사실
|
||||
|
||||
- 확인된 사실을 적는다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 아직 모르는 것을 적는다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 답을 구할 방법을 적는다.
|
||||
|
||||
닫는 조건 : 어떤 결과가 나오면 닫는지 적는다.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: fixture-reference
|
||||
title: 고정 사례 참조
|
||||
topic: fixture-topic
|
||||
project: fixture
|
||||
status: 게시 전
|
||||
sourceRevision: 0000000000000000000000000000000000000000
|
||||
---
|
||||
|
||||
# 고정 사례 참조
|
||||
|
||||
무엇을 참고하는 기준인지 한 문단으로 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **고정 사례 케이스**
|
||||
그 사건에서 이 기준이 쓰였다.
|
||||
|
||||
## 목적
|
||||
|
||||
이 기준을 쓰는 이유를 적는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 판단 기준을 적는다
|
||||
근거와 함께 적는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
어느 버전·어느 전제에서 쓰는지 적는다.
|
||||
|
||||
## 예외
|
||||
|
||||
적용되지 않는 조건을 적는다.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user