From e3230ce5edb8019e766be0adde3238390bfdeba9 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 16:33:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20=EA=B2=BD=EA=B3=A0=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=BD=EB=8A=94=20=EC=9E=A5=EC=B9=98=EA=B0=80=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=9D=BC=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EC=9E=A5?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 남은 셋은 묶음 경고가 0건이라 게이트를 아무리 조여도 지나간다. 뿌리가 하나다 — 지금 검사기는 사라진 것과 새로 생긴 보호 구간을 보고, 범위가 넓어진 것과 정본을 안 거친 것을 보는 자리가 없었다. 그림이 정본을 거쳤는지 본다. SVG 는 바뀌었는데 spec.json 은 그대로면 그 그림은 정본에서 나온 것이 아니고, 화살표 뒤집기가 그 모양이다. spec 의 간선과 SVG 의 경로를 직접 견주려면 렌더러가 id 를 어떻게 붙이는지 알아야 하는데, 정본을 거쳤는지만 보면 몰라도 된다. 작업 트리와 이력 두 자리를 본다. 정본이 없는 그림은 볼 것이 아니라 세기만 한다. 적용 범위를 넓히는 말이 새로 들어왔는지 본다. 로컬에서 확인했다에 운영 환경에서도를 더하기만 하면 유보도 보호 구간도 안 바뀐다 — 지운 것이 없기 때문이다. 유보 감소의 반대편이고, 판정하지 않고 경고로 올린다. 경고 생산자를 더하는 것이 과잉 차단이 생기는 자리라 후보마다 채택 편집 100쌍을 돌렸다. error 도 경고도 안 늘었다. 정상 편집에 경고가 붙으면 그것도 사실상 차단이다. 목록에 운영·항상 같은 흔한 말이 들어가서, 원래 있던 말을 두고 주변만 고쳐도 걸리는지 보는 대조군을 회귀에 넣었다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/check-figure-provenance.py | 121 ++++++++++++++++++++++++ scripts/check-preservation.py | 26 +++++ scripts/tests/test_figure_provenance.py | 65 +++++++++++++ scripts/tests/test_preservation.py | 43 +++++++++ 4 files changed, 255 insertions(+) create mode 100644 scripts/check-figure-provenance.py create mode 100644 scripts/tests/test_figure_provenance.py diff --git a/scripts/check-figure-provenance.py b/scripts/check-figure-provenance.py new file mode 100644 index 0000000..5437a6c --- /dev/null +++ b/scripts/check-figure-provenance.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""그림이 정본에서 나온 것인지 본다 — **손으로 고친 SVG 를 잡는다.** + +`technical-visualizer` 는 `.techviz/<이름>/spec.json` 을 정본으로 삼아 SVG 를 만든다. +**SVG 는 바뀌었는데 spec 은 그대로면 그 그림은 정본에서 나온 것이 아니다.** 화살표를 +뒤집거나 라벨을 바꾸는 편집이 정확히 그 모양이다. + +지금 그림 쪽 검사기가 보는 것은 셋이다 — `` 가 이름인지 · 상자와 라벨이 겹치는지 · +해시가 검토 뒤에 바뀌었는지. **셋 다 그림이 무엇을 말하는지는 안 본다.** 화살표 방향과 +주체는 사람이 봐야 하지만, **그림이 정본을 거치지 않았다는 것은 문자열로 판정된다.** + +`spec.json` 의 간선과 SVG 의 경로를 직접 견주는 길도 있지만, 어느 `` 가 어느 간선인지 +대조하려면 렌더러가 id 를 어떻게 붙이는지 알아야 한다. **정본을 거쳤는지만 보면 렌더러를 +몰라도 된다.** + +두 자리를 본다. + +- **작업 트리** — SVG 가 고쳐졌는데 spec 은 안 고쳐졌다. 지금 손으로 고치는 중이다 +- **이력** — SVG 의 마지막 커밋이 spec 의 마지막 커밋보다 나중이다. 손으로 고쳐 커밋했다 + +**spec 이 없는 그림은 이 검사기가 볼 것이 아니다.** 저장소의 그림 272장 중 209장이 그렇고, +그것은 `verify-project-layout.py` 의 「techviz 정본이 없는 그림」이 세는 자리다. 여기서는 +못 본 것으로 센다 — 조용히 건너뛰면 「전부 맞다」가 「본 것만 맞다」를 가린다. + + python3 scripts/check-figure-provenance.py <프로젝트> +""" +from __future__ import annotations + +import argparse +import glob +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +import techlog # noqa: E402 + + +def _git(*args: str) -> str: + try: + p = subprocess.run(["git", "-C", ROOT, *args], + capture_output=True, text=True, timeout=30) + except (OSError, subprocess.SubprocessError): + return "" + return p.stdout.strip() if p.returncode == 0 else "" + + +def _dirty(rel: str) -> bool: + return bool(_git("status", "--porcelain", "--", rel)) + + +def _last_commit_time(rel: str) -> int | None: + out = _git("log", "-1", "--format=%ct", "--", rel) + return int(out) if out.isdigit() else None + + +def verify(project: str) -> techlog.Report: + rep = techlog.Report(project) + base = os.path.join(ROOT, "docs", project, "final") + paired = unpaired = 0 + for svg in sorted(glob.glob(os.path.join(base, "assets", "**", "*.svg"), + recursive=True)): + name = os.path.basename(svg)[:-4] + spec = os.path.join(base, ".techviz", name, "spec.json") + svg_rel = os.path.relpath(svg, ROOT) + if not os.path.exists(spec): + unpaired += 1 + continue + paired += 1 + spec_rel = os.path.relpath(spec, ROOT) + + if _dirty(svg_rel) and not _dirty(spec_rel): + rep.error("정본을 거치지 않고 고친 그림 — 작업 트리", + f"{svg_rel} 이 고쳐졌는데 {spec_rel} 은 그대로다") + continue + st, sp = _last_commit_time(svg_rel), _last_commit_time(spec_rel) + if st is not None and sp is not None and st > sp: + rep.error("정본을 거치지 않고 고친 그림 — 이력", + f"{svg_rel} 의 마지막 커밋이 {spec_rel} 보다 {st - sp}초 나중이다") + rep.facts["정본과 짝지은 그림"] = paired + if unpaired: + rep.facts["정본이 없어 못 본 그림"] = unpaired + return rep + + +def main() -> int: + ap = argparse.ArgumentParser(description="그림이 정본에서 나온 것인지 본다.") + ap.add_argument("projects", nargs="*") + ap.add_argument("--samples", type=int, default=3) + args = ap.parse_args() + + projects = 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/assets")) + if not os.path.basename(os.path.dirname(os.path.dirname(p))).startswith("_")) + missing = [p for p in projects if not os.path.isdir(os.path.join(ROOT, "docs", p))] + if missing: + print(f"대상이 성립하지 않는다 — 그런 프로젝트가 없다: {', '.join(missing)}", + file=sys.stderr) + return 2 + if not projects: + print("대상이 성립하지 않는다 — 그림을 가진 프로젝트가 없다", file=sys.stderr) + return 2 + + reports = [verify(p) for p in projects] + for rep in reports: + facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items()) or "그림 없음" + print(f"\n[{rep.project}] {facts}") + for rule, details in sorted(rep.errors.items(), key=lambda kv: -len(kv[1])): + print(f" ✗ error {len(details):>4} {rule}") + for d in details[:args.samples]: + print(f" · {d}") + e = sum(r.error_count for r in reports) + print(f"\nFIGURE PROVENANCE: {'FAIL' if e else 'PASS'} — " + f"프로젝트 {len(reports)} · error {e}") + return 1 if e else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-preservation.py b/scripts/check-preservation.py index 39dd9da..4c4c3e9 100644 --- a/scripts/check-preservation.py +++ b/scripts/check-preservation.py @@ -98,6 +98,20 @@ VOICE_MARKS = ( "시행착오", "삽질", "헤맸", "덕분에", "다행히", "결국 내", ) +# 적용 범위를 넓히는 말. **늘어난 것만** 본다. +# +# 지금 검사기는 「사라진 것」과 「새로 생긴 보호 구간」을 본다. **「범위가 넓어진 것」을 보는 +# 자리가 없었다.** 「로컬에서 확인했다」에 「운영 환경에서도」를 **더하기만** 하면 유보도 +# 보호 구간도 안 바뀐다 — 지운 것이 없기 때문이다. 그래서 관문 전부를 지나갔다. +# +# 유보 감소의 반대편이다. 유보를 지워도 확신이 오르고, 범위 말을 더해도 오른다. +# **판정하지 않는다** — 범위가 실제로 넓어졌는지는 근거를 읽어야 안다. +SCOPE_MARKS = ( + "운영 환경", "운영에서", "운영에도", "프로덕션", + "항상", "언제나", "언제든", "어디서나", "어느 경우에도", + "모든 경우", "전부 그렇다", "예외 없이", "무조건", "반드시", "절대", +) + # `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면 # 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다 SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M) @@ -157,6 +171,18 @@ def compare(before: str, after: str) -> dict: "note": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 " "근거를 읽어야 안다"}) + # 적용 범위가 넓어졌나. 늘어난 것만 본다 — 좁히는 것은 이 규범에서 안전한 쪽이다 + for mark in SCOPE_MARKS: + gained = after.count(mark) - before.count(mark) + if gained > 0: + warnings.append({ + "kind": "적용 범위", "change": "새로생김", "count": gained, "value": mark, + "sentence": _sentence_of(after, mark), + "note": "편집이 적용 범위를 넓혔다. 그 범위에서 실제로 확인했는지 근거를 " + "읽어야 안다 — 로컬에서 잰 것을 운영에서 확인한 것으로 올리는 " + "자리가 여기다", + }) + # 자료에 없는 1인칭이 들어왔나. 늘어난 것만 본다 — 지우는 것은 이 규범에서 안전한 쪽이다 for mark in VOICE_MARKS: gained = after.count(mark) - before.count(mark) diff --git a/scripts/tests/test_figure_provenance.py b/scripts/tests/test_figure_provenance.py new file mode 100644 index 0000000..4d9ed83 --- /dev/null +++ b/scripts/tests/test_figure_provenance.py @@ -0,0 +1,65 @@ +"""그림이 정본에서 나온 것인지 보는 검사기. + +`spec.json` 의 간선과 SVG 의 경로를 직접 견주려면 렌더러가 id 를 어떻게 붙이는지 알아야 +한다. **정본을 거쳤는지만 보면 렌더러를 몰라도 된다** — 그리고 손으로 고친 그림이 +화살표 뒤집기의 모양이다. +""" +import importlib.util +import os +import subprocess +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TOOL = os.path.join(ROOT, "scripts", "check-figure-provenance.py") +_spec = importlib.util.spec_from_file_location("check_figure_provenance", TOOL) +fp = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(fp) + +SVG = ("docs/virtualization/final/assets/diagrams/" + "guest-memory-address-translation-path/guest-memory-address-translation-path.svg") +SPEC = ("docs/virtualization/final/.techviz/" + "guest-memory-address-translation-path/spec.json") + + +def _cli(*args): + return subprocess.run(["python3", TOOL, *args], cwd=ROOT, + capture_output=True, text=True) + + +class ProvenanceTest(unittest.TestCase): + def test_every_figure_in_this_repository_passes(self): + """대조군. 지금 저장소의 그림에 하나라도 걸리면 정책이 과하다.""" + p = _cli("--samples", "1") + self.assertEqual(0, p.returncode, p.stdout + p.stderr) + + def test_a_hand_edited_svg_is_caught(self): + """SVG 만 고치고 spec 은 그대로면 정본을 안 거친 그림이다. + + 고치고 나서 반드시 되돌린다 — 재현용 변경을 남기지 않는 것까지가 검증이다. + """ + svg = os.path.join(ROOT, SVG) + if not os.path.exists(svg): + self.skipTest("견줄 그림이 없다") + original = open(svg, encoding="utf-8").read() + try: + open(svg, "w", encoding="utf-8").write( + original.replace("marker-end", "marker-start", 1)) + p = _cli("virtualization") + self.assertEqual(1, p.returncode, p.stdout) + self.assertIn("정본을 거치지 않고 고친 그림", p.stdout) + finally: + open(svg, "w", encoding="utf-8").write(original) + self.assertEqual(0, _cli("virtualization").returncode) + + def test_a_figure_without_a_spec_is_counted_not_flagged(self): + """정본이 없는 그림은 이 검사기가 볼 것이 아니다. 세기만 한다.""" + rep = fp.verify("ca-tmpl") + self.assertEqual(0, rep.error_count) + self.assertGreater(rep.facts.get("정본이 없어 못 본 그림", 0), 0) + + def test_a_missing_project_is_not_reported_as_clean(self): + self.assertEqual(2, _cli("nosuchxyz").returncode) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_preservation.py b/scripts/tests/test_preservation.py index f0cfe36..1860062 100644 --- a/scripts/tests/test_preservation.py +++ b/scripts/tests/test_preservation.py @@ -204,3 +204,46 @@ class UnsourcedVoiceTest(unittest.TestCase): """대조군.""" self.assertEqual([], [w for w in cp.compare(self.BEFORE, self.BEFORE)["warnings"] if w["kind"] == "1인칭"]) + + +class WidenedScopeTest(unittest.TestCase): + """범위를 넓히는 말이 새로 들어왔나. + + 지금까지 검사기는 「사라진 것」과 「새로 생긴 보호 구간」을 봤다. **「범위가 넓어진 것」을 + 보는 자리가 없었다** — 더하기만 하면 유보도 보호 구간도 안 바뀐다. + """ + + BEFORE = "PK 조회라 두 쿼리 모두 Index Scan으로 1건을 약 0.02 ms에 가져온다." + + def test_adding_a_scope_word_is_surfaced_as_a_warning(self): + after = self.BEFORE.replace("조회라 ", "조회라 운영 환경에서도 ") + res = cp.compare(self.BEFORE, after) + self.assertEqual([], res["errors"], "판정이 아니라 라우팅이다") + self.assertIn("적용 범위", {w["kind"] for w in res["warnings"]}) + + def test_the_warning_points_at_the_sentence(self): + after = "항상 그렇다.\n\n" + self.BEFORE + w = next(x for x in cp.compare(self.BEFORE, after)["warnings"] + if x["kind"] == "적용 범위") + self.assertIn("항상", w["sentence"]) + + def test_narrowing_the_scope_is_not_reported(self): + """좁히는 것은 이 규범에서 안전한 쪽이다.""" + wide = "항상 그렇다. " + self.BEFORE + self.assertEqual([], [w for w in cp.compare(wide, self.BEFORE)["warnings"] + if w["kind"] == "적용 범위"]) + + def test_a_scope_word_that_was_already_there_is_not_reported(self): + """대조군. 원래 있던 말을 그대로 두고 주변만 고치면 조용해야 한다. + + 이게 없으면 「운영」이 든 문장을 다듬기만 해도 걸리는지 알 수 없다. + """ + before = "운영 환경에서 잰 값이다. " + self.BEFORE + after = "운영 환경에서 측정한 값이다. " + self.BEFORE + self.assertEqual([], [w for w in cp.compare(before, after)["warnings"] + if w["kind"] == "적용 범위"]) + + def test_an_ordinary_rewrite_raises_no_scope_warning(self): + after = self.BEFORE.replace("가져온다", "돌려준다") + self.assertEqual([], [w for w in cp.compare(self.BEFORE, after)["warnings"] + if w["kind"] == "적용 범위"])