From 0f753c3be58f3dedd609e1362b9538837dc959aa Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 11 Sep 2026 09:18:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20TechLog=20=EB=A6=AC=EB=B9=84=EC=A0=84=20?= =?UTF-8?q?=EC=85=8B=EC=9D=84=20=EB=A7=A4=EB=8B=88=ED=8E=98=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EB=A7=9E=EC=B6=94=EA=B3=A0,=20=E3=80=8C?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=200=EA=B1=B4=E3=80=8D=EC=9D=84=20=EB=91=90?= =?UTF-8?q?=20=EA=B0=80=EC=A7=80=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부채 ④. TechLog 의 tech-log-design-package 가 ca1bbfe·1aae8dc·ffa088b 를 적고 있었는데 매니페스트는 그 뒤 다시 만들어져 세 파일 모두를 tech-log-frontend @ 0d4d1e5 로 적는다. 옛 셋은 tech-log-frontend 에 없다 — 옛 매니페스트가 안 남아 그 사이 계약 내용이 바뀌었는지는 대조할 수 없고, 그 사실을 verified 에 적었다. 기록의 「검증 환경」이 적은 리비전은 그때 잰 조건이라 고치지 않는다. 파일 sha256 셋을 함께 적어 리비전 문자열이 아니라 내용에 못박는다. check_evidence --repo TechLog exit 0 「문제 없음」 (3 → 0) review-package.py TechLog exit 0 · 관문 10 전부 exit 0 R14. 이 묶음의 막는 경고는 전부 편집 전후 보존 비교에서 나오고(review-package.py 의 _warn 자리 둘이 모두 if preservation.get("available") 안이다) 그 비교는 --before 를 줘야 돈다. 빼면 검토 관문이 아무 줄도 안 남기고 통과한다. - --before 를 필수로 만들지 않는다. 새로 쓴 기록엔 윤문 전 사본이 없어 첫 기록이 막힌다. 없다는 것을 적고 출력한다 - preservation.reason 으로 두 원인을 가른다 — NO_BEFORE · CHECKER_UNREADABLE - 묶음이 자기 입력을 적는다. 줬으면 before·beforeSha256, 안 줬으면 명시적 null. 빠진 칸과 null 은 다르다 - 종료 코드는 양쪽 다 0 이다. 새 관문이 아니라 읽는 계약이라 갈리는 것은 문구다. _review_gate 의 stderr 와 계획 한 줄 요약 둘 다 가른다 - B-B004c-plans/verdict.json 을 지웠다. 옛 묶음 53ce6a5b… 에 묶여 있는데 지금 계획은 0beb5420… 이라 읽히지 않는다 — 검토를 받은 것처럼 보이는 파일이 남는다 python3 -m unittest discover -s scripts/tests — Ran 266 · OK (skipped=13) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- .../tech-log-studio/tech-log-tree.json | 13 +- scripts/review-package.py | 25 ++- scripts/studio-save.py | 33 +++- scripts/tests/test_review_scope.py | 151 ++++++++++++++++++ 4 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 scripts/tests/test_review_scope.py diff --git a/docs/TechLog/tech-log-studio/tech-log-tree.json b/docs/TechLog/tech-log-studio/tech-log-tree.json index a020ed0..e00b2ba 100644 --- a/docs/TechLog/tech-log-studio/tech-log-tree.json +++ b/docs/TechLog/tech-log-studio/tech-log-tree.json @@ -19,11 +19,16 @@ "name": "tech-log-design-package", "path": "/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/openapi/MANIFEST.sha256", "revisions": { - "studio-v1.yaml": "ca1bbfe", - "public-v1.yaml": "1aae8dc", - "studio-management-v1.yaml": "ffa088b" + "studio-v1.yaml": "0d4d1e5", + "public-v1.yaml": "0d4d1e5", + "studio-management-v1.yaml": "0d4d1e5" }, - "verified": "설계 패키지 저장소는 이 기계에 없다. 반입된 계약의 MANIFEST.sha256 이 세 파일의 원본 리비전과 해시를 고정하고 있어 그것을 리비전으로 적는다 (2026-09-07 확인)" + "verified": "설계 패키지는 따로 있는 저장소가 아니라 tech-log-frontend 의 계약을 반입한 것이다. MANIFEST.sha256 이 세 파일 모두를 `tech-log-frontend src/features/tech-log/contracts/**` @ 0d4d1e5 (main) 로 적고 있고, `sha256sum -c MANIFEST.sha256` 이 셋 다 OK 이며, `git -C tech-log-frontend cat-file -t 0d4d1e5` 가 commit 이다 (2026-09-11 확인). 이전에 적혀 있던 ca1bbfe·1aae8dc·ffa088b 는 tech-log-frontend 에 없다 — 매니페스트가 그 뒤에 다시 만들어졌고 옛 판이 남아 있지 않아 **그 사이에 계약 내용이 바뀌었는지는 대조할 수 없다.** 기록의 `검증 환경` 이 적은 리비전은 그때 잰 조건이므로 고치지 않는다.", + "fileSha256": { + "studio-v1.yaml": "d06434f33a944f4bbcb26d8725e36c0391685125d2f902176c7ec4b16a78dc40", + "public-v1.yaml": "99f75d51cf19c8efe9847e9080babb8e5bf14ad28223e1a90d6501d96a0b4e15", + "studio-management-v1.yaml": "0c226600c45b06433480a8a2ef3d812480dce3850c333bfcab5f626143ce77ea" + } } ], "sourceRevision": "tech-log@2026-09-02", diff --git a/scripts/review-package.py b/scripts/review-package.py index 67b15ba..fb5911a 100644 --- a/scripts/review-package.py +++ b/scripts/review-package.py @@ -160,7 +160,10 @@ def _preservation(before: str, record: str) -> dict: path = os.path.join(ROOT, "scripts", "check-preservation.py") spec = importlib.util.spec_from_file_location("check_preservation", path) if spec is None or spec.loader is None: - return {"available": False} + # **「비교를 안 했다」와 「비교기를 못 읽었다」는 다른 일이다.** 같은 값이면 + # 묶음을 읽는 쪽이 왜 비어 있는지 모른다 + return {"available": False, "reason": "CHECKER_UNREADABLE", + "detail": f"check-preservation.py 를 못 읽었다: {path}"} m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) res = m.compare(open(before, encoding="utf-8").read(), @@ -223,7 +226,12 @@ def build(project: str, record: str, figures_dir: str | None = None, gates.append(_run(["python3", "scripts/check-figure-overlap.py", project])) warnings: list[dict] = [] - preservation = _preservation(before, rec_abs) if before else {"available": False} + # **빠진 칸과 null 은 다르다.** `--before` 를 줬으면 경로와 해시를, 안 줬으면 `None` 을 + # 명시적으로 적는다. 안 적으면 「비교가 없다」가 「비교할 것이 없었다」와 구별되지 않는다 + preservation = (_preservation(before, rec_abs) if before else + {"available": False, "reason": "NO_BEFORE", + "detail": "--before 를 안 줬다. 편집 전후 비교를 돌리지 않았다", + "before": None, "beforeSha256": None}) def _warn(entry: dict) -> None: """경고에 안정 키를 붙여 담는다. @@ -288,6 +296,11 @@ def build(project: str, record: str, figures_dir: str | None = None, "warnings 는 관문이 아니다. 종료 코드로 안 걸리는 것만 여기 올라온다 — " "유보 표현이 줄어든 자리가 그것이고, 확신 승격이 딱 그 모양이다. " "gates 가 전부 0 이어도 warnings 는 따로 읽는다.", + "**경고 0 건이 「봤는데 없었다」는 뜻이 아니다.** 이 묶음의 막는 경고는 전부 " + "편집 전후 보존 비교에서 나오고, 그 비교는 `--before` 를 줘야 돈다. " + "`preservation.available` 이 false 면 경고가 0 건인 것은 **사정권 밖**이지 " + "초록이 아니다. `preservation.reason` 이 왜 안 돌았는지 적는다 — " + "`NO_BEFORE`(안 줬다) · `CHECKER_UNREADABLE`(비교기를 못 읽었다).", "이 묶음이 못 보는 것이 있다. 수치도 인용도 없이 더한 산문 — 자료에 없는 1인칭 " "경험이나 선택 이유 — 은 보호 구간 비교로 원리적으로 안 보이고 gates 도 warnings 도 " "비어 있다. 본문을 읽는 것 말고는 방법이 없다.", @@ -318,9 +331,15 @@ def main() -> int: with open(args.out, "w", encoding="utf-8") as fh: fh.write(text) failed = [g for g in pkg["gates"] if g["exit"] != 0] + pres = pkg["preservation"] + # **`--before` 를 필수로 만들지 않는다** — 새로 쓴 기록에는 윤문 전 사본이 없어서 + # 첫 기록이 저장을 못 하게 된다. 없다는 것을 적고 출력하는 것으로 가른다 + note = ("경고 %d건" % len(pkg["warnings"]) if pres.get("available") else + "경고 0건 — 편집 전후 비교를 안 돌렸다(%s). 사정권 밖이지 초록이 아니다" + % pres.get("reason", "?")) print(f"{args.out} — 증거 {len(pkg['evidence'])} · 주장 후보 " f"{len(pkg['claimCandidates'])} · 관문 {len(pkg['gates'])}" - f" (exit≠0 {len(failed)}건) · 경고 {len(pkg['warnings'])}건") + f" (exit≠0 {len(failed)}건) · {note}") else: print(text, end="") return 0 diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 4d66c2f..3b9827d 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -503,8 +503,14 @@ def _figure_kind_coverage() -> tuple[int, int]: return total, unmarked +def _zero_note(pkg: dict) -> str: + """경고 0 건이 「봤는데 없었다」인지 「볼 것이 아직 없었다」인지 한 줄로 가른다.""" + pres = pkg.get("preservation") or {} + return "(봤고 없었다)" if pres.get("available") else "(사정권 밖 — 편집 전후 비교를 안 돌렸다)" + + def _review_gate(warnings: list[dict], verdict_path: str | None, - package_path: str) -> list[dict]: + package_path: str, preservation: dict | None = None) -> list[dict]: """경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다. 검토로 라우팅한다는 것은 **저장이 막힌다**는 뜻이어야 한다. 막지 않으면 라우팅이 아니라 @@ -522,6 +528,12 @@ def _review_gate(warnings: list[dict], verdict_path: str | None, 기록이 전부 막히고, 그러면 사람이 게이트를 우회하기 시작한다. 세는 쪽도 **조용히 빼지 않는다.** 몇 건인지 함께 낸다. + + **경고 0 건에는 두 가지가 있다.** 「봤는데 없었다」와 「볼 것이 아직 없었다」다. 이 묶음의 + 막는 경고는 전부 편집 전후 보존 비교에서 나오고(`review-package.py` 의 `_warn` 자리 둘이 + 모두 `if preservation.get("available")` 안이다), 그 비교는 `--before` 를 줘야 돈다. + 빼면 이 게이트가 **아무 줄도 안 남기고 통과한다.** 종료 코드는 양쪽 다 0 이다 — 새 + 관문이 아니라 **읽는 계약**이라, 갈리는 것은 찍는 문구다. """ blocking = [w for w in warnings if w.get("scope") != "repository"] counted = [w for w in warnings if w.get("scope") == "repository"] @@ -534,6 +546,17 @@ def _review_gate(warnings: list[dict], verdict_path: str | None, f"조용히 빼지 않고 센다", file=sys.stderr) warnings = blocking if not warnings: + pres = preservation or {} + if pres.get("available"): + print("검토 관문 — 경고 0건. 편집 전후 비교를 돌렸고 걸린 것이 없다", + file=sys.stderr) + else: + why = {"NO_BEFORE": "--before 를 안 줘서 편집 전후 비교를 안 돌렸다", + "CHECKER_UNREADABLE": "check-preservation.py 를 못 읽었다"}.get( + pres.get("reason"), "묶음이 왜 비었는지 적지 않았다") + print(f"검토 관문 — 경고 0건이지만 **본 것이 없다**: {why}.\n" + " 이 묶음의 막는 경고는 전부 편집 전후 비교에서 나온다. " + "초록이 아니라 사정권 밖이다", file=sys.stderr) return [] if not verdict_path: raise Refused( @@ -848,7 +871,8 @@ def main() -> int: raise Refused("그림 허용 정책에 걸린다 — 저장 단계에서 막는다\n " + "\n ".join(f"{e['asset']}: {e['detail']}" for e in asset_errors)) all_warnings = list(pkg.get("warnings") or []) + asset_warnings - cleared = _review_gate(all_warnings, args.verdicts, args.package) + cleared = _review_gate(all_warnings, args.verdicts, args.package, + pkg.get("preservation")) doc = build_input(args.record) if args.harness_test: doc = as_harness_test(doc) @@ -889,7 +913,10 @@ def main() -> int: open(args.out, "w", encoding="utf-8").write(text) ops = " · ".join(f"{s['op']} {s['method']}" for s in steps) print(f"{args.out} — {ops} · 그림 {len(pkg.get('assets') or [])}장 " - f"· 경고 {len(all_warnings)}건(검토 통과 {len(cleared)}) " + # **한 줄 요약도 「0건」을 두 가지로 찍는다.** 여기만 그냥 0 이면 + # 사람이 보는 마지막 줄에서 사정권 밖이 초록으로 돌아온다 + f"· 경고 {len(all_warnings)}건" + f"{'(검토 통과 %d)' % len(cleared) if all_warnings else _zero_note(pkg)} " f"· 무인 저장 {'켬' if UNATTENDED_SAVE_ENABLED else '끔'}") if cleared: print(f"검토가 통과시킨 경고 {len(cleared)}건 — 판정 파일 {args.verdicts}", diff --git a/scripts/tests/test_review_scope.py b/scripts/tests/test_review_scope.py new file mode 100644 index 0000000..c7f0994 --- /dev/null +++ b/scripts/tests/test_review_scope.py @@ -0,0 +1,151 @@ +"""경고 0 건에는 두 가지가 있다 — 「봤는데 없었다」와 「볼 것이 아직 없었다」. + +이 묶음의 **막는** 경고는 전부 편집 전후 보존 비교에서 나온다. `review-package.py` 에서 +`_warn` 을 부르는 자리가 둘뿐이고 둘 다 `if preservation.get("available"):` 안이다. +그 비교는 `--before` 를 줘야 돈다. 빼면 `studio-save.py` 의 검토 관문이 **아무 줄도 안 +남기고 통과한다.** + +종료 코드는 양쪽 다 0 이다. 새 관문이 아니라 **읽는 계약**이라 갈리는 것은 찍는 문구다. +**같은 문구가 나오면 고친 것이 아니다.** +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +RECORD = os.path.join( + "docs", "document-haness", "tech-log-studio", "pipeline-gate-exit-codes", + "case", "case-exit-code-read-behind-a-pipe.md") + + +def _package(out: str, before: str | None) -> subprocess.CompletedProcess: + cmd = [sys.executable, "scripts/review-package.py", "document-haness", + "--record", RECORD, "-o", out] + if before: + cmd += ["--before", before] + return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True) + + +class PreservationScopeTest(unittest.TestCase): + """묶음이 자기 입력을 적는가. 빠진 칸과 null 은 다르다.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + # 윤문 전 사본. 한 문장만 다르게 둔다 — 비교가 실제로 돌아야 한다 + self.before = os.path.join(self.tmp, "before.md") + src = open(os.path.join(ROOT, RECORD), encoding="utf-8").read() + open(self.before, "w", encoding="utf-8").write( + src.replace("열넷이 전부 `exit=0` 이었다.", + "열넷이 전부 `exit=0` 이었을 수도 있다.")) + + def test_without_before_the_package_says_it_did_not_look(self): + out = os.path.join(self.tmp, "nb.json") + r = _package(out, None) + self.assertEqual(0, r.returncode, r.stderr) + pkg = json.load(open(out, encoding="utf-8")) + pres = pkg["preservation"] + self.assertFalse(pres["available"]) + self.assertEqual("NO_BEFORE", pres["reason"]) + # 빠진 칸과 null 은 다르다 — 안 줬다는 것을 **명시적으로** 적는다 + self.assertIn("before", pres) + self.assertIsNone(pres["before"]) + self.assertIsNone(pres["beforeSha256"]) + + def test_with_before_the_package_records_its_input(self): + out = os.path.join(self.tmp, "wb.json") + r = _package(out, self.before) + self.assertEqual(0, r.returncode, r.stderr) + pres = json.load(open(out, encoding="utf-8"))["preservation"] + self.assertTrue(pres["available"]) + self.assertNotIn("reason", pres) # 안 돈 이유가 있을 리 없다 + self.assertTrue(pres["before"]) + self.assertEqual(64, len(pres["beforeSha256"])) + + def test_the_two_packages_print_different_lines(self): + """**같은 문구가 나오면 고친 것이 아니다.**""" + a = _package(os.path.join(self.tmp, "a.json"), None) + b = _package(os.path.join(self.tmp, "b.json"), self.before) + self.assertEqual(0, a.returncode) + self.assertEqual(0, b.returncode) + self.assertIn("편집 전후 비교를 안 돌렸다", a.stdout) + self.assertIn("사정권 밖", a.stdout) + self.assertNotIn("사정권 밖", b.stdout) + + +class ReviewGateWordingTest(unittest.TestCase): + """저장 게이트가 「0 건」을 두 가지로 찍는가. 종료 코드는 양쪽 다 0 이다.""" + + def setUp(self): + path = os.path.join(ROOT, "scripts", "studio-save.py") + import importlib.util as u + spec = u.spec_from_file_location("studio_save", path) + self.ss = u.module_from_spec(spec) + spec.loader.exec_module(self.ss) + + def _gate(self, preservation): + import io + import contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + out = self.ss._review_gate([], None, __file__, preservation) + return out, buf.getvalue() + + def test_it_says_it_looked_when_the_comparison_ran(self): + out, err = self._gate({"available": True}) + self.assertEqual([], out) + self.assertIn("편집 전후 비교를 돌렸고", err) + self.assertNotIn("사정권 밖", err) + + def test_it_says_it_did_not_look_when_there_was_no_before(self): + out, err = self._gate({"available": False, "reason": "NO_BEFORE"}) + self.assertEqual([], out) + self.assertIn("본 것이 없다", err) + self.assertIn("--before", err) + self.assertIn("사정권 밖", err) + + def test_it_names_the_other_cause_too(self): + """`available: false` 의 두 원인을 같은 문구로 찍지 않는다.""" + _, a = self._gate({"available": False, "reason": "NO_BEFORE"}) + _, b = self._gate({"available": False, "reason": "CHECKER_UNREADABLE"}) + self.assertIn("check-preservation.py 를 못 읽었다", b) + self.assertNotEqual(a, b) + + def test_a_package_that_says_nothing_is_not_read_as_green(self): + """옛 묶음에는 `reason` 이 없다. 그래도 초록으로 읽지 않는다.""" + _, err = self._gate({"available": False}) + self.assertIn("본 것이 없다", err) + self.assertIn("사정권 밖", err) + + +class PlanSummaryLineTest(unittest.TestCase): + """사람이 보는 마지막 줄에서도 갈려야 한다. 여기만 그냥 0 이면 + 사정권 밖이 초록으로 돌아온다.""" + + def setUp(self): + path = os.path.join(ROOT, "scripts", "studio-save.py") + import importlib.util as u + spec = u.spec_from_file_location("studio_save", path) + self.ss = u.module_from_spec(spec) + spec.loader.exec_module(self.ss) + + def test_the_summary_splits_zero_two_ways(self): + looked = self.ss._zero_note({"preservation": {"available": True}}) + blind = self.ss._zero_note({"preservation": {"available": False, + "reason": "NO_BEFORE"}}) + self.assertNotEqual(looked, blind) + self.assertIn("사정권 밖", blind) + self.assertNotIn("사정권 밖", looked) + + def test_an_old_package_without_preservation_is_not_green(self): + self.assertIn("사정권 밖", self.ss._zero_note({})) + + +if __name__ == "__main__": + unittest.main()