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:
co-authored by
Claude Opus 5
parent
0d588814cc
commit
f765a79d6f
+72
-9
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user