fix(studio-save): CONCEPT·REFERENCE 의 칸이 계약과 달랐다
C 가 V-009 에서 운영에 직접 걸어 찾았다. QUESTION 에서 고친 것과 같은 결함족이다.
① CONCEPT — basisVersion 이 required 인데(studio-v1.yaml:979) 어댑터가 절만 봤다.
값은 frontmatter 에 있다. 상한 120자를 넘으면 조용히 자르지 않고 거절한다.
② REFERENCE — 셋이 한꺼번에 틀렸다(studio-v1.yaml:955-963).
이름: appliesWhen 이 아니라 applyWhen 이다 — nextValidation 과 같은 자리다
모양: rules 는 ReferenceRule 배열, applyWhen·exceptions·examples 는 OrderedText
배열이다. purpose 만 문자열이다
없는 칸: verifiedOn 이 required 인데 아예 없었다. 기록이 안 적었으면 null 로 둔다
③ 되읽기 비교가 항목 id 를 견주고 있었다. 보내는 것은 uuid5 이고 서버는 저장하면서
uuid4 를 새로 발급한다 — 그대로 두면 모든 QUESTION·REFERENCE 저장이 「차이 있음」이
된다. 칸 전체를 빼지 않고 id 만 뺐다. text·title·body·order 는 계속 대조하므로
항목이 빠지거나 순서가 바뀌는 것은 여전히 걸린다. 무엇을 왜 안 보는지는
itemIdsNotCompared 에 값으로 적는다.
python3 -m unittest discover -s scripts/tests — Ran 273 · 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
14137382fe
commit
0d588814cc
+84
-5
@@ -59,8 +59,16 @@ REPLAYED_HEADER = "Idempotency-Replayed"
|
|||||||
FIELD_MAP = {
|
FIELD_MAP = {
|
||||||
"CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment",
|
"CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment",
|
||||||
"재현 조건": "reproduction", "본문": "bodyMarkdown"},
|
"재현 조건": "reproduction", "본문": "bodyMarkdown"},
|
||||||
|
# **`basisVersion` 은 절이 아니라 frontmatter 에 있다.** `required` 인데 어댑터가
|
||||||
|
# 절만 봐서 빠졌다 — `422 /basisVersion must not be null` (C 가 V-009 의
|
||||||
|
# `P-CONCEPT-01`·`02` 에서 맞았다). `_concept_shape` 가 frontmatter 에서 가져온다
|
||||||
"CONCEPT": {"본문": "bodyMarkdown"},
|
"CONCEPT": {"본문": "bodyMarkdown"},
|
||||||
"REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen",
|
# **REFERENCE 는 셋이 한꺼번에 틀려 있었다** (`studio-v1.yaml:955-963`).
|
||||||
|
# 이름 — `appliesWhen` 이 아니라 **`applyWhen`** 이다. nextValidation 과 같은 자리다
|
||||||
|
# 모양 — `rules` 는 `ReferenceRule` 배열, `applyWhen`·`exceptions`·`examples` 는
|
||||||
|
# `OrderedText` 배열이다. 문자열로 보내면 MismatchedInputException 이다
|
||||||
|
# 없는 칸 — `verifiedOn` 이 required 인데 아예 없었다
|
||||||
|
"REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "applyWhen",
|
||||||
"예외": "exceptions", "예시": "examples"},
|
"예외": "exceptions", "예시": "examples"},
|
||||||
# **QUESTION 의 칸은 문자열이 아니다.** `facts`·`assumptions`·`unknowns`·`constraints` 는
|
# **QUESTION 의 칸은 문자열이 아니다.** `facts`·`assumptions`·`unknowns`·`constraints` 는
|
||||||
# `OrderedText` 배열이고 `options` 는 `QuestionOption` 배열이며, 마지막 칸의 이름은
|
# `OrderedText` 배열이고 `options` 는 `QuestionOption` 배열이며, 마지막 칸의 이름은
|
||||||
@@ -342,6 +350,49 @@ def _question_options(chunk: str) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered(slug: str, field: str, raw) -> list[dict]:
|
||||||
|
"""`- ` 항목을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다."""
|
||||||
|
items = _bullets(raw) if isinstance(raw, str) else []
|
||||||
|
return [{"id": _ordered_id(slug, field, i), "text": t, "order": i}
|
||||||
|
for i, t in enumerate(items)]
|
||||||
|
|
||||||
|
|
||||||
|
def _titled(slug: str, field: str, raw) -> list[dict]:
|
||||||
|
"""`### N. 제목` 과 그 아래 문단을 `{제목, 본문}` 쌍으로."""
|
||||||
|
out: list[dict] = []
|
||||||
|
parts = re.split(r"^###\s+(.+)$", raw, flags=re.M) if isinstance(raw, str) else []
|
||||||
|
for i in range(1, len(parts), 2):
|
||||||
|
title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip())
|
||||||
|
out.append({"id": _ordered_id(slug, field, len(out)), "title": title[:120],
|
||||||
|
"body": parts[i + 1].strip(), "order": len(out)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _concept_shape(doc: dict, fm: dict) -> dict:
|
||||||
|
"""`basisVersion` 은 frontmatter 에 있다. 절만 보면 빠진다."""
|
||||||
|
basis = (fm.get("basisVersion") or "").strip()
|
||||||
|
if len(basis) > 120:
|
||||||
|
# **조용히 자르지 않는다.** 무엇을 보고 쓴 글인지가 잘리면 읽는 사람이 모른다
|
||||||
|
raise Refused(f"basisVersion 이 {len(basis)}자다. 상한 120자 "
|
||||||
|
f"(studio-v1.yaml:990) — 기록에서 줄인다")
|
||||||
|
doc["basisVersion"] = basis # 비워도 되지만 **빠지면 안 된다**
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_shape(doc: dict, fm: dict) -> dict:
|
||||||
|
"""REFERENCE 의 칸을 서버 스키마의 모양으로. `purpose` 만 문자열이다."""
|
||||||
|
slug = doc.get("slug") or ""
|
||||||
|
doc["rules"] = _titled(slug, "rules", doc.get("rules"))
|
||||||
|
for field in ("applyWhen", "exceptions", "examples"):
|
||||||
|
doc[field] = _ordered(slug, field, doc.get(field))
|
||||||
|
if not isinstance(doc.get("purpose"), str):
|
||||||
|
doc["purpose"] = ""
|
||||||
|
# required 이고 `[string, "null"]` 이다. **기록이 안 적었으면 지어내지 않는다**
|
||||||
|
on = (fm.get("verifiedOn") or fm.get("lastVerifiedOn") or "").strip()
|
||||||
|
doc["verifiedOn"] = on or None
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
def _question_shape(doc: dict, fm: dict) -> dict:
|
def _question_shape(doc: dict, fm: dict) -> dict:
|
||||||
"""QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다.
|
"""QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다.
|
||||||
|
|
||||||
@@ -349,11 +400,8 @@ def _question_shape(doc: dict, fm: dict) -> dict:
|
|||||||
"""
|
"""
|
||||||
slug = doc.get("slug") or ""
|
slug = doc.get("slug") or ""
|
||||||
for field in ("facts", "assumptions", "unknowns", "constraints"):
|
for field in ("facts", "assumptions", "unknowns", "constraints"):
|
||||||
raw = doc.get(field)
|
|
||||||
items = _bullets(raw) if isinstance(raw, str) else []
|
|
||||||
# **빈 배열이지 빠뜨리는 것이 아니다.** 넷 다 required 다 (`studio-v1.yaml:1023`)
|
# **빈 배열이지 빠뜨리는 것이 아니다.** 넷 다 required 다 (`studio-v1.yaml:1023`)
|
||||||
doc[field] = [{"id": _ordered_id(slug, field, i), "text": t, "order": i}
|
doc[field] = _ordered(slug, field, doc.get(field))
|
||||||
for i, t in enumerate(items)]
|
|
||||||
raw = doc.get("options")
|
raw = doc.get("options")
|
||||||
opts = _question_options(raw) if isinstance(raw, str) else []
|
opts = _question_options(raw) if isinstance(raw, str) else []
|
||||||
doc["options"] = [{"id": _ordered_id(slug, "options", i), "title": o["title"],
|
doc["options"] = [{"id": _ordered_id(slug, "options", i), "title": o["title"],
|
||||||
@@ -405,6 +453,10 @@ def build_input(record_path: str) -> dict:
|
|||||||
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
|
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
|
||||||
if kind == "QUESTION":
|
if kind == "QUESTION":
|
||||||
doc = _question_shape(doc, fm)
|
doc = _question_shape(doc, fm)
|
||||||
|
if kind == "CONCEPT":
|
||||||
|
doc = _concept_shape(doc, fm)
|
||||||
|
if kind == "REFERENCE":
|
||||||
|
doc = _reference_shape(doc, fm)
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
@@ -777,6 +829,26 @@ NOT_COMPARED = {
|
|||||||
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
|
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 항목 배열의 **`id` 만** 못 본다. 보내는 것은 `uuid5(slug|칸|순번)` 이고 서버는 저장하면서
|
||||||
|
# 새 uuid4 를 발급한다 — C 가 V-009 의 `P-QUESTION-02` 에서 갈랐다(`text`·`order` 는 같고
|
||||||
|
# `id` 만 달랐다). 칸 전체를 빼면 **모든 QUESTION·REFERENCE 저장이 「차이 있음」으로 보고되거나
|
||||||
|
# 반대로 항목이 빠진 것을 못 보게 된다.** 그래서 `id` 만 빼고 나머지는 계속 대조한다.
|
||||||
|
ITEM_ID_REISSUED = {
|
||||||
|
"facts", "assumptions", "unknowns", "constraints", "options",
|
||||||
|
"rules", "applyWhen", "exceptions", "examples", "consequences",
|
||||||
|
}
|
||||||
|
ITEM_ID_NOTE = ("항목 `id` 만 못 본다 — 보낸 것은 uuid5 이고 서버가 새 uuid4 를 발급한다. "
|
||||||
|
"`text`·`title`·`body`·`order` 는 그대로 대조한다 — 항목이 빠지거나 "
|
||||||
|
"순서가 바뀌는 것은 여전히 걸린다")
|
||||||
|
|
||||||
|
|
||||||
|
def _without_item_ids(value):
|
||||||
|
"""항목 배열에서 `id` 만 뺀다. 나머지 칸은 그대로 둔다."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return [{k: v for k, v in x.items() if k != "id"} if isinstance(x, dict) else x
|
||||||
|
for x in value]
|
||||||
|
|
||||||
|
|
||||||
def normalize(value) -> str:
|
def normalize(value) -> str:
|
||||||
"""되읽어 견주기 전에 줄 끝 공백과 줄바꿈 표기만 맞춘다.
|
"""되읽어 견주기 전에 줄 끝 공백과 줄바꿈 표기만 맞춘다.
|
||||||
@@ -804,10 +876,16 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
|
|||||||
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
|
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
|
||||||
"""
|
"""
|
||||||
differences, whitespace_only, truncated = [], [], []
|
differences, whitespace_only, truncated = [], [], []
|
||||||
|
item_ids_skipped = []
|
||||||
for field, want in sent.items():
|
for field, want in sent.items():
|
||||||
if field in NOT_COMPARED:
|
if field in NOT_COMPARED:
|
||||||
continue
|
continue
|
||||||
got = fetched.get(field)
|
got = fetched.get(field)
|
||||||
|
if field in ITEM_ID_REISSUED and isinstance(want, list):
|
||||||
|
# **빼먹은 것이 아니라 못 보는 것**이므로 무엇을 왜 안 보는지 값에 적는다
|
||||||
|
item_ids_skipped.append({"field": field, "items": len(want),
|
||||||
|
"note": ITEM_ID_NOTE})
|
||||||
|
want, got = _without_item_ids(want), _without_item_ids(got)
|
||||||
nw, ng = normalize(want), normalize(got)
|
nw, ng = normalize(want), normalize(got)
|
||||||
raw_same = str(want or "") == str(got or "")
|
raw_same = str(want or "") == str(got or "")
|
||||||
if nw != ng:
|
if nw != ng:
|
||||||
@@ -828,6 +906,7 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
|
|||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"whitespaceOnly": whitespace_only,
|
"whitespaceOnly": whitespace_only,
|
||||||
"notCompared": NOT_COMPARED,
|
"notCompared": NOT_COMPARED,
|
||||||
|
"itemIdsNotCompared": item_ids_skipped,
|
||||||
"unexpected": unexpected,
|
"unexpected": unexpected,
|
||||||
"comparedFields": [f for f in sent if f not in NOT_COMPARED],
|
"comparedFields": [f for f in sent if f not in NOT_COMPARED],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -667,6 +667,72 @@ class HarnessTestPlanTest(unittest.TestCase):
|
|||||||
out = ss._question_shape({"slug": "s"}, {})
|
out = ss._question_shape({"slug": "s"}, {})
|
||||||
self.assertIsNone(out["questionStatus"])
|
self.assertIsNone(out["questionStatus"])
|
||||||
|
|
||||||
|
def test_concept_carries_basis_version_from_frontmatter(self):
|
||||||
|
"""`basisVersion` 은 절이 아니라 frontmatter 에 있다. required 라 빠지면 422 다
|
||||||
|
(C 의 `P-CONCEPT-01`·`02`)."""
|
||||||
|
import tempfile, os
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
f = os.path.join(d, "c.md")
|
||||||
|
open(f, "w", encoding="utf-8").write(
|
||||||
|
"---\nkind: CONCEPT\ntitle: t\nslug: s\n"
|
||||||
|
"basisVersion: Keycloak 26.7.0\n---\n\n## 본문\n\n가.\n")
|
||||||
|
doc = ss.build_input(f)
|
||||||
|
self.assertEqual("Keycloak 26.7.0", doc["basisVersion"])
|
||||||
|
|
||||||
|
def test_concept_refuses_an_overlong_basis_version(self):
|
||||||
|
"""상한 120자. **조용히 자르지 않는다** — 무엇을 보고 쓴 글인지가 잘리면 모른다."""
|
||||||
|
with self.assertRaises(ss.Refused):
|
||||||
|
ss._concept_shape({}, {"basisVersion": "가" * 121})
|
||||||
|
|
||||||
|
def test_concept_keeps_the_field_even_when_empty(self):
|
||||||
|
"""비워도 되지만 **빠지면 안 된다**."""
|
||||||
|
self.assertEqual("", ss._concept_shape({}, {})["basisVersion"])
|
||||||
|
|
||||||
|
def test_reference_uses_the_contract_field_name(self):
|
||||||
|
"""계약은 `applyWhen` 이다. 어댑터가 `appliesWhen` 을 보내고 있었다 —
|
||||||
|
`nextVerification` → `nextValidation` 과 같은 자리다."""
|
||||||
|
doc = ss._reference_shape({"slug": "s", "applyWhen": "- 하나"}, {})
|
||||||
|
self.assertIn("applyWhen", doc)
|
||||||
|
self.assertNotIn("appliesWhen", doc)
|
||||||
|
self.assertEqual("하나", doc["applyWhen"][0]["text"])
|
||||||
|
|
||||||
|
def test_reference_shapes_are_arrays(self):
|
||||||
|
"""`rules` 는 `ReferenceRule`, 나머지 셋은 `OrderedText` 다. `purpose` 만 문자열."""
|
||||||
|
doc = ss._reference_shape(
|
||||||
|
{"slug": "s", "purpose": "무엇을 위한 것인가", "rules": "### 1. 첫 규칙\n\n몸통이다.",
|
||||||
|
"applyWhen": "- 하나", "exceptions": "", "examples": ""}, {})
|
||||||
|
self.assertEqual({"id", "title", "body", "order"}, set(doc["rules"][0]))
|
||||||
|
self.assertEqual("첫 규칙", doc["rules"][0]["title"])
|
||||||
|
self.assertEqual({"id", "text", "order"}, set(doc["applyWhen"][0]))
|
||||||
|
self.assertEqual([], doc["exceptions"]) # required — 비어도 [] 다
|
||||||
|
self.assertEqual([], doc["examples"])
|
||||||
|
self.assertIsInstance(doc["purpose"], str)
|
||||||
|
|
||||||
|
def test_reference_verified_on_is_not_invented(self):
|
||||||
|
"""required 이고 `[string, "null"]` 이다. 기록이 안 적었으면 지어내지 않는다."""
|
||||||
|
self.assertIsNone(ss._reference_shape({"slug": "s"}, {})["verifiedOn"])
|
||||||
|
self.assertEqual("2026-08-30",
|
||||||
|
ss._reference_shape({"slug": "s"},
|
||||||
|
{"lastVerifiedOn": "2026-08-30"})["verifiedOn"])
|
||||||
|
|
||||||
|
def test_item_ids_are_not_compared_but_text_and_order_are(self):
|
||||||
|
"""서버가 항목 `id` 를 새로 발급한다. 칸 전체를 빼면 항목이 빠진 것도 못 본다."""
|
||||||
|
sent = {"facts": [{"id": "a5", "text": "하나", "order": 0},
|
||||||
|
{"id": "b5", "text": "둘", "order": 1}]}
|
||||||
|
same = {"facts": [{"id": "x4", "text": "하나", "order": 0},
|
||||||
|
{"id": "y4", "text": "둘", "order": 1}]}
|
||||||
|
self.assertTrue(ss.compare_saved(sent, same)["same"])
|
||||||
|
# **못 보는 것을 값에 적는다** — 빼먹은 것이 아니다
|
||||||
|
skipped = ss.compare_saved(sent, same)["itemIdsNotCompared"]
|
||||||
|
self.assertEqual("facts", skipped[0]["field"])
|
||||||
|
self.assertIn("order", skipped[0]["note"])
|
||||||
|
# 항목이 빠지거나 순서가 바뀌면 여전히 걸린다
|
||||||
|
dropped = {"facts": [{"id": "x4", "text": "하나", "order": 0}]}
|
||||||
|
self.assertFalse(ss.compare_saved(sent, dropped)["same"])
|
||||||
|
swapped = {"facts": [{"id": "x4", "text": "둘", "order": 0},
|
||||||
|
{"id": "y4", "text": "하나", "order": 1}]}
|
||||||
|
self.assertFalse(ss.compare_saved(sent, swapped)["same"])
|
||||||
|
|
||||||
def test_a_harness_plan_still_has_no_publish_path(self):
|
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,
|
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
|
||||||
harness_test=True)
|
harness_test=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user