fix(studio-save): 양쪽이 빈 칸을 「같다」에 섞지 않는다 (C 의 B-006 ② 수정안)

C 가 V-009 에서 걸렸다 — P-QUESTION-01 의 「되읽기 차이 0」이 배열 다섯이 전부 비어
견줄 것이 없어서 나온 값이었다. same: true 가 「13칸이 다 맞았다」로 읽히는데 실제로는
찬 칸만 맞은 것이다. R14 와 같은 모양이 대조기 안에 한 번 더 있었다.

- _is_empty 로 양쪽이 빈 칸을 갈라 emptyBoth 로 낸다. 한쪽만 비면 차이다
- comparedFields 에서도 빠지고 comparedCount 가 실제로 견준 수를 낸다
- 사람이 보는 한 줄에 「N칸 중 M칸을 견줬다. K칸은 양쪽이 비어 견줄 것이 없었다」

C 의 수정안은 comparedFields 를 개수로 바꿨는데 목록을 유지하고 개수를 따로 뒀다 —
이미 목록으로 읽는 회귀가 있고, 어느 칸을 못 봤는지는 이름이 있어야 안다.

C-B006 §4 의 대조를 돌렸다. 손으로 센 찬 칸과 도구 출력이 맞는다.

  CONCEPT   6 / 6      REFERENCE 10 / 10      QUESTION 11 / 11

묶음은 B 가 읽을 수 없어(계약 §3.2) 같은 종류의 기록으로 셌다.

python3 -m unittest discover -s scripts/tests — Ran 282 · 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 11:06:12 +09:00
co-authored by Claude Opus 5
parent f765a79d6f
commit 252f14c88e
2 changed files with 88 additions and 2 deletions
+37 -2
View File
@@ -898,6 +898,11 @@ ITEM_ID_NOTE = ("항목 `id` 만 못 본다 — 보낸 것은 uuid5 이고 서
"순서가 바뀌는 것은 여전히 걸린다")
def _is_empty(v) -> bool:
"""값이 「없음」인가. 서버가 빈 배열로, 어댑터가 `None` 으로 두는 자리를 같게 본다."""
return v is None or v == "" or v == [] or v == {}
def _without_item_ids(value):
"""항목 배열에서 `id` 만 뺀다. 나머지 칸은 그대로 둔다."""
if not isinstance(value, list):
@@ -919,6 +924,21 @@ def normalize(value) -> str:
return "\n".join(line.rstrip() for line in text.split("\n")).strip()
def _compare_summary(sent: dict, empty_both: list, differences: list,
whitespace_only: list) -> str:
"""사람이 보는 한 줄. **그냥 「같음」이면 사정권 밖이 초록으로 돌아온다** —
`NO_BEFORE` 를 요약 줄에 적은 것과 같은 이유다."""
total = len([f for f in sent if f not in NOT_COMPARED])
seen = total - len(empty_both)
head = "같음" if not differences and not whitespace_only else (
f"다름 {len(differences)}" + (f" · 공백만 {len(whitespace_only)}"
if whitespace_only else ""))
tail = (f"{total}칸 중 {seen}칸을 견줬다. "
f"{len(empty_both)}칸은 양쪽이 비어 견줄 것이 없었다"
if empty_both else f"{total}칸을 전부 견줬다")
return head + tail
def compare_saved(sent: dict, fetched: dict) -> dict:
"""보낸 것과 되읽은 것을 견준다.
@@ -932,11 +952,18 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
"""
differences, whitespace_only, truncated = [], [], []
item_ids_skipped = []
item_ids_skipped, empty_both = [], []
for field, want in sent.items():
if field in NOT_COMPARED:
continue
got = fetched.get(field)
if _is_empty(want) and _is_empty(got):
# **견준 것이 아니라 견줄 것이 없었던 칸이다.** 이것을 「같다」에 섞으면
# 「칸이 다 맞았다」가 「찬 칸만 맞았다」를 가린다 — R14 와 같은 모양이다.
# C 가 V-009 에서 걸렸다: `P-QUESTION-01` 의 「되읽기 차이 0」이 배열 다섯이
# 전부 비어 **견줄 것이 없어서** 나온 값이었다
empty_both.append(field)
continue
if field in ITEM_ID_REISSUED and isinstance(want, list):
# **빼먹은 것이 아니라 못 보는 것**이므로 무엇을 왜 안 보는지 값에 적는다
item_ids_skipped.append({"field": field, "items": len(want),
@@ -964,7 +991,15 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
"notCompared": NOT_COMPARED,
"itemIdsNotCompared": item_ids_skipped,
"unexpected": unexpected,
"comparedFields": [f for f in sent if f not in NOT_COMPARED],
# **실제로 견준 칸만 남는다.** 양쪽이 빈 칸은 `emptyBoth` 로 갈라 나간다.
# C 의 수정안은 이 칸을 개수로 바꿨는데 여기서는 목록을 유지하고 개수를 따로 둔다 —
# 이미 목록으로 읽는 회귀가 있고, 어느 칸을 못 봤는지는 이름이 있어야 안다
"comparedFields": [f for f in sent
if f not in NOT_COMPARED and f not in empty_both],
"comparedCount": len([f for f in sent
if f not in NOT_COMPARED and f not in empty_both]),
"emptyBoth": empty_both,
"summary": _compare_summary(sent, empty_both, differences, whitespace_only),
}
+51
View File
@@ -788,6 +788,57 @@ class HarnessTestPlanTest(unittest.TestCase):
verify = next(x for x in steps if x["op"] == "verify")
self.assertIn(pid, verify["expect"]["projectId"])
def test_fields_empty_on_both_sides_are_not_counted_as_compared(self):
"""C 가 V-009 에서 걸린 자리. 「되읽기 차이 0」이 배열 다섯이 전부 비어
**견줄 것이 없어서** 나온 값이었다 — R14 와 같은 모양이 대조기 안에 한 번 더 있다."""
sent = {"title": "t", "summary": "s", "facts": [], "assumptions": [],
"unknowns": [], "constraints": [], "options": []}
r = ss.compare_saved(sent, dict(sent))
self.assertTrue(r["same"])
self.assertEqual(2, r["comparedCount"])
self.assertEqual(["facts", "assumptions", "unknowns", "constraints", "options"],
r["emptyBoth"])
# 견준 목록에서도 빠진다 — 어느 칸을 못 봤는지 이름이 남아야 한다
self.assertNotIn("facts", r["comparedFields"])
def test_the_compare_summary_says_how_many_it_looked_at(self):
"""사람이 보는 한 줄이 그냥 「같음」이면 사정권 밖이 초록으로 돌아온다."""
blind = ss.compare_saved({"title": "t", "facts": []},
{"title": "t", "facts": []})["summary"]
full = ss.compare_saved({"title": "t", "facts": [{"id": "a", "text": "하나",
"order": 0}]},
{"title": "t", "facts": [{"id": "x", "text": "하나",
"order": 0}]})["summary"]
self.assertIn("견줄 것이 없었다", blind)
self.assertNotIn("견줄 것이 없었다", full)
self.assertNotEqual(blind, full)
def test_an_empty_field_on_one_side_only_is_still_a_difference(self):
"""**양쪽이 비었을 때만 뺀다.** 한쪽만 비면 값이 사라진 것이다."""
sent = {"facts": [{"id": "a", "text": "하나", "order": 0}]}
r = ss.compare_saved(sent, {"facts": []})
self.assertFalse(r["same"])
self.assertEqual([], r["emptyBoth"])
def test_the_tool_count_matches_what_c_counted_by_hand(self):
"""C-B006 §4 — 손으로 센 「찬 칸」과 도구 출력이 맞는가. 안 맞으면 그게 발견이다.
묶음은 B 가 읽을 수 없으므로(계약 §3.2) 같은 종류의 기록으로 센다."""
for path, want in (
("docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/"
"concept-bearer-jwt-validation-chain.md", 6),
("docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/"
"reference-token-vs-session.md", 10),
# `verify-pipeline.py:172` 의 `FORBIDDEN_LITERAL` 이 「옛 저장소 이름 의존」과
# 「그 이름의 docs 프로젝트를 가리킴」을 못 가른다(B-008 §7.6). 글자를 쪼개
# 피하지 않고 같은 종류의 다른 프로젝트 기록을 쓴다
("docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/"
"question-bff-state-store.md", 11),
):
with self.subTest(path=path):
import os
doc = ss.build_input(os.path.join(ROOT, path))
self.assertEqual(want, ss.compare_saved(doc, dict(doc))["comparedCount"])
def test_a_harness_plan_still_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)