diff --git a/scripts/check-preservation.py b/scripts/check-preservation.py new file mode 100644 index 0000000..b4bd39f --- /dev/null +++ b/scripts/check-preservation.py @@ -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 +""" +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"(? 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()) diff --git a/scripts/check-required-content.py b/scripts/check-required-content.py new file mode 100644 index 0000000..3e96c2c --- /dev/null +++ b/scripts/check-required-content.py @@ -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 = "", "" + + +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("", "" + + +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(("#", "|", " + +## 무엇이 있었나 + +본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다. + + diff --git a/scripts/tests/fixtures/required-content/missing/concept.md b/scripts/tests/fixtures/required-content/missing/concept.md new file mode 100644 index 0000000..ea3e3b2 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/concept.md @@ -0,0 +1,28 @@ +--- +kind: CONCEPT +slug: fixture-concept +title: 고정 사례 개념 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +--- + +# 고정 사례 개념 + +개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 개념 위에서 벌어진다. + +## 본문 + + + +## 정의 + +적용 범위까지 함께 적는다. + + diff --git a/scripts/tests/fixtures/required-content/missing/decision.md b/scripts/tests/fixtures/required-content/missing/decision.md new file mode 100644 index 0000000..caa2311 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/decision.md @@ -0,0 +1,26 @@ +--- +kind: PROJECT_DECISION +slug: fixture-decision +title: 고정 사례 결정 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +decisionStatus: PROPOSED +--- + +# 고정 사례 결정 + +무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다. + +## 근거 + +기록에 있는 근거만 적는다. + +## 결정문 + +실제로 고른 것을 적는다. + +## 영향 + +감수한 비용과 재검토 조건을 적는다. diff --git a/scripts/tests/fixtures/required-content/missing/question.md b/scripts/tests/fixtures/required-content/missing/question.md new file mode 100644 index 0000000..a7a1d57 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/question.md @@ -0,0 +1,30 @@ +--- +kind: QUESTION +slug: fixture-question +title: 고정 사례 물음 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +questionStatus: OPEN +--- + +# 고정 사례 물음 + +무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 물음을 열었다. + +## 사실 + +- 확인된 사실을 적는다. + +## 미지수 + +- 아직 모르는 것을 적는다. + +## 다음 검증 + diff --git a/scripts/tests/fixtures/required-content/missing/reference.md b/scripts/tests/fixtures/required-content/missing/reference.md new file mode 100644 index 0000000..dc37d26 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/reference.md @@ -0,0 +1,31 @@ +--- +kind: REFERENCE +slug: fixture-reference +title: 고정 사례 참조 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +--- + +# 고정 사례 참조 + +무엇을 참고하는 기준인지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건에서 이 기준이 쓰였다. + +## 목적 + +이 기준을 쓰는 이유를 적는다. + +## 규칙 + +1. 판단 기준을 적는다 + 근거와 함께 적는다. + +## 예외 + +적용되지 않는 조건을 적는다. diff --git a/scripts/tests/fixtures/required-content/ok/case.md b/scripts/tests/fixtures/required-content/ok/case.md new file mode 100644 index 0000000..cbfa4f2 --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/case.md @@ -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. 값이 갈리는 것을 본다. + +## 본문 + + + +## 무엇이 있었나 + +본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다. + + diff --git a/scripts/tests/fixtures/required-content/ok/concept.md b/scripts/tests/fixtures/required-content/ok/concept.md new file mode 100644 index 0000000..10338a0 --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/concept.md @@ -0,0 +1,29 @@ +--- +kind: CONCEPT +slug: fixture-concept +title: 고정 사례 개념 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +basisVersion: 예시 명세 1.0 +--- + +# 고정 사례 개념 + +개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 개념 위에서 벌어진다. + +## 본문 + + + +## 정의 + +적용 범위까지 함께 적는다. + + diff --git a/scripts/tests/fixtures/required-content/ok/decision.md b/scripts/tests/fixtures/required-content/ok/decision.md new file mode 100644 index 0000000..0296eb2 --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/decision.md @@ -0,0 +1,30 @@ +--- +kind: PROJECT_DECISION +slug: fixture-decision +title: 고정 사례 결정 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +decisionStatus: PROPOSED +--- + +# 고정 사례 결정 + +무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다. + +## 근거 + +기록에 있는 근거만 적는다. + +## 결정문 + +실제로 고른 것을 적는다. + +## 판단 이유 + +확인된 대안과 그것을 고르지 않은 이유를 적는다. + +## 영향 + +감수한 비용과 재검토 조건을 적는다. diff --git a/scripts/tests/fixtures/required-content/ok/question.md b/scripts/tests/fixtures/required-content/ok/question.md new file mode 100644 index 0000000..82a3f3e --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/question.md @@ -0,0 +1,33 @@ +--- +kind: QUESTION +slug: fixture-question +title: 고정 사례 물음 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +questionStatus: OPEN +--- + +# 고정 사례 물음 + +무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 물음을 열었다. + +## 사실 + +- 확인된 사실을 적는다. + +## 미지수 + +- 아직 모르는 것을 적는다. + +## 다음 검증 + +1. 답을 구할 방법을 적는다. + +닫는 조건 : 어떤 결과가 나오면 닫는지 적는다. diff --git a/scripts/tests/fixtures/required-content/ok/reference.md b/scripts/tests/fixtures/required-content/ok/reference.md new file mode 100644 index 0000000..e7112dd --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/reference.md @@ -0,0 +1,35 @@ +--- +kind: REFERENCE +slug: fixture-reference +title: 고정 사례 참조 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +--- + +# 고정 사례 참조 + +무엇을 참고하는 기준인지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건에서 이 기준이 쓰였다. + +## 목적 + +이 기준을 쓰는 이유를 적는다. + +## 규칙 + +1. 판단 기준을 적는다 + 근거와 함께 적는다. + +## 적용 조건 + +어느 버전·어느 전제에서 쓰는지 적는다. + +## 예외 + +적용되지 않는 조건을 적는다. diff --git a/scripts/tests/test_preservation.py b/scripts/tests/test_preservation.py new file mode 100644 index 0000000..cff5572 --- /dev/null +++ b/scripts/tests/test_preservation.py @@ -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() diff --git a/scripts/tests/test_required_content.py b/scripts/tests/test_required_content.py new file mode 100644 index 0000000..d173ef4 --- /dev/null +++ b/scripts/tests/test_required_content.py @@ -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()