fix(studio-save): PROJECT_DECISION 의 칸을 계약에 맞추고 지울 수 있게 만든다

B-010 §4.1 에서 찾고 안 고친 것이다. 그때 안 고친 이유는 예산이 아니라 측정이었고,
이번에는 그 런에서 잰다.

- 결정문→statement · 영향→consequences(OrderedText 배열) · 판단 이유→rationale
- 근거→basis 를 뺐다. ProjectDecisionInput 에 그 칸이 없고 unevaluatedProperties:
  false 라 보내면 거절된다. 기록의 ## 근거 절은 그대로 둔다
- decisionStatus·decidedOn 을 frontmatter 에서 읽는다. enum 밖의 값은 지어내지 않고
  거절한다 — 운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다

그리고 절의 모양이 둘이었다. 사실·가정은 `- ` 목록이고 DECISION 의 영향은 빈 줄로 나뉜
문단이다. 목록만 읽어서 문단으로 쓴 절이 조용히 [] 가 되고 있었다 — minItems 가 없어
그대로 저장되고 내용만 사라진다. 목록을 먼저 보고 없으면 문단으로 나누며, 글이 있는데
항목이 0 개면 거절한다. QUESTION·REFERENCE 의 이미 잰 값은 안 바뀐다(목록이라 첫 갈래에서
끝난다).

--harness-test 의 PROJECT_DECISION 거절은 우회하지 않고 값을 받게 했다. --project-id 는
사람이 Studio 목록에서 읽은 uuid 다 — 어댑터가 이름을 uuid 로 바꾸지 않는다. 없으면
여전히 거절한다. projectId 는 [string, "null"] 이고 계약이 저장 시점에는 강제하지 않으니
null 로도 만들어지고, 만들어지면 지울 수 없다. 되읽기가 그 값을 확인한다.

python3 -m unittest discover -s scripts/tests — Ran 278 · 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:03:03 +09:00
co-authored by Claude Opus 5
parent 0d588814cc
commit f765a79d6f
2 changed files with 127 additions and 9 deletions
+72 -9
View File
@@ -77,8 +77,13 @@ FIELD_MAP = {
# C 가 V-009 의 `P-QUESTION-02` 에서 맞았다. 여기서는 이름만 잇고 모양은 `_question_shape` 가 만든다 # C 가 V-009 의 `P-QUESTION-02` 에서 맞았다. 여기서는 이름만 잇고 모양은 `_question_shape` 가 만든다
"QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns", "QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns",
"제약": "constraints", "선택지": "options", "다음 검증": "nextValidation"}, "제약": "constraints", "선택지": "options", "다음 검증": "nextValidation"},
"PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale", # **`근거` 절은 Studio 로 안 보낸다.** `ProjectDecisionInput` 에 그 칸이 없고
"영향": "impact"}, # `unevaluatedProperties: false` 라 보내면 거절된다(`studio-v1.yaml:1046`).
# 기록의 `## 근거` 절은 그대로 둔다 — 저장소가 아는 것과 서버가 받는 것은 다르다.
# `결정문`→`statement` · `영향`→`consequences`(OrderedText 배열) 이고
# `decisionStatus`·`decidedOn` 은 frontmatter 에 있다 (`_decision_shape`)
"PROJECT_DECISION": {"결정문": "statement", "판단 이유": "rationale",
"영향": "consequences"},
} }
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->" BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
@@ -351,8 +356,23 @@ def _question_options(chunk: str) -> list[dict]:
def _ordered(slug: str, field: str, raw) -> list[dict]: def _ordered(slug: str, field: str, raw) -> list[dict]:
"""`- ` 항목을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다.""" """을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다.
items = _bullets(raw) if isinstance(raw, str) else []
**항목의 모양이 절마다 다르다.** `사실`·`가정` 은 `- ` 목록이고 DECISION 의 `영향` 은
빈 줄로 나뉜 문단이다. 목록만 읽으면 문단으로 쓴 절이 **조용히 `[]` 가 된다** —
스키마에 `minItems` 가 없어 그대로 저장되고 내용만 사라진다.
그래서 목록을 먼저 보고, 없으면 문단으로 나눈다. **글이 있는데 항목이 0 개면 거절한다** —
비어 있는 것과 못 읽은 것은 다른 일이다.
"""
if not isinstance(raw, str) or not raw.strip():
return [] # 절이 비어 있다 — 정상이다
items = _bullets(raw)
if not items:
items = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()]
if not items:
raise Refused(f"{field} 절에 글이 있는데 항목을 하나도 못 읽었다. "
"빈 칸으로 보내지 않는다 — 무엇이 사라졌는지 보고 고친다")
return [{"id": _ordered_id(slug, field, i), "text": t, "order": i} return [{"id": _ordered_id(slug, field, i), "text": t, "order": i}
for i, t in enumerate(items)] for i, t in enumerate(items)]
@@ -393,6 +413,27 @@ def _reference_shape(doc: dict, fm: dict) -> dict:
return doc return doc
DECISION_STATUS = ("PROPOSED", "ADOPTED")
def _decision_shape(doc: dict, fm: dict) -> dict:
"""PROJECT_DECISION 의 칸을 계약의 모양으로 (`studio-v1.yaml:1050-1063`)."""
slug = doc.get("slug") or ""
doc["consequences"] = _ordered(slug, "consequences", doc.get("consequences"))
for field in ("statement", "rationale"):
if not isinstance(doc.get(field), str):
doc[field] = ""
status = (fm.get("decisionStatus") or "").strip().upper()
if status and status not in DECISION_STATUS:
# **지어내지 않는다.** enum 이 셋뿐이라 다른 값은 서버가 거절한다 —
# 여기서 막는 편이 운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다
raise Refused(f"decisionStatus 가 {status!r} 다. 계약은 "
f"{' · '.join(DECISION_STATUS)} 와 null 뿐이다 (studio-v1.yaml:1055)")
doc["decisionStatus"] = status or None
doc["decidedOn"] = (fm.get("decidedOn") or "").strip() or None
return doc
def _question_shape(doc: dict, fm: dict) -> dict: def _question_shape(doc: dict, fm: dict) -> dict:
"""QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다. """QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다.
@@ -457,6 +498,8 @@ def build_input(record_path: str) -> dict:
doc = _concept_shape(doc, fm) doc = _concept_shape(doc, fm)
if kind == "REFERENCE": if kind == "REFERENCE":
doc = _reference_shape(doc, fm) doc = _reference_shape(doc, fm)
if kind == "PROJECT_DECISION":
doc = _decision_shape(doc, fm)
return doc return doc
@@ -769,7 +812,12 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"body": None, "body": None,
"expect": {"status": 200, "expect": {"status": 200,
"compare": "정규화한 본문·칸·자료를 보낸 것과 견준다", "compare": "정규화한 본문·칸·자료를 보낸 것과 견준다",
"private": "currentPublication 이 null 이거나 status != PUBLISHED"}, "private": "currentPublication 이 null 이거나 status != PUBLISHED",
# 정리 경로가 이 값을 쓴다. 되읽은 값이 보낸 값과 다르면 **지우려는 곳이
# 문서가 있는 곳이 아니다** — 그 상태로 DELETE 하면 404 가 나고 초안이 남는다
**({"projectId": f"보낸 값 {doc.get('projectId')} 와 같아야 한다 — "
"정리 경로가 이 값을 쓴다"}
if doc.get("projectId") else {})},
}) })
if harness_test: if harness_test:
# 시험 초안은 만든 자리에서 지운다. **`--harness-test` 일 때만** 이 단계를 낸다 — # 시험 초안은 만든 자리에서 지운다. **`--harness-test` 일 때만** 이 단계를 낸다 —
@@ -788,13 +836,21 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
raise Refused( raise Refused(
f"이 종류의 삭제 경로를 모른다: {kind!r}. 지울 수 없는 것을 만들지 않는다\n" f"이 종류의 삭제 경로를 모른다: {kind!r}. 지울 수 없는 것을 만들지 않는다\n"
f" 아는 종류: {' · '.join(sorted(DELETE_PATHS))}") f" 아는 종류: {' · '.join(sorted(DELETE_PATHS))}")
if "{projectId}" in template: project_id = doc.get("projectId")
if "{projectId}" in template and not project_id:
# **우회하지 않는다.** 프로젝트 id 없이 만들면 그 초안을 못 지운다.
# `projectId` 는 `[string, "null"]` 이고 계약이 「게시 시점에 non-null,
# 저장 시점에는 강제하지 않는다」라고 적어 두었다(`studio-v1.yaml:932-934`) —
# 그러니 null 로도 만들어지고, 만들어지면 지울 수 없다.
raise Refused( raise Refused(
f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다. " f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다.\n"
"이 어댑터는 그 값을 모른다 — 시험 초안으로 만들지 않는다") " `--project-id <uuid>` 로 준다. 값은 사람이 Studio 목록에서 읽은 것이어야 "
"한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다.\n"
" 없으면 시험 초안을 만들지 않는다. 지울 수 없는 것을 운영에 만들지 않는다")
path = template.format(id=document_id or "<생성된 id>", projectId=project_id)
steps.append({ steps.append({
"op": "cleanup", "method": "DELETE", "op": "cleanup", "method": "DELETE",
"path": template.format(id=document_id or "<생성된 id>"), "path": path,
"headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"}, "headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"}, "cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
# **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가 # **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가
@@ -930,6 +986,10 @@ def main() -> int:
ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version") ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version")
ap.add_argument("-o", "--out", help="계획을 적을 파일") ap.add_argument("-o", "--out", help="계획을 적을 파일")
ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일") ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일")
ap.add_argument("--project-id",
help="PROJECT_DECISION 이 걸리는 프로젝트의 uuid. 사람이 Studio 목록에서 "
"읽은 값이어야 한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다. "
"삭제 경로가 이 값을 쓴다")
ap.add_argument("--harness-test", action="store_true", ap.add_argument("--harness-test", action="store_true",
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다") help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)") ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
@@ -953,6 +1013,9 @@ def main() -> int:
cleared = _review_gate(all_warnings, args.verdicts, args.package, cleared = _review_gate(all_warnings, args.verdicts, args.package,
pkg.get("preservation")) pkg.get("preservation"))
doc = build_input(args.record) doc = build_input(args.record)
if args.project_id:
# **지어내지 않는다.** 사람이 Studio 목록에서 읽어 준 값만 들어온다
doc["projectId"] = args.project_id
if args.harness_test: if args.harness_test:
doc = as_harness_test(doc) doc = as_harness_test(doc)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version, steps = plan_requests(args.record, doc, args.document_id, args.expected_version,
+55
View File
@@ -733,6 +733,61 @@ class HarnessTestPlanTest(unittest.TestCase):
{"id": "y4", "text": "하나", "order": 1}]} {"id": "y4", "text": "하나", "order": 1}]}
self.assertFalse(ss.compare_saved(sent, swapped)["same"]) self.assertFalse(ss.compare_saved(sent, swapped)["same"])
def test_decision_uses_the_contract_field_names(self):
"""`결정문`→`statement` · `영향`→`consequences`. 그리고 `근거`→`basis` 는
계약에 없는 칸이라 `unevaluatedProperties: false` 에 거절된다 — 안 보낸다."""
self.assertEqual({"결정문": "statement", "판단 이유": "rationale",
"영향": "consequences"}, ss.FIELD_MAP["PROJECT_DECISION"])
doc = ss._decision_shape(
{"slug": "s", "statement": "이렇게 한다", "rationale": "왜냐하면",
"consequences": "- 하나\n- 둘"},
{"decisionStatus": "ADOPTED", "decidedOn": "2026-08-30"})
self.assertEqual({"id", "text", "order"}, set(doc["consequences"][0]))
self.assertEqual("ADOPTED", doc["decisionStatus"])
self.assertEqual("2026-08-30", doc["decidedOn"])
for gone in ("basis", "decision", "impact"):
self.assertNotIn(gone, doc)
def test_decision_status_outside_the_enum_is_refused(self):
"""enum 이 셋뿐이다. 지어내지 않고 여기서 막는다 —
운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다."""
with self.assertRaises(ss.Refused):
ss._decision_shape({"slug": "s"}, {"decisionStatus": "REJECTED"})
self.assertIsNone(ss._decision_shape({"slug": "s"}, {})["decisionStatus"])
self.assertIsNone(ss._decision_shape({"slug": "s"}, {})["decidedOn"])
def test_paragraph_sections_are_not_silently_empty(self):
"""항목의 모양이 절마다 다르다. `- ` 목록만 읽으면 문단으로 쓴 절이 조용히
`[]` 가 된다 — 스키마에 `minItems` 가 없어 그대로 저장되고 내용만 사라진다."""
bullets = ss._ordered("s", "consequences", "- 하나\n- 둘")
paras = ss._ordered("s", "consequences", "감수하는 것 : 가.\n\n얻는 것 : 나.")
self.assertEqual(2, len(bullets))
self.assertEqual(2, len(paras))
self.assertEqual("얻는 것 : 나.", paras[1]["text"])
# 절이 비어 있는 것은 정상이다 — 못 읽은 것과 다르다
self.assertEqual([], ss._ordered("s", "consequences", ""))
self.assertEqual([], ss._ordered("s", "consequences", None))
def test_a_decision_without_a_project_id_is_refused(self):
"""프로젝트 id 없이 만들면 그 초안을 못 지운다. 우회하지 않는다."""
doc = dict(SENT, kind="PROJECT_DECISION", slug="s")
doc.pop("projectId", None)
with self.assertRaises(ss.Refused) as cm:
ss.plan_requests("docs/p/t/decision/x.md", doc, None, None, harness_test=True)
self.assertIn("--project-id", str(cm.exception))
def test_a_decision_with_a_project_id_plans_a_deletable_draft(self):
"""값이 있으면 정리 경로에 들어가고, 되읽기가 그 값을 확인한다 —
되읽은 값이 다르면 지우려는 곳이 문서가 있는 곳이 아니다."""
pid = "11111111-aaaa-bbbb-cccc-222222222222"
doc = dict(SENT, kind="PROJECT_DECISION", slug="s", projectId=pid)
steps = ss.plan_requests("docs/p/t/decision/x.md", doc, None, None,
harness_test=True)
cleanup = next(x for x in steps if x["op"] == "cleanup")
self.assertIn(f"/projects/{pid}/decisions/", cleanup["path"])
verify = next(x for x in steps if x["op"] == "verify")
self.assertIn(pid, verify["expect"]["projectId"])
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)