feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""그림 안에서 상자와 라벨이 서로를 덮는지 본다.
`technical-visualizer` 스킬이 적어 둔 사각지대를 기계로 옮긴 것이다.
**lint 도 검사기도 라벨이 상자를 덮는 것은 못 잡는다.**
lint 는 의미(관계가 이어져 있는가)를 보고 `check-figure-text.py` 는 글자가 이름인가를 본다.
둘 다 **좌표**를 안 본다. 그래서 zone 둘이 겹쳐 그려지거나 라벨이 상자에 먹혀도 통과한다.
실제로 keycloak AP4 그림을 다시 만들었을 때 Compose zone 이 Host zone 을 삼키고
`Host network` 라벨이 `Compose network` 라벨 뒤에 숨었는데 lint 는 PASS 였다.
python3 scripts/check-figure-overlap.py [프로젝트 ...]
python3 scripts/check-figure-overlap.py --file 그림.svg
renderer 가 배경 사각형을 정확한 좌표로 남기므로 글자 폭을 어림하지 않는다 —
`edge-label-bg` 와 `group-label-bg` 가 그 라벨이 실제로 차지하는 자리다.
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RECT = re.compile(r"<rect\b([^>]*)>")
ATTR = re.compile(r'(\w[\w-]*)="([^"]*)"')
TEXT = re.compile(r'<text\b([^>]*)>([^<]*)</text>')
# 겹치면 안 되는 짝. (왼쪽 종류, 오른쪽 종류, 무엇이 잘못됐나)
# group-label-bg 가 group-box 위에 앉는 것과 node-shape 가 group-box 안에 드는 것은 설계다.
PAIRS = (
("group-box", "group-box", "구역 둘이 겹쳐 그려졌다"),
("group-label-bg", "group-label-bg", "구역 이름이 서로를 덮는다"),
("node-shape", "node-shape", "상자 둘이 겹쳐 그려졌다"),
("edge-label-bg", "node-shape", "이음 라벨이 상자에 먹혔다"),
("edge-label-bg", "edge-label-bg", "이음 라벨끼리 겹친다"),
)
# 겹침으로 세려면 가로·세로가 둘 다 이만큼은 넘어야 한다.
# 넓이만 보면 상자가 클수록 예민해진다 — 0.5px 이 50px 짜리 변을 만나면 25px² 이다
EPS = 1.0
def _kind(cls: str) -> str:
if "group-box" in cls:
return "group-box"
if "group-label-bg" in cls:
return "group-label-bg"
if "edge-label-bg" in cls:
return "edge-label-bg"
if "node-shape" in cls:
return "node-shape"
return ""
def boxes(svg: str) -> list[tuple[str, float, float, float, float]]:
"""(종류, x, y, w, h). 좌표가 없는 것은 뺀다 — canvas 가 그렇다."""
out = []
for attrs in RECT.findall(svg):
a = dict(ATTR.findall(attrs))
kind = _kind(a.get("class", ""))
if not kind:
continue
try:
x, y = float(a["x"]), float(a["y"])
w, h = float(a["width"]), float(a["height"])
except (KeyError, ValueError):
continue
out.append((kind, x, y, w, h))
return out
def labels(svg: str) -> list[tuple[float, float, str]]:
"""(x, y, 글자). 겹친 자리에 무엇이 있었는지 말해 주려고 쓴다."""
out = []
for attrs, body in TEXT.findall(svg):
a = dict(ATTR.findall(attrs))
try:
out.append((float(a["x"]), float(a["y"]), body.strip()))
except (KeyError, ValueError):
continue
return out
def _overlap(a, b) -> float:
"""겹친 넓이. 맞닿기만 한 것과 한쪽으로만 얇게 스친 것은 0 이다."""
_, ax, ay, aw, ah = a
_, bx, by, bw, bh = b
dx = min(ax + aw, bx + bw) - max(ax, bx)
dy = min(ay + ah, by + bh) - max(ay, by)
if dx <= EPS or dy <= EPS:
return 0.0
return dx * dy
def _contains(a, b) -> bool:
"""a 가 b 를 통째로 품는가. 구역 안의 구역은 설계이지 겹침이 아니다."""
_, ax, ay, aw, ah = a
_, bx, by, bw, bh = b
return ax <= bx and ay <= by and ax + aw >= bx + bw and ay + ah >= by + bh
def _near(lbls, box, limit=2) -> str:
"""상자 안에 든 글자. 어느 라벨이 문제인지 이름으로 말해 준다."""
_, x, y, w, h = box
hit = [t for lx, ly, t in lbls
if t and x - 2 <= lx <= x + w + 2 and y - 14 <= ly <= y + h + 2]
return " · ".join(hit[:limit])
def check(path: str) -> list[str]:
try:
svg = open(path, encoding="utf-8").read()
except OSError as exc:
return [f"읽지 못했다 — {exc}"]
bs = boxes(svg)
lbls = labels(svg)
found: list[str] = []
for i, a in enumerate(bs):
for b in bs[i + 1:]:
for left, right, why in PAIRS:
if {a[0], b[0]} != {left, right} if left != right else not (
a[0] == left and b[0] == left):
continue
if left == right == "group-box" and (_contains(a, b) or _contains(b, a)):
continue
area = _overlap(a, b)
if area <= 0:
continue
names = " / ".join(n for n in (_near(lbls, a), _near(lbls, b)) if n)
found.append(f"{why}{int(area)}px²{' ' + names if names else ''}")
break
return found
def main() -> int:
ap = argparse.ArgumentParser(description="그림 안에서 상자와 라벨이 겹치는지 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--file", action="append", default=[], help="SVG 를 직접 준다")
ap.add_argument("--samples", type=int, default=4)
args = ap.parse_args()
files = list(args.file)
if not files:
names = args.projects or sorted(
os.path.basename(os.path.dirname(os.path.dirname(p)))
for p in glob.glob(os.path.join(ROOT, "docs/*/final/document.md")))
for name in names:
files += sorted(glob.glob(
os.path.join(ROOT, "docs", name, "final/assets/**/*.svg"), recursive=True))
if not files:
print("볼 그림이 없다", file=sys.stderr)
return 0
bad = 0
for path in files:
hits = check(path)
if not hits:
continue
bad += 1
rel = os.path.relpath(path, ROOT)
print(f"{rel}")
for line in hits[:args.samples]:
print(f" · {line}")
if len(hits) > args.samples:
print(f" … 외 {len(hits) - args.samples}")
print(f"FIGURE OVERLAP: {'FAIL' if bad else 'PASS'} — 그림 {len(files)} · 겹친 그림 {bad}")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""그림 안 <text> 가 전부 이름인지 본다.
`code-tables-diagrams.md` 의 자가 점검을 기계로 옮긴 것이다. 그림은 관계를 보이고
문장은 `<desc>` 와 옆 문단에 둔다. 길이가 아니라 **서술하느냐** 가 기준이다.
python3 scripts/check-figure-text.py [프로젝트 ...]
python3 scripts/check-figure-text.py --file 그림.svg
`<title>` 과 `<desc>` 는 화면을 못 보는 사람이 듣는 자리라 검사하지 않는다.
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEXT = re.compile(r"<text[^>]*>([^<]*)</text>")
# 서술어로 끝난다 — `~있다`, `~막는다`, `~바꾼다`
PREDICATE = re.compile(
r"(?:이다|아니다|있다|없다|한다|된다|본다|같다|다르다|늘어난다|줄어든다"
r"|막는다|바꾼다|만든다|넣는다|뺀다|남는다|생긴다|끝난다)$")
# 조사로 두 대상을 잇는다 — `A를 B로`, `A에서 B까지`
# 관형형 어미(`깊은 페이지`)와 겹치는 는·은·이·가 는 넣지 않는다. 그쪽은 서술어 규칙이 잡는다
JOINING = re.compile(
r"[가-힣A-Za-z0-9_)\]]+(?:을|를|에서|에게|에는|으로|로서|로써|으로써|마다|부터|까지|보다|처럼)\s+\S")
# 문장 부호로 끝난다. `…` 과 축약형 `(?,…)` 은 뺀다
SENTENCE_END = re.compile(r"[.?!]$")
def offences(label: str) -> list[str]:
t = label.strip()
if not t:
return []
found = []
if SENTENCE_END.search(t) and not t.endswith(("", "..")):
found.append("문장 부호로 끝난다")
if PREDICATE.search(t):
found.append("서술어가 있다")
if JOINING.search(t):
found.append("조사로 두 대상을 잇는다")
return found
def check(path: str) -> list[tuple[str, list[str]]]:
body = open(path, encoding="utf-8").read()
out = []
for label in TEXT.findall(body):
why = offences(label)
if why:
out.append((label.strip(), why))
return out
def svgs_of(project: str) -> list[str]:
assets = os.path.join(ROOT, "docs", project, "final", "assets")
return sorted(glob.glob(os.path.join(assets, "**", "*.svg"), recursive=True))
def main() -> int:
ap = argparse.ArgumentParser(description="그림 안 <text> 가 전부 이름인지 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--file", action="append", default=[],
help="프로젝트 대신 SVG 파일을 직접 준다")
args = ap.parse_args()
targets = list(args.file)
if not targets:
projects = args.projects or sorted(
d for d in os.listdir(os.path.join(ROOT, "docs"))
if not d.startswith("_")
and os.path.isdir(os.path.join(ROOT, "docs", d, "final")))
for project in projects:
targets.extend(svgs_of(project))
total = 0
files = 0
for path in targets:
hits = check(path)
if not hits:
continue
files += 1
total += len(hits)
print(f" {os.path.relpath(path, ROOT)}")
for label, why in hits:
print(f" {label}{' · '.join(why)}")
checked = len(targets)
if total:
print(f"\nFIGURE TEXT: FAIL — 그림 {checked}장 · 문장 {total}건 / 그림 {files}")
print("그림 안 <text> 는 이름만 담는다. 문장은 <desc> 와 옆 문단으로 내린다.")
return 1
print(f"FIGURE TEXT: PASS — 그림 {checked}장 · 문장 0건")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""그림을 PNG 로 떠서 눈으로 볼 수 있게 만든다.
lint 는 엣지가 노드를 지나가는 것은 잡지만 **라벨이 상자를 덮는 것**은 못 잡는다.
라벨 폭을 재지 않고 앵커 점만 보기 때문이다. 그래서 컴파일한 뒤 한 번은 봐야 한다.
python3 scripts/preview-figure.py <프로젝트> # 그 프로젝트의 그림 전부
python3 scripts/preview-figure.py --file 그림.svg
python3 scripts/preview-figure.py <프로젝트> -o /tmp/png
Chrome 을 headless 로 띄워 SVG 를 그대로 찍는다. 경로가 다르면 CHROME 으로 알려 준다.
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import shutil
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SIZE = re.compile(r'width="(\d+)"\s+height="(\d+)"')
def chrome() -> str:
found = os.environ.get("CHROME") or shutil.which("google-chrome") \
or shutil.which("chromium") or shutil.which("chromium-browser")
if not found:
sys.exit("Chrome 을 찾지 못했다. CHROME 으로 경로를 알려 준다.")
return found
def shoot(svg: str, out_dir: str, binary: str) -> str:
body = open(svg, encoding="utf-8").read()
m = SIZE.search(body)
w, h = (int(m.group(1)), int(m.group(2))) if m else (1200, 800)
name = os.path.basename(svg)[:-4]
png = os.path.join(out_dir, f"{name}.png")
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False,
encoding="utf-8") as fh:
fh.write("<style>html,body{margin:0;padding:0;background:#fff}"
"img{display:block}</style>\n"
f'<img src="file://{os.path.abspath(svg)}" width="{w}" height="{h}">')
wrapper = fh.name
try:
subprocess.run([binary, "--headless", "--disable-gpu", "--no-sandbox",
"--hide-scrollbars", f"--screenshot={png}",
f"--window-size={w + 20},{h + 20}", f"file://{wrapper}"],
check=True, capture_output=True)
finally:
os.unlink(wrapper)
return png
def main() -> int:
ap = argparse.ArgumentParser(description="그림을 PNG 로 떠서 눈으로 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--file", action="append", default=[])
ap.add_argument("-o", "--out", default=None, help="PNG 를 둘 곳 (기본: 임시 폴더)")
args = ap.parse_args()
targets = list(args.file)
for project in args.projects:
targets.extend(sorted(glob.glob(
os.path.join(ROOT, "docs", project, "final", "assets", "**", "*.svg"),
recursive=True)))
if not targets:
sys.exit("볼 그림을 주지 않았다.")
out_dir = args.out or tempfile.mkdtemp(prefix="figure-preview-")
os.makedirs(out_dir, exist_ok=True)
binary = chrome()
for svg in targets:
print(shoot(svg, out_dir, binary))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""기록의 본문을 Studio 가 받는 형태로 바꿔 낸다.
저장소의 `.md` 는 사람이 읽는 파일이라 그림을 마크다운 이미지로 적는다. 그래야 편집기에서
그대로 보인다. Studio 는 그 자리에 `:::evidence key=""` 를 요구하고 상대 경로 이미지는
`unsafe image URL` 로 거절한다. 두 형태를 한 파일에 담을 수 없으므로 **저장소는 읽는 형태로
두고 Studio 로 보낼 때 이 스크립트가 바꾼다.**
python3 scripts/studio-body.py <기록.md> # 바꾼 전문을 표준출력으로
python3 scripts/studio-body.py <기록.md> -o /tmp/x.md # 파일로 (check_body 용)
python3 scripts/studio-body.py <기록.md> --body-only # 본문 구간만 (Studio 붙여넣기용)
python3 scripts/studio-body.py <기록.md> --key a=a-1234 # 서버가 준 키로 바꿔서
`--key` 를 주지 않으면 frontmatter 의 `assets` key 를 그대로 쓴다. Studio 에 올린 뒤에는
서버가 `<이름>-<해시8>` 을 주므로 그때 `--key` 로 짝지어 준다.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
BODY = re.compile(r"(<!-- body:start -->)(.*?)(<!-- body:end -->)", re.S)
IMAGE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)$", re.M)
def asset_keys(text: str) -> dict[str, str]:
"""frontmatter 의 assets 를 상대경로 → key 로 읽는다."""
head = text.split("\n---\n", 1)[0]
keys = re.findall(r"^ - key: (\S+)$", head, re.M)
files = re.findall(r"^ file: (\S+)$", head, re.M)
return {f: k for k, f in zip(keys, files)}
# 본문이 있는 종류는 둘뿐이다. 나머지 셋은 본문 구간이 없는 것이 정상이다
BODY_KINDS = {"CASE", "CONCEPT"}
def record_kind(text: str) -> str:
"""frontmatter 의 kind. 없으면 빈 문자열."""
head = text.split("\n---\n", 1)[0]
m = re.search(r"^kind:\s*(\S+)", head, re.M)
return m.group(1).upper() if m else ""
def convert(text: str, overrides: dict[str, str]) -> tuple[str, list[str]]:
by_path = asset_keys(text)
missing: list[str] = []
def one(m: re.Match) -> str:
alt, path = m.group(1), m.group(2)
key = by_path.get(path)
if key is None:
missing.append(path)
return m.group(0)
key = overrides.get(key, key)
return f':::evidence key="{key}" alt="{alt}" caption=" " zoom="true"\n:::'
m = BODY.search(text)
if not m:
if record_kind(text) in BODY_KINDS:
return text, ["본문 구간(body:start ~ body:end)이 없다"]
# Reference·Question·Decision 은 본문을 둘 자리가 없다. 결함이 아니다
return text, []
body = IMAGE.sub(one, m.group(2))
return text[:m.start(2)] + body + text[m.end(2):], missing
def main() -> int:
ap = argparse.ArgumentParser(description="기록 본문을 Studio 형태로 바꾼다.")
ap.add_argument("record")
ap.add_argument("-o", "--out")
ap.add_argument("--body-only", action="store_true")
ap.add_argument("--key", action="append", default=[],
help="frontmatter key=서버가 준 key. 여러 번 줄 수 있다")
args = ap.parse_args()
overrides = {}
for pair in args.key:
if "=" not in pair:
sys.exit(f"--key 는 a=b 형태다: {pair}")
a, b = pair.split("=", 1)
overrides[a] = b
text = open(args.record, encoding="utf-8").read()
out, missing = convert(text, overrides)
for path in missing:
print(f"경고: frontmatter 의 assets 에 없다 — {path}", file=sys.stderr)
if args.body_only:
m = BODY.search(out)
out = m.group(2).strip("\n") if m else out
if args.out:
open(args.out, "w", encoding="utf-8").write(out)
print(args.out)
else:
sys.stdout.write(out)
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())
+91
View File
@@ -0,0 +1,91 @@
"""그림 겹침 검사기. 겹친 것을 실제로 잡고 안 겹친 것은 안 잡는지 본다."""
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(ROOT, "scripts", "check-figure-overlap.py")
HEAD = '<svg xmlns="http://www.w3.org/2000/svg"><rect class="canvas" width="400" height="300"/>'
TAIL = "</svg>"
def svg(*rects):
return HEAD + "".join(rects) + TAIL
def rect(cls, x, y, w, h):
return f'<rect class="{cls}" x="{x}" y="{y}" width="{w}" height="{h}"/>'
def run(body):
with tempfile.NamedTemporaryFile("w", suffix=".svg", delete=False,
encoding="utf-8") as fh:
fh.write(body)
path = fh.name
try:
p = subprocess.run([sys.executable, SCRIPT, "--file", path],
capture_output=True, text=True, cwd=ROOT)
return p.returncode, p.stdout + p.stderr
finally:
os.unlink(path)
class Overlap(unittest.TestCase):
def test_안_겹치면_통과한다(self):
code, out = run(svg(rect("group-box", 0, 0, 100, 100),
rect("group-box", 150, 0, 100, 100)))
self.assertEqual(code, 0, out)
self.assertIn("PASS", out)
def test_맞닿기만_한_것은_겹친_것이_아니다(self):
code, out = run(svg(rect("group-box", 0, 0, 100, 100),
rect("group-box", 100, 0, 100, 100)))
self.assertEqual(code, 0, out)
def test_구역_둘이_겹치면_잡는다(self):
code, out = run(svg(rect("group-box", 0, 0, 100, 100),
rect("group-box", 50, 50, 100, 100)))
self.assertEqual(code, 1)
self.assertIn("구역 둘이 겹쳐 그려졌다", out)
def test_이음_라벨이_상자에_먹히면_잡는다(self):
code, out = run(svg(rect("node-shape kind-service", 100, 100, 120, 60),
rect("edge-label-bg", 80, 110, 60, 20)))
self.assertEqual(code, 1)
self.assertIn("이음 라벨이 상자에 먹혔다", out)
def test_구역_이름이_서로를_덮으면_잡는다(self):
code, out = run(svg(rect("group-label-bg", 10, 10, 120, 22),
rect("group-label-bg", 20, 12, 120, 22)))
self.assertEqual(code, 1)
self.assertIn("구역 이름이 서로를 덮는다", out)
def test_상자_안에_든_상자는_설계다(self):
"""node-shape 가 group-box 안에 드는 것은 정상이라 잡지 않는다."""
code, out = run(svg(rect("group-box", 0, 0, 200, 200),
rect("node-shape kind-service", 20, 20, 80, 40)))
self.assertEqual(code, 0, out)
def test_겹친_자리의_이름을_말해_준다(self):
body = svg(rect("node-shape kind-service", 100, 100, 120, 60),
rect("edge-label-bg", 80, 110, 60, 20)) \
.replace(TAIL, '<text class="edge-label" x="82" y="124">토큰</text>' + TAIL)
code, out = run(body)
self.assertEqual(code, 1)
self.assertIn("토큰", out)
class Project(unittest.TestCase):
@unittest.skipUnless(os.path.isdir(os.path.join(ROOT, "docs/keycloak/final/assets")),
"keycloak 그림이 없다")
def test_프로젝트_단위로도_돈다(self):
p = subprocess.run([sys.executable, SCRIPT, "keycloak"],
capture_output=True, text=True, cwd=ROOT)
self.assertIn("FIGURE OVERLAP", p.stdout)
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""런 원장 검사기. 절차를 안 지킨 원장을 실제로 잡는지 본다.
이 검사기의 값은 「통과시키는 것」이 아니라 「안 지킨 것을 잡는 것」이라, 시험도 전부
위반을 넣어 걸리는지 보는 모양이다.
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(ROOT, "scripts", "verify-pipeline-run.py")
LEDGER = os.path.join(ROOT, "runs", "keycloak", "2026-09-07-2215", "run.json")
def run(path, *args):
p = subprocess.run([sys.executable, SCRIPT, path, *args],
capture_output=True, text=True, cwd=ROOT)
return p.returncode, p.stdout + p.stderr
@unittest.skipUnless(os.path.exists(LEDGER), "실증 런 원장이 없다")
class LedgerRules(unittest.TestCase):
"""실제로 돈 원장을 밑감으로 삼아 한 군데씩 어긴다."""
def setUp(self):
self.base = json.load(open(LEDGER, encoding="utf-8"))
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def write(self, mutate):
run_ = json.loads(json.dumps(self.base))
mutate({s["id"]: s for s in run_["stages"]}, run_)
path = os.path.join(self.dir.name, "run.json")
json.dump(run_, open(path, "w", encoding="utf-8"), ensure_ascii=False)
return path
def test_실증한_원장은_통과한다(self):
code, out = run(LEDGER)
self.assertEqual(code, 0, out)
self.assertIn("PASS", out)
def test_영수증이_그_스킬의_문장이_아니면_잡는다(self):
path = self.write(lambda st, _: st["S5"].update(
skillEcho="이 문장은 그 스킬 어디에도 없다 정말로 없다 한 글자도"))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
def test_영수증이_비면_잡는다(self):
path = self.write(lambda st, _: st["S6"].update(skillEcho=""))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("스킬 영수증이 없다", out)
def test_관문이_실패하면_잡는다(self):
def m(st, _):
st["S3"]["gates"][0]["exit"] = 1
path = self.write(m)
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("관문이 통과하지 못했다", out)
def test_관문이_빠지면_잡는다(self):
path = self.write(lambda st, _: st["S6"].update(gates=[]))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("관문이 빠졌다", out)
def test_건너뛸_수_없는_단계를_건너뛰면_잡는다(self):
path = self.write(lambda st, _: st["S3"].update(
status="SKIPPED", skipReason="그냥"))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("건너뛸 수 없는 단계를 건너뛰었다", out)
def test_건너뛴_사유가_없으면_잡는다(self):
path = self.write(lambda st, _: st["S4"].update(skipReason=""))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("건너뛴 사유가 없다", out)
def test_없는_산출물을_적으면_잡는다(self):
path = self.write(lambda st, _: st["S1"].update(
outputs=["docs/keycloak/없는파일.md"]))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("적어 낸 산출물이 디스크에 없다", out)
def test_다른_스킬을_쓰면_잡는다(self):
path = self.write(lambda st, _: st["S7"].update(skill="technical-visualizer"))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("단계가 다른 스킬을 썼다", out)
def test_끝나지_않은_단계를_잡는다(self):
path = self.write(lambda st, _: st["S5"].update(status="RUNNING"))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("끝나지 않은 단계가 있다", out)
def test_단계가_통째로_빠지면_잡는다(self):
def m(_, run_):
run_["stages"] = [s for s in run_["stages"] if s["id"] != "S6"]
path = self.write(m)
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("단계가 원장에 없다", out)
def test_곁증명의_영수증도_대조한다(self):
"""건너뛴 단계라도 곁증명을 냈으면 같은 잣대로 본다."""
proof = os.path.join(ROOT, "runs/keycloak/2026-09-07-2215/stage/S4/stage-report.json")
if not os.path.exists(proof):
self.skipTest("곁증명이 없다")
data = json.load(open(proof, encoding="utf-8"))
data["skillEcho"] = "그 스킬에 없는 문장을 곁증명에 적었다 정말로 없다"
bad = os.path.join(self.dir.name, "stage-report.json")
json.dump(data, open(bad, "w", encoding="utf-8"), ensure_ascii=False)
rel = os.path.relpath(bad, ROOT)
path = self.write(lambda st, _: st["S4"].update(outputs=[rel]))
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("곁증명의 스킬 영수증이 그 스킬의 문장이 아니다", out)
class Init(unittest.TestCase):
def test_틀에서_런을_연다(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "run.json")
p = subprocess.run(
[sys.executable, SCRIPT, "--init", path,
"--project", "keycloak", "--record", "docs/keycloak/final/document.md"],
capture_output=True, text=True, cwd=ROOT)
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
run_ = json.load(open(path, encoding="utf-8"))
self.assertEqual([s["id"] for s in run_["stages"]],
["S1", "S2", "S3", "S4", "S5", "S6", "S7"])
self.assertEqual(run_["project"], "keycloak")
# 갓 연 원장은 아직 아무 단계도 안 끝났으므로 통과하면 안 된다
code, out = run(path)
self.assertEqual(code, 1)
self.assertIn("끝나지 않은 단계가 있다", out)
if __name__ == "__main__":
unittest.main()
+28 -10
View File
@@ -123,15 +123,12 @@ class Fixture:
self.cite(name)
def cite(self, name: str) -> None:
"""그림을 Studio 자리로 복사하고 기록이 그을 가리키게 한다 — 실제 작업 순서다."""
studio_assets = os.path.join(self.base, "final/assets/tech-log-studio")
os.makedirs(studio_assets, exist_ok=True)
open(os.path.join(studio_assets, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
"""기록이 그을 가리키게 한다. 사본을 만들지 않고 그림이 사는 자리를 그대로 쓴다."""
record = os.path.join(self.studio, "session-custody/case/case-session-split.md")
open(record, "w", encoding="utf-8").write(RECORD.replace(
"status: 게시 전\n",
f"status: 게시 전\nassets:\n - key: {name}\n"
f" file: ../../../final/assets/tech-log-studio/{name}.svg\n"))
f" file: ../../../final/assets/{name}/{name}.svg\n"))
def __enter__(self):
self._saved = (verifier.ROOT, builder.ROOT, layout.ROOT)
@@ -396,12 +393,33 @@ class LayoutTest(unittest.TestCase):
fx.diagram("hand-drawn", with_source=False)
self.assertIn("techviz 정본이 없는 그림", layout.verify("fixture").warns)
def test_studio_presentation_copies_need_no_source(self):
def test_a_figure_that_is_really_a_table_is_counted(self):
# 관계선이 없고 항목마다 같은 수의 details 면 표다. 표를 그림으로 그리지 않는다
with Fixture() as fx:
assets = os.path.join(fx.base, "final/assets/tech-log-studio")
os.makedirs(assets)
open(os.path.join(assets, "custody.svg"), "w", encoding="utf-8").write("<svg/>")
self.assertEqual(layout.verify("fixture").warns, {})
fx.diagram("distribution-choice")
spec = os.path.join(fx.base, "final/.techviz/distribution-choice/spec.json")
with open(spec, "w", encoding="utf-8") as fh:
json.dump({"nodes": [{"id": "a", "details": ["형태", "선택"]},
{"id": "b", "details": ["형태", "선택"]}],
"edges": []}, fh)
self.assertIn("표로 되는 그림", layout.verify("fixture").warns)
def test_a_figure_with_relations_is_not_a_table(self):
with Fixture() as fx:
fx.diagram("request-path", cited=True)
spec = os.path.join(fx.base, "final/.techviz/request-path/spec.json")
with open(spec, "w", encoding="utf-8") as fh:
json.dump({"nodes": [{"id": "a", "details": ["형태"]},
{"id": "b", "details": ["형태"]}],
"edges": [{"from": "a", "to": "b"}]}, fh)
self.assertNotIn("표로 되는 그림", layout.verify("fixture").warns)
def test_record_figure_without_a_techviz_source_is_counted(self):
# 사본을 가리키면 그 그림은 다시 만들 수 없다. 정본이 둘이 되는 자리다
with Fixture() as fx:
fx.diagram("hand-drawn", with_source=False, cited=True)
self.assertIn("기록이 가리키는 그림에 techviz 정본이 없다",
layout.verify("fixture").warns)
def test_evidence_folder_outside_the_convention_is_an_error(self):
with Fixture() as fx:
+133
View File
@@ -0,0 +1,133 @@
"""앵커가 실재하는 절을 가리키는지, 그림 대장이 실물과 맞는지 보는 검사.
`verify-tech-log-tree.py` 가 오래 못 보던 두 자리다 — 앵커는 SSOT 경로를 포함하는지만
봤고, `assetLedger` 는 아무 스크립트도 읽지 않았다.
"""
import importlib.util
import os
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
_spec = importlib.util.spec_from_file_location(
"verify_tech_log_tree", os.path.join(ROOT, "scripts", "verify-tech-log-tree.py"))
V = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(V)
class Headings(unittest.TestCase):
def test_제목을_슬러그로_바꾼다(self):
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False,
encoding="utf-8") as fh:
fh.write("# 제목\n\n## 검토한 선택지와 막힌 지점\n\n### AP1 완주: code가 되기까지\n")
path = fh.name
try:
heads = V._headings(path)
finally:
os.unlink(path)
self.assertEqual([h[0] for h in heads], [2, 3])
self.assertEqual(heads[0][2], "검토한-선택지와-막힌-지점")
self.assertEqual(heads[1][2], "ap1-완주-code가-되기까지")
def test_없는_파일은_빈_목록이다(self):
self.assertEqual(V._headings("/없는/경로.md"), [])
class AnchorBase(unittest.TestCase):
SLUGS = ["검토한-선택지와-막힌-지점", "결정이-지켜지는지-확인하는-방법", "ap1-완주"]
def test_절_제목과_같으면_그것이_바탕이다(self):
self.assertEqual(V._anchor_base("검토한-선택지와-막힌-지점", self.SLUGS),
"검토한-선택지와-막힌-지점")
def test_구분자가_붙어도_바탕을_찾는다(self):
self.assertEqual(V._anchor_base("검토한-선택지와-막힌-지점-ap1", self.SLUGS),
"검토한-선택지와-막힌-지점")
def test_가장_긴_것을_고른다(self):
slugs = ["ap1", "ap1-완주"]
self.assertEqual(V._anchor_base("ap1-완주-code", slugs), "ap1-완주")
def test_어느_절과도_안_맞으면_없다(self):
self.assertIsNone(V._anchor_base("§1.1", self.SLUGS))
self.assertIsNone(V._anchor_base("a18", self.SLUGS))
def test_슬러그의_앞부분만_같은_것은_바탕이_아니다(self):
"""`검토한-선택지` 로 시작한다고 `검토한-선택지와-막힌-지점` 이 되지는 않는다."""
self.assertIsNone(V._anchor_base("검토한-선택", self.SLUGS))
class LedgerNames(unittest.TestCase):
def test_이름만_적은_칸(self):
self.assertEqual(V._ledger_names(["a", "b"]), {"a", "b"})
def test_사유를_함께_적은_칸(self):
entries = [{"asset": ["a", "b"], "reason": "쓸 자리가 없다"}]
self.assertEqual(V._ledger_names(entries), {"a", "b"})
def test_두_모양이_섞여도_편다(self):
self.assertEqual(V._ledger_names(["a", {"asset": ["b"]}]), {"a", "b"})
def test_비면_빈_집합이다(self):
self.assertEqual(V._ledger_names(None), set())
class BareAnchor(unittest.TestCase):
def test_그대로인_것은_그대로다(self):
self.assertEqual(V._bare_anchor("final/document.md#가-나"), "final/document.md#가-나")
def test_백틱을_걷는다(self):
self.assertEqual(V._bare_anchor("`final/document.md#10-3`"), "final/document.md#10-3")
def test_뒤에_붙은_절_번호를_걷는다(self):
self.assertEqual(V._bare_anchor("`final/document.md#a05` §14.1"),
"final/document.md#a05")
def test_빈_것은_빈_것이다(self):
self.assertEqual(V._bare_anchor(" "), "")
class RecordSources(unittest.TestCase):
def test_frontmatter_의_source_를_읽는다(self):
import tempfile
body = ("---\n"
"kind: CASE\n"
"source:\n"
" - final/document.md#가\n"
" - final/document.md#나\n"
"---\n\n# 제목\n")
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False,
encoding="utf-8") as fh:
fh.write(body)
path = fh.name
try:
self.assertEqual(V._record_sources(path),
["final/document.md#가", "final/document.md#나"])
finally:
os.unlink(path)
def test_source_가_없으면_빈_목록(self):
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False,
encoding="utf-8") as fh:
fh.write("---\nkind: CASE\n---\n")
path = fh.name
try:
self.assertEqual(V._record_sources(path), [])
finally:
os.unlink(path)
class RealProjects(unittest.TestCase):
"""실물 프로젝트에 돌려 error 가 0 인지 본다. warn 은 편집 판단이라 세지 않는다."""
def test_keycloak_이_error_0(self):
"""이 작업이 증명 대상으로 삼은 프로젝트. 다른 프로젝트의 미해결은 따로 센다."""
rep = V.verify("keycloak")
self.assertEqual(rep.error_count, 0, dict(rep.errors))
if __name__ == "__main__":
unittest.main()
+141
View File
@@ -0,0 +1,141 @@
"""새 규칙이 **어긴 것을 잡는지** 본다.
헬퍼 함수가 맞는지는 `test_tree_anchors.py` 가 본다. 여기서는 프로젝트 사본을 만들어
한 군데씩 어기고 검사기가 그것을 세는지 본다 — 검사기의 값은 통과시키는 것이 아니라
안 지킨 것을 잡는 것이다.
"""
import importlib.util
import json
import os
import shutil
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SRC = os.path.join(ROOT, "docs", "keycloak")
def _load(name, filename, root):
"""검사기를 그 저장소 루트를 보도록 다시 읽는다."""
spec = importlib.util.spec_from_file_location(
name, os.path.join(ROOT, "scripts", filename))
mod = importlib.util.module_from_spec(spec)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
spec.loader.exec_module(mod)
mod.ROOT = root
return mod
@unittest.skipUnless(os.path.isdir(SRC), "keycloak 프로젝트가 없다")
class Sandbox(unittest.TestCase):
"""keycloak 사본을 만들어 한 군데씩 어긴다. 원본은 건드리지 않는다."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = self.tmp.name
os.makedirs(os.path.join(self.root, "docs"))
shutil.copytree(SRC, os.path.join(self.root, "docs", "keycloak"),
symlinks=True)
self.tree_path = os.path.join(
self.root, "docs/keycloak/tech-log-studio/tech-log-tree.json")
def tree(self):
return json.load(open(self.tree_path, encoding="utf-8"))
def save(self, data):
json.dump(data, open(self.tree_path, "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
def run_tree(self):
return _load("vt", "verify-tech-log-tree.py", self.root).verify("keycloak")
def run_layout(self):
return _load("vl", "verify-project-layout.py", self.root).verify("keycloak")
# ── 사본이 깨끗한지 먼저 ────────────────────────────────────────
def test_사본은_깨끗하다(self):
rep = self.run_tree()
self.assertEqual(rep.error_count, 0, dict(rep.errors))
# ── 규칙 1 · 앵커가 실재하는 절을 가리키나 ─────────────────────
def test_없는_절을_가리키는_앵커를_잡는다(self):
t = self.tree()
t["candidates"][0]["sourceRefs"] = ["final/document.md#이런-절은-없다-정말로"]
self.save(t)
rep = self.run_tree()
self.assertIn("SSOT 에 없는 절을 가리키는 앵커", rep.errors)
# ── 규칙 2 · 계약과 기록의 source 가 같은가 ────────────────────
def test_계약과_기록의_source_가_다르면_잡는다(self):
t = self.tree()
for kind, items in t["topics"]["oauth-oidc-auth-boundary"]["kinds"].items():
for node in items:
if node.get("file") and node.get("source"):
node["source"] = node["source"][:1]
break
else:
continue
break
self.save(t)
rep = self.run_tree()
self.assertIn("계약과 기록의 source 가 다르다", rep.errors)
# ── 규칙 3 · 범위 안인데 아무 후보도 안 짚은 절 ────────────────
def test_아무_후보도_안_짚은_절을_센다(self):
# 전부 비우면 앵커 형식 판별이 꺼진다. 한 h2 를 가리키는 앵커만 걷어낸다
gone = "선택이-코드와-흐름에-반영되는-방식"
t = self.tree()
for c in t["candidates"]:
c["sourceRefs"] = [r for r in (c.get("sourceRefs") or []) if gone not in r]
for _, items in t["topics"]["oauth-oidc-auth-boundary"]["kinds"].items():
for node in items:
node["source"] = [r for r in (node.get("source") or []) if gone not in r]
self.save(t)
rep = self.run_tree()
self.assertIn("범위 안인데 아무 후보도 가리키지 않는 절", rep.warns)
# ── 규칙 4 · assetLedger 가 실물과 맞나 ────────────────────────
def test_없는_그림을_배정했다고_적으면_잡는다(self):
t = self.tree()
t["assetLedger"]["assigned"] = list(t["assetLedger"]["assigned"]) + ["없는-그림"]
self.save(t)
rep = self.run_tree()
self.assertIn("assetLedger 가 없는 그림을 배정했다고 적었다", rep.errors)
def test_그림이_대장에_없으면_잡는다(self):
t = self.tree()
t["assetLedger"]["assigned"] = list(t["assetLedger"]["assigned"])[1:]
self.save(t)
rep = self.run_tree()
self.assertIn("그림이 assetLedger 에 없다", rep.errors)
# ── 규칙 5 · 그림의 근거가 SSOT 인가 ───────────────────────────
def test_그림의_근거가_SSOT_가_아니면_잡는다(self):
spec_path = os.path.join(
self.root, "docs/keycloak/final/.techviz/ap4-edge-trust-architecture/spec.json")
spec = json.load(open(spec_path, encoding="utf-8"))
spec["source_context"]["document"] = "case-ap4-identity-header-trust.md"
json.dump(spec, open(spec_path, "w", encoding="utf-8"), ensure_ascii=False)
rep = self.run_layout()
self.assertIn("그림의 근거가 SSOT 가 아니다", rep.errors)
# ── 규칙 6 · 그림 안에서 상자와 라벨이 겹치나 ──────────────────
def test_겹친_그림을_배치_검사가_잡는다(self):
"""`check-figure-overlap.py` 가 `verify-project-layout.py` 의 관문인지."""
before = os.path.join(
ROOT, "runs/keycloak/2026-09-08-0030/stage/S4/before/assets",
"ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg")
if not os.path.exists(before):
self.skipTest("고치기 전 그림 사본이 없다")
target = os.path.join(
self.root, "docs/keycloak/final/assets",
"ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg")
shutil.copyfile(before, target)
rep = self.run_layout()
self.assertIn("그림 안에서 상자와 라벨이 겹친다", rep.errors)
if __name__ == "__main__":
unittest.main()
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""파이프라인 런 원장이 절차를 지켰는지 본다.
이 검사기는 글의 품질을 보지 않는다. **절차의 준수**를 본다 — 단계가 빠졌는지, 그 단계가
자기 스킬을 실제로 열었는지, 관문이 돌았고 종료 코드가 0 이었는지, 적어 낸 산출물이
디스크에 있는지.
python3 scripts/verify-pipeline-run.py --init runs/<프로젝트>/<runId>/run.json \\
--project <프로젝트> --record <기록 경로>
python3 scripts/verify-pipeline-run.py runs/<프로젝트>/<runId>/run.json
**`skillEcho` 가 이 검사기의 핵심이다.** 단계마다 그 SKILL.md 에서 한 줄을 원문 그대로
옮겨 오게 하고, 그 문자열이 실제로 그 파일 안에 있는지 대조한다. 스킬을 안 읽고 결과만
그럴듯하게 낸 단계는 여기서 걸린다.
계약은 `.agents/skills/running-tech-log-pipeline/references/stage-contracts.md` 다.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from techlog import Report # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKILLS = os.path.join(ROOT, ".agents", "skills")
TEMPLATE = os.path.join(SKILLS, "running-tech-log-pipeline", "templates", "run.json")
STATUSES = ("PENDING", "RUNNING", "DONE", "SKIPPED", "FAILED")
# 단계마다 어떤 스킬이 맡고, 관문에 어떤 명령이 있어야 하는가.
# 관문은 명령 문자열에 이 토큰이 들어 있는지로 본다 — 호출형이 조금씩 달라도 같은 검사다.
STAGES = {
"S1": {"skill": "analyzing-codebase-for-tech-log",
"gates": ["verify-project-layout.py"], "skippable": True},
"S2": {"skill": "deriving-tech-log-root-tree",
"gates": ["build-tech-log-tree.py", "verify-tech-log-tree.py"], "skippable": True},
"S3": {"skill": "writing-tech-log-records",
"gates": ["check_body.mjs", "check_prose.mjs", "check_evidence.mjs"],
"skippable": False},
"S4": {"skill": "technical-visualizer",
"gates": ["lint", "check-figure-text.py", "check-figure-overlap.py"],
"skippable": True},
"S5": {"skill": "rewriting-technical-prose-naturally",
"gates": ["check_prose.mjs", "style_profile.mjs", "check_body.mjs"],
"skippable": False},
"S6": {"skill": "writing-as-the-person-who-did-it",
"gates": ["check_voice.mjs", "check_prose.mjs"], "skippable": False},
"S7": {"skill": "publishing-tech-log-to-studio",
"gates": ["저장됨", "verify-tech-log-tree.py"], "skippable": True},
}
ORDER = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"]
def _norm(text: str) -> str:
"""공백을 하나로 접는다. 인용을 줄바꿈까지 똑같이 옮기라고 요구하지 않는다."""
return re.sub(r"\s+", " ", text).strip()
def _skill_text(skill: str) -> str | None:
"""스킬 폴더 전체(SKILL.md 와 references)의 글자. 영수증을 여기서 찾는다."""
base = os.path.join(SKILLS, skill)
if not os.path.isdir(base):
return None
out = []
for dirpath, _, names in os.walk(base):
for name in sorted(names):
if name.endswith(".md"):
try:
out.append(open(os.path.join(dirpath, name), encoding="utf-8").read())
except OSError:
pass
return _norm("\n".join(out))
def init(path: str, project: str, record: str, run_id: str | None) -> int:
if os.path.exists(path):
print(f"이미 있다: {path}", file=sys.stderr)
return 1
run = json.load(open(TEMPLATE, encoding="utf-8"))
now = dt.datetime.now()
run["runId"] = run_id or now.strftime("%Y-%m-%d-%H%M")
run["project"] = project
run["record"] = record
run["startedAt"] = now.astimezone().isoformat(timespec="seconds")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(run, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"런을 열었다: {path} (runId={run['runId']} · project={project})")
return 0
def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> None:
"""건너뛴 단계의 곁증명(`sideProof`)을 본 단계와 같은 잣대로 검사한다.
`outputs` 가 가리키는 `*stage-report.json` 중 `"stage"` 가 이 단계인 것을 곁증명으로
본다. 곁증명이 없는 것은 정상이다 — 있는데 엉터리인 것만 잡는다.
"""
for out in st.get("outputs") or []:
if not out.endswith(".json"):
continue
full = os.path.join(ROOT, out)
if not os.path.exists(full):
continue
try:
proof = json.load(open(full, encoding="utf-8"))
except (OSError, json.JSONDecodeError):
rep.error("곁증명을 읽지 못했다", f"{where}{out}")
continue
stage_of = str(proof.get("stage") or "")
if stage_of != sid and not stage_of.startswith(sid + "-"):
continue
if proof.get("skill") != spec["skill"]:
rep.error("곁증명이 다른 스킬을 썼다",
f"{where}{proof.get('skill')!r} · 계약은 {spec['skill']!r}")
echo = _norm(proof.get("skillEcho") or "")
text = _skill_text(spec["skill"])
if not echo:
rep.error("곁증명에 스킬 영수증이 없다", f"{where}{out}")
elif text is not None and echo not in text:
rep.error("곁증명의 스킬 영수증이 그 스킬의 문장이 아니다",
f"{where}{echo[:60]}")
gates = proof.get("gates") or []
if not gates:
rep.error("곁증명에 관문이 없다", f"{where}{out}")
for g in gates:
if g.get("exit") not in (0, "0"):
rep.error("곁증명의 관문이 통과하지 못했다",
f"{where}{str(g.get('cmd'))[:70]} → exit {g.get('exit')}")
rep.facts.setdefault("곁증명", []).append(f"{sid}:{os.path.basename(out)}")
def verify(path: str) -> Report:
rel = os.path.relpath(path, ROOT)
rep = Report(rel)
try:
run = json.load(open(path, encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
rep.error("원장을 읽지 못했다", f"{rel}{exc}")
return rep
for key in ("project", "record", "stages"):
if not run.get(key):
rep.error("원장에 칸이 없다", key)
project = run.get("project") or ""
rep.facts["project"] = project or ""
stages = {s.get("id"): s for s in run.get("stages") or []}
missing = [sid for sid in ORDER if sid not in stages]
if missing:
rep.error("단계가 원장에 없다", " · ".join(missing))
counts: dict[str, int] = {}
for sid in ORDER:
st = stages.get(sid)
if st is None:
continue
spec = STAGES[sid]
status = st.get("status") or "PENDING"
counts[status] = counts.get(status, 0) + 1
where = f"{sid} {st.get('name') or ''}".strip()
if status not in STATUSES:
rep.error("status 값이 계약 밖이다", f"{where}{status}")
continue
if st.get("skill") != spec["skill"]:
rep.error("단계가 다른 스킬을 썼다",
f"{where}{st.get('skill')!r} · 계약은 {spec['skill']!r}")
if st.get("runBy") != "subagent":
rep.warn("서브에이전트가 아닌 것으로 적혀 있다",
f"{where} — runBy={st.get('runBy')!r}")
if status in ("PENDING", "RUNNING"):
rep.error("끝나지 않은 단계가 있다", f"{where}{status}")
continue
if status == "FAILED":
rep.error("단계가 실패했다", f"{where}{st.get('notes') or '사유 없음'}")
continue
if status == "SKIPPED":
if not spec["skippable"]:
rep.error("건너뛸 수 없는 단계를 건너뛰었다", where)
elif not (st.get("skipReason") or "").strip():
rep.error("건너뛴 사유가 없다",
f"{where} — 판단해서 건너뛴 것과 빠뜨린 것을 구분해야 한다")
# 이 기록에서는 건너뛰었지만 그 단계가 도는지 따로 증명했으면 그것도 검사한다.
# 안 그러면 곁증명은 아무도 읽지 않는 파일이 된다
_side_proof(rep, st, sid, spec, where)
continue
# ── 여기부터 DONE ────────────────────────────────────────────
echo = _norm(st.get("skillEcho") or "")
if not echo:
rep.error("스킬 영수증이 없다",
f"{where} — SKILL.md 를 열었다는 증거가 원장에 없다")
else:
text = _skill_text(spec["skill"])
if text is None:
rep.error("스킬 폴더가 없다", f"{where}{spec['skill']}")
elif echo not in text:
rep.error("스킬 영수증이 그 스킬의 문장이 아니다",
f"{where}{echo[:60]}")
elif len(echo) < 20:
rep.warn("스킬 영수증이 너무 짧다", f"{where}{echo}")
gates = st.get("gates") or []
cmds = " ; ".join(str(g.get("cmd") or "") for g in gates)
for token in spec["gates"]:
if token not in cmds:
rep.error("관문이 빠졌다", f"{where}{token}")
for g in gates:
if g.get("exit") not in (0, "0"):
rep.error("관문이 통과하지 못했다",
f"{where}{str(g.get('cmd'))[:70]} → exit {g.get('exit')}")
for out in st.get("outputs") or []:
if not os.path.exists(os.path.join(ROOT, out)):
rep.error("적어 낸 산출물이 디스크에 없다", f"{where}{out}")
# 한 단계를 두 번 돌렸으면 두 번째 것도 같은 잣대로 본다
_side_proof(rep, st, sid, spec, where)
rep.facts["stages"] = counts
record = run.get("record")
if record and not os.path.exists(os.path.join(ROOT, record)):
rep.error("런이 만든다는 기록이 없다", record)
return rep
def render(rep: Report, samples: int) -> None:
facts = " · ".join(
f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in rep.facts.items())
print(f" [{rep.project}] {facts or ''}")
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]:
if d:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="파이프라인 런 원장이 절차를 지켰는지 본다.")
ap.add_argument("ledgers", nargs="*", help="run.json 경로")
ap.add_argument("--init", metavar="PATH", help="틀에서 런 원장을 만든다")
ap.add_argument("--project")
ap.add_argument("--record", default="")
ap.add_argument("--run-id")
ap.add_argument("--samples", type=int, default=3)
ap.add_argument("--strict", action="store_true")
args = ap.parse_args()
if args.init:
if not args.project:
print("--init 에는 --project 가 필요하다", file=sys.stderr)
return 2
return init(args.init, args.project, args.record, args.run_id)
if not args.ledgers:
print("검사할 run.json 을 달라", file=sys.stderr)
return 2
reports = [verify(p) for p in args.ledgers]
e = sum(r.error_count for r in reports)
w = sum(r.warn_count for r in reports)
print(f"PIPELINE RUN: {'FAIL' if e or (args.strict and w) else 'PASS'}"
f" — 런 {len(reports)} · error {e} · warn {w}")
for r in reports:
render(r, args.samples)
return 1 if e or (args.strict and w) else 0
if __name__ == "__main__":
raise SystemExit(main())
+36 -1
View File
@@ -9,6 +9,13 @@ from pathlib import Path
REQUIRED_PATHS = (
# 스킬 — 분석에서 게시까지
".agents/skills/running-tech-log-pipeline/SKILL.md",
".agents/skills/running-tech-log-pipeline/references/stage-contracts.md",
".agents/skills/running-tech-log-pipeline/references/subagent-prompts.md",
".agents/skills/running-tech-log-pipeline/templates/run.json",
".agents/skills/publishing-tech-log-to-studio/SKILL.md",
".agents/skills/publishing-tech-log-to-studio/references/studio-form-map.md",
".agents/skills/publishing-tech-log-to-studio/references/playwright-recipes.md",
".agents/skills/analyzing-codebase-for-tech-log/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md",
@@ -30,6 +37,9 @@ REQUIRED_PATHS = (
".agents/skills/rewriting-technical-prose-naturally/references/research-method.md",
".agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs",
".agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs",
".agents/skills/writing-as-the-person-who-did-it/SKILL.md",
".agents/skills/writing-as-the-person-who-did-it/references/voice-moves.md",
".agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs",
".agents/skills/technical-visualizer/SKILL.md",
".agents/skills/refactoring-from-analysis/SKILL.md",
# 프로젝트 폴더 틀 — 끝난 프로젝트의 모양. 작업 재료는 여기 없다
@@ -52,6 +62,8 @@ REQUIRED_PATHS = (
"scripts/build-tech-log-tree.py",
"scripts/techlog.py",
"scripts/verify-tech-log-tree.py",
"scripts/verify-pipeline-run.py",
"scripts/check-figure-overlap.py",
"scripts/verify-project-layout.py",
"scripts/fold-analysis-into-final.py",
"scripts/fold-studio-contract-into-index.py",
@@ -253,6 +265,17 @@ def verify_layouts(shared_root: Path) -> list:
return [verifier.verify(name) for name in projects]
def verify_runs(shared_root: Path):
"""`runs/<프로젝트>/<runId>/run.json` 이 절차를 지켰는지 본다.
원장이 없는 것은 정상이다 — 파이프라인을 한 줄기로 돌린 적이 없다는 뜻이다.
있는데 안 지킨 것만 잡는다.
"""
module = _load(shared_root, "verify-pipeline-run.py", "verify_pipeline_run")
ledgers = sorted((shared_root / "runs").glob("*/*/run.json"))
return [module.verify(str(p)) for p in ledgers]
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the Tech Log documentation pipeline workspace.")
parser.add_argument("shared_root", nargs="?", type=Path,
@@ -265,7 +288,10 @@ def main() -> int:
errors = verify_pipeline(args.shared_root)
reports = [] if args.skip_projects else verify_projects(args.shared_root)
layouts = [] if args.skip_projects else verify_layouts(args.shared_root)
project_errors = sum(r.error_count for r in reports) + sum(r.error_count for r in layouts)
runs = [] if args.skip_projects else verify_runs(args.shared_root)
project_errors = (sum(r.error_count for r in reports)
+ sum(r.error_count for r in layouts)
+ sum(r.error_count for r in runs))
if errors:
print("PIPELINE CONTRACT: FAIL")
@@ -287,6 +313,15 @@ def main() -> int:
for report in layouts:
verifier_render(report, args.samples)
if runs:
run_errors = sum(r.error_count for r in runs)
print()
print(f"PIPELINE RUNS: {'FAIL' if run_errors else 'PASS'}"
f" — 런 {len(runs)} · error {run_errors} ·"
f" warn {sum(r.warn_count for r in runs)}")
for report in runs:
verifier_render(report, args.samples)
if reports:
tree_errors = sum(r.error_count for r in reports)
print()
+96 -26
View File
@@ -10,8 +10,7 @@ CLAUDE.md 「문서 위치」가 같은 것을 말로 적은 것이다.
├── analysis/ · notes/ · checkpoints/
├── final/ SSOT
│ ├── document.md
│ ├── assets/<이름>/ 그림 하나가 폴더 하나
│ ├── assets/tech-log-studio/ Studio 에 올릴 표현물
│ ├── assets/<이름>/ 그림 하나가 폴더 하나. 기록의 assets: file: 도 여기를 가리킨다
│ ├── .techviz/<이름>/ 그림의 정본
│ └── evidence/{raw,meta,rendered,browser}
└── tech-log-studio/
@@ -25,6 +24,8 @@ from __future__ import annotations
import argparse
import glob
import hashlib
import importlib.util
import json
import os
import re
@@ -41,19 +42,49 @@ EVIDENCE_DIRS = {"raw", "meta", "rendered", "browser"}
WORKING_MATERIAL = ("analysis", "notes", "checkpoints", "state.json", "source-index.md")
# 밖에서 가져올 때만 있는 재료. final/ 이 그 내용을 담으면 원본은 사본이 된다
IMPORT_MATERIAL = "source"
# Studio 에 올릴 표현물이 사는 곳. 여기 SVG 는 그림의 정본을 따로 갖지 않는다
STUDIO_ASSETS = "tech-log-studio"
def _svg_stems(assets: str) -> list[tuple[str, str]]:
"""(stem, 상대경로). assets/tech-log-studio/ 아래는 표현물이라 뺀다."""
out = []
for path in sorted(glob.glob(os.path.join(assets, "**", "*.svg"), recursive=True)):
rel = os.path.relpath(path, assets)
if rel.split(os.sep)[0] == STUDIO_ASSETS:
continue
out.append((os.path.basename(path)[:-4], rel))
return out
"""(stem, 상대경로). 그림은 한 곳에만 산다 — 사본을 두는 폴더를 따로 두지 않는다."""
return [(os.path.basename(path)[:-4], os.path.relpath(path, assets))
for path in sorted(glob.glob(os.path.join(assets, "**", "*.svg"), recursive=True))]
def _load_overlap():
"""`check-figure-overlap.py` 의 검사 함수. 없으면 None."""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"check-figure-overlap.py")
if not os.path.exists(path):
return None
spec = importlib.util.spec_from_file_location("check_figure_overlap", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.check
def _context_sha(path: str) -> str | None:
"""`techviz prepare` 가 context 에 적는 것과 같은 해시.
**원본 바이트가 아니다.** prepare 는 문서의 관리 블록(`techviz:begin … end`)을 접은
정규화본을 해싱한다. 원본으로 비교하면 관리 블록이 있는 프로젝트는 그림을 방금 다시
만들어도 영영 「SSOT 가 바뀌었다」로 남는다. 도구가 없으면 대조하지 않는다.
"""
try:
raw = open(path, encoding="utf-8").read()
except OSError:
return None
home = os.environ.get("TECHVIZ_HOME",
"/home/donghyeon/workspace/ai-tool/technical-visualization-haness")
src = os.path.join(home, "src")
if not os.path.isdir(os.path.join(src, "techviz")):
return None
if src not in sys.path:
sys.path.insert(0, src)
try:
from techviz.document import canonicalize_document # noqa: PLC0415
except ImportError:
return None
return hashlib.sha256(canonicalize_document(raw).encode("utf-8")).hexdigest()
def verify(project: str) -> Report:
@@ -119,24 +150,64 @@ def verify(project: str) -> Report:
f"raw {counts['raw']}건 — command·cwd·executedAt·exitCode·revision 이 없다")
# ── 그림 ───────────────────────────────────────────────────────
ssot_sha = _context_sha(os.path.join(final, "document.md"))
assets = os.path.join(final, "assets")
techviz = os.path.join(final, ".techviz")
# 정본은 그림 이름 폴더다. .techviz 에 놓인 파일은 정본이 아니다
techviz_sources = {n for n in os.listdir(techviz)
if os.path.isdir(os.path.join(techviz, n))} \
if os.path.isdir(techviz) else set()
if os.path.isdir(assets):
# 정본은 그림 이름 폴더다. .techviz 에 놓인 파일은 정본이 아니다
sources = {n for n in os.listdir(techviz)
if os.path.isdir(os.path.join(techviz, n))} \
if os.path.isdir(techviz) else set()
sources = techviz_sources
svgs = _svg_stems(assets)
rep.facts["diagrams"] = {"svg": len(svgs), "techviz": len(sources)}
overlap = _load_overlap()
for stem, rel in svgs:
if stem not in sources:
rep.warn("techviz 정본이 없는 그림", f"final/assets/{rel}")
# lint 는 좌표를 안 본다. 상자와 라벨이 서로를 덮는 것은 여기서만 걸린다
if overlap is not None:
for hit in overlap(os.path.join(assets, rel)):
rep.error("그림 안에서 상자와 라벨이 겹친다", f"final/assets/{rel}{hit}")
if os.path.dirname(rel) in ("", "diagrams"):
rep.warn("그림이 이름 폴더로 묶여 있지 않다", f"final/assets/{rel}")
stems = {s for s, _ in svgs}
for name in sorted(sources - stems):
rep.warn("정본만 있고 그림이 없다", f"final/.techviz/{name}")
# 관계선이 없고 항목마다 같은 수의 details 를 늘어놓았으면 그것은 표다.
# 표는 값을 비교하고 그림은 포함·순서·경계처럼 자리로만 보이는 것을 맡는다
for name in sorted(sources):
spec_path = os.path.join(techviz, name, "spec.json")
if not os.path.exists(spec_path):
continue
try:
spec = json.load(open(spec_path, encoding="utf-8"))
except (ValueError, OSError):
continue
# 그림의 근거는 SSOT 다. 기록은 SSOT 의 인용이라 줄 번호가 근거가 되지 못하는데,
# techviz prepare 는 기록 .md 를 받아도 에러 없이 돈다. 여기서 잡는다
ctx = spec.get("source_context") or {}
doc = os.path.basename(str(ctx.get("document") or ""))
if doc and doc != "document.md":
rep.error("그림의 근거가 SSOT 가 아니다",
f"final/.techviz/{name} — source_context.document = {doc}")
elif ssot_sha and ctx.get("document_sha256") and \
ctx["document_sha256"] != ssot_sha:
rep.warn("SSOT 가 바뀐 뒤 그림을 다시 보지 않았다",
f"final/.techviz/{name}")
elif ssot_sha is None and ctx.get("document_sha256"):
rep.warn("그림이 어느 SSOT 를 보고 만들어졌는지 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없다")
nodes = spec.get("nodes") or []
if spec.get("edges") or len(nodes) < 2:
continue
counts = [len(n.get("details") or []) for n in nodes]
if all(counts) and len(set(counts)) == 1:
rep.warn("표로 되는 그림", f"final/.techviz/{name} — 관계선이 없고 "
f"{len(nodes)}항목이 같은 {counts[0]}줄을 늘어놓는다")
# ── 기록이 가리키는 그림 ───────────────────────────────────────
if os.path.isdir(studio):
wrong = 0
@@ -151,24 +222,23 @@ def verify(project: str) -> Report:
text = open(path, encoding="utf-8").read()
for m in re.finditer(r"^ file: (\S+)$", text, re.M):
target = os.path.normpath(os.path.join(os.path.dirname(path), m.group(1)))
# 이름이 같으면 같은 그림이다. tech-log-studio/ 사본도 SSOT 원본을 쓴 것으로 센다
cited_figures.add(os.path.basename(target)[:-4]
if target.endswith(".svg") else os.path.basename(target))
stem = (os.path.basename(target)[:-4] if target.endswith(".svg")
else os.path.basename(target))
cited_figures.add(stem)
if not os.path.exists(target):
broken += 1
if broken <= 5:
rep.error("기록이 가리키는 그림이 없다",
f"{os.path.relpath(path, base)}{m.group(1)}")
continue
if f"assets{os.sep}{STUDIO_ASSETS}{os.sep}" not in target:
# 기록이 가리키는 그림도 다시 만들 수 있어야 한다. 사본을 따로 두면 정본이 둘이 된다
if stem not in techviz_sources:
wrong += 1
rep.warn("기록이 가리키는 그림에 techviz 정본이 없다",
f"{os.path.relpath(path, base)}{os.path.basename(target)}")
for m in re.finditer(r"^ - (\.\./\S*final/evidence/\S+)$", text, re.M):
cited_evidence.add(
os.path.normpath(os.path.join(os.path.dirname(path), m.group(1))))
if wrong:
rep.warn("Studio 자산이 assets/tech-log-studio/ 밖에 있다",
f"{wrong}건 — 다른 프로젝트는 전부 그 폴더를 쓴다")
# ── SSOT 가 만들어 둔 것을 기록이 쓰고 있나 ────────────────────
# 기록이 하나도 없는 프로젝트는 아직 안 쓴 것이지 안 쓰기로 한 것이 아니다
if records:
+186
View File
@@ -102,6 +102,85 @@ def _values(node: dict, key: str) -> list[str]:
return [str(value)] if str(value).strip() else []
def _headings(path: str) -> list[tuple[int, str, str]]:
"""(단계, 제목, 슬러그). 앵커가 실재하는 절을 가리키는지 대조하는 데 쓴다."""
try:
text = open(path, encoding="utf-8").read()
except OSError:
return []
out = []
for m in re.finditer(r"^(#{2,4})\s+(.+)$", text, re.M):
title = m.group(2).strip()
slug = re.sub(r"\s+", "-",
re.sub(r"[`*(),:·—?./]", " ", title).strip()).lower()
out.append((len(m.group(1)), title, slug))
return out
def _anchor_base(anchor: str, slugs: list[str]) -> str | None:
"""앵커가 어느 절 슬러그로 시작하는가. 가장 긴 것을 고른다.
이 저장소의 앵커는 「h2 슬러그 + 구분자」다 — `검토한-선택지와-막힌-지점-ap1` 처럼.
구분자는 패턴 번호이거나 그 아래 h3 의 슬러그 앞부분이다.
"""
best = None
for slug in slugs:
if anchor == slug or anchor.startswith(slug + "-"):
if best is None or len(slug) > len(best):
best = slug
return best
def _ledger_names(entries) -> set[str]:
"""assetLedger 의 한 칸을 이름 집합으로 편다.
사람이 쓰는 칸이라 모양이 둘이다 — 이름만 적기도 하고, 왜 그렇게 두었는지를
`{"asset": [...], "reason": "..."}` 로 적기도 한다.
"""
out: set[str] = set()
for entry in entries or []:
if isinstance(entry, str):
out.add(entry)
elif isinstance(entry, dict):
names = entry.get("asset") or entry.get("assets") or []
out.update(names if isinstance(names, list) else [names])
return {str(n) for n in out}
def _bare_anchor(ref: str) -> str:
"""앵커만 남긴다. 계약은 `` `경로#앵커` §14.1 `` 처럼 꾸며 적기도 한다."""
return ref.strip().strip("`").split()[0].strip("`") if ref.strip() else ""
def _record_sources(path: str) -> list[str]:
"""기록 frontmatter 의 `source` 목록. 계약과 같은 것을 말하는지 대조하는 데 쓴다."""
try:
text = open(path, encoding="utf-8").read()
except OSError:
return []
m = re.search(r"^source:\s*\n((?:\s+-\s+\S+\n)+)", text, re.M)
if not m:
return []
return [line.strip()[2:].strip() for line in m.group(1).splitlines() if line.strip()]
def _all_anchors(index: dict):
"""(어디, 앵커 목록). 후보의 sourceRefs 와 글감의 source 를 함께 낸다."""
for c in index.get("candidates") or []:
refs = c.get("sourceRefs") or []
if refs:
yield f"후보 {c.get('id')}", refs
for slug, kind, node in techlog.nodes(index):
refs = _values(node, "source")
if refs:
yield f"{slug}/{kind}/{node.get('slug') or node.get('title')}", refs
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
@@ -186,6 +265,7 @@ def verify(project: str) -> Report:
f"{pattern}{exc}")
# ── 주제 ───────────────────────────────────────────────────────
assigned_to_nodes: set[str] = set()
topics = index.get("topics") or {}
rep.facts["topics"] = len(topics)
for slug, topic in topics.items():
@@ -236,8 +316,11 @@ def verify(project: str) -> Report:
# ── SSOT 가 이미 가진 그림·증거를 이 글감에 배정했는가 ─────────
# 배정만 해 두고 기록이 쓰지 않으면 글 쓸 때 새로 그리게 된다. 그것을 여기서 센다
for name in (node.get("assets") or []) + (node.get("assetFiles") or []):
assigned_to_nodes.add(os.path.basename(str(name)).replace(".svg", ""))
for name in _values(node, "ssot-assets"):
stem = os.path.basename(name)[:-4] if name.endswith(".svg") else os.path.basename(name)
assigned_to_nodes.add(stem)
if not glob.glob(os.path.join(base, "final", "assets", "**", f"{stem}.svg"),
recursive=True):
rep.error("배정한 SSOT 그림이 final/assets 에 없다", f"{where}{name}")
@@ -252,6 +335,30 @@ def verify(project: str) -> Report:
f.endswith(rel_ev) for f in (node.get("evidenceFiles") or [])):
rep.error("배정한 SSOT 증거를 기록이 쓰지 않는다",
f"{where}{rel_ev} — 기록의 evidence 가 가리키지 않는다")
# 계약의 source 와 기록의 source 가 갈리면 어느 쪽이 근거인지 알 수 없다.
# build 는 이 칸을 다시 채우지 않으므로 갈린 채로 남는다
if node.get("file"):
on_disk = _record_sources(os.path.join(studio, node["file"]))
if on_disk:
# SSOT 를 가리키는 것끼리만 견준다. 기록의 `source` 에는 코드 파일 경로가
# 함께 적히기도 하는데(clean-architecture 가 그렇다) 그것은 계약이 적는
# 자리가 아니라 이 검사의 대상이 아니다
def _ssot_only(refs):
return {a for a in (_bare_anchor(r) for r in refs)
if a and ssot_rel in a}
have = _ssot_only(on_disk)
want = _ssot_only(_values(node, "source"))
if have != want:
only_record = sorted(have - want)
only_tree = sorted(want - have)
detail = []
if only_record:
detail.append(f"기록에만 {only_record[:2]}")
if only_tree:
detail.append(f"계약에만 {only_tree[:2]}")
rep.error("계약과 기록의 source 가 다르다",
f"{where}{' · '.join(detail)}")
anchors = " ".join(_values(node, "source"))
if anchors and ssot_rel not in anchors:
rep.warn("근거가 SSOT 밖에만 있다", f"{where}{anchors[:60]}")
@@ -289,6 +396,85 @@ def verify(project: str) -> Report:
rep.facts["written"] = written
rep.facts["unwritten"] = total - written
# ── 앵커가 실재하는 절을 가리키나 ──────────────────────────────
# 검사기가 지금까지 본 것은 「SSOT 경로를 포함하는가」뿐이었다. 그래서 어느 절도
# 가리키지 않는 앵커가 그대로 통과했다
heads = _headings(ssot_path) if os.path.exists(ssot_path) else []
head_slugs = [h[2] for h in heads]
if heads and has_contract:
# 앵커 형식은 프로젝트마다 다르다 — 절 제목 슬러그를 쓰는 곳도 있고
# `§1.1`·`10-2`·`a18` 처럼 번호나 마커를 쓰는 곳도 있다. 형식을 강요하지 않고,
# 슬러그를 쓰는 프로젝트에서만 실재를 대조한다
seen_anchors: list[tuple[str, str]] = []
for where, refs in _all_anchors(index):
for ref in refs:
if "#" in ref:
seen_anchors.append((where, ref.split("#", 1)[1]))
resolved = [(w, a, _anchor_base(a, head_slugs)) for w, a in seen_anchors]
hit = sum(1 for _, _, b in resolved if b)
slug_style = seen_anchors and hit * 2 >= len(seen_anchors)
pointed: list[tuple[str, str]] = []
if slug_style:
for where, anchor_slug, base_slug in resolved:
if base_slug is None:
rep.error("SSOT 에 없는 절을 가리키는 앵커",
f"{where} — #{anchor_slug}")
else:
pointed.append((base_slug, anchor_slug[len(base_slug):].lstrip("-")))
elif seen_anchors:
rep.warn("앵커가 절 제목이 아니라 번호·마커다",
f"{len(seen_anchors)}건 — 검사기가 그 절이 실재하는지 대조하지 못한다")
# 범위 안의 절을 후보 대장이 하나도 안 짚었나.
# 검사기는 「후보 ↔ 글감」만 봐서 SSOT 재료를 통째로 지나쳐도 error 가 0 이었다.
# h2 만 보면 성기다 — 놓치는 것은 그 아래 h3 이다
included = {re.sub(r"\s+", "-",
re.sub(r"[`*(),:·—?./]", " ", t).strip()).lower()
for t in (scope.get("sections") or [])}
if included and slug_style:
groups: dict[str, list[tuple[str, str]]] = {}
current = None
for level, title, slug in heads:
if level == 2:
current = slug
groups.setdefault(current, [])
elif level == 3 and current is not None:
groups[current].append((title, slug))
for h2_slug, children in groups.items():
if h2_slug not in included:
continue
rems = [rem for base, rem in pointed if base == h2_slug]
if not rems:
rep.warn("범위 안인데 아무 후보도 가리키지 않는 절",
f"{h2_slug} — SSOT 재료를 후보 대장이 지나쳤다")
continue
if "" in rems: # 절 전체를 가리키는 앵커가 있다
continue
for title, slug in children:
if any(r and (slug.startswith(r) or r.startswith(slug)) for r in rems):
continue
rep.warn("범위 안인데 아무 후보도 가리키지 않는 절",
f"{title} — 처분도 적히지 않았다")
# ── SSOT 가 만들어 둔 그림의 대장 ──────────────────────────────
ledger = index.get("assetLedger") or {}
if ledger and has_contract:
# 그림 폴더는 `assets/<이름>/` 이기도 하고 `assets/diagrams/<이름>/` 이기도 하다.
# 이름은 SVG 파일 이름이 정한다
on_disk = {os.path.basename(p)[:-4] for p in glob.glob(
os.path.join(base, "final", "assets", "**", "*.svg"), recursive=True)}
assigned = _ledger_names(ledger.get("assigned"))
unassigned = _ledger_names(ledger.get("unassigned"))
for name in sorted(assigned - on_disk):
rep.error("assetLedger 가 없는 그림을 배정했다고 적었다", name)
missing = on_disk - assigned - unassigned
for name in sorted(missing):
rep.error("그림이 assetLedger 에 없다",
f"{name} — 배정했는지 안 했는지 적히지 않았다")
for name in sorted(assigned - assigned_to_nodes):
rep.error("assetLedger 는 배정했다는데 글감이 안 쓴다", name)
# ── 후보와 처분 ────────────────────────────────────────────────
candidates = index.get("candidates") or []
if candidates: