feat: 가상화 문서들 추가
This commit is contained in:
@@ -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())
|
||||
Executable
+105
@@ -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())
|
||||
Executable
+82
@@ -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())
|
||||
Executable
+104
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user