pipeline: make tech-log-tree.json the one decomposition contract and enforce it
리뷰 두 건을 반영했다. 계약 - tech-log-tree.json 하나가 분해 계약이자 색인이다. 사람이 읽는 트리·Node Specification· 후보 대장은 없어졌고, 문서에 남아 있던 그 개념을 걷어냈다 - candidateScope — 후보를 찾는 SSOT 범위. 접어 넣은 제2부·제3부는 근거이지 후보가 아니다 - sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 리비전을 모르면 null 로 두고 지어내지 않는다. 갈래가 여럿이면 revisions - 검사기: 계약 미채택·PENDING·PROMOTE↔글감 양방향·candidateScope·sourceRepository 를 error/warn 으로 센다. 옛 스키마도 검사를 피하지 못한다. 테스트 22 → 31 기록 쓰기 - 템플릿 5종에 source·sourceRevision·topicName, Question 에 닫는 조건, 본문 없는 종류에서 assets 제거. 고정 절 개수 삭제 - check_evidence.mjs — 인용한 코드가 SSOT 에 있는지, 앵커가 SSOT 를 가리키는지, 제목이 계약과 같은지, 리비전이 저장소에 있는지. 게시된 기록에서 SSOT 와 다른 URL 을 잡았다 문체 - 문체 규칙의 정본을 ai-tells.md 로. explaining.md 의 질문체 제목·절 끝 대조 반복·그림 예고 규칙을 삭제해 충돌을 없앴다. 첫 절 「설명 뒤에 평가를 붙이지 않는다」에 지우는 사례 네 유형 - voice 스킬의 「독자 쪽을 본다」를 자료에 오독 기록이 있을 때로 좁히고, 평가만 더한 예시를 교체 - check_prose: 안내 문장을 요구하던 경고 제거, 문장이 끝나지 않은 채 문단이 끝나는 조각 검사 추가 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
73026cada6
commit
9d2a3725c5
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""글감 계약과 폴더 배치를 작은 픽스처로 확인한다.
|
||||
|
||||
python3 -m unittest discover -s scripts/tests
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPTS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
import techlog # noqa: E402
|
||||
|
||||
|
||||
def _load(name: str, filename: str):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(SCRIPTS, filename))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verifier = _load("verify_tech_log_tree", "verify-tech-log-tree.py")
|
||||
builder = _load("build_tech_log_tree", "build-tech-log-tree.py")
|
||||
layout = _load("verify_project_layout", "verify-project-layout.py")
|
||||
|
||||
CASE = {
|
||||
"title": "세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다",
|
||||
"kind": "case", "slug": "session-split-across-nodes", "readiness": "READY",
|
||||
"source": ["final/document.md#4-2"], "code": ["SessionStore.java:41"],
|
||||
"evidence": ["evidence/raw/session-probe.txt"],
|
||||
"classification": "두 노드에 요청을 번갈아 보내 재현했고 로그로 확인했다",
|
||||
"missing-verification": "없음",
|
||||
"relations": ["concept:authorization-code-exchange"],
|
||||
}
|
||||
CONCEPT = {
|
||||
"title": "Authorization Code 교환이 한 번 더 일어나는 자리",
|
||||
"kind": "concept", "slug": "authorization-code-exchange", "readiness": "READY",
|
||||
"source": ["final/document.md#3-1"], "basis-version": "Keycloak 26.7.0",
|
||||
"classification": "이 교환을 알아야 아래 Case 의 관측을 읽을 수 있다",
|
||||
"relations": ["case:session-split-across-nodes"],
|
||||
}
|
||||
INDEX = {
|
||||
"schemaVersion": 4, "project": "fixture", "ssot": "final/document.md",
|
||||
"sourceRevision": "abc1234", "generatedAt": "2026-09-05",
|
||||
"candidateScope": {"document": "final/document.md", "sections": ["§3", "§11"]},
|
||||
"sourceRepository": {"path": "https://example.invalid/fixture.git",
|
||||
"revision": "0" * 40, "verified": "픽스처"},
|
||||
"contract": {"readinessValues": techlog.READINESS},
|
||||
"topics": {
|
||||
"session-custody": {
|
||||
"topic": "session-custody", "title": "세션을 누가 보관하는가",
|
||||
"readerQuestion": "자격증명과 세션을 누가 보관하고 보호 자원은 무엇을 신뢰하는가?",
|
||||
"kinds": {"case": [CASE], "concept": [CONCEPT],
|
||||
"reference": [], "question": [], "decision": []},
|
||||
}
|
||||
},
|
||||
"candidates": [
|
||||
{"id": "F001", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
|
||||
"target": "case:session-split-across-nodes"},
|
||||
{"id": "F002", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
|
||||
"target": "concept:authorization-code-exchange"},
|
||||
{"id": "F003", "disposition": "KEEP_IN_SSOT", "dispositionReview": "CONFIRMED"},
|
||||
],
|
||||
}
|
||||
RECORD = """\
|
||||
---
|
||||
kind: CASE
|
||||
slug: session-split-across-nodes
|
||||
title: 세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다
|
||||
topic: session-custody
|
||||
project: fixture
|
||||
status: 게시 전
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
class Fixture:
|
||||
def __init__(self, index: dict | None = None, with_record: bool = True) -> None:
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.root = self.dir.name
|
||||
self.base = os.path.join(self.root, "docs/fixture")
|
||||
self.studio = os.path.join(self.base, "tech-log-studio")
|
||||
os.makedirs(os.path.join(self.studio, "session-custody/case"))
|
||||
os.makedirs(os.path.join(self.base, "final"))
|
||||
|
||||
ssot = os.path.join(self.base, "final/document.md")
|
||||
open(ssot, "w", encoding="utf-8").write("# fixture\n")
|
||||
data = copy.deepcopy(index if index is not None else INDEX)
|
||||
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
|
||||
self.index_path = os.path.join(self.studio, "tech-log-tree.json")
|
||||
self.write(data)
|
||||
if with_record:
|
||||
open(os.path.join(self.studio, "session-custody/case/case-session-split.md"),
|
||||
"w", encoding="utf-8").write(RECORD)
|
||||
|
||||
def write(self, data: dict) -> None:
|
||||
with open(self.index_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
def read(self) -> dict:
|
||||
return json.load(open(self.index_path, encoding="utf-8"))
|
||||
|
||||
def diagram(self, name: str, *, bundled: bool = True, with_source: bool = True) -> None:
|
||||
assets = os.path.join(self.base, "final/assets")
|
||||
target = os.path.join(assets, name) if bundled else assets
|
||||
os.makedirs(target, exist_ok=True)
|
||||
open(os.path.join(target, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
|
||||
if with_source:
|
||||
src = os.path.join(self.base, "final/.techviz", name)
|
||||
os.makedirs(src, exist_ok=True)
|
||||
open(os.path.join(src, "spec.json"), "w", encoding="utf-8").write("{}")
|
||||
|
||||
def __enter__(self):
|
||||
self._saved = (verifier.ROOT, builder.ROOT, layout.ROOT)
|
||||
verifier.ROOT = builder.ROOT = layout.ROOT = self.root
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
verifier.ROOT, builder.ROOT, layout.ROOT = self._saved
|
||||
self.dir.cleanup()
|
||||
|
||||
|
||||
def mutate(**changes):
|
||||
"""글감 하나의 칸을 바꾼 색인을 만든다."""
|
||||
index = copy.deepcopy(INDEX)
|
||||
case = index["topics"]["session-custody"]["kinds"]["case"][0]
|
||||
for key, value in changes.items():
|
||||
if value is None:
|
||||
case.pop(key, None)
|
||||
else:
|
||||
case[key] = value
|
||||
return index
|
||||
|
||||
|
||||
class ContractTest(unittest.TestCase):
|
||||
def test_clean_fixture_has_no_errors(self):
|
||||
with Fixture():
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
builder.main(["build", "fixture"])
|
||||
report = verifier.verify("fixture")
|
||||
self.assertEqual(report.errors, {}, report.errors)
|
||||
|
||||
def test_missing_reader_question_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["topics"]["session-custody"]["readerQuestion"] = ""
|
||||
with Fixture(index):
|
||||
self.assertIn("Topic 에 독자 질문이 없다", verifier.verify("fixture").errors)
|
||||
|
||||
def test_concept_without_basis_version_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
del index["topics"]["session-custody"]["kinds"]["concept"][0]["basis-version"]
|
||||
with Fixture(index):
|
||||
self.assertIn("CONCEPT 노드에 `basis-version` 가 없다",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_case_without_classification_is_an_error(self):
|
||||
with Fixture(mutate(classification=None)):
|
||||
self.assertIn("CASE 노드에 `classification` 가 없다",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_source_anchored_outside_the_ssot_is_flagged(self):
|
||||
with Fixture(mutate(source=["analysis/05-persistence.md §3.5"])):
|
||||
self.assertIn("근거가 SSOT 밖에만 있다", verifier.verify("fixture").warns)
|
||||
|
||||
def test_readiness_is_not_publication(self):
|
||||
with Fixture(mutate(readiness="NEEDS_EVIDENCE")):
|
||||
self.assertIn("글을 쓰면 안 되는 readiness 인데 기록이 있다",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_unknown_readiness_is_an_error(self):
|
||||
with Fixture(mutate(readiness="REJECTED")):
|
||||
self.assertIn("readiness 값이 계약에 없다", verifier.verify("fixture").errors)
|
||||
|
||||
def test_record_outside_the_contract_is_an_error(self):
|
||||
with Fixture() as fx:
|
||||
os.makedirs(os.path.join(fx.studio, "orphan-topic/concept"))
|
||||
open(os.path.join(fx.studio, "orphan-topic/concept/c.md"), "w",
|
||||
encoding="utf-8").write("---\nkind: CONCEPT\nslug: nobody-listed-me\n---\n")
|
||||
self.assertIn("계약에 없는 기록", verifier.verify("fixture").errors)
|
||||
|
||||
def test_stale_ssot_hash_is_an_error(self):
|
||||
with Fixture() as fx:
|
||||
open(os.path.join(fx.base, "final/document.md"), "a",
|
||||
encoding="utf-8").write("바뀌었다\n")
|
||||
self.assertIn("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_pending_disposition_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["candidates"][0]["dispositionReview"] = "PENDING"
|
||||
with Fixture(index):
|
||||
report = verifier.verify("fixture")
|
||||
self.assertIn("disposition 을 다시 판정하지 않은 후보", report.errors)
|
||||
self.assertNotIn("disposition 을 다시 판정하지 않은 후보", report.warns)
|
||||
|
||||
def test_promote_without_a_node_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["candidates"][0]["target"] = "case:never-written"
|
||||
with Fixture(index):
|
||||
self.assertIn("PROMOTE 후보가 글감에 없다", verifier.verify("fixture").errors)
|
||||
|
||||
def test_node_without_a_promote_candidate_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["candidates"][0]["disposition"] = "KEEP_IN_SSOT"
|
||||
index["candidates"][0]["target"] = None
|
||||
with Fixture(index):
|
||||
self.assertIn("글감을 낳은 PROMOTE 후보가 없다",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_missing_candidate_scope_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
del index["candidateScope"]
|
||||
with Fixture(index):
|
||||
self.assertIn("candidateScope 가 없다", verifier.verify("fixture").errors)
|
||||
|
||||
def test_candidate_scope_pointing_at_another_document_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["candidateScope"]["document"] = "analysis/05-persistence.md"
|
||||
with Fixture(index):
|
||||
self.assertIn("candidateScope.document 가 ssot 과 다르다",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_a_node_sourced_only_outside_the_candidate_scope_is_an_error(self):
|
||||
index = mutate(source=["final/document.md#a19-messaging-runtime-core"])
|
||||
index["candidateScope"]["excludedAnchorPattern"] = r"#a\d+-"
|
||||
with Fixture(index):
|
||||
self.assertIn("후보를 찾는 범위 밖에서만 나온 글감",
|
||||
verifier.verify("fixture").errors)
|
||||
|
||||
def test_a_project_without_the_contract_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["schemaVersion"] = 2
|
||||
del index["contract"]
|
||||
del index["candidateScope"]
|
||||
with Fixture(index):
|
||||
report = verifier.verify("fixture")
|
||||
self.assertIn("글감 계약을 아직 쓰지 않았다", report.errors)
|
||||
self.assertNotIn("글감 계약을 아직 쓰지 않았다", report.warns)
|
||||
|
||||
def test_an_old_index_is_not_flooded_with_per_field_errors(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["schemaVersion"] = 2
|
||||
del index["contract"]
|
||||
del index["candidateScope"]
|
||||
index["topics"]["session-custody"]["readerQuestion"] = ""
|
||||
for kind in ("case", "concept"):
|
||||
for node in index["topics"]["session-custody"]["kinds"][kind]:
|
||||
node.pop("classification", None)
|
||||
node.pop("basis-version", None)
|
||||
with Fixture(index):
|
||||
rules = set(verifier.verify("fixture").errors)
|
||||
self.assertEqual(
|
||||
{r for r in rules if "노드에" in r or "독자 질문" in r}, set(),
|
||||
"계약을 안 쓴 프로젝트에 칸마다 error 를 내면 안 된다")
|
||||
|
||||
def test_a_tree_without_the_source_repository_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
del index["sourceRepository"]
|
||||
with Fixture(index):
|
||||
self.assertIn("sourceRepository.path 가 없다", verifier.verify("fixture").errors)
|
||||
|
||||
def test_a_repository_without_a_pinned_revision_is_a_warning(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["sourceRepository"]["revision"] = None
|
||||
with Fixture(index):
|
||||
report = verifier.verify("fixture")
|
||||
self.assertIn("sourceRepository 에 리비전이 없다", report.warns)
|
||||
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
|
||||
|
||||
def test_branch_tips_count_as_a_pinned_revision(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["sourceRepository"]["revision"] = None
|
||||
index["sourceRepository"]["revisions"] = {"pattern1": "0" * 40, "pattern2": "1" * 40}
|
||||
with Fixture(index):
|
||||
report = verifier.verify("fixture")
|
||||
self.assertNotIn("sourceRepository 에 리비전이 없다", report.warns)
|
||||
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
|
||||
|
||||
def test_duplicate_slug_is_an_error(self):
|
||||
index = copy.deepcopy(INDEX)
|
||||
index["topics"]["session-custody"]["kinds"]["concept"][0]["slug"] = \
|
||||
"session-split-across-nodes"
|
||||
with Fixture(index):
|
||||
self.assertIn("slug 가 두 글감에 있다", verifier.verify("fixture").errors)
|
||||
|
||||
|
||||
class BuildTest(unittest.TestCase):
|
||||
def test_derived_fields_come_from_the_record_file(self):
|
||||
with Fixture():
|
||||
index, warnings = builder.build("fixture")
|
||||
case = index["topics"]["session-custody"]["kinds"]["case"][0]
|
||||
self.assertEqual(case["file"], "session-custody/case/case-session-split.md")
|
||||
self.assertEqual(case["publication"], "초안")
|
||||
self.assertEqual(case["readiness"], "READY", "readiness 는 사람이 적는다")
|
||||
concept = index["topics"]["session-custody"]["kinds"]["concept"][0]
|
||||
self.assertEqual(concept["publication"], "미작성")
|
||||
self.assertNotIn("file", concept)
|
||||
self.assertEqual(index["counts"]["written"], 1)
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_human_written_fields_survive_a_rebuild(self):
|
||||
with Fixture() as fx:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
builder.main(["build", "fixture"])
|
||||
builder.main(["build", "fixture"])
|
||||
case = fx.read()["topics"]["session-custody"]["kinds"]["case"][0]
|
||||
self.assertEqual(case["classification"], CASE["classification"])
|
||||
self.assertEqual(case["relations"], CASE["relations"])
|
||||
|
||||
def test_directory_left_behind_does_not_become_a_topic(self):
|
||||
with Fixture() as fx:
|
||||
os.makedirs(os.path.join(fx.studio, "deleted-from-the-contract/case"))
|
||||
open(os.path.join(fx.studio, "deleted-from-the-contract/case/x.md"), "w",
|
||||
encoding="utf-8").write("---\nkind: CASE\nslug: revived-by-its-folder\n---\n")
|
||||
index, warnings = builder.build("fixture")
|
||||
self.assertEqual(set(index["topics"]), {"session-custody"})
|
||||
self.assertEqual(index["unlisted"], ["deleted-from-the-contract/case/x.md"])
|
||||
self.assertTrue(warnings)
|
||||
|
||||
|
||||
class LayoutTest(unittest.TestCase):
|
||||
def test_a_diagram_with_its_source_is_clean(self):
|
||||
with Fixture() as fx:
|
||||
fx.diagram("session-custody-map")
|
||||
report = layout.verify("fixture")
|
||||
self.assertEqual(report.errors, {}, report.errors)
|
||||
self.assertEqual(report.warns, {}, report.warns)
|
||||
|
||||
def test_svg_without_a_techviz_source_is_counted(self):
|
||||
with Fixture() as fx:
|
||||
fx.diagram("hand-drawn", with_source=False)
|
||||
self.assertIn("techviz 정본이 없는 그림", layout.verify("fixture").warns)
|
||||
|
||||
def test_studio_presentation_copies_need_no_source(self):
|
||||
with Fixture() as fx:
|
||||
assets = os.path.join(fx.base, "final/assets/tech-log-studio")
|
||||
os.makedirs(assets)
|
||||
open(os.path.join(assets, "custody.svg"), "w", encoding="utf-8").write("<svg/>")
|
||||
self.assertEqual(layout.verify("fixture").warns, {})
|
||||
|
||||
def test_evidence_folder_outside_the_convention_is_an_error(self):
|
||||
with Fixture() as fx:
|
||||
os.makedirs(os.path.join(fx.base, "final/evidence/screenshots"))
|
||||
self.assertIn("evidence 하위 폴더 이름이 규약 밖이다",
|
||||
layout.verify("fixture").errors)
|
||||
|
||||
def test_finished_analysis_must_not_leave_working_material(self):
|
||||
with Fixture() as fx:
|
||||
os.makedirs(os.path.join(fx.base, "analysis"))
|
||||
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
|
||||
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump({"analysisStatus": "COMPLETE"}, fh)
|
||||
self.assertIn("분석이 끝났는데 작업 재료가 남아 있다",
|
||||
layout.verify("fixture").warns)
|
||||
|
||||
def test_analysis_in_progress_is_not_debt(self):
|
||||
with Fixture() as fx:
|
||||
os.makedirs(os.path.join(fx.base, "analysis"))
|
||||
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
|
||||
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump({"analysisStatus": "IN_PROGRESS"}, fh)
|
||||
report = layout.verify("fixture")
|
||||
self.assertEqual(report.errors, {}, report.errors)
|
||||
self.assertEqual(report.warns, {}, report.warns)
|
||||
|
||||
def test_import_source_left_behind_is_counted(self):
|
||||
with Fixture() as fx:
|
||||
os.makedirs(os.path.join(fx.base, "source/docs"))
|
||||
open(os.path.join(fx.base, "source/docs/lab.md"), "w",
|
||||
encoding="utf-8").write("원본\n")
|
||||
self.assertIn("반입 원본이 남아 있다", layout.verify("fixture").warns)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user