diff --git a/scripts/studio-save.py b/scripts/studio-save.py index bde50e7..f2c1b35 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -77,8 +77,13 @@ FIELD_MAP = { # C 가 V-009 의 `P-QUESTION-02` 에서 맞았다. 여기서는 이름만 잇고 모양은 `_question_shape` 가 만든다 "QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns", "제약": "constraints", "선택지": "options", "다음 검증": "nextValidation"}, - "PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale", - "영향": "impact"}, + # **`근거` 절은 Studio 로 안 보낸다.** `ProjectDecisionInput` 에 그 칸이 없고 + # `unevaluatedProperties: false` 라 보내면 거절된다(`studio-v1.yaml:1046`). + # 기록의 `## 근거` 절은 그대로 둔다 — 저장소가 아는 것과 서버가 받는 것은 다르다. + # `결정문`→`statement` · `영향`→`consequences`(OrderedText 배열) 이고 + # `decisionStatus`·`decidedOn` 은 frontmatter 에 있다 (`_decision_shape`) + "PROJECT_DECISION": {"결정문": "statement", "판단 이유": "rationale", + "영향": "consequences"}, } BODY_START, BODY_END = "", "" @@ -351,8 +356,23 @@ def _question_options(chunk: str) -> list[dict]: def _ordered(slug: str, field: str, raw) -> list[dict]: - """`- ` 항목을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다.""" - items = _bullets(raw) if isinstance(raw, str) else [] + """절을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다. + + **항목의 모양이 절마다 다르다.** `사실`·`가정` 은 `- ` 목록이고 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} for i, t in enumerate(items)] @@ -393,6 +413,27 @@ def _reference_shape(doc: dict, fm: dict) -> dict: 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: """QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다. @@ -457,6 +498,8 @@ def build_input(record_path: str) -> dict: doc = _concept_shape(doc, fm) if kind == "REFERENCE": doc = _reference_shape(doc, fm) + if kind == "PROJECT_DECISION": + doc = _decision_shape(doc, fm) return doc @@ -769,7 +812,12 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None, "body": None, "expect": {"status": 200, "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: # 시험 초안은 만든 자리에서 지운다. **`--harness-test` 일 때만** 이 단계를 낸다 — @@ -788,13 +836,21 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None, raise Refused( f"이 종류의 삭제 경로를 모른다: {kind!r}. 지울 수 없는 것을 만들지 않는다\n" 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( - f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다. " - "이 어댑터는 그 값을 모른다 — 시험 초안으로 만들지 않는다") + f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다.\n" + " `--project-id ` 로 준다. 값은 사람이 Studio 목록에서 읽은 것이어야 " + "한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다.\n" + " 없으면 시험 초안을 만들지 않는다. 지울 수 없는 것을 운영에 만들지 않는다") + path = template.format(id=document_id or "<생성된 id>", projectId=project_id) steps.append({ "op": "cleanup", "method": "DELETE", - "path": template.format(id=document_id or "<생성된 id>"), + "path": path, "headers": {"X-CSRF-TOKEN": ""}, "cookies": {"TECHLOG_SESSION": ""}, # **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가 @@ -930,6 +986,10 @@ def main() -> int: 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("--project-id", + help="PROJECT_DECISION 이 걸리는 프로젝트의 uuid. 사람이 Studio 목록에서 " + "읽은 값이어야 한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다. " + "삭제 경로가 이 값을 쓴다") ap.add_argument("--harness-test", action="store_true", help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다") ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)") @@ -953,6 +1013,9 @@ def main() -> int: cleared = _review_gate(all_warnings, args.verdicts, args.package, pkg.get("preservation")) doc = build_input(args.record) + if args.project_id: + # **지어내지 않는다.** 사람이 Studio 목록에서 읽어 준 값만 들어온다 + doc["projectId"] = args.project_id if args.harness_test: doc = as_harness_test(doc) steps = plan_requests(args.record, doc, args.document_id, args.expected_version, diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index 5782cc5..39e0b86 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -733,6 +733,61 @@ class HarnessTestPlanTest(unittest.TestCase): {"id": "y4", "text": "하나", "order": 1}]} 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): steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None, harness_test=True)