fix: TechLog 리비전 셋을 매니페스트에 맞추고, 「경고 0건」을 두 가지로 가른다

부채 ④. 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
DongHyeonka
2026-09-11 09:18:45 +09:00
co-authored by Claude Opus 5
parent d2855d1e6c
commit 0f753c3be5
4 changed files with 212 additions and 10 deletions
@@ -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",
+22 -3
View File
@@ -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
+30 -3
View File
@@ -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}",
+151
View File
@@ -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()