diff --git a/scripts/review-package.py b/scripts/review-package.py index fb24835..fec82b1 100644 --- a/scripts/review-package.py +++ b/scripts/review-package.py @@ -220,9 +220,20 @@ def build(project: str, record: str, figures_dir: str | None = None, warnings: list[dict] = [] preservation = _preservation(before, rec_abs) if before else {"available": False} + + def _warn(entry: dict) -> None: + """경고에 안정 키를 붙여 담는다. + + 판정이 **어느 경고에 붙은 것인지** 정해져야 저장 게이트가 대조할 수 있다. + 내용이 바뀌면 키도 바뀐다 — 다른 경고에 옛 판정이 붙지 않는다. + 읽는 쪽은 `scripts/studio-save.py` 의 `_review_gate` 다. + """ + seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset")) + entry["key"] = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12] + warnings.append(entry) if preservation.get("available"): for h in preservation["hedgesDropped"]: - warnings.append({ + _warn({ "id": "유보 감소", "detail": f"{h['word']} {h['before']}회 → {h['after']}회", "sentence": h.get("sentence", ""), @@ -230,7 +241,7 @@ def build(project: str, record: str, figures_dir: str | None = None, "덜어 낼 만했던 것인지는 근거를 읽어야 안다", }) for f in preservation.get("warnings", []): - warnings.append({ + _warn({ "id": f"보호 구간 {f['change']}", "detail": f"{f['kind']} — {f['value'][:80]}", "sentence": f.get("sentence", ""), diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 6db0f4f..dbbb2ab 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -143,6 +143,10 @@ def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]: "detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다", "note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다", }) + for entry in warnings: + # 묶음의 경고와 같은 방식으로 키를 붙인다. 저장 게이트가 판정을 이 키로 찾는다 + seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset")) + entry["key"] = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12] return errors, warnings @@ -241,6 +245,75 @@ def build_input(record_path: str) -> dict: return doc +VERDICTS = ("PASS", "FAIL", "UNKNOWN") + + +def _review_gate(warnings: list[dict], verdict_path: str | None, + package_path: str) -> list[dict]: + """경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다. + + 검토로 라우팅한다는 것은 **저장이 막힌다**는 뜻이어야 한다. 막지 않으면 라우팅이 아니라 + 주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」. + + 이 함수가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다. 경고는 실렸는데 읽는 쪽이 + 없었다 — **싣는 것과 막는 것은 다른 일이다.** + + 경고가 없으면 판정 파일이 없어도 그대로 나간다. 정상은 이 게이트에 안 걸린다. + """ + if not warnings: + return [] + if not verdict_path: + raise Refused( + f"이 묶음에 검토가 필요한 것이 {len(warnings)}건 있다. --verdicts 로 판정 파일을 준다\n " + + "\n ".join(f"[{w.get('key')}] {w['id']} — {str(w.get('detail'))[:70]}" + for w in warnings) + + "\n검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다") + if not os.path.isfile(verdict_path): + raise Refused(f"그런 판정 파일이 없다: {verdict_path}") + try: + book = json.load(open(verdict_path, encoding="utf-8")) + except (OSError, ValueError) as e: + raise Refused(f"판정 파일을 읽지 못했다: {e}") + + want = book.get("packageSha256") + now = _sha256_file(package_path) + if want and want != now: + raise Refused( + "판정이 다른 묶음에 붙어 있다 — 그 판정을 이 묶음에 쓸 수 없다\n" + f" 판정이 본 묶음 {want}\n 지금 묶음 {now}") + + by_key = {v.get("key"): v for v in book.get("verdicts") or []} + missing, failed, unknown, passed = [], [], [], [] + for w in warnings: + v = by_key.get(w.get("key")) + if v is None: + missing.append(w) + continue + verdict = str(v.get("verdict", "")).upper() + if verdict not in VERDICTS: + raise Refused(f"판정은 {' · '.join(VERDICTS)} 중 하나다: {verdict!r}") + {"PASS": passed, "FAIL": failed, "UNKNOWN": unknown}[verdict].append((w, v)) + + if missing: + raise Refused( + "판정이 안 붙은 경고가 있다 — 빠뜨린 것과 통과시킨 것은 다르다\n " + + "\n ".join(f"[{w.get('key')}] {w['id']}" for w in missing)) + if failed: + raise Refused( + "검토가 **근거를 읽고 틀렸다고 봤다.** 고치기 전에는 저장하지 않는다\n " + + "\n ".join(f"[{w.get('key')}] {w['id']} — {v.get('why', '')[:70]}" + for w, v in failed)) + if unknown: + raise Refused( + "검토가 **근거가 모자라 판정을 못 했다.** UNKNOWN 은 통과가 아니다 — " + "근거를 채우고 다시 본다\n " + + "\n ".join(f"[{w.get('key')}] {w['id']} — {v.get('why', '')[:70]}" + for w, v in unknown)) + return [{"key": w.get("key"), "id": w["id"], "verdict": "PASS", + "why": v.get("why", ""), "reviewer": book.get("reviewer")} + for w, v in passed] + + def approved(record_path: str, package_path: str) -> dict: """검토를 지난 최종본만 통과시킨다. @@ -400,6 +473,7 @@ def main() -> int: ap.add_argument("--document-id", help="이미 있는 문서. 없으면 만들기 계획을 낸다") ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version") ap.add_argument("-o", "--out", help="계획을 적을 파일") + ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일") ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)") args = ap.parse_args() @@ -412,6 +486,8 @@ def main() -> int: if asset_errors: 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) doc = build_input(args.record) steps = plan_requests(args.record, doc, args.document_id, args.expected_version) @@ -432,7 +508,8 @@ def main() -> int: "approvedAgainst": pkg["target"]["sha256"], "checkerVersions": pkg.get("checkerVersions"), "requests": steps, - "warnings": asset_warnings, + "warnings": all_warnings, + "reviewCleared": cleared, "notes": [ "이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.", "게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.", @@ -448,10 +525,11 @@ 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(asset_warnings)}건 " + f"· 경고 {len(all_warnings)}건(검토 통과 {len(cleared)}) " f"· 무인 저장 {'켬' if UNATTENDED_SAVE_ENABLED else '끔'}") - if asset_warnings: - print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr) + if cleared: + print(f"검토가 통과시킨 경고 {len(cleared)}건 — 판정 파일 {args.verdicts}", + file=sys.stderr) else: print(text, end="") return 0 diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index 7a39d13..b3d802e 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -304,3 +304,89 @@ class CliTest(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class ReviewGateTest(unittest.TestCase): + """검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다. + + 막지 않으면 라우팅이 아니라 주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」. + 이 게이트가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다. + """ + + WARN = [{"id": "유보 감소", "detail": "가능성 2회 → 1회", "key": "aaaa1111"}, + {"id": "그림이 검토 뒤에 바뀌었다", "detail": "fd5f → 3ba4", "key": "bbbb2222"}] + + def setUp(self): + self.dir = tempfile.mkdtemp() + self.pkg = os.path.join(self.dir, "pkg.json") + json.dump({"target": {"sha256": "x"}}, open(self.pkg, "w", encoding="utf-8")) + self.book = os.path.join(self.dir, "v.json") + + def _write(self, verdicts, sha=None, keys=None): + json.dump({"schemaVersion": 1, "reviewer": "사람", + "packageSha256": sha if sha is not None else ss._sha256_file(self.pkg), + "verdicts": [{"key": k, "verdict": v, "why": "왜"} + for k, v in zip(keys or [w["key"] for w in self.WARN], + verdicts)]}, + open(self.book, "w", encoding="utf-8"), ensure_ascii=False) + + def test_no_warnings_needs_no_verdict_file(self): + """대조군. 정상은 이 게이트에 안 걸린다 — 불필요한 차단을 만들지 않는다.""" + self.assertEqual([], ss._review_gate([], None, self.pkg)) + + def test_warnings_without_a_verdict_file_are_refused(self): + """검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다.""" + with self.assertRaises(ss.Refused) as e: + ss._review_gate(self.WARN, None, self.pkg) + self.assertIn("검토를 안 받은 것", str(e.exception)) + + def test_all_pass_lets_the_save_through(self): + self._write(["PASS", "PASS"]) + cleared = ss._review_gate(self.WARN, self.book, self.pkg) + self.assertEqual(2, len(cleared)) + self.assertEqual({"PASS"}, {c["verdict"] for c in cleared}) + + def test_fail_and_unknown_both_block_with_different_words(self): + """둘 다 막되 문구가 갈린다 — 근거가 모자란 것과 틀렸다고 본 것은 다르다.""" + self._write(["PASS", "FAIL"]) + with self.assertRaises(ss.Refused) as fail: + ss._review_gate(self.WARN, self.book, self.pkg) + self._write(["PASS", "UNKNOWN"]) + with self.assertRaises(ss.Refused) as unknown: + ss._review_gate(self.WARN, self.book, self.pkg) + self.assertIn("틀렸다고 봤다", str(fail.exception)) + self.assertIn("근거가 모자라", str(unknown.exception)) + self.assertNotEqual(str(fail.exception), str(unknown.exception)) + + def test_a_warning_with_no_verdict_is_refused(self): + """빠뜨린 것과 통과시킨 것은 다르다.""" + self._write(["PASS"], keys=[self.WARN[0]["key"]]) + with self.assertRaises(ss.Refused) as e: + ss._review_gate(self.WARN, self.book, self.pkg) + self.assertIn("판정이 안 붙은", str(e.exception)) + + def test_a_verdict_for_another_package_is_refused(self): + """묶음이 바뀌면 그 판정을 이 묶음에 쓸 수 없다.""" + self._write(["PASS", "PASS"], sha="0" * 64) + with self.assertRaises(ss.Refused) as e: + ss._review_gate(self.WARN, self.book, self.pkg) + self.assertIn("다른 묶음", str(e.exception)) + + def test_a_missing_verdict_file_is_refused(self): + with self.assertRaises(ss.Refused): + ss._review_gate(self.WARN, os.path.join(self.dir, "nope.json"), self.pkg) + + def test_an_unknown_verdict_word_is_refused(self): + self._write(["PASS", "MAYBE"]) + with self.assertRaises(ss.Refused): + ss._review_gate(self.WARN, self.book, self.pkg) + + def test_the_key_changes_when_the_warning_changes(self): + """판정이 다른 경고에 옛 판정으로 붙지 않아야 한다.""" + import hashlib + def key(entry): + seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset")) + return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12] + a = {"id": "유보 감소", "detail": "가능성 2회 → 1회"} + b = {"id": "유보 감소", "detail": "가능성 3회 → 1회"} + self.assertNotEqual(key(a), key(b))