fix(studio-save): 선택지 절의 세 가지 모양을 전부 읽는다

A 가 전수로 재서 알려 줬다. 내가 목록형 기록 하나를 보고 「이미 잰 값은 안 바뀐다」로
일반화한 것이 틀렸다 — 문단형인 openquestion-* 계열이 다섯 칸을 통째로 잃고 있었다.

`## 선택지` 를 쓰는 모양이 셋이다. `### N. 제목` · `**굵은 한 줄**` · 표시 없이 첫 줄이
제목인 문단. 첫째만 읽고 있었고, `_ordered` 의 「글이 있는데 항목 0 개면 거절」이 이 칸에는
안 걸려서 읽지도 않고 막지도 않는 상태였다.

전수로 다시 쟀다 (0d58881 대 지금):

  배열 칸이 있는 기록 150건 · 값이 달라진 것 83건 · 거절 0건
  종류별 — decision 29 · question 26 · reference 28

고친 뒤에도 0 인 칸이 24건 남는데 전부 REFERENCE 의 rules 다. 그것은 다음 배치 16번이고
설계 조건(「없는 규칙」과 「다른 모양으로 쓴 규칙」을 가른다)이 붙어 있어 안 건드렸다.

options 의 거절 가지는 이제 안 닿는다 — 세 번째 모양이 글이 있으면 언제나 항목을 하나는
만든다. MAX_KEY_LENGTH 와 같은 자리라 지우지 않고 그렇게 주석에 적었다.

python3 -m unittest discover -s scripts/tests — Ran 284 · 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:11:17 +09:00
co-authored by Claude Opus 5
parent 252f14c88e
commit 325b6008ab
2 changed files with 67 additions and 6 deletions
+40 -6
View File
@@ -345,13 +345,37 @@ def _bullets(chunk: str) -> list[str]:
return ["\n".join(x).strip() for x in items if "\n".join(x).strip()]
# 제목이 붙은 항목의 모양이 둘이다. `### N. 제목` 도 있고 **굵은 한 줄**도 있다.
# 둘 다 `{title, description}` 에 그대로 맞는다 — 굵은 줄이 제목이고 다음 문단이 몸통이다.
BOLD_TITLE = re.compile(r"^\*\*(.+?)\*\*\s*$", re.M)
HASH_TITLE = re.compile(r"^###\s+(.+)$", re.M)
def _question_options(chunk: str) -> list[dict]:
"""`### N. 제목` 과 그 아래 문단을 `QuestionOption` 으로."""
out: list[dict] = []
parts = re.split(r"^###\s+(.+)$", chunk, flags=re.M)
for i in range(1, len(parts), 2):
title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip())
out.append({"title": title[:120], "description": parts[i + 1].strip()})
"""제목이 붙은 항목을 `{title, description}` 으로.
**모양 하나만 읽으면 다른 모양으로 쓴 절이 조용히 `[]` 가 된다.** `_ordered` 에서
문단형을 못 읽던 것과 같은 자리인데, 이쪽은 `_ordered` 를 안 거쳐서 **거절도 안 걸렸다** —
읽지도 않고 막지도 않으니 그게 제일 나쁜 상태다.
"""
for pattern in (HASH_TITLE, BOLD_TITLE):
parts = pattern.split(chunk)
if len(parts) < 3: # 그 모양이 아니다 — 다음 모양을 본다
continue
out: list[dict] = []
for i in range(1, len(parts), 2):
title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip())
out.append({"title": title[:120], "description": parts[i + 1].strip()})
return out
# 세 번째 모양 — 표시 없이 **첫 줄이 제목이고 다음 줄들이 몸통**이다. 빈 줄로 나뉜다.
# 이 저장소의 `openquestion-*` 여섯이 이 모양이라 앞 둘만 읽으면 그 여섯이 막힌다
out = []
for block in re.split(r"\n\s*\n", chunk):
lines = [x for x in block.strip().split("\n") if x.strip()]
if not lines:
continue
title = re.sub(r"^\d+[.)]\s*", "", lines[0].strip())
out.append({"title": title[:120], "description": "\n".join(lines[1:]).strip()})
return out
@@ -445,6 +469,16 @@ def _question_shape(doc: dict, fm: dict) -> dict:
doc[field] = _ordered(slug, field, doc.get(field))
raw = doc.get("options")
opts = _question_options(raw) if isinstance(raw, str) else []
if isinstance(raw, str) and raw.strip() and not opts:
# **`_ordered` 의 거절이 여기서는 안 걸린다.** 이 칸은 그 함수를 안 거친다 —
# 같은 규칙을 여기에도 둔다. 비어 있는 것과 못 읽은 것은 다른 일이다.
#
# **지금은 여기 안 온다.** 세 번째 모양(빈 줄로 나눈 문단)이 글이 있으면 언제나
# 항목을 하나는 만든다. `MAX_KEY_LENGTH` 와 같은 자리다 — 죽은 코드가 아니라
# **모양을 더하거나 고칠 때 살아나는 방어선**이다. 지우지 않는다
raise Refused("선택지 절에 글이 있는데 항목을 하나도 못 읽었다. "
"빈 칸으로 보내지 않는다 — `### N. 제목` 이나 `**굵은 한 줄**` 로 "
"제목을 붙이거나, 무엇이 사라졌는지 보고 고친다")
doc["options"] = [{"id": _ordered_id(slug, "options", i), "title": o["title"],
"description": o["description"], "order": i}
for i, o in enumerate(opts)]
+27
View File
@@ -839,6 +839,33 @@ class HarnessTestPlanTest(unittest.TestCase):
doc = ss.build_input(os.path.join(ROOT, path))
self.assertEqual(want, ss.compare_saved(doc, dict(doc))["comparedCount"])
def test_options_reads_all_three_shapes(self):
"""`## 선택지` 를 쓰는 모양이 셋이다. 하나만 읽으면 나머지가 조용히 `[]` 가 된다."""
shapes = {
"hash": "### 1. 넷이 실패를 낸다\n\n설명이다.",
"bold": "**홈 비교표에 기록 수를 붙인다**\n목록 호출이 그 수를 실을 수 있다.",
"plain": "한 번 부팅해 둘을 함께 캡처한다\nmessaging 을 켜고 부팅한다.",
}
for name, raw in shapes.items():
with self.subTest(shape=name):
out = ss._question_options(raw)
self.assertEqual(1, len(out), out)
self.assertTrue(out[0]["title"])
self.assertTrue(out[0]["description"])
def test_an_empty_options_section_stays_empty(self):
"""절이 비어 있는 것은 정상이다 — 못 읽은 것과 다르다.
**`options` 의 거절 가지는 지금 안 닿는다.** 세 번째 모양(빈 줄로 나눈 문단)이
글이 있으면 언제나 항목을 하나는 만든다. `MAX_KEY_LENGTH` 와 같은 자리라
지우지 않는다 — 모양을 더하거나 고칠 때 살아나는 방어선이다."""
for blank in ("", "\n \n", " "):
with self.subTest(raw=repr(blank)):
out = ss._question_shape({"slug": "s", "options": blank}, {})
self.assertEqual([], out["options"])
# 글이 있으면 반드시 무언가를 읽는다 — 조용히 비지 않는다
self.assertTrue(ss._question_options("제목 한 줄만 있다"))
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)