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