From c2742a66cc054d7aa6d586e55351ed5e9a30e529 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 11:06:59 +0900 Subject: [PATCH] =?UTF-8?q?pipeline:=20=EC=9B=90=EC=9E=A5=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=EA=B8=B0=EB=A5=BC=20=EB=AC=B8=EC=84=9C=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=97=90=20=EB=A7=9E=EC=B6=94=EA=B3=A0=20=ED=84=B0?= =?UTF-8?q?=EB=AF=B8=EB=84=90=20=EC=A6=9D=EA=B1=B0=20=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=87=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 — `stage-contracts.md` 「관문 요약」이 정본인데 `STAGES` 가 넷을 빠뜨리고 있었다. S4 `preview-figure.py` · S5 `check_evidence.mjs` · S6 `check_body.mjs`·`check_evidence.mjs` · S7 `build-tech-log-tree.py` 를 더한다. 그리고 `style_profile.mjs` 는 관문이 아니라 측정이다 — 문서 계약이 「error 0」을 붙인 것은 `check_prose` 뿐이다(`stage-contracts.md:178`·`:252`). 그런데 원장 검사기가 exit≠0 을 전부 error 로 세어서, 정직하게 exit 1 로 적은 원장은 무조건 실패하고 0 으로 고쳐 적으면 그건 지어낸 것이 된다. `MEASUREMENT_GATES` 로 갈라 **돌았다는 것만 요구하고 종료 코드 0 은 요구하지 않는다.** 대신 `exit` 칸이 없으면 error 다 — 안 돌리고 넘어가는 것을 막는다. 곁증명(`_side_proof`)에도 같은 규칙을 넣는다. 대조군 셋으로 확인했다. preview-figure 를 뺀 원장 → FAIL 「관문이 빠졌다」 style_profile 을 exit 1 로 적은 원장 → PASS style_profile 의 exit 칸을 지운 원장 → FAIL 「측정 관문의 종료 코드가 없다」 이 변경으로 `runs/virtualization/2026-09-08-1958/run.json` 이 빨개진다. S6 에서 `check_evidence.mjs` 를 실제로 안 돌린 원장이라 맞는 결과다. 낮춰서 초록으로 만들지 않는다. R10 — `scripts/terminal-evidence/tests/` 가 문서에 적힌 `unittest discover -s scripts/tests` 범위 밖이고, 직접 `discover` 를 걸면 `render_terminal` import 경로가 `sys.path` 에 없어 깨진다. 회귀가 초록인데 아무도 안 부르는 상태였다. `scripts/tests/test_terminal_evidence.py` 가 `load_tests` 로 경로를 얹고 끌어온다 (86 → 92). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wp9jNbePAmWc5jQwCYhK9v --- scripts/tests/test_terminal_evidence.py | 42 +++++++++++++++++++++++++ scripts/verify-pipeline-run.py | 37 ++++++++++++++++++---- 2 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 scripts/tests/test_terminal_evidence.py diff --git a/scripts/tests/test_terminal_evidence.py b/scripts/tests/test_terminal_evidence.py new file mode 100644 index 0000000..ef93208 --- /dev/null +++ b/scripts/tests/test_terminal_evidence.py @@ -0,0 +1,42 @@ +"""`scripts/terminal-evidence/tests/` 를 문서에 적힌 한 명령이 함께 돌게 잇는다. + +`python3 -m unittest discover -s scripts/tests` 는 그 폴더만 훑는다. 터미널 증거 렌더러의 +테스트는 `scripts/terminal-evidence/tests/` 에 따로 있어서 그 명령에 안 잡혔고, +`verify-pipeline.py` 도 unittest 를 부르지 않는다. 그래서 **초록인데 아무도 안 부르는 회귀**가 됐다. + +`discover -s scripts/terminal-evidence/tests` 를 직접 돌려도 깨진다 — 그 테스트가 +`render_terminal` 을 같은 폴더 기준으로 부르는데 그 경로가 `sys.path` 에 없다. 여기서 +경로를 얹고 파일을 직접 불러 `load_tests` 로 끌어온다. 중첩 `discover` 는 쓰지 않는다 — +`top_level_dir` 이 바깥을 가리키면 unittest 가 거절한다. +""" +from __future__ import annotations + +import importlib.util +import os +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_TE = os.path.join(os.path.dirname(_HERE), "terminal-evidence") +_TE_TESTS = os.path.join(_TE, "tests") + + +def load_tests(loader: unittest.TestLoader, tests: unittest.TestSuite, + pattern: str | None) -> unittest.TestSuite: + if not os.path.isdir(_TE_TESTS): + return tests + if _TE not in sys.path: + sys.path.insert(0, _TE) + for name in sorted(os.listdir(_TE_TESTS)): + if not (name.startswith("test") and name.endswith(".py")): + continue + mod_name = "terminal_evidence_" + name[:-3] + spec = importlib.util.spec_from_file_location( + mod_name, os.path.join(_TE_TESTS, name)) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) + tests.addTests(loader.loadTestsFromModule(module)) + return tests diff --git a/scripts/verify-pipeline-run.py b/scripts/verify-pipeline-run.py index 7484f97..931b40e 100644 --- a/scripts/verify-pipeline-run.py +++ b/scripts/verify-pipeline-run.py @@ -44,16 +44,29 @@ STAGES = { "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"], + "gates": ["lint", "check-figure-text.py", "check-figure-overlap.py", + "preview-figure.py"], "skippable": True}, + # S5·S6 은 문장을 고친 뒤라 S3 관문을 다시 돈다 (stage-contracts.md 「관문 요약」) "S5": {"skill": "rewriting-technical-prose-naturally", - "gates": ["check_prose.mjs", "style_profile.mjs", "check_body.mjs"], + "gates": ["check_prose.mjs", "style_profile.mjs", "check_body.mjs", + "check_evidence.mjs"], "skippable": False}, "S6": {"skill": "writing-as-the-person-who-did-it", - "gates": ["check_voice.mjs", "check_prose.mjs"], "skippable": False}, + "gates": ["check_voice.mjs", "check_prose.mjs", "check_body.mjs", + "check_evidence.mjs"], + "skippable": False}, "S7": {"skill": "publishing-tech-log-to-studio", - "gates": ["저장됨", "verify-tech-log-tree.py"], "skippable": True}, + "gates": ["저장됨", "build-tech-log-tree.py", "verify-tech-log-tree.py"], + "skippable": True}, } + +# 측정 관문 — 돌았다는 것은 요구하지만 종료 코드 0 은 요구하지 않는다. +# 문서 계약이 「error 0」을 붙인 것은 check_prose 뿐이고 style_profile 은 문체 수치를 보여 주는 +# 측정이다 (stage-contracts.md:178·:252). 여기에 0 을 요구하면 정직하게 적은 원장이 실패하고, +# 0 으로 고쳐 적으면 그건 지어낸 것이 된다. +MEASUREMENT_GATES = ("style_profile.mjs",) + ORDER = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"] @@ -130,9 +143,14 @@ def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> None if not gates: rep.error("곁증명에 관문이 없다", f"{where} — {out}") for g in gates: + cmd = str(g.get("cmd") or "") + if any(tok in cmd for tok in MEASUREMENT_GATES): + if g.get("exit") is None: + rep.error("곁증명의 측정 관문에 종료 코드가 없다", f"{where} — {cmd[:70]}") + continue if g.get("exit") not in (0, "0"): rep.error("곁증명의 관문이 통과하지 못했다", - f"{where} — {str(g.get('cmd'))[:70]} → exit {g.get('exit')}") + f"{where} — {cmd[:70]} → exit {g.get('exit')}") rep.facts.setdefault("곁증명", []).append(f"{sid}:{os.path.basename(out)}") @@ -214,9 +232,16 @@ def verify(path: str) -> Report: if token not in cmds: rep.error("관문이 빠졌다", f"{where} — {token}") for g in gates: + cmd = str(g.get("cmd") or "") + if any(tok in cmd for tok in MEASUREMENT_GATES): + # 측정 관문 — 돌았는지만 본다. exit 칸이 아예 없으면 안 돌린 것이다 + if g.get("exit") is None: + rep.error("측정 관문의 종료 코드가 없다", + f"{where} — {cmd[:70]} — 돌렸다는 기록이 없다") + continue if g.get("exit") not in (0, "0"): rep.error("관문이 통과하지 못했다", - f"{where} — {str(g.get('cmd'))[:70]} → exit {g.get('exit')}") + f"{where} — {cmd[:70]} → exit {g.get('exit')}") for out in st.get("outputs") or []: if not os.path.exists(os.path.join(ROOT, out)):