chore: 이전 세션이 남긴 변경을 커밋한다

이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 11:02:02 +09:00
co-authored by Claude Opus 5
parent 2109f726fe
commit ab59130196
1524 changed files with 3160026 additions and 8369 deletions
@@ -0,0 +1,36 @@
---
kind: SETUP
slug: fixture-setup
title: 고정 사례 환경 구성
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 환경 구성
이 절차를 따라 하면 무엇이 서는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건을 이 환경 위에서 재현한다.
## 본문
<!-- body:start -->
## 실행 절차
1. 예시 명령을 그대로 실행한다.
## 구성 값
어떤 값을 어디에 두는지 적는다.
## 확인 방법
제대로 섰는지 확인하는 명령과 그때 보이는 출력을 적는다.
<!-- body:end -->
+39
View File
@@ -0,0 +1,39 @@
---
kind: SETUP
slug: fixture-setup
title: 고정 사례 환경 구성
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
pinnedVersions:
- name: 예시 런타임
version: 1.0.0
---
# 고정 사례 환경 구성
이 절차를 따라 하면 무엇이 서는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건을 이 환경 위에서 재현한다.
## 본문
<!-- body:start -->
## 실행 절차
1. 예시 명령을 그대로 실행한다.
## 구성 값
어떤 값을 어디에 두는지 적는다.
## 확인 방법
제대로 섰는지 확인하는 명령과 그때 보이는 출력을 적는다.
<!-- body:end -->
+31
View File
@@ -89,3 +89,34 @@ class Project(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class UnseenIsNotClean(unittest.TestCase):
"""「안 겹친다」와 「못 봤다」는 다른 출력이다.
이 검사기는 techviz 렌더러가 붙인 `class` 로만 상자를 알아본다. 손으로 그린 SVG 에는
그 class 가 없어 `boxes()` 가 비고, 겹칠 짝이 없으니 겹침 0 이 나온다. 고치기 전에는
그것을 `그림 182 · 겹친 그림 0` 으로 찍었는데 **실제로 본 것은 18장**이었다.
나머지 164장은 안 겹친 것이 아니라 볼 수 없었던 것이다.
"""
HAND_DRAWN = ('<svg xmlns="http://www.w3.org/2000/svg">'
'<rect x="0" y="0" width="400" height="300" fill="#fff"/>'
'<rect x="10" y="10" width="100" height="40"/>'
'<rect x="20" y="20" width="100" height="40"/>'
'<text x="30" y="30">겹친 상자 둘</text></svg>')
def test_a_hand_drawn_svg_is_counted_as_unseen(self):
_, out = run(self.HAND_DRAWN)
self.assertIn("못 본 그림 1", out, out)
self.assertIn("본 그림 0", out, out)
def test_a_hand_drawn_svg_does_not_read_as_checked(self):
"""상자가 실제로 겹쳐 있는데도 이 검사기는 답하지 못한다. 그 사실이 보여야 한다."""
_, out = run(self.HAND_DRAWN)
self.assertIn("답하지 못한다", out, out)
def test_a_techviz_svg_is_counted_as_seen(self):
_, out = run(svg(rect("node-shape", 10, 10, 100, 40)))
self.assertIn("본 그림 1", out, out)
self.assertNotIn("못 본 그림", out, out)
+25 -1
View File
@@ -4,9 +4,12 @@
한다. **정본을 거쳤는지만 보면 렌더러를 몰라도 된다** — 그리고 손으로 고친 그림이
화살표 뒤집기의 모양이다.
"""
import contextlib
import importlib.util
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -26,6 +29,26 @@ def _cli(*args):
capture_output=True, text=True)
@contextlib.contextmanager
def _a_figure_without_a_spec():
"""`.techviz/<이름>/spec.json` 이 없는 그림 한 장짜리 프로젝트를 만들어 준다.
이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트의 그림이 전부 정본을 갖는
순간 시험이 깨지고, 프로젝트가 빠지면 `verify()` 가 그림 0장을 보고 「못 본 그림」도
0 이 되어 시험이 엉뚱한 이유로 깨진다. 이름이 아니라 상태가 필요한 시험이다.
"""
docs = os.path.join(ROOT, "docs")
path = tempfile.mkdtemp(prefix="zz-no-spec-", dir=docs)
try:
assets = os.path.join(path, "final", "assets")
os.makedirs(assets)
with open(os.path.join(assets, "handmade.svg"), "w", encoding="utf-8") as fh:
fh.write('<svg xmlns="http://www.w3.org/2000/svg"><title>손그림</title></svg>\n')
yield os.path.basename(path)
finally:
shutil.rmtree(path, ignore_errors=True)
class ProvenanceTest(unittest.TestCase):
def test_every_figure_in_this_repository_passes(self):
"""대조군. 지금 저장소의 그림에 하나라도 걸리면 정책이 과하다."""
@@ -53,7 +76,8 @@ class ProvenanceTest(unittest.TestCase):
def test_a_figure_without_a_spec_is_counted_not_flagged(self):
"""정본이 없는 그림은 이 검사기가 볼 것이 아니다. 세기만 한다."""
rep = fp.verify("ca-tmpl")
with _a_figure_without_a_spec() as project:
rep = fp.verify(project)
self.assertEqual(0, rep.error_count)
self.assertGreater(rep.facts.get("정본이 없어 못 본 그림", 0), 0)
+75 -17
View File
@@ -10,13 +10,34 @@
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# `docs/` 안에 만드는 픽스처의 접두사. 실재 프로젝트와 섞이지 않게 `zz-` 로 시작한다
FIXTURE_PREFIX = "zz-no-contract-"
def _sweep_stale_fixtures(docs: str) -> None:
"""지난 실행이 남긴 픽스처를 치운다.
`finally` 는 SIGKILL 을 못 막는다. 한 번 남으면 그 폴더가 **진짜 프로젝트로
세어져서** `verify-pipeline.py` 가 프로젝트 수를 하나 더 세고 계약이 없다고
error 를 낸다 — 시험이 저장소를 고장 낸 것처럼 보인다. 지우는 것은 이 접두사로
시작하는 것뿐이라 실재 프로젝트를 건드리지 않는다.
"""
for name in os.listdir(docs):
if name.startswith(FIXTURE_PREFIX):
shutil.rmtree(os.path.join(docs, name), ignore_errors=True)
PY_CHECKERS = (
"check-figure-text.py",
"check-figure-overlap.py",
@@ -28,6 +49,49 @@ MJS_CHECKER = os.path.join(".agents", "skills", "writing-tech-log-records",
"scripts", "check_evidence.mjs")
ABSENT = "no-such-project-r4-regression"
@contextlib.contextmanager
def _a_contract_with_no_records():
"""계약은 있고 기록이 0건인 프로젝트를 만들어 준다.
이 상태를 살아 있는 프로젝트 이름으로 가리키면, 누가 그 프로젝트에 기록 한 편을
쓰는 순간 시험이 깨진다. 실제로 그렇게 깨졌다. 이름이 아니라 상태가 필요한
시험이므로 여기서 그 상태를 만든다.
"""
docs = os.path.join(ROOT, "docs")
path = tempfile.mkdtemp(prefix="zz-no-records-", dir=docs)
try:
studio = os.path.join(path, "tech-log-studio")
os.makedirs(studio)
name = os.path.basename(path)
with open(os.path.join(studio, "tech-log-tree.json"), "w", encoding="utf-8") as fh:
json.dump({"schemaVersion": 4, "project": name, "ssot": "final/document.md",
"topics": {}, "candidates": []}, fh)
yield name
finally:
shutil.rmtree(path, ignore_errors=True)
@contextlib.contextmanager
def _a_project_without_a_contract():
"""SSOT 는 있고 분해 계약이 없는 프로젝트를 만들어 준다.
이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트가 저장소에서 빠지는 순간
시험이 조용히 `skip` 으로 넘어간다. 실제로 그렇게 됐다 — `ca-tmpl` 을 지웠다.
이름이 아니라 상태가 필요한 시험이므로 여기서 그 상태를 만든다.
"""
docs = os.path.join(ROOT, "docs")
_sweep_stale_fixtures(docs)
path = tempfile.mkdtemp(prefix=FIXTURE_PREFIX, dir=docs)
try:
final = os.path.join(path, "final")
os.makedirs(final)
with open(os.path.join(final, "document.md"), "w", encoding="utf-8") as fh:
fh.write("# 계약 없는 프로젝트\n\n## §1 아무것도 아니다\n\n본문.\n")
yield os.path.basename(path)
finally:
shutil.rmtree(path, ignore_errors=True)
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=300)
@@ -52,24 +116,20 @@ class NoTargetExitCode(unittest.TestCase):
def test_project_without_contract_is_not_a_target(self) -> None:
"""계약이 없는 실재 프로젝트도 「대상이 성립하지 않는다」다."""
base = os.path.join(ROOT, "docs", "ca-tmpl")
if not os.path.isdir(base) or os.path.exists(os.path.join(base, "tech-log-studio")):
self.skipTest("계약 없는 프로젝트가 이 저장소에 없다")
for name in ("audit-records.py", "verify-tech-log-tree.py"):
with self.subTest(checker=name):
run = _run([sys.executable, os.path.join("scripts", name), "ca-tmpl"])
self.assertEqual(run.returncode, 2, run.stdout + run.stderr)
with _a_project_without_a_contract() as project:
for name in ("audit-records.py", "verify-tech-log-tree.py"):
with self.subTest(checker=name):
run = _run([sys.executable, os.path.join("scripts", name), project])
self.assertEqual(run.returncode, 2, run.stdout + run.stderr)
class EmptyTargetIsNotGreen(unittest.TestCase):
"""계약은 있고 기록이 0건인 것은 결함이 아니다. 다만 「문제 없음」이라고 쓰지 않는다."""
def test_zero_records_says_so(self) -> None:
base = os.path.join(ROOT, "docs", "keycloak-session-store")
if not os.path.isdir(base):
self.skipTest("기록 0건 프로젝트가 이 저장소에 없다")
run = _run([sys.executable, os.path.join("scripts", "audit-records.py"),
"keycloak-session-store"])
with _a_contract_with_no_records() as project:
run = _run([sys.executable, os.path.join("scripts", "audit-records.py"),
project])
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
self.assertIn("기록 0건", run.stdout)
self.assertNotIn("문제 없음", run.stdout)
@@ -108,16 +168,14 @@ class FileTargets(unittest.TestCase):
class ContractlessProjectIsCounted(unittest.TestCase):
"""계약이 없는 프로젝트가 전체 훑기의 목록에서 사라지면 안 된다 (R6).
`verify_projects()` 가 `docs/*/tech-log-studio` 만 훑던 동안 `ca-tmpl` 은
`verify_projects()` 가 `docs/*/tech-log-studio` 만 훑던 동안 계약 없는 프로젝트는
`TECH LOG TREES` 블록에 아예 안 나왔다. CLAUDE.md 는 「계약 미채택도 error」라고
적었는데 그 error 를 셀 자리가 없었다.
"""
def test_missing_contract_is_an_error(self) -> None:
base = os.path.join(ROOT, "docs", "ca-tmpl")
if not os.path.isdir(base) or os.path.exists(os.path.join(base, "tech-log-studio")):
self.skipTest("계약 없는 프로젝트가 이 저장소에 없다")
run = _run([sys.executable, os.path.join("scripts", "verify-pipeline.py")])
with _a_project_without_a_contract():
run = _run([sys.executable, os.path.join("scripts", "verify-pipeline.py")])
self.assertIn("분해 계약 없음", run.stdout)
block = run.stdout.split("TECH LOG TREES:", 1)
self.assertEqual(len(block), 2, "TECH LOG TREES 블록이 없다")
+97 -7
View File
@@ -1,21 +1,93 @@
"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
고정 사례는 다섯 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
고정 사례는 **여섯** 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
"""
import contextlib
import importlib.util
import json
import os
import shutil
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FIXTURES = os.path.join(ROOT, "scripts", "tests", "fixtures", "required-content")
# `docs/` 안에 만드는 픽스처의 접두사. 실재 프로젝트와 섞이지 않게 `zz-` 로 시작한다
FIXTURE_PREFIX = "zz-no-contract-"
def _sweep_stale_fixtures(docs: str) -> None:
"""지난 실행이 남긴 픽스처를 치운다.
`finally` 는 SIGKILL 을 못 막는다. 한 번 남으면 그 폴더가 **진짜 프로젝트로
세어져서** `verify-pipeline.py` 가 프로젝트 수를 하나 더 세고 계약이 없다고
error 를 낸다 — 시험이 저장소를 고장 낸 것처럼 보인다. 지우는 것은 이 접두사로
시작하는 것뿐이라 실재 프로젝트를 건드리지 않는다.
"""
for name in os.listdir(docs):
if name.startswith(FIXTURE_PREFIX):
shutil.rmtree(os.path.join(docs, name), ignore_errors=True)
_spec = importlib.util.spec_from_file_location(
"check_required_content", os.path.join(ROOT, "scripts", "check-required-content.py"))
crc = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(crc)
KINDS = ("case", "concept", "reference", "question", "decision")
# 계약의 `RecordKind` 와 같은 여섯이다. 손으로 세지 않는다 — 이 목록에 종류를 빠뜨리면
# 그 종류의 고정 사례가 있어도 **아무도 열어 보지 않는다**
KINDS = tuple(crc.techlog.KINDS)
@contextlib.contextmanager
def _a_contract_with_no_records():
"""계약은 있고 기록이 0건인 프로젝트를 만들어 준다.
이 상태를 살아 있는 프로젝트 이름으로 가리키면, 누가 그 프로젝트에 기록 한 편을
쓰는 순간 시험이 깨진다. 실제로 그렇게 깨졌다. 이름이 아니라 상태가 필요한
시험이므로 여기서 그 상태를 만든다.
"""
docs = os.path.join(ROOT, "docs")
path = tempfile.mkdtemp(prefix="zz-no-records-", dir=docs)
try:
studio = os.path.join(path, "tech-log-studio")
os.makedirs(studio)
name = os.path.basename(path)
with open(os.path.join(studio, "tech-log-tree.json"), "w", encoding="utf-8") as fh:
json.dump({"schemaVersion": 4, "project": name, "ssot": "final/document.md",
"topics": {}, "candidates": []}, fh)
yield name
finally:
shutil.rmtree(path, ignore_errors=True)
@contextlib.contextmanager
def _a_project_without_a_contract():
"""SSOT 는 있고 분해 계약이 없는 프로젝트를 만들어 준다.
`_sweep_stale_fixtures` 를 먼저 부르는 이유는 `finally` 가 못 도는 경우가 있어서다.
이 시험이 SIGKILL 을 맞으면(메모리 부족 등) 픽스처가 `docs/` 에 남고, 그러면
**그것이 진짜 프로젝트로 세어진다** — `verify-pipeline.py` 가 「프로젝트 7」로
찍고 계약이 없으니 error 를 낸다. 실제로 그렇게 됐다. 저장소를 어지럽히지 않는
자리에 두는 것이 낫지만, `check_evidence.mjs <프로젝트>` 가 `docs/` 아래에서만
프로젝트를 찾으므로 여기 두어야 한다. 그래서 다음 실행이 스스로 치운다.
이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트가 저장소에서 빠지는 순간
시험이 엉뚱한 이유로 통과한다 — 「계약이 없다」가 「그런 프로젝트가 없다」로 바뀐다.
둘 다 exit 2 라 종료 코드만 보면 구분되지 않는다. 그래서 상태를 여기서 만든다.
"""
docs = os.path.join(ROOT, "docs")
_sweep_stale_fixtures(docs)
path = tempfile.mkdtemp(prefix=FIXTURE_PREFIX, dir=docs)
try:
final = os.path.join(path, "final")
os.makedirs(final)
with open(os.path.join(final, "document.md"), "w", encoding="utf-8") as fh:
fh.write("# 계약 없는 프로젝트\n\n## §1 아무것도 아니다\n\n본문.\n")
yield os.path.basename(path)
finally:
shutil.rmtree(path, ignore_errors=True)
def _report(sub, name):
@@ -47,6 +119,7 @@ class RequiredContentTest(unittest.TestCase):
"reference": "적용 조건",
"question": "다음 검증",
"decision": "판단 이유",
"setup": "pinnedVersions",
}
for kind, part in expected.items():
with self.subTest(kind=kind):
@@ -64,8 +137,22 @@ class RequiredContentTest(unittest.TestCase):
rep = _report("ok", "question")
self.assertEqual(0, rep.error_count)
def test_body_markers_belong_only_to_case_and_concept(self):
self.assertEqual({"case", "concept"}, crc.BODY_KINDS)
def test_body_markers_belong_to_case_concept_and_setup(self):
"""**본문이 있는 종류는 셋이다.** 환경 구성의 본문도 Case 와 같은 파서를 탄다."""
self.assertEqual({"case", "concept", "setup"}, crc.BODY_KINDS)
def test_every_kind_has_a_fixture_pair(self):
"""종류를 더하면서 고정 사례를 안 만들면 위 시험들이 조용히 그 종류를 건너뛴다.
`_report` 가 없는 파일을 열면 터지지만, 그 전에 **어느 종류가 비었는지**를 말해 준다.
「볼 것이 없어서 통과」를 「문제 없음」으로 쓰지 않는 것과 같은 자리다.
"""
for sub in ("ok", "missing"):
for kind in KINDS:
with self.subTest(sub=sub, kind=kind):
self.assertTrue(
os.path.isfile(os.path.join(FIXTURES, sub, f"{kind}.md")),
f"{sub}/{kind}.md 이 없다 — 종류를 더했으면 고정 사례도 둘 만든다")
def _cli(self, *args):
import subprocess
@@ -81,13 +168,15 @@ class RequiredContentTest(unittest.TestCase):
def test_a_project_without_a_contract_does_not_come_back_green(self):
"""계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다."""
p = self._cli("ca-tmpl")
with _a_project_without_a_contract() as project:
p = self._cli(project)
self.assertEqual(2, p.returncode)
self.assertIn("tech-log-tree.json 이 없다", p.stderr)
def test_a_contract_with_no_records_yet_is_not_an_error(self):
"""아직 안 쓴 것은 결함이 아니다. 다만 초록으로 보이면 안 된다."""
p = self._cli("keycloak-session-store")
with _a_contract_with_no_records() as project:
p = self._cli(project)
self.assertEqual(0, p.returncode)
self.assertIn("아직 안 쓰였다", p.stdout)
@@ -98,7 +187,8 @@ class RequiredContentTest(unittest.TestCase):
저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은
프로젝트를 테스트에 적으면 계약 검사가 깨진다.
"""
p = self._cli("keycloak", "ca-tmpl")
with _a_project_without_a_contract() as project:
p = self._cli("keycloak", project)
self.assertEqual(2, p.returncode)
+289 -4
View File
@@ -8,6 +8,7 @@ import glob
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
@@ -18,6 +19,14 @@ _spec = importlib.util.spec_from_file_location("run_ledger", TOOL)
rl = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(rl)
VERIFIER = os.path.join(ROOT, "scripts", "verify-pipeline-run.py")
_vspec = importlib.util.spec_from_file_location("verify_pipeline_run", VERIFIER)
vpr = importlib.util.module_from_spec(_vspec)
_vspec.loader.exec_module(vpr)
# 지금 `writing-tech-log-records/SKILL.md` 에 있는 문장. 영수증으로 적으면 통과해야 한다.
CURRENT_SENTENCE = "본문이 있는 종류는 Case·Concept·Setup 셋이다."
def _cli(*args, **kw):
return subprocess.run(["python3", TOOL, *args], cwd=ROOT,
@@ -69,7 +78,7 @@ class LedgerTest(unittest.TestCase):
self.assertEqual(2, _cli("end", self.led, "--stage", "S3",
"--status", "DONE").returncode)
self.assertEqual(0, _cli("end", self.led, "--stage", "S3", "--status", "DONE",
"--echo", "본문이 있는 종류는 Case 와 Concept 둘뿐이다.").returncode)
"--echo", CURRENT_SENTENCE).returncode)
def test_a_gate_must_carry_an_exit_code(self):
"""종료 코드 없는 관문을 못 적는다. 돌리지 않고 적는 경로를 막는다."""
@@ -189,10 +198,23 @@ class LedgerTest(unittest.TestCase):
def test_the_added_fields_do_not_break_the_verifier(self):
"""이 도구가 더한 칸이 있어도 검사기가 그대로 읽어야 한다."""
# 프로젝트 이름을 적지 않는다 — verify-pipeline.py 의 FORBIDDEN_LITERAL 가드가
# scripts/ 안에서 저장소 체크아웃 이름을 금지한다. 아무 실제 원장이나 하나 고른다
found = sorted(glob.glob(os.path.join(ROOT, "runs", "*", "*", "run.json")))
# scripts/ 안에서 저장소 체크아웃 이름을 금지한다. 실제 원장 하나 고른다.
#
# **아무거나 고르면 안 된다.** 이 시험이 묻는 것은 「더한 칸이 검사기를 깨뜨리는가」
# 이므로 밑바탕은 **원래 통과하는 원장**이어야 한다. 파이프라인을 돌리는 중에는
# 단계가 RUNNING·PENDING 인 원장이 `runs/` 에 있고, 그걸 고르면 더한 칸과 아무
# 상관없이 「끝나지 않은 단계가 있다」로 실패한다 — 실제로 그렇게 깨졌다.
# 단계가 전부 닫힌 것만 고른다.
found = []
for cand in sorted(glob.glob(os.path.join(ROOT, "runs", "*", "*", "run.json"))):
try:
stages = json.load(open(cand, encoding="utf-8")).get("stages") or []
except (OSError, ValueError):
continue
if stages and all(s.get("status") in ("DONE", "SKIPPED") for s in stages):
found.append(cand)
if not found:
self.skipTest("견줄 실제 원장이 없다")
self.skipTest("단계가 전부 닫힌 실제 원장이 없다")
real = found[-1]
d = json.load(open(real, encoding="utf-8"))
d.update({"revision": 1, "riders": [], "sessions": [], "updatedAt": "x"})
@@ -208,5 +230,268 @@ class LedgerTest(unittest.TestCase):
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
def _git(*args):
"""시험이 저장소에 직접 묻는다. 검사기와 **다른 방법**으로 물어야 대조가 된다."""
p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True,
encoding="utf-8", errors="replace")
return p.stdout if p.returncode == 0 else None
def _commit_that_still_had(skill, sentence):
"""그 문장을 아직 담고 있던 가장 최근 커밋. 없으면 None.
검사기는 `ls-tree` + `cat-file` 로 본문을 모아 부분 문자열을 찾는다. 여기서는
`git grep` 으로 묻는다 — 같은 코드로 확인하면 시험이 아무것도 안 보는 것이 된다.
"""
rel = f".agents/skills/{skill}"
out = _git("log", "--max-count=200", "--format=%H", "--", rel) or ""
for commit in out.split():
got = subprocess.run(["git", "-C", ROOT, "grep", "-F", "-q", sentence,
commit, "--", rel], capture_output=True)
if got.returncode == 0:
return commit
return None
def _a_sentence_from(skill):
"""그 스킬의 SKILL.md 에서 지금 실재하는 한 줄. 문구를 시험에 박아 두지 않는다."""
path = os.path.join(ROOT, ".agents", "skills", skill, "SKILL.md")
for line in open(path, encoding="utf-8"):
line = line.strip()
if len(line) >= 30 and not line.startswith(("#", "|", "-", ">", "`")):
return line
raise AssertionError(f"{skill}/SKILL.md 에서 쓸 만한 줄을 못 찾았다")
@unittest.skipUnless(_git("rev-parse", "--git-dir"), "저장소가 아니라 과거를 볼 수 없다")
class EchoAgainstSkillHistory(unittest.TestCase):
"""영수증이 지금 스킬에 없을 때, 위조와 「그 뒤에 스킬이 고쳐졌다」를 가르는지 본다.
스킬은 고쳐진다. 2026-09-12 에 `writing-tech-log-records` 의 「본문이 있는 종류」
문장을 고쳤고, 그 문장을 인용한 과거 원장 4건이 한꺼번에 error 가 됐다. **그 영수증은
사실이다** — 그때 그 문장이 거기 있었다. 원장을 고쳐 쓰는 것은 위조이고 틀린 문장을
스킬에 되살리는 것은 검사기에 답하는 것이라, 둘 다 하지 않고 검사기가 가른다.
"""
SKILL = "writing-tech-log-records"
# 2026-09-12 에 물러난 문장. Studio 의 여섯 번째 종류 SETUP 이 빠져 있던 것을 메우며
# 바뀌었다. **이것을 현재 SKILL.md 에 되살리지 않는다** — 과거 커밋에만 있어야 한다
RETIRED = "본문이 있는 종류는 Case 와 Concept 둘뿐이다."
FABRICATED = "이 문장은 그 스킬의 어느 판에도 없다 한 글자도 없다 정말로"
def setUp(self):
self.dir = tempfile.mkdtemp()
def _ledger(self, echo, revision="", carry_field=True):
"""S3 의 영수증만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
run.update({"runId": "2026-01-01-0000", "project": "demo",
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00"})
for st in run["stages"]:
spec = vpr.STAGES[st["id"]]
if not carry_field:
st.pop(vpr.REVISION_FIELD, None)
elif st["id"] == "S3":
st[vpr.REVISION_FIELD] = revision or None
if spec["skippable"]:
st.update({"status": "SKIPPED", "skipReason": "이 시험은 영수증만 본다"})
continue
st.update({
"status": "DONE",
"skillEcho": echo if st["id"] == "S3" else _a_sentence_from(st["skill"]),
"gates": [{"cmd": tok, "exit": 0} for tok in spec["gates"]],
})
path = os.path.join(self.dir, f"run-{len(os.listdir(self.dir))}.json")
json.dump(run, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
return path
def _run(self, path, *args):
p = subprocess.run([sys.executable, VERIFIER, path, *args], cwd=ROOT,
capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
def test_현재_스킬에_있는_영수증은_통과한다(self):
"""대조군. 이 자리가 통과하지 않으면 나머지 둘은 아무것도 말하지 않는다."""
code, out = self._run(self._ledger(CURRENT_SENTENCE))
self.assertEqual(0, code, out)
self.assertIn("대조 못 한 영수증 0", out)
self.assertNotIn("그 뒤에 스킬이 고쳐져", out)
def test_과거_판에만_있는_영수증은_error_가_아니라_warn_이다(self):
code, out = self._run(self._ledger(self.RETIRED))
self.assertEqual(0, code, out)
self.assertIn("그 뒤에 스킬이 고쳐져 영수증을 대조할 수 없다", out)
self.assertNotIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
self.assertIn("대조 못 한 영수증 1", out)
# 어느 커밋에 있었는지 함께 적는다. 「과거 어딘가」로는 다시 찾아갈 수 없다
commit = _commit_that_still_had(self.SKILL, self.RETIRED)
self.assertIsNotNone(commit, "그 문장을 담은 커밋이 이력에 없다")
self.assertIn(commit[:12], out)
def test_warn_은_통과가_아니다(self):
"""초록으로 보이면 안 된다. `--strict` 에서는 이것이 실패다."""
code, out = self._run(self._ledger(self.RETIRED), "--strict")
self.assertEqual(1, code, out)
def test_어느_판에도_없는_영수증은_error_다(self):
code, out = self._run(self._ledger(self.FABRICATED))
self.assertEqual(1, code, out)
self.assertIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
self.assertNotIn("그 뒤에 스킬이 고쳐져", out)
def test_원장이_적은_리비전이_있으면_그_커밋을_본다(self):
"""새 원장은 대조를 싸게 만든다 — 이력을 훑지 않고 적힌 커밋만 본다."""
commit = _commit_that_still_had(self.SKILL, self.RETIRED)
self.assertIsNotNone(commit)
code, out = self._run(self._ledger(self.RETIRED, revision=commit))
self.assertEqual(0, code, out)
self.assertIn("원장이 적은 리비전", out)
def test_그_칸이_없는_옛_원장도_같은_답을_낸다(self):
"""`skillRevision` 을 모르는 원장은 이력 훑기로 떨어진다. 칸이 없다고 잡지 않는다."""
path = self._ledger(self.RETIRED, carry_field=False)
self.assertNotIn(vpr.REVISION_FIELD,
json.load(open(path, encoding="utf-8"))["stages"][2])
code, out = self._run(path)
self.assertEqual(0, code, out)
self.assertIn("그 뒤에 스킬이 고쳐져 영수증을 대조할 수 없다", out)
def test_git_이_없으면_못_봤다고_한다(self):
"""과거를 볼 수 없는 것은 「없다」가 아니다. error 로 올리지 않는다."""
empty = os.path.join(self.dir, "bin")
os.makedirs(empty, exist_ok=True)
env = dict(os.environ, PATH=empty)
# PATH 를 비우면 python3 도 같이 사라진다. 해석기는 절대 경로로 부른다
p = subprocess.run([sys.executable, VERIFIER, self._ledger(self.RETIRED)],
cwd=ROOT, capture_output=True, text=True, env=env)
out = p.stdout + p.stderr
self.assertEqual(0, p.returncode, out)
self.assertIn("스킬의 과거 본문을 못 봐서 영수증을 대조하지 못했다", out)
self.assertIn("대조 못 한 영수증 1", out)
class RunByNamesAManagedAgent(unittest.TestCase):
"""단계를 **누가** 돌렸는지가 원장에 남는가.
`skillEcho` 는 「스킬을 열었다」를 증명하지만 누가 열었는지는 증명하지 않는다 — 매번
새로 띄운 일반 에이전트도 SKILL.md 를 읽고 한 줄을 옮겨 적을 수 있다. 그동안 `runBy`
는 `"subagent"` 라는 상수였고, 그래서 원장 5건이 전부 통과하는 동안에도 어느 에이전트가
돌았는지는 아무 데도 없었다.
"""
def setUp(self):
self.dir = tempfile.mkdtemp()
def _ledger(self, schema=2, run_by=None):
"""runBy 만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
run.update({"runId": "2026-01-01-0000", "project": "demo",
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00",
"finishedAt": "2026-01-01T01:00:00+09:00", "schemaVersion": schema})
for st in run["stages"]:
spec = vpr.STAGES[st["id"]]
if run_by is not None:
st["runBy"] = run_by
if spec["skippable"]:
st.update({"status": "SKIPPED", "skipReason": "이 시험은 runBy 만 본다"})
continue
st.update({"status": "DONE", "skillEcho": _a_sentence_from(st["skill"]),
"gates": [{"cmd": tok, "exit": 0} for tok in spec["gates"]]})
path = os.path.join(self.dir, f"run-{len(os.listdir(self.dir))}.json")
json.dump(run, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
return path
def _run(self, path, *args):
p = subprocess.run([sys.executable, VERIFIER, path, *args], cwd=ROOT,
capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
# ── 계약이 스스로 맞는가 ─────────────────────────────────────────
def test_모든_단계에_에이전트가_배정돼_있다(self):
"""빠진 단계가 있으면 그 단계만 조용히 일반 에이전트로 돌아간다."""
for sid in vpr.ORDER:
with self.subTest(stage=sid):
self.assertTrue(vpr.STAGES[sid].get("agent"),
f"{sid} 에 agent 가 없다")
def test_배정된_에이전트가_실재한다(self):
"""`.claude/agents/<이름>.md` 가 없으면 그 이름은 약속일 뿐이다."""
for sid in vpr.ORDER:
agent = vpr.STAGES[sid]["agent"]
with self.subTest(stage=sid, agent=agent):
self.assertTrue(
os.path.exists(os.path.join(ROOT, ".claude", "agents", f"{agent}.md")),
f"{sid} 이 가리키는 .claude/agents/{agent}.md 가 없다")
def test_틀과_검사기가_같은_에이전트를_말한다(self):
"""틀에만 적어 두면 STAGES 와 갈린다. 갈린 채로는 둘 다 「계약」이라고 말한다."""
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
for st in run["stages"]:
with self.subTest(stage=st["id"]):
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
# ── 판정 ────────────────────────────────────────────────────────
def test_계약대로_적은_원장은_통과한다(self):
"""대조군. 이 자리가 통과하지 않으면 나머지는 아무것도 말하지 않는다."""
code, out = self._run(self._ledger())
self.assertEqual(0, code, out)
self.assertIn("누가 돌렸는지 모르는 단계 0", out)
def test_옛_판의_원장은_error_가_아니라_warn_이다(self):
"""`schemaVersion` 1 에는 그 칸이 없었다. 위조가 아니라 그때의 계약이다."""
code, out = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY))
self.assertEqual(0, code, out)
self.assertIn("옛 판의 원장이라 누가 돌렸는지 적혀 있지 않다", out)
self.assertNotIn("단계를 맡은 에이전트가 계약과 다르다", out)
def test_옛_판이어도_초록으로_보이지_않는다(self):
"""warn 은 통과가 아니다. 요약 줄이 그 수를 따로 센다."""
code, out = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY))
self.assertIn("누가 돌렸는지 모르는 단계 7", out)
code, _ = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY), "--strict")
self.assertEqual(1, code)
def test_새_판에서_상수를_적으면_error_다(self):
"""유예는 옛 원장의 것이다. 지금 판으로 열고 상수를 적는 것은 다른 일이다."""
code, out = self._run(self._ledger(schema=2, run_by=vpr.LEGACY_RUNBY))
self.assertEqual(1, code, out)
self.assertIn("단계를 맡은 에이전트가 계약과 다르다", out)
def test_다른_관리_에이전트를_적어도_error_다(self):
"""실재하는 이름이라고 맞는 것은 아니다. 단계마다 맡은 역할이 다르다."""
code, out = self._run(self._ledger(schema=2, run_by="fact-reviewer"))
self.assertEqual(1, code, out)
self.assertIn("단계를 맡은 에이전트가 계약과 다르다", out)
# ── 원장을 여는 도구가 계약값을 지우지 않는가 ───────────────────
def test_런을_열면_계약이_적힌다(self):
led = os.path.join(self.dir, "opened", "run.json")
p = _cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
self.assertEqual(0, p.returncode, p.stderr)
run = json.load(open(led, encoding="utf-8"))
self.assertEqual(vpr.AGENT_RUNBY_SCHEMA, run["schemaVersion"])
for st in run["stages"]:
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
def test_단계를_열어도_계약값이_남는다(self):
"""`begin` 의 기본값이 계약값을 덮어쓰면 원장은 다시 누가 돌렸는지 잃는다."""
led = os.path.join(self.dir, "begun", "run.json")
_cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
p = _cli("begin", led, "--stage", "S3")
self.assertEqual(0, p.returncode, p.stderr)
st = next(s for s in json.load(open(led, encoding="utf-8"))["stages"]
if s["id"] == "S3")
self.assertEqual(vpr.STAGES["S3"]["agent"], st["runBy"])
def test_사람이_지목하면_그것을_적는다(self):
"""계약과 다르면 검사기가 잡는다. 도구가 값을 막지는 않는다 — 거짓말은 원장에 남아야 한다."""
led = os.path.join(self.dir, "named", "run.json")
_cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
_cli("begin", led, "--stage", "S3", "--runby", "fact-reviewer")
st = next(s for s in json.load(open(led, encoding="utf-8"))["stages"]
if s["id"] == "S3")
self.assertEqual("fact-reviewer", st["runBy"])
if __name__ == "__main__":
unittest.main()
+191
View File
@@ -0,0 +1,191 @@
"""SSOT 가 코드베이스를 옳게 읽었는지 보는 검사기.
이 검사기의 첫 판이 **거짓 error 를 두 종류 냈다.** 둘 다 SSOT 가 아니라 검사기가 틀린
것이었고, 그대로 뒀으면 「SSOT 가 67곳 틀렸다」는 보고가 나갔을 것이다. 아래 테스트의 절반은
그 둘을 고정한다.
· 「main Java 27 · test Java 18」의 27 을 Java **버전**으로 읽었다 → 이 문서에서 그것은
파일 수다. 버전 주장은 스택을 한 줄에 모아 적는 자리에만 있다
· SSOT 가 줄여 쓴 경로(`app-bootstrap/application.yml`)를 「그 리비전에 없다」로 셌다 →
있는 파일을 없다고 하는 것이다. `check-code-anchors.py` 가 `_undecidable` 로 막아 둔
실패와 같은 모양이다
"""
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TOOL = os.path.join(ROOT, "scripts", "check-ssot-facts.py")
_spec = importlib.util.spec_from_file_location("check_ssot_facts", TOOL)
sf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sf)
techlog = sf.techlog
class ReadingTheHeader(unittest.TestCase):
"""머리표에서 수치를 꺼낸다."""
DOC = ("# 제목\n\n> 머리말\n\n"
"| | |\n|---|---|\n"
"| 추적 파일 | 6,747 |\n"
"| main Java | 4,614 파일 / 320,318 LOC |\n"
"| finding 총계 | **462** — P1 30 · P2 147 · P3 285 |\n"
"\n## 1. 본문\n\n| 가족 | leaf |\n|---|---|\n| core | 5 |\n")
def test_reads_the_first_table_only(self) -> None:
claims = sf.header_claims(self.DOC)
self.assertIn("추적 파일", claims)
self.assertNotIn("가족", claims, "본문 표를 머리표로 읽으면 안 된다")
def test_num_strips_commas_and_bold(self) -> None:
self.assertEqual(sf._num("6,747"), 6747)
self.assertEqual(sf._num("**462** — P1 30"), 462)
self.assertIsNone(sf._num("없음"))
class InternalArithmetic(unittest.TestCase):
"""합계 = 부분의 합. 값이 옳은지가 아니라 자기 안에서 맞는지만 본다."""
def _run(self, text: str) -> techlog.Report:
rep = techlog.Report("t")
sf.check_arithmetic(rep, {"finding 총계": text})
return rep
def test_a_consistent_total_passes(self) -> None:
self.assertEqual(self._run("**462** — P1 30 · P2 147 · P3 285").error_count, 0)
def test_a_total_that_is_not_the_sum_fails(self) -> None:
rep = self._run("**999** — P1 30 · P2 147 · P3 285")
self.assertEqual(rep.error_count, 1)
class EvidenceCountsAreASnapshot(unittest.TestCase):
"""증거 수치는 리비전에 고정된 값이 아니다. 지금 파일 수와 견주는 것이 범주 오류였다.
소스는 `21234e38` 에 묶여 있어 추적 파일과 LOC 는 지금 다시 세도 같다. 증거는 이 저장소
안에 살고 뒤이은 배치가 계속 늘린다. 그래서 판정은 크고 작음이 아니라 **기준 시점을
선언했는가**다. 첫 판이 이 구분 없이 error 2건을 냈다.
"""
def _run(self, cell: str, base: str) -> techlog.Report:
rep = techlog.Report("t")
sf.check_evidence_counts(rep, {"증거": cell}, base)
return rep
def setUp(self) -> None:
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
for name, n in (("raw", 5), ("meta", 3)):
d = os.path.join(self.tmp.name, "final", "evidence", name)
os.makedirs(d)
for i in range(n):
open(os.path.join(d, f"{i}.txt"), "w").close()
def test_a_stale_number_without_a_basis_is_an_error(self) -> None:
rep = self._run("`evidence/raw` 370 · `evidence/meta` 14", self.tmp.name)
self.assertEqual(rep.error_count, 2, "기준 시점이 없으면 지금 값으로 읽힌다")
def test_the_same_number_with_a_declared_basis_passes(self) -> None:
rep = self._run("분석 시점(2026-08-31) 스냅샷 — `evidence/raw` 370 · `evidence/meta` 14",
self.tmp.name)
self.assertEqual(rep.error_count, 0)
def test_a_matching_number_needs_no_basis(self) -> None:
rep = self._run("`evidence/raw` 5 · `evidence/meta` 3", self.tmp.name)
self.assertEqual(rep.error_count, 0)
def test_both_numbers_are_reported_as_facts(self) -> None:
"""차이를 숨기지 않는다 — error 가 아니어도 머리표와 지금 값을 함께 낸다."""
rep = techlog.Report("t")
facts = sf.check_evidence_counts(
rep, {"증거": "스냅샷 — `evidence/raw` 370 · `evidence/meta` 14"}, self.tmp.name)
self.assertIn("머리표 370", facts["증거 raw"])
self.assertIn("지금 5", facts["증거 raw"])
class VersionClaimsAreNotFileCounts(unittest.TestCase):
"""「main Java 27」의 27 은 버전이 아니다. 첫 판이 이것을 버전으로 읽었다."""
def test_file_count_lines_are_not_stack_lines(self) -> None:
doc = "main Java 27 · test Java 18\n| 가족 | main Java 26 | test Java 13 |\n"
self.assertEqual(sf.stack_lines(doc).strip(), "",
"파일 수를 적은 줄을 버전 주장으로 읽으면 안 된다")
def test_the_stack_sentence_is_a_stack_line(self) -> None:
doc = "Java 21 · Spring Boot 4.0.8 · Gradle 9.0.0 멀티모듈.\n"
self.assertIn("Java 21", sf.stack_lines(doc))
def test_two_names_are_required(self) -> None:
self.assertEqual(sf.stack_lines("Gradle 9.0.0 을 쓴다\n").strip(), "")
class CitationsThatCannotBeDecided(unittest.TestCase):
"""판정할 수 있는 것만 판정한다. 못 보는 것은 세어서 낸다."""
TRACKED = {"src/app-bootstrap/src/main/resources/application.yml",
"src/app-bootstrap/build.gradle",
"src/config/architecture/modules.json"}
def _run(self, doc: str, base: str = "/nonexistent"):
rep = techlog.Report("t")
stats = sf.check_citations(rep, doc, "/nonexistent-repo", "deadbeef",
self.TRACKED, base)
return rep, stats
def test_an_exact_path_resolves(self) -> None:
rep, stats = self._run("`src/app-bootstrap/build.gradle`")
self.assertEqual(rep.error_count, 0)
self.assertEqual(stats["대조한 인용"], 1)
def test_a_src_relative_path_resolves_and_is_counted(self) -> None:
rep, stats = self._run("`app-bootstrap/build.gradle`")
self.assertEqual(rep.error_count, 0)
self.assertEqual(stats["src/ 접두사로 풀린 인용"], 1)
def test_a_shortened_path_is_a_warning_not_an_error(self) -> None:
"""첫 판이 이것을 error 로 냈다. 있는 파일을 없다고 한 것이다."""
rep, stats = self._run("`app-bootstrap/application.yml`")
self.assertEqual(rep.error_count, 0)
self.assertEqual(stats["못 대조한 인용"], 1)
def test_a_name_that_exists_nowhere_is_an_error(self) -> None:
rep, _ = self._run("`src/app-bootstrap/NoSuchThing.java`")
self.assertEqual(rep.error_count, 1)
def test_shapes_this_checker_does_not_decide(self) -> None:
for doc, why in (("`/tmp/CeProbe.java`", "저장소 밖 절대 경로"),
("`.../avro/AvroMessageCodec.java`", "축약된 경로"),
("`build/evidence/manifest.json`", "빌드 산출물")):
with self.subTest(why=why):
rep, stats = self._run(doc)
self.assertEqual(rep.error_count, 0, why)
self.assertEqual(stats["못 대조한 인용"], 1, why)
class NoTarget(unittest.TestCase):
"""「볼 것이 없어서 통과」를 「문제 없음」이라고 쓰지 않는다."""
def _run(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, TOOL, *args],
cwd=ROOT, capture_output=True, text=True, timeout=600)
def test_absent_project_exits_2(self) -> None:
run = self._run("no-such-project-ssot-facts")
self.assertEqual(run.returncode, 2, run.stdout + run.stderr)
self.assertIn("대상이 성립하지 않는다", run.stdout + run.stderr)
def test_the_limit_is_printed_before_any_verdict(self) -> None:
"""빠뜨린 finding 은 못 본다. 그 한계가 판정보다 먼저 보여야 한다."""
run = self._run("clean-architecture-backend-template")
self.assertIn("빠뜨린 finding 은 찾지 못한다", run.stdout)
self.assertLess(run.stdout.index("빠뜨린 finding"),
run.stdout.index("SSOT FACTS:"))
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -757,6 +757,43 @@ class HarnessTestPlanTest(unittest.TestCase):
for gone in ("basis", "decision", "impact"):
self.assertNotIn(gone, doc)
def test_every_kind_in_the_contract_has_a_field_map_shape_and_delete_path(self):
"""**세 표가 같은 여섯 종류를 말해야 한다.**
빠뜨렸을 때 나는 일이 표마다 다르다 — `FIELD_MAP` 이면 「모르는 kind」로 거절되고,
`SHAPES` 면 `KeyError` 로 죽고, `DELETE_PATHS` 면 **지울 수 없는 초안이 운영에 남는다.**
`_kinds_line_up()` 이 import 할 때 보지만, 그 관문이 지워지면 여기서 걸린다.
"""
six = {"CASE", "CONCEPT", "REFERENCE", "QUESTION", "PROJECT_DECISION", "SETUP"}
self.assertEqual(six, set(ss.FIELD_MAP))
self.assertEqual(six, set(ss.SHAPES))
self.assertEqual(six, set(ss.DELETE_PATHS))
def test_setup_takes_its_pinned_versions_from_the_frontmatter(self):
"""`pinnedVersions` 는 절이 아니라 frontmatter 에 있다. 절만 보면 빠지고,
`SetupInput.required` 에 있어 빠지면 422 다 — Concept 의 `basisVersion` 과 같은 자리다."""
self.assertEqual({"본문": "bodyMarkdown"}, ss.FIELD_MAP["SETUP"])
doc = ss._setup_shape(
{"slug": "s"},
{"pinnedVersions": "- name: Keycloak\n version: 26.7.0"})
self.assertEqual([{"name": "Keycloak", "version": "26.7.0"}], doc["pinnedVersions"])
# 이 종류에는 검증일 칸이 없다. 다른 종류를 보고 넣지 않는다 —
# `unevaluatedProperties: false` 라 보내면 거절된다
for gone in ("lastVerifiedOn", "verifiedOn", "basisVersion"):
self.assertNotIn(gone, doc)
def test_an_empty_pinned_version_list_is_not_the_same_as_an_unreadable_one(self):
"""「비우면 화면에 표를 그리지 않습니다」 — 비운 것은 정상이다.
글이 있는데 못 읽은 것은 거절한다. 빈 칸으로 보내지 않는다."""
self.assertEqual([], ss._pinned_versions(""))
self.assertEqual([], ss._pinned_versions(None))
with self.assertRaises(ss.Refused):
ss._pinned_versions("- Keycloak / 26.7.0") # 모양이 다르다
with self.assertRaises(ss.Refused):
ss._pinned_versions("- name: Keycloak") # 버전이 없다
with self.assertRaises(ss.Refused):
ss._pinned_versions("- name: K\n version: " + "9" * 41) # 상한 40자
def test_decision_status_outside_the_enum_is_refused(self):
"""enum 이 셋뿐이다. 지어내지 않고 여기서 막는다 —
운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다."""
@@ -968,6 +1005,17 @@ class HarnessTestPlanTest(unittest.TestCase):
# 묶음은 worktree 밖에 있다. 없으면 **안 돌았다고 적고 넘어간다** —
# 조용히 통과시키면 이 회귀가 초록인 채로 아무것도 안 재게 된다
self.skipTest(f"묶음이 없어 계획을 못 낸다: {PACKAGE}")
# 이 시험이 재는 것은 **런 식별자를 계획이 적는가** 이고, 고정 해시는 그저 입력이다.
# 작업 트리에서 이 기록을 고치면 어댑터가 (맞게) 거절해서 계획 자체가 안 나온다.
# 그때 **고치는 것은 기록도 묶음도 아니다** — 재지 못했다고 적고 넘어간다.
# 고정값이 이 파일의 과거 판일 때만 그렇다. 남의 묶음이면 그건 진짜 결함이라 실패한다
want = (json.load(open(PACKAGE, encoding="utf-8")).get("target") or {}).get("sha256")
record = os.path.join(ROOT, DECISION_RECORD)
if want and want != ss._sha256_file(record):
kind, detail = ss._pin_verdict(record, want)
if kind != ss.PIN_ALIEN:
self.skipTest(
f"검토가 본 판과 작업 트리가 다르다 — 계획을 못 낸다 ({kind} · {detail})")
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "plan.json")
r = subprocess.run(