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:
co-authored by
Claude Opus 5
parent
d2855d1e6c
commit
0f753c3be5
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user