feat(scripts): 형식으로 판정 가능한 것만 코드로 막고 나머지는 검토로 넘긴다

형식 관문 여덟이 확신 승격·수치 조작·화살표 뒤집기·필수 칸 삭제를 하나도 못 막는
것이 재현됐다. 그 가운데 결정적으로 판정 가능한 것을 코드로 옮긴다.

check-required-content.py — 종류가 요구하는 칸이 없거나 비었는지 본다.
audit-records.py 는 평문 칸 안에 마크업이 있는지만 보고 칸이 있는지는 안 센다.
고정 목차·자료 개수·답은 강제하지 않는다. 답이 없는 QUESTION 은 정상이고
「다음 검증」이 빈 것만 결함이다.
대상이 성립하지 않으면(프로젝트 없음·계약 없음) exit 2 로 막고, 계약은 있고 기록이
0건이면 통과시키되 초록으로 두지 않는다. 「봤고 괜찮다」와 「볼 것이 없어서 통과」는
다르다.

check-preservation.py — 윤문 전후를 견준다. 지금 관문 가운데 편집 전후를 보는 것이
하나도 없어 수치를 바꾸거나 유보를 지운 편집이 그대로 통과했다.
사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸 값일 수 있다.
유보 표현이 줄면 내되 옳은지는 판정하지 않는다. 늘어난 것은 세지 않는다.

review-package.py — 아무것도 판정하지 않는다. 판정할 사람이 받을 것을 모은다.
해시·검사기 버전·여기서 실제로 돌린 관문·주장 후보·판정 기준. 종료 코드로 안 걸리는
것은 warnings 로 따로 올린다 — 확신 승격이 딱 그 모양이라 안 실으면 아무도 못 본다.

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-10 11:06:32 +09:00
co-authored by Claude Opus 5
parent 7fc7c69157
commit cf3996711f
18 changed files with 1250 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 가운데 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
이 값은 로컬에서 잰 것이고 운영에서 같은지는 확인하지 못했다.
```
build-tech-log-tree.py exit=1
```
문서가 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적어 두었다.
자세한 것은 https://example.test/docs 에 있다.
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 중 셋이 `exit 1` 이었다. 응답 시간은 4ms 였다.
이 값은 운영에서 확인한 값이다.
```
build-tech-log-tree.py exit=0
```
문서는 「관문은 종료 코드가 0 이면 지난 것이다」 라고 적었다.
자세한 것은 https://example.test/doc 에 있다.
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 중 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
이 값은 로컬에서 잰 것이라 운영에서 같은지는 확인하지 못했다.
```
build-tech-log-tree.py exit=1
```
문서는 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적었다.
자세한 것은 https://example.test/docs 에 있다.
+46
View File
@@ -0,0 +1,46 @@
---
kind: CASE
slug: fixture-case
title: 고정 사례 케이스
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
source:
- final/document.md#s1
evidence:
- ../../../final/evidence/raw/x.txt
---
# 고정 사례 케이스
## 관계
- **고정 사례 개념**
이 사건을 읽으려면 그 개념이 먼저 필요하다.
## 문제
관측한 현상을 적는다. 범위도 함께 적는다.
## 결론
## 검증 환경
python 3.12.3 · 리비전 0000000
## 재현 조건
1. 이 순서로 돌린다.
2. 값이 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 무엇이 있었나
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
<!-- body:end -->
@@ -0,0 +1,28 @@
---
kind: CONCEPT
slug: fixture-concept
title: 고정 사례 개념
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 개념
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건이 이 개념 위에서 벌어진다.
## 본문
<!-- body:start -->
## 정의
적용 범위까지 함께 적는다.
<!-- body:end -->
@@ -0,0 +1,26 @@
---
kind: PROJECT_DECISION
slug: fixture-decision
title: 고정 사례 결정
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
decisionStatus: PROPOSED
---
# 고정 사례 결정
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
## 근거
기록에 있는 근거만 적는다.
## 결정문
실제로 고른 것을 적는다.
## 영향
감수한 비용과 재검토 조건을 적는다.
@@ -0,0 +1,30 @@
---
kind: QUESTION
slug: fixture-question
title: 고정 사례 물음
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
questionStatus: OPEN
---
# 고정 사례 물음
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
## 관계
- **고정 사례 케이스**
그 사건이 이 물음을 열었다.
## 사실
- 확인된 사실을 적는다.
## 미지수
- 아직 모르는 것을 적는다.
## 다음 검증
@@ -0,0 +1,31 @@
---
kind: REFERENCE
slug: fixture-reference
title: 고정 사례 참조
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 참조
무엇을 참고하는 기준인지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건에서 이 기준이 쓰였다.
## 목적
이 기준을 쓰는 이유를 적는다.
## 규칙
1. 판단 기준을 적는다
근거와 함께 적는다.
## 예외
적용되지 않는 조건을 적는다.
+49
View File
@@ -0,0 +1,49 @@
---
kind: CASE
slug: fixture-case
title: 고정 사례 케이스
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
source:
- final/document.md#s1
evidence:
- ../../../final/evidence/raw/x.txt
---
# 고정 사례 케이스
한 문장으로 무엇이 있었는지 적는다. 이 문단이 Studio 의 요약 칸이 된다.
## 관계
- **고정 사례 개념**
이 사건을 읽으려면 그 개념이 먼저 필요하다.
## 문제
관측한 현상을 적는다. 범위도 함께 적는다.
## 결론
근거가 뒷받침하는 만큼만 적는다.
## 검증 환경
python 3.12.3 · 리비전 0000000
## 재현 조건
1. 이 순서로 돌린다.
2. 값이 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 무엇이 있었나
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
<!-- body:end -->
+29
View File
@@ -0,0 +1,29 @@
---
kind: CONCEPT
slug: fixture-concept
title: 고정 사례 개념
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
basisVersion: 예시 명세 1.0
---
# 고정 사례 개념
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건이 이 개념 위에서 벌어진다.
## 본문
<!-- body:start -->
## 정의
적용 범위까지 함께 적는다.
<!-- body:end -->
+30
View File
@@ -0,0 +1,30 @@
---
kind: PROJECT_DECISION
slug: fixture-decision
title: 고정 사례 결정
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
decisionStatus: PROPOSED
---
# 고정 사례 결정
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
## 근거
기록에 있는 근거만 적는다.
## 결정문
실제로 고른 것을 적는다.
## 판단 이유
확인된 대안과 그것을 고르지 않은 이유를 적는다.
## 영향
감수한 비용과 재검토 조건을 적는다.
+33
View File
@@ -0,0 +1,33 @@
---
kind: QUESTION
slug: fixture-question
title: 고정 사례 물음
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
questionStatus: OPEN
---
# 고정 사례 물음
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
## 관계
- **고정 사례 케이스**
그 사건이 이 물음을 열었다.
## 사실
- 확인된 사실을 적는다.
## 미지수
- 아직 모르는 것을 적는다.
## 다음 검증
1. 답을 구할 방법을 적는다.
닫는 조건 : 어떤 결과가 나오면 닫는지 적는다.
+35
View File
@@ -0,0 +1,35 @@
---
kind: REFERENCE
slug: fixture-reference
title: 고정 사례 참조
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 참조
무엇을 참고하는 기준인지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건에서 이 기준이 쓰였다.
## 목적
이 기준을 쓰는 이유를 적는다.
## 규칙
1. 판단 기준을 적는다
근거와 함께 적는다.
## 적용 조건
어느 버전·어느 전제에서 쓰는지 적는다.
## 예외
적용되지 않는 조건을 적는다.
+78
View File
@@ -0,0 +1,78 @@
"""윤문 전후에 보호 구간이 그대로인지 보는 검사기.
정상 윤문은 통과하고, 수치·코드·인용·URL 을 건드린 편집은 걸린다.
유보 표현이 줄어든 것은 걸러 내되 판정하지 않는다 — 판단은 근거를 읽는 검토가 한다.
"""
import importlib.util
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
F = os.path.join(ROOT, "scripts", "tests", "fixtures", "preservation")
_spec = importlib.util.spec_from_file_location(
"check_preservation", os.path.join(ROOT, "scripts", "check-preservation.py"))
cp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cp)
def _read(name):
return open(os.path.join(F, name), encoding="utf-8").read()
class PreservationTest(unittest.TestCase):
def setUp(self):
self.before = _read("before.md")
def test_an_ordinary_rewrite_passes(self):
res = cp.compare(self.before, _read("after-ok.md"))
self.assertEqual([], res["findings"])
self.assertEqual([], res["hedgesDropped"])
def test_a_number_changed_in_prose_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
values = {f["value"] for f in res["findings"] if f["kind"] == "수치"}
self.assertIn("14ms", values)
self.assertIn("4ms", values)
def test_a_number_changed_inside_a_code_block_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
blocks = {f["value"] for f in res["findings"] if f["kind"] == "코드블록"}
self.assertTrue(any("exit=1" in b for b in blocks))
self.assertTrue(any("exit=0" in b for b in blocks))
def test_a_changed_direct_quotation_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("직접인용", kinds)
def test_a_changed_url_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("URL", kinds)
def test_dropped_hedges_are_surfaced_without_a_verdict(self):
"""유보가 줄면 낸다. 옳은지 그른지는 말하지 않는다."""
res = cp.compare(self.before, _read("after-tampered.md"))
dropped = {h["word"] for h in res["hedgesDropped"]}
self.assertIn("확인하지 못했다", dropped)
self.assertIn("로컬", dropped)
self.assertEqual(0, res["hedgeTotalAfter"])
def test_adding_a_hedge_is_not_reported(self):
"""유보를 더하는 것은 이 규범에서 안전한 쪽이다."""
after = self.before.replace("14ms 였다", "14ms 였다. 다만 한 번만 쟀다")
res = cp.compare(self.before, after)
self.assertEqual([], res["hedgesDropped"])
def test_a_missing_file_is_not_reported_as_clean(self):
import subprocess
p = subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-preservation.py"),
os.path.join(F, "before.md"), os.path.join(F, "nope.md")],
cwd=ROOT, capture_output=True, text=True)
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
고정 사례는 다섯 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
"""
import importlib.util
import os
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")
_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")
def _report(sub, name):
rep = crc.techlog.Report("fixture")
crc.check_record(os.path.join(FIXTURES, sub, f"{name}.md"), rep)
return rep
class RequiredContentTest(unittest.TestCase):
def test_every_kind_passes_when_filled(self):
for kind in KINDS:
with self.subTest(kind=kind):
rep = _report("ok", kind)
self.assertEqual(0, rep.error_count,
f"{kind}: {dict(rep.errors)}")
def test_every_kind_fails_when_a_required_part_is_missing(self):
for kind in KINDS:
with self.subTest(kind=kind):
rep = _report("missing", kind)
self.assertGreater(rep.error_count, 0,
f"{kind} 의 누락 사례가 통과했다")
def test_the_missing_part_is_named(self):
"""무엇이 빠졌는지 말한다. 「어딘가 잘못됐다」로 끝나지 않는다."""
expected = {
"case": "결론",
"concept": "basisVersion",
"reference": "적용 조건",
"question": "다음 검증",
"decision": "판단 이유",
}
for kind, part in expected.items():
with self.subTest(kind=kind):
rules = " / ".join(_report("missing", kind).errors)
self.assertIn(part, rules)
def test_decision_kind_is_project_decision_in_frontmatter(self):
"""decision/ 폴더의 기록은 kind: PROJECT_DECISION 이다 (templates/decision.md)."""
self.assertEqual("decision", crc.KIND_ALIASES["PROJECT_DECISION"])
rep = _report("ok", "decision")
self.assertNotIn("kind 를 모르겠다", rep.errors)
def test_an_unanswered_question_is_not_an_error(self):
"""답이 없는 QUESTION 자체는 결함이 아니다. 답을 구할 방법이 없는 것이 결함이다."""
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 _cli(self, *args):
import subprocess
return subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-required-content.py"), *args],
cwd=ROOT, capture_output=True, text=True)
def test_a_missing_project_is_not_reported_as_clean(self):
"""대상이 없으면 통과가 아니다. 오타 하나로 관문이 무효가 되면 안 된다."""
p = self._cli("nonexistent-project")
self.assertEqual(2, p.returncode)
self.assertIn("대상이 성립하지 않는다", p.stderr)
def test_a_project_without_a_contract_does_not_come_back_green(self):
"""계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다."""
p = self._cli("ca-tmpl")
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")
self.assertEqual(0, p.returncode)
self.assertIn("아직 안 쓰였다", p.stdout)
def test_one_ungrounded_target_stops_the_whole_run(self):
"""성립하는 것과 안 하는 것을 함께 주면 통과로 뭉개지 않는다.
성립하는 쪽으로 `keycloak` 을 쓴다. `verify-pipeline.py` 의 계약이 scripts/ 안에
저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은
프로젝트를 테스트에 적으면 계약 검사가 깨진다.
"""
p = self._cli("keycloak", "ca-tmpl")
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()