@chrono-meta/fh-gate 3.1.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude/rules/fh_4axis_gate.md +1 -1
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/AGENTS.md +6 -0
  4. package/CLAUDE.md +50 -6
  5. package/README.ja.md +3 -2
  6. package/README.ko.md +3 -2
  7. package/README.md +3 -2
  8. package/README.zh.md +3 -2
  9. package/docs/OUTPUT_EVIDENCE.md +1 -1
  10. package/knowledge/shared/harness-core/claude_md_gate_details.md +26 -0
  11. package/knowledge/shared/harness-core/fh_three_layer_canon.md +1 -1
  12. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +46 -0
  13. package/knowledge/shared/harness-core/governance_engineering_definition.md +89 -0
  14. package/knowledge/shared/learnings/subagent_invocations_log.yaml +68 -0
  15. package/knowledge/shared/rules/auto_project_mapping.md +1 -1
  16. package/package.json +11 -1
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/preprep/SKILL.md +23 -0
  19. package/plugins/fh-commons/skills/preprep/fixtures/font_revert_probe.py +92 -0
  20. package/plugins/fh-commons/skills/preprep/lane_font.py +462 -0
  21. package/plugins/fh-commons/skills/preprep/preprep.py +16 -1
  22. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +17 -0
  23. package/plugins/fh-commons/skills/preprep/test_lane_font.py +452 -0
  24. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  25. package/plugins/fh-meta/CHANGELOG.md +91 -0
  26. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  27. package/scripts/doc_claim_triad_scan.py +303 -0
  28. package/scripts/finding_fleet.sh +180 -0
  29. package/scripts/finding_pipeline.sh +213 -0
  30. package/scripts/finding_verifier.sh +144 -0
  31. package/scripts/finding_verify.py +283 -0
  32. package/scripts/gate_pathspec_check.sh +1 -0
  33. package/scripts/gate_shape_scan.sh +106 -0
  34. package/scripts/selfcheck.sh +50 -0
  35. package/scripts/test_doc_claim_triad_lanes.sh +124 -0
  36. package/scripts/test_finding_pipeline_lanes.sh +459 -0
  37. package/scripts/test_gate_shape_scan_lanes.sh +36 -0
  38. package/scripts/test_heavy_classifier_lanes.sh +13 -3
  39. package/scripts/test_preprep_font_lanes.sh +87 -0
  40. package/templates/.git-hooks/pre-commit +6 -5
  41. package/templates/PRE-PUBLISH-CHECKLIST.md +29 -0
@@ -182,6 +182,7 @@ python3 "$SKILL/preprep.py"
182
182
  | **L10 adjacent-dup** | **인접 장이 같은 문장을 다시 읽나** — 쪼갤 때 뒤 프레임에 원본을 통째로 남기면 앞 장에서 읽은 문장을 또 읽는다. 실측 117자 중 82자(70%) | 차단 (임계는 사람이 적는다. 미기재면 측정만) |
183
183
  | **L9 progression** | **선언된 단계 중 하나가 화면에서 빠졌나** — 실사고: ①②③④ 중 ①이 화면에서 떨어져 리뷰어가 논지를 **정반대로** 읽었다. 개별 문장은 전부 옳았다 | 차단 (선언된 것만 본다) |
184
184
  | **L12 diagram** | **타입 JSON 도해가 «지금 JSON» 에서 validate 를 거쳐 구워졌나** — `kind: diagram_source` 표면의 굽기 영수증(`*.receipt.json`)으로 JSON 지문 일치 · showcase validate ok · PNG 실제 폭(IHDR 직독) ≥ 3840 · viewBox 폭 ≤ 720 · 여백. 실사고 후보 셋: JSON 고치고 PNG 안 구움 · 미검증 JSON 손렌더 · 픽셀은 충분한데 글자 12pt | 차단 (선언된 표면만 · 영수증 없는 손그림은 **UNMEASURED**, 0 아님) |
185
+ | **L15 font** | **서체가 그 덱의 배포 템플릿이 정한 집합을 벗어났나** — `slides/` 안에서 «명시된» 서체만 판정하고, `slideLayouts`·`slideMasters`·`theme` 은 **템플릿과 해시가 같으면 상속분으로 보고 판정하지 않는다**. `fonts.allow` 미선언이면 **템플릿 자신의 `slides/` 분포에서 허용 집합을 유도**한다. 테마가 이탈이면 `+mn-*` 참조 전부가 거기서 오므로 **테마 한 곳만** 지적한다 | 차단 (🟥 `fonts.template` 미선언 = 상속과 저자 이탈을 **구분 불가** → 그 사실을 노트에 적는다 · 서체 토큰 0 = **UNMEASURED**, 「이탈 없음」 아님 · `fonts.intended` 로 선언한 것은 «면제됨»으로 출력 — 면제도 **가족 단위**다 · 🟥 렌더되지 않는 것은 안 센다: 빈 런 · `endParaRPr`(빈 문단 끝서식) · 테마의 `<a:font script=…>` 폴백) |
185
186
  | **G ooxml** | pptx 구조 9종 — 관계 참조 무결성 · 죽은 Content_Types Override · **화면 밖 도형** · 열린 도형 채움 · 선 굵기 토큰 래칫 | 차단 (`python3 ooxml/gate.py <풀린 pptx 트리>`) |
186
187
  | **R1 orphan-connective** | 선두 줄이 «그러나/그래서»로 뒤집는데 그 문장의 주어가 **직전 장 어디에도 없다** | 🟥 advisory · **실물 known-positive**(실제 백업에서 뜬 결함) |
187
188
  | **R2 enum-dropped** | 앞 장이 ①②③ 으로 센 것을, 이 장이 «이 셋 중…» 이라 부르며 **번호 없이** 재편한다 — 1:1 대응이 안 보인다 | 🟥 advisory · **실물 known-positive** |
@@ -191,6 +192,28 @@ python3 "$SKILL/preprep.py"
191
192
  | **P1 build-jitter** | 같은 이름 도형이 **연속한 두 장**에서 조금 다른 자리에 있다 — 넘길 때 튄다 | 🟥 advisory · **실물 known-positive** — 🟥 대조된 도형-쌍이 0 건이면 「튐 없음」이 아니라 **UNMEASURED**(이름 중복 도형은 대조 못 해 통째로 뺀다) |
192
193
  | **P3 adjacency** | 한 도형의 **오른쪽 끝**과 다른 도형의 **왼쪽 끝**이 맞닿으려다 어긋났다 | 🟥 advisory · **실물 known-positive** · 🟥 절대 목록으로 읽지 마라(기저 오탐 76건 실측) — `surfaces.yaml` 의 `geometry.baseline`(이전 판 pptx)을 주면 **편집 전/후 델타**로 돈다. **P2(near-miss, 같은 종류 모서리만 대조)는 미채택** — 구조적으로 실제 결함(다른 종류 모서리의 인접)을 못 잡아서다 |
193
194
 
195
+ 🟥 **L15 의 본체는 검출이 아니라 «상속과 저작의 분리»다.** 순진하게 서체 이름만 세면 이 코퍼스에서
196
+ **오탐 85건**이 나온다 — 그 85건은 전부 `slideMasters/` 안에 있고, **배포 템플릿의 마스터에도 정확히
197
+ 같은 85건이 있다**(해시 동일, 2026-09-11 실측). 저자는 그 글자를 찍은 적이 없다. 진짜 이탈은 3런
198
+ 하나뿐이었고(코드 데모의 고정폭 서체) 그건 «의도»였다. ⇒ 판별자는 **영역 + 템플릿 해시 대조**이지
199
+ 서체 이름이 아니다. 뒤집으면, 템플릿에서 **갈라진** 마스터·레이아웃은 반대로 **저자 책임**이라 그
200
+ 사실을 따로 보고한다. 코드: `lane_font.py` · self-test: `test_lane_font.py`(21항) · 레인 래퍼 `scripts/test_preprep_font_lanes.sh`
201
+ (되돌림 프로브 `fixtures/font_revert_probe.py`). known-negative 가 «서체 하나뿐인 덱»이 아니라
202
+ **«마스터에 이질 서체가 있지만 템플릿과 동일한 덱»** 인 것이 핵심이다 — 그게 실제로 틀렸던 자리다.
203
+
204
+ 🟥 **cross-family 2라운드가 판정을 한 번 뒤집었고, 그 되돌림 자체가 계약이다.** 1라운드는
205
+ «템플릿 자신의 마스터/레이아웃이 쓰는 서체는 오탐이니 노트로 강등하라»고 했고 그렇게 고쳤다.
206
+ 2라운드가 그것을 **fail-open 으로 지목했다** — 배포 템플릿의 레이아웃은 쓰이지도 않는 자리에
207
+ Office 기본값(Arial·Calibri)을 흔히 들고 있어서, 강등하면 저자가 그 이름으로 본문을 찍어도
208
+ 종료코드가 안 움직인다. ⇒ **억제는 걷고 맥락 주석만 남긴다**(finding 은 유지). 래퍼의
209
+ `annotation` 프로브가 이 되돌림이 다시 억제로 가지 않는지를 고정한다.
210
+ 2라운드가 닫은 나머지 다섯: 자식 있는 `defRPr` 에서 비탐욕 정규식이 **서체를 조용히 버림** ·
211
+ `presentation.xml` 의 `r:id`/`id` **속성 순서**가 다르면 장 순서가 빈 리스트가 되어 0건 PASS ·
212
+ 테마의 `<a:font script="...">` **보조 폴백**을 위반으로 셈 · 면제가 정확일치라 선언한 가족의
213
+ 굵기 변형이 안 덮임 · 갈라진 부품에서 **원래 있던** 서체까지 이탈로 보고.
214
+ 🟥 **구조적으로 못 보는 것 셋**: 이미지에 구워진 글자 · 그 서체가 **설치돼 있는지**(파일은 이름만 적고,
215
+ 없으면 렌더러가 조용히 대체한다) · 굵기 변형의 실재. 셋 다 파일 밖의 사실이라 0 으로 세지 않는다.
216
+
194
217
  🟥 **L8 이 advisory 인 이유**: 두 계기의 오탐을 **최저비용으로 무마하는 길이
195
218
  「그 문장을 지우는 것」**이라, 종료코드에 태우면 원고를 나쁜 방향으로 미는 압력이 된다.
196
219
  후보를 내고 판정은 사람이 한다. 오탐 다수가 정상이다.
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """L15 되돌림 프로브 — 상속 분리가 하중을 지는지, 그리고 방어가 몇 겹인지.
3
+
4
+ 🟥 **왜 «template 미선언» 조건에서 재는가 (2026-09-11, 이 프로브가 한 번 거짓 적색을 낸 뒤).**
5
+ L15 의 오탐 방어는 **둘이고 겹친다**:
6
+
7
+ ① 영역 분리 `slide_runs()` 가 애초에 `ppt/slides/` 만 읽는다
8
+ ② A2 강등 템플릿 자신의 마스터·레이아웃이 쓰는 서체는 finding→note 로 내린다
9
+
10
+ 해시가 같은 부품에서는 ②가 ①을 **완전히 덮는다** — 마스터가 템플릿과 동일하면 그 서체는
11
+ 정의상 «템플릿 자신이 쓰는 서체»이기 때문이다. 그래서 둘 다 켠 채 ①만 죽이면 결과가 안 바뀌고,
12
+ 프로브는 「분리가 장식이다」라는 **거짓 판정**을 낸다. 실제로 그렇게 빨개졌고, 그때 내린 결론이
13
+ 「분리를 지워도 된다」였다면 template 미선언 install 전부에서 오탐 85건이 돌아왔을 것이다.
14
+
15
+ ⇒ **컨트롤은 방어를 하나만 남긴 조건에서 건다** — `separation` 은 template 을 빼서 ①만 남긴다.
16
+ (이건 이 저장소가 이름 붙인 「레인이 초록인 이유는 셋」의 변종이다 — 여기서는 *적색*인 이유가
17
+ 계기 쪽에 있었다.)
18
+
19
+ 🟥 **②는 그 뒤 «방어»에서 «주석»으로 강등됐다 (cross-family 2라운드, 같은 날).** 템플릿
20
+ 레이아웃은 쓰이지도 않는 자리에 Office 기본값(Arial·Calibri)을 흔히 들고 있어서, 그 이름을
21
+ 노트로 내리면 저자가 그것으로 본문을 찍어도 종료코드가 안 움직인다 — 오탐을 줄이려던 것이
22
+ **fail-open** 이었다. 그래서 지금 방어는 ① 하나뿐이고, ②의 자리에는 finding 에 붙는 맥락
23
+ 주석만 남는다. `annotation` 모드가 **그 주석이 억제로 되돌아가지 않는지**를 고정한다.
24
+ (옛 `depth` 모드는 그 되돌림 때 폐기됐다. 낡은 계약을 단언하다 옳게 빨개졌고, 그 적색이
25
+ 계약이 바뀌었음을 알린 신호였다 — 지우지 말고 교체하는 것이 맞다.)
26
+
27
+ usage: font_revert_probe.py separation|annotation
28
+ """
29
+ import os
30
+ import re
31
+ import sys
32
+ import shutil
33
+ import tempfile
34
+
35
+ HERE = os.path.dirname(os.path.abspath(__file__))
36
+ sys.path.insert(0, os.path.dirname(HERE))
37
+
38
+ import lane_font as L # noqa: E402
39
+ import test_lane_font as T # noqa: E402
40
+
41
+
42
+ def _widen(z):
43
+ """뮤턴트: 상속 영역까지 판정 대상에 넣는다 = 영역 분리를 죽인다."""
44
+ rows, n = _widen.orig(z)
45
+ for name in z.namelist():
46
+ if re.match(r'ppt/(slideMasters|slideLayouts)/[^/]+\.xml$', name):
47
+ for tf in L._typefaces(z, name):
48
+ if tf and not L.THEME_REF.match(tf):
49
+ # 🟥 튜플 모양은 slide_runs 의 계약이다 — (장, 서체, 슬롯, 텍스트, 출처).
50
+ # 계약이 바뀌면 이 프로브가 빨개진다. 빨개지는 것이 옳다.
51
+ rows.append((0, tf, 'latin', '(상속)', 'inherited'))
52
+ return rows, n
53
+
54
+
55
+ def separation(d):
56
+ """방어 ①만 남긴다 (template 미선언) → 죽이면 오탐이 돌아와야 한다."""
57
+ k1 = T.build(os.path.join(d, 'k1.pptx'), [T.run('본문', 'Brand Display Bold')])
58
+ cfg = T.cfg_for(k1, None, ['Brand Display'])
59
+ base = len(L.scan(cfg, d)[0])
60
+ _widen.orig = L.slide_runs
61
+ L.slide_runs = _widen
62
+ try:
63
+ mut = len(L.scan(cfg, d)[0])
64
+ finally:
65
+ L.slide_runs = _widen.orig
66
+ print('BASE', base, 'MUT', mut)
67
+
68
+
69
+ def annotation(d):
70
+ """템플릿도 쓰는 이름이 **억제되지 않고 finding 으로 남는지** 고정한다.
71
+
72
+ 저자가 본문에 `Helvetica Neue`(템플릿 마스터에 있는 이름)를 찍은 상황. 억제로 되돌아가면
73
+ FINDING 0 이 나오고, 그것이 2라운드가 지목한 fail-open 이다."""
74
+ tpl = T.build(os.path.join(d, 'tpl.pptx'), [T.run('본문', 'Brand Display Bold')])
75
+ k = T.build(os.path.join(d, 'k.pptx'),
76
+ [T.run('본문', 'Brand Display Bold'), T.run('각주', 'Helvetica Neue')])
77
+ f, _ = L.scan(T.cfg_for(k, tpl, ['Brand Display']), d)
78
+ annotated = sum(1 for x in f if '템플릿 자신의 마스터' in x[4])
79
+ print('FINDING', len(f), 'ANNOTATED', annotated)
80
+
81
+
82
+ if __name__ == '__main__':
83
+ mode = sys.argv[1] if len(sys.argv) > 1 else ''
84
+ fn = {'separation': separation, 'annotation': annotation}.get(mode)
85
+ if not fn:
86
+ print('usage: font_revert_probe.py separation|annotation', file=sys.stderr)
87
+ sys.exit(10)
88
+ d = tempfile.mkdtemp(prefix='fontprobe_')
89
+ try:
90
+ fn(d)
91
+ finally:
92
+ shutil.rmtree(d, ignore_errors=True)
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env python3
2
+ """L15 — 서체 일관성: 이 덱이 «자기 템플릿이 정한 서체»를 벗어난 자리를 찾는다 (2026-09-11 신설).
3
+
4
+ WHY: preprep 은 지금까지 **글자의 배치**(P1/P3 기하)와 **말의 정합**(L1·L5·L8·R1~R5)을 봤지,
5
+ **무슨 서체로 찍혔는지**는 어느 레인도 안 봤다. 그런데 발표 템플릿을 주는 조직은 거의 항상
6
+ 서체를 규정하고(배포 템플릿이 테마·마스터에 그 서체를 박아 둔다), 서체 이탈은 렌더링에서만
7
+ 드러나 **사람이 눈으로 훑기 전에는 안 보인다** — 정확히 기계가 대신해야 할 부류다.
8
+
9
+ 🟥 **이 레인의 난점은 검출이 아니라 «상속과 저작의 분리»다.** 실측이 그것을 강제했다
10
+ (2026-09-11, 실제 발표 덱 120장 — 조직 배포 템플릿 기반):
11
+
12
+ 순진하게 세면 Helvetica Neue 85건 → 「서체 위반 85건」
13
+ 영역을 갈라 세면 그 85건은 전부 ppt/slideMasters/ 안에 있다
14
+ 공식 템플릿과 대조하면 가이드 v2.0 의 마스터에도 **정확히 같은 85건**이 있다 (해시 동일)
15
+
16
+ 즉 **저자는 그 글자를 찍은 적이 없다.** 배포된 템플릿에 원래 들어 있던 것이고, 그걸 위반으로
17
+ 세면 오탐률이 100% 인 레인이 된다(진짜 이탈은 그 덱에 6건뿐이었다 — 37p 코드 데모의 Roboto
18
+ Mono, 그리고 그건 «의도»였다). ⇒ 판별자는 **영역 + 템플릿 해시 대조**이지 서체 이름이 아니다.
19
+
20
+ 판정 구조 (엄격한 순서):
21
+ ① 영역 분리 slides/ = 저자가 찍은 것 · slideLayouts·slideMasters·theme = 템플릿 상속
22
+ ② 템플릿 대조 `fonts.template` 이 선언돼 있으면 그 부품들의 해시를 맞춘다
23
+ · 동일 → 그 영역의 서체는 **상속분**, 판정 대상 아님 (노트로만)
24
+ · 다름 → **저자가 템플릿을 건드렸다** = 그건 진짜 판정 대상이다
25
+ ③ 허용 집합 `fonts.allow` 선언 우선. 없으면 **템플릿 자신의 slides/ 분포에서 유도**한다
26
+ (「내 체크리스트가 아니라 덱 자신의 분포가 기준」 — attr 계열과 같은 규율)
27
+ ④ 테마 참조 `+mn-lt`/`+mj-ea` 류는 테마를 타고 풀린다. 그 자체는 서체가 아니므로
28
+ **테마의 실제 서체**를 허용 집합에 대고 본다. 테마가 벗어나 있으면 그 참조
29
+ 전부가 벗어난 것이고, 테마 한 곳만 지적한다(장마다 중복 지적하지 않는다)
30
+
31
+ 🟥 **0 은 통과가 아니다.** 서체 토큰을 하나도 못 읽었으면 추출이 죽은 것이고 UNMEASURED 다.
32
+ 같은 이유로 «대조한 부품 수»를 항상 같이 낸다 — 0 이면 대조를 안 한 것이다.
33
+
34
+ 🟥 **면제는 «선언»으로만 된다** (surfaces.yaml `fonts.intended`, 다른 레인과 같은 관례).
35
+ 코드 화면의 고정폭 서체처럼 «알고도 남긴 것»은 why 와 함께 적는다. 안 적으면 다음 감사자가
36
+ 그것을 다시 «발견»하고 «고친다» — 이 코퍼스가 2026-09-10 에 실제로 그렇게 고쳤다가
37
+ 운영자 지적으로 되돌렸다.
38
+
39
+ 🟥 **이 레인이 구조적으로 못 보는 것**: ⓐ 이미지에 구워진 글자 ⓑ 폰트가 설치돼 있는지
40
+ (파일은 이름만 적는다 — 없는 서체는 렌더러가 조용히 대체한다) ⓒ 같은 가족의 굵기 변형이
41
+ 실제로 존재하는지. 셋 다 **파일 밖**의 사실이라 여기서 0 으로 세지 않는다.
42
+ """
43
+ import os
44
+ import re
45
+ import zipfile
46
+ import hashlib
47
+ import collections
48
+
49
+ # 테마 참조 토큰 — 서체 이름이 아니라 «테마를 보라»는 지시다
50
+ THEME_REF = re.compile(r'^\+(mn|mj)-(lt|ea|cs)$')
51
+
52
+ AREA_SLIDES = 'slides'
53
+ AREA_INHERIT = ('slideLayouts', 'slideMasters', 'theme')
54
+
55
+
56
+ # 🟥 **속성 순서를 가정하지 않는다 — 이 레인이 세 번 같은 자리에서 샜다.**
57
+ # `<a:latin typeface="X"/>` 만 가정한 패턴은 `<a:latin pitchFamily="34" charset="0"
58
+ # typeface="X"/>` 를 **통째로 놓친다**(실측 재현, cross-family R3-2 지목). OOXML 은 속성
59
+ # 순서를 강제하지 않고 PowerPoint 는 실제로 pitchFamily/charset 을 앞에 붙인다.
60
+ # 같은 부류가 `presentation.xml.rels`(Id↔Target 순서)에서도 났다 — 그래서 한 군데씩 깁지
61
+ # 않고 **태그를 먼저 잡고 속성은 그 안에서 따로 읽는** 형태로 통일한다.
62
+ # 🟥 `sym`(기호 글꼴 슬롯)도 본다 — 실측 덱에 157개 있었다. 빼면 금지 기호 서체를
63
+ # 명시로 덮어써도 판정망을 그냥 빠져나간다(4라운드 지목).
64
+ _FONT_TAG = re.compile(r'<a:(latin|ea|cs|sym)\b([^>]*?)/?>')
65
+ # 🟥 속성 이름에 **네임스페이스 접두사**가 붙는다(`r:id`). `\w+` 는 콜론을 못 먹어서
66
+ # `r:id="rId2"` 를 `id="rId2"` 로 잘못 읽고 **엉뚱한 값을 `id` 키에 넣는다** — 3라운드에서
67
+ # 속성 순서를 고치며 내가 심은 결함이고, 4라운드가 잡았다(실측: 실물 덱이 그 형태라
68
+ # 장 순서가 계속 파일명 순 폴백으로 돌고 있었다).
69
+ _ATTR = re.compile(r'([\w:.-]+)="([^"]*)"')
70
+
71
+
72
+ def font_refs(xml):
73
+ """[(슬롯, 서체이름)] — 속성 순서와 무관하게 읽는다. typeface 가 없는 태그는 건너뛴다."""
74
+ out = []
75
+ for m in _FONT_TAG.finditer(xml):
76
+ a = dict(_ATTR.findall(m.group(2)))
77
+ tf = a.get('typeface')
78
+ if tf:
79
+ out.append((m.group(1), tf))
80
+ return out
81
+
82
+
83
+ def font_names(xml):
84
+ """서체 이름만 (슬롯 무시)."""
85
+ return [t for _, t in font_refs(xml)]
86
+
87
+
88
+ _PART = re.compile(r'ppt/(slides|slideLayouts|slideMasters|theme)/[^/]+\.xml$')
89
+
90
+
91
+ def _parts(z):
92
+ """{영역: {부품경로: sha256[:16]}}"""
93
+ out = collections.defaultdict(dict)
94
+ for n in z.namelist():
95
+ m = _PART.match(n)
96
+ if m:
97
+ out[m.group(1)][n] = hashlib.sha256(z.read(n)).hexdigest()[:16]
98
+ return out
99
+
100
+
101
+ def _typefaces(z, name):
102
+ """한 부품이 쓰는 서체 이름들 (등장 순서 유지, 중복 포함)."""
103
+ return font_names(z.read(name).decode('utf-8', 'replace'))
104
+
105
+
106
+ def theme_fonts(z):
107
+ """테마가 정한 major/minor 서체 집합. 테마가 없으면 빈 집합."""
108
+ got = set()
109
+ for n in z.namelist():
110
+ if re.match(r'ppt/theme/theme\d+\.xml$', n):
111
+ x = z.read(n).decode('utf-8', 'replace')
112
+ for tag in ('majorFont', 'minorFont'):
113
+ m = re.search(r'<a:%s>.*?</a:%s>' % (tag, tag), x, re.S)
114
+ if m:
115
+ # 🟥 `<a:font script="Hans" typeface="SimSun"/>` 류 **보조 스크립트 폴백은
116
+ # 제외**한다. 테마가 정한 «본 서체»는 latin/ea/cs 셋이고, script 폴백은
117
+ # 해당 문자가 없으면 렌더되지 않는다 — 넣으면 SimSun·Mangal 이 위반으로
118
+ # 뜬다(agy 3.8 지적 R2-4. 이 코퍼스엔 폴백이 0개라 재현은 안 됐고,
119
+ # Office 기본 테마에는 흔하다 — 미재현을 부재로 읽지 않는다).
120
+ got |= {t for t in font_names(m.group(0))
121
+ if t and not THEME_REF.match(t)}
122
+ return got
123
+
124
+
125
+ def _occurrences(xml, where):
126
+ """한 부품 XML 에서 (서체, 슬롯, 텍스트, 출처) 를 뽑는다.
127
+
128
+ 🟥 **`<a:r>` 만 보면 새는 자리가 셋이다** (cross-family agy 지적 S2·S3, 2026-09-11):
129
+ · `<a:fld>` — 장 번호·바닥글·날짜. 자기 rPr 과 `<a:t>` 를 갖는데 런이 아니다
130
+ · `defRPr` — 텍스트 박스 전체를 선택해 서체를 바꾸면 **런에 안 남고 여기 남는다**.
131
+ 실측: 이 코퍼스에 115건 있었다(전부 테마 참조라 무해했을 뿐이다)
132
+ · `lstStyle` — 목록 수준별 기본. defRPr 과 같은 경로로 잡힌다
133
+ 🟥 **`endParaRPr` 은 일부러 뺀다** — 빈 문단의 «끝 서식»이라 **렌더되는 글자가 없다**.
134
+ 실측으로 오탐을 냈다: 같은 문구인 49p/50p 가 endParaRPr 62pt 하나 때문에 «크기가
135
+ 달라졌다»로 떴다(2026-09-11). 있지도 않은 글자의 서체는 이탈이 아니다.
136
+ """
137
+ out = []
138
+ # ① 런과 필드 — 찍힌 글자가 있다
139
+ for tag in ('r', 'fld'):
140
+ for m in re.finditer(r'<a:%s[ >].*?</a:%s>' % (tag, tag), xml, re.S):
141
+ b = m.group(0)
142
+ # 🟥 `<a:t xml:space="preserve">` — 앞뒤 공백이 있으면 PowerPoint 가 붙인다.
143
+ # 속성 없는 `<a:t>` 만 잡으면 그 런의 서체 검사가 통째로 건너뛰어진다(4라운드).
144
+ txt = re.sub(r'\s+', ' ', ''.join(re.findall(r'<a:t\b[^>]*>([^<]*)</a:t>', b))).strip()
145
+ # 🟥 글자가 없는 런은 건너뛴다 — 지우고 남은 찌꺼기 속성이라 렌더되지 않는다
146
+ # (agy 지적 A3). «해결이 불가능한 지적»은 지적이 아니라 소음이다.
147
+ if not txt:
148
+ continue
149
+ for slot, tf in font_refs(b):
150
+ out.append((tf, slot, txt, 'run' if tag == 'r' else 'field'))
151
+ # ② 단락/목록 기본 서식 — 글자는 상속되므로 렌더된다
152
+ # 🟥 비탐욕 `.*?(?:/>|</a:defRPr>)` 는 **첫 자식 self-closing 태그에서 끊긴다.**
153
+ # `<a:defRPr sz="1800"><a:solidFill><a:srgbClr val="0"/></a:solidFill>
154
+ # <a:latin typeface="Comic Sans MS"/></a:defRPr>` 에서 매치가
155
+ # `…<a:srgbClr val="0"/>` 까지만 잡혀 **서체를 조용히 버린다** — 실측 재현,
156
+ # fail-open 이다(cross-family agy 3.8 지적 R2-1). 자기닫힘과 여는-태그를 갈라 처리한다.
157
+ for m in re.finditer(r'<a:defRPr\b[^>]*/>', xml):
158
+ for slot, tf in font_refs(m.group(0)):
159
+ out.append((tf, slot, '', 'para-default'))
160
+ for m in re.finditer(r'<a:defRPr\b[^>]*(?<!/)>(.*?)</a:defRPr>', xml, re.S):
161
+ for slot, tf in font_refs(m.group(1)):
162
+ out.append((tf, slot, '', 'para-default'))
163
+ return out
164
+
165
+
166
+ def slide_runs(z):
167
+ """[(장 인덱스 1-base, 서체, 슬롯, 텍스트, 출처)] — slides/ 안에서 «명시된» 서체만."""
168
+ try:
169
+ pres = z.read('ppt/presentation.xml').decode('utf-8', 'replace')
170
+ # 🟥 Id 와 Target 의 순서를 가정하지 않는다 — 가정하면 rels 가 통째로 빈 dict 가 되고
171
+ # 장 순서가 파일명 순으로 조용히 대체돼 「N번째 장」이 어긋난다(R3-4).
172
+ rels = {}
173
+ for rm in re.finditer(r'<Relationship\b[^>]*/?>',
174
+ z.read('ppt/_rels/presentation.xml.rels').decode('utf-8', 'replace')):
175
+ a = dict(_ATTR.findall(rm.group(0)))
176
+ tgt = re.search(r'slides/slide(\d+)\.xml$', a.get('Target', ''))
177
+ if a.get('Id') and tgt:
178
+ rels[a['Id']] = tgt.group(1)
179
+ lst = re.search(r'<p:sldIdLst>.*?</p:sldIdLst>', pres, re.S).group(0)
180
+ # 🟥 속성 순서를 가정하지 않는다. 초판은 `id="\d+" r:id=` 라 **`r:id` 가 앞에 오면
181
+ # findall 이 빈 리스트를 내고 예외도 안 난다** → order=[] → rows=[] → 0건 PASS.
182
+ # 파서가 조용히 아무것도 못 읽는 형태라 fail-open 이었다(agy 3.8 지적 R2-2).
183
+ order = []
184
+ for sm in re.finditer(r'<p:sldId\b[^>]*/?>', lst):
185
+ rid = dict(_ATTR.findall(sm.group(0))).get('r:id')
186
+ if rid and rid in rels:
187
+ order.append(int(rels[rid]))
188
+ if not order:
189
+ raise ValueError('sldIdLst 에서 장 순서를 못 읽었다')
190
+ except Exception:
191
+ # 순서를 못 읽어도 «못 읽었다»로 끝내지 않는다 — 파일명 순으로라도 센다(번호는 근사).
192
+ order = sorted(int(re.search(r'slide(\d+)', n).group(1)) for n in z.namelist()
193
+ if re.match(r'ppt/slides/slide\d+\.xml$', n))
194
+ rows = []
195
+ for i, sn in enumerate(order, 1):
196
+ x = z.read('ppt/slides/slide%d.xml' % sn).decode('utf-8', 'replace')
197
+ for tf, slot, txt, src in _occurrences(x, 'slide'):
198
+ rows.append((i, tf, slot, txt, src))
199
+ return rows, len(order)
200
+
201
+
202
+ def drifted_occurrences(z, drifted, zt=None):
203
+ """템플릿에서 갈라진 상속 부품의 서체 — **저자가 손댄 것이므로 판정 대상이다.**
204
+
205
+ 🟥 이게 없으면 fail-open 이다(cross-family agy 지적 S1): 저자가 마스터를 열어 금지 서체를
206
+ 꽂아도 `slides/` 만 읽는 추출기는 못 보고, 해시 대조는 «갈라짐 1» 이라는 **노트**만 냈다.
207
+ 노트는 종료코드를 안 움직인다 ⇒ 0건 PASS 로 뚫린다.
208
+
209
+ 🟥 **그 부품의 «원래 서체»는 빼고 낸다.** 안 그러면 저자가 레이아웃에서 도형 크기만
210
+ 바꿔도 해시가 갈라지고, 그 부품에 원래 있던 템플릿 서체 전부가 «이탈»로 쏟아진다
211
+ (agy 3.8 지적 R2-6). 갈라진 것은 부품이지 그 안의 모든 서체가 아니다 —
212
+ 판정 대상은 **저자가 그 부품에 새로 들인 서체**뿐이다."""
213
+ rows = []
214
+ for area, name in drifted:
215
+ try:
216
+ x = z.read(name).decode('utf-8', 'replace')
217
+ except Exception:
218
+ continue
219
+ before = set()
220
+ if zt is not None:
221
+ try:
222
+ before = set(font_names(re.sub(
223
+ r'<a:endParaRPr\b[^>]*(?:/>|>.*?</a:endParaRPr>)', '',
224
+ zt.read(name).decode('utf-8', 'replace'), flags=re.S)))
225
+ except Exception:
226
+ before = set() # 템플릿에 없던 부품(신설) — 전부가 새 것이다
227
+ # 🟥 렌더되지 않는 자리는 여기서도 안 센다 — `_occurrences` 만 엄격하고 여기는
228
+ # 파일 전체를 훑으면, 빈 문단의 `endParaRPr` 하나로 갈라짐 오탐이 난다(R3-5).
229
+ scanned = re.sub(r'<a:endParaRPr\b[^>]*(?:/>|>.*?</a:endParaRPr>)', '', x, flags=re.S)
230
+ for slot, tf in font_refs(scanned):
231
+ if not THEME_REF.match(tf) and tf not in before:
232
+ rows.append((area, name, tf, slot))
233
+ return rows
234
+
235
+
236
+ def _allow_from_template(zt):
237
+ """템플릿의 slides/ 가 실제로 쓰는 서체 = 허용 집합. 「덱 자신의 분포가 기준」.
238
+
239
+ 🟥 **마스터·레이아웃은 일부러 안 넣는다.** 넣으면 상속된 잠재 기본값(이 코퍼스의
240
+ Helvetica Neue)까지 «규정된 서체»가 되어 탐지력이 깎인다. 대신 `_template_own_fonts()`
241
+ 가 그 집합을 따로 들고, 거기 있는 서체의 지적은 **finding 이 아니라 노트로 강등**한다
242
+ (cross-family agy 지적 A2 — 오탐이되, 허용으로 승격시킬 일은 아니다)."""
243
+ got = set()
244
+ for n in zt.namelist():
245
+ if re.match(r'ppt/slides/slide\d+\.xml$', n):
246
+ # 🟥 템플릿의 «렌더 안 되는» 끝 서식은 허용 집합에 넣지 않는다 — 넣으면 템플릿의
247
+ # 보이지도 않는 잔재가 덱 전체의 합법 서체로 **승격**된다(4라운드 지목).
248
+ # 판정 쪽(`_occurrences`)만 엄격하고 유도 쪽이 헐거우면 그 비대칭이 곧 누수다.
249
+ x = re.sub(r'<a:endParaRPr\b[^>]*(?:/>|>.*?</a:endParaRPr>)', '',
250
+ zt.read(n).decode('utf-8', 'replace'), flags=re.S)
251
+ got |= {t for t in font_names(x) if t and not THEME_REF.match(t)}
252
+ return got | theme_fonts(zt)
253
+
254
+
255
+ def _template_own_fonts(zt):
256
+ """템플릿의 마스터·레이아웃이 쓰는 서체 — «저자가 만든 것은 아닌» 이름들."""
257
+ got = set()
258
+ for n in zt.namelist():
259
+ if re.match(r'ppt/(slideMasters|slideLayouts)/[^/]+\.xml$', n):
260
+ got |= {t for t in _typefaces(zt, n) if t and not THEME_REF.match(t)}
261
+ return got
262
+
263
+
264
+ def _norm(s):
265
+ return re.sub(r'\s+', ' ', s).strip().lower()
266
+
267
+
268
+ def _allowed(tf, allow):
269
+ """가족 단위 비교 — 「Brand Display」가 허용이면 「Brand Display Bold」도 허용이다.
270
+
271
+ 🟥 **한 방향뿐이다.** 초판은 `na.startswith(n + ' ')` 도 함께 봤는데, 그러면
272
+ allow=["Arial Black"] 일 때 tf="Arial" 이 통과한다 — **구체적인 파생 하나만 허용했는데
273
+ 상위 가족 전체가 열리는** 역방향 누수다(cross-family agy 지적 B2, 실측 재현).
274
+ 허용은 «가족 → 변형» 으로만 내려간다."""
275
+ n = _norm(tf)
276
+ for a in allow:
277
+ na = _norm(a)
278
+ if n == na or n.startswith(na + ' '):
279
+ return True
280
+ return False
281
+
282
+
283
+ def _intended(cfg):
284
+ """[(슬라이드집합|None, 서체목록|None, why)] — 선언된 면제."""
285
+ out = []
286
+ for e in ((cfg.get('fonts') or {}).get('intended') or []):
287
+ out.append((set(e.get('slides') or []) or None,
288
+ list(e.get('fonts') or []) or None,
289
+ e.get('why') or '(사유 미기재)'))
290
+ return out
291
+
292
+
293
+ def _exempt(slide, tf, rules):
294
+ """면제도 **가족 단위**다 — `_allowed` 와 같은 규칙을 쓴다.
295
+
296
+ 🟥 초판은 정확 일치였다. 그러면 `intended: ["Roboto Mono"]` 를 선언해도
297
+ `Roboto Mono Bold` 는 면제가 안 되어, **선언을 해놓고도 지적이 남는다**
298
+ (agy 3.8 지적 R2-5). 허용과 면제가 같은 의미의 «가족»을 서로 다르게 읽으면
299
+ 선언하는 사람이 그 차이를 알 길이 없다."""
300
+ for slides, fonts, why in rules:
301
+ if slides is not None and slide not in slides:
302
+ continue
303
+ if fonts is not None and not _allowed(tf, fonts):
304
+ continue
305
+ return why
306
+ return None
307
+
308
+
309
+ def scan(cfg, root):
310
+ """preprep.py 진입점. (findings, notes)
311
+
312
+ findings 는 **저자가 찍은 이탈**만 태운다. 상속분·선언된 면제는 노트로만 나간다."""
313
+ deck_s = (cfg.get('surfaces_by_id') or {}).get('built_deck')
314
+ if not deck_s:
315
+ return [], ['L15 font : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
316
+ deck = os.path.normpath(os.path.join(root, os.path.expanduser(deck_s['path'])))
317
+ if not os.path.exists(deck):
318
+ return [], [f'L15 font : built_deck 실물 없음({deck}) — UNMEASURED (0 아님)']
319
+
320
+ spec = cfg.get('fonts') or {}
321
+ tpl_p = spec.get('template')
322
+ allow = [a for a in (spec.get('allow') or []) if a]
323
+ notes = []
324
+ tpl_own = set() # 템플릿의 마스터·레이아웃이 쓰는 서체 (finding 에 주석으로 실린다)
325
+ ztpl = None # 템플릿 zip — 갈라진 부품의 «원래 서체» 차분에 쓴다
326
+
327
+ try:
328
+ z = zipfile.ZipFile(deck)
329
+ rows, nslides = slide_runs(z)
330
+ parts = _parts(z)
331
+ th = theme_fonts(z)
332
+ except Exception as e:
333
+ return [], [f'L15 font : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
334
+
335
+ # ── ② 템플릿 대조 ──────────────────────────────────────────────────────────
336
+ inherited_ok = set() # 상속분으로 확인된 영역
337
+ drifted = [] # 템플릿에서 갈라진 부품
338
+ if tpl_p:
339
+ tpl = os.path.normpath(os.path.join(root, os.path.expanduser(tpl_p)))
340
+ if not os.path.exists(tpl):
341
+ notes.append(f'L15 font : template 실물 없음({tpl}) — 상속 판정 UNMEASURED (0 아님). '
342
+ f'상속 영역의 서체는 «판정 안 함»이 아니라 «못 쟀음»이다')
343
+ else:
344
+ try:
345
+ zt = zipfile.ZipFile(tpl)
346
+ ztpl = zt
347
+ tp = _parts(zt)
348
+ for area in AREA_INHERIT:
349
+ mine, theirs = parts.get(area, {}), tp.get(area, {})
350
+ if not mine:
351
+ continue
352
+ same = [k for k, v in mine.items() if theirs.get(k) == v]
353
+ diff = [k for k, v in mine.items() if k in theirs and theirs[k] != v]
354
+ new = [k for k in mine if k not in theirs]
355
+ if len(same) == len(mine):
356
+ inherited_ok.add(area)
357
+ drifted += [(area, k) for k in diff + new]
358
+ notes.append(f'L15 font : {area} 부품 {len(mine)} — 템플릿과 동일 {len(same)} · '
359
+ f'갈라짐 {len(diff)} · 템플릿에 없음 {len(new)}')
360
+ tpl_own = _template_own_fonts(zt)
361
+ if not allow:
362
+ allow = sorted(_allow_from_template(zt))
363
+ notes.append('L15 font : allow 미선언 — 템플릿 자신의 분포에서 유도했다 '
364
+ f'({len(allow)}가족: ' + ' · '.join(allow[:6])
365
+ + (' …' if len(allow) > 6 else '') + ')')
366
+ except Exception as e:
367
+ notes.append(f'L15 font : template 읽기 오류({type(e).__name__}: {e}) — '
368
+ f'상속 판정 UNMEASURED (0 아님)')
369
+ else:
370
+ notes.append('L15 font : template 미선언 — 상속분을 저자 이탈과 **구분할 수 없다**. '
371
+ '이 상태의 판정은 마스터 상속 서체를 위반으로 셀 수 있다(실측 오탐 85건). '
372
+ 'surfaces.yaml 에 `fonts.template` 로 배포 템플릿을 대라')
373
+
374
+ if not allow:
375
+ return [], notes + ['L15 font : 허용 서체 집합이 비었다(allow 미선언 + template 미선언) — '
376
+ 'NOT_CONFIGURED (0 아님)']
377
+
378
+ # ── ③ 판정 ────────────────────────────────────────────────────────────────
379
+ rules = _intended(cfg)
380
+ seen = collections.Counter()
381
+ findings = []
382
+ offenders = collections.defaultdict(list) # 서체 → [(장, 슬롯, 텍스트, 출처)]
383
+ exempted = collections.Counter()
384
+ demoted = collections.Counter() # 템플릿 자신이 쓰는 서체 (A2)
385
+ for slide, tf, slot, txt, src in rows:
386
+ seen[tf] += 1
387
+ if THEME_REF.match(tf):
388
+ continue
389
+ if _allowed(tf, allow):
390
+ continue
391
+ why = _exempt(slide, tf, rules)
392
+ if why:
393
+ exempted[(tf, why)] += 1
394
+ continue
395
+ offenders[tf].append((slide, slot, txt, src))
396
+ if tf in tpl_own:
397
+ demoted[tf] += 1 # 억제가 아니라 «주석» 이다 — 아래 finding 문구에 실린다
398
+
399
+ if not seen:
400
+ return [], notes + [f'L15 font : slides/ {nslides}장에서 서체 토큰 0건 — UNMEASURED '
401
+ f'(0 아님. 추출이 죽어도 0 이 나온다)']
402
+
403
+ # 테마가 허용 밖이면 장마다 지적하지 않고 테마 한 곳만 — 참조는 전부 거기서 온다
404
+ # 🟥 테마 이탈도 `fonts.intended` 를 존중한다 — 런과 갈라짐은 면제를 보는데 테마만
405
+ # 안 보면, 같은 선언이 자리에 따라 먹기도 하고 안 먹기도 한다(R3-3).
406
+ # 슬라이드 번호가 없는 자리이므로 «슬라이드 무관» 선언(slides 미지정)만 적용된다.
407
+ bad_theme = sorted(t for t in th
408
+ if not _allowed(t, allow) and not _exempt(None, t, rules))
409
+ if bad_theme and 'theme' not in inherited_ok:
410
+ findings.append(('built_deck', 'font-theme', 'ppt/theme',
411
+ ' · '.join(bad_theme),
412
+ '테마 서체가 허용 집합 밖이다 — `+mn-*`/`+mj-*` 참조가 전부 이걸 탄다'))
413
+
414
+ # 🟥 갈라진 상속 부품의 서체 — 저자가 템플릿을 손댄 것이므로 **판정 대상**이다(S1).
415
+ # 이게 없으면 「마스터를 열어 금지 서체를 꽂는」 경로가 통째로 fail-open 이었다.
416
+ if drifted:
417
+ dr = drifted_occurrences(z, drifted, ztpl)
418
+ dbad = collections.defaultdict(set)
419
+ for area, name, tf, slot in dr:
420
+ if not _allowed(tf, allow) and not _exempt(0, tf, rules):
421
+ dbad[tf].add(name.split('/')[-1])
422
+ for tf, names in sorted(dbad.items()):
423
+ findings.append(('built_deck', 'font-inherited-drift',
424
+ ' · '.join(sorted(names)[:4]), tf,
425
+ '템플릿에서 **갈라진** 상속 부품이 허용 밖 서체를 쓴다 — '
426
+ '상속분이 아니라 저자가 손댄 것이므로 판정한다'))
427
+
428
+ for tf, hits in sorted(offenders.items(), key=lambda kv: -len(kv[1])):
429
+ pages = sorted({p for p, _, _, _ in hits})
430
+ slots = sorted({sl for _, sl, _, _ in hits})
431
+ srcs = sorted({sc for _, _, _, sc in hits})
432
+ samp = next((t for _, _, t, _ in hits if t), '')
433
+ findings.append(('built_deck', 'font', 'slides ' + ','.join(str(p) for p in pages[:8])
434
+ + (' …' if len(pages) > 8 else ''),
435
+ tf,
436
+ f'허용 집합 밖 서체 {len(hits)}회 / {len(pages)}장 · 슬롯 {"/".join(slots)}'
437
+ + f' · 출처 {"/".join(srcs)}'
438
+ + (' · ⓘ 이 이름은 **템플릿 자신의 마스터/레이아웃에도 있다**'
439
+ '(Office 기본값일 수 있다 — 그래도 «규정된 본문 서체»는 아니므로 지적한다)'
440
+ if tf in tpl_own else '')
441
+ + (f' · 예: «{samp[:34]}»' if samp else '')
442
+ + ' — 🟥 슬롯이 `ea`/`cs` 뿐이면 그 문자가 없을 때 렌더되지 않을 수 있다'
443
+ '(사람이 판정한다). 의도라면 `fonts.intended` 에 why 와 함께 선언해라'))
444
+
445
+ notes.append(f'L15 font : slides/ {nslides}장 · 명시 서체 {len(rows)}회 · 서체 {len(seen)}종 · '
446
+ f'허용 {len(allow)}가족 · 이탈 {len(offenders)}종 · 선언 면제 {sum(exempted.values())}회 · '
447
+ f'그중 템플릿 자신도 쓰는 이름 {sum(demoted.values())}회'
448
+ + ('' if rows else ' — 🟥 0 이면 UNMEASURED, 「이탈 없음」이 아니다'))
449
+ if inherited_ok:
450
+ notes.append('L15 font : 상속 확인(템플릿과 해시 동일) — ' + ' · '.join(sorted(inherited_ok))
451
+ + ' 의 서체는 저자 산물이 아니므로 판정하지 않는다')
452
+ for (tf, why), c in exempted.most_common():
453
+ notes.append(f' 면제됨 {tf} ({c}회) — {why}')
454
+ for tf, c in demoted.most_common():
455
+ notes.append(f' ⓘ {tf} ({c}회) — 템플릿 자신의 마스터/레이아웃에도 있는 이름이다. '
456
+ f'🟥 **억제하지 않는다**: 1라운드에서 이것을 노트로 «강등»했다가 2라운드가 '
457
+ f'fail-open 으로 지목했다 — 템플릿 레이아웃은 쓰이지도 않는 자리에 Office '
458
+ f'기본값(Arial·Calibri)을 흔히 들고 있어서, 강등하면 저자가 그 이름으로 '
459
+ f'본문을 찍어도 종료코드가 안 움직인다. 지적은 남기고 맥락만 붙인다')
460
+ for area, k in drifted[:8]:
461
+ notes.append(f' 🟥 템플릿에서 갈라진 부품: {k} — 여기 서체는 **저자 책임**이다')
462
+ return findings, notes
@@ -10,6 +10,8 @@
10
10
  L3 inventory — 선언된 표면이 실물로 있나 (부재 = UNMEASURED, **0 이 아니다**)
11
11
  L4 getput — 왕복 no-op 컨트롤. **v0.1 미배선 → NOT_WIRED 로 말한다**(거짓 초록 금지)
12
12
  L12 diagram — 타입 JSON 도해가 «지금 JSON» 에서 validate 를 거쳐 구워졌나 (lane_diagram.py · 2026-09-05)
13
+ L15 font — 서체가 그 덱의 **배포 템플릿**이 정한 집합을 벗어났나 (lane_font.py · 2026-09-11).
14
+ 🟥 마스터/레이아웃 상속분을 저자 이탈과 가르는 것이 이 레인의 본체다
13
15
 
14
16
  exit 0 = 전 레인 통과 · 1 = 발견 있음 · 2 = 계기 오류/미측정으로 판정 불가 (PASS 아님)
15
17
 
@@ -774,7 +776,8 @@ def main():
774
776
  # [[feedback_rule_misdescribes_its_own_machine]] · [[feedback_instrument_vs_target_and_budget]]
775
777
  _lanes = sorted(n[5:] for n in globals() if n.startswith('lane_') and callable(globals()[n]))
776
778
  _mods = sorted(m for m in ('lane_promise', 'lane_adjacent_dup', 'lane_progression',
777
- 'lane_slide_relations', 'lane_geometry', 'lane_diagram', 'lane_slide_refs')
779
+ 'lane_slide_relations', 'lane_geometry', 'lane_diagram', 'lane_slide_refs',
780
+ 'lane_font')
778
781
  if os.path.exists(os.path.join(HERE, m + '.py')))
779
782
  # 🟥 2026-09-04 신설 — `--lane R1,R2,...` 로 **새 모듈 레인(R1-R5·P1/P3)만** 골라 끈다/켠다.
780
783
  # L1~L11 은 아직 이 필터를 안 탄다(usage 줄의 --lane 은 그쪽엔 미배선인 채 남아 있다 —
@@ -867,6 +870,18 @@ def main():
867
870
  except Exception as _e:
868
871
  notes.append('L13 slide-refs : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
869
872
  % (type(_e).__name__, _e))
873
+ # L15 font — 서체가 «그 덱의 템플릿이 정한 것»을 벗어났나(2026-09-11 신설).
874
+ # 🟥 번호는 L15 다 — L14 는 컴패니언 배포본에서 screen-parity 가 이미 쓰고 있었다.
875
+ # FH 트리만 훑어서 빈 줄 알았고, 드리프트 앵커(D2)가 그 충돌을 잡았다.
876
+ # 차단: slides/ 안에서 명시된 서체 중 허용 집합 밖인 것. 🟥 마스터·레이아웃·테마는
877
+ # 배포 템플릿 상속분이라 해시가 같으면 판정하지 않는다 — 안 가르면 오탐 85건이 나온다
878
+ # (실측 2026-09-11). `fonts.template` 미선언이면 그 분리가 불가능하고, 그 사실을 노트에 적는다.
879
+ try:
880
+ import lane_font
881
+ _f15, _n15 = lane_font.scan(cfg, root)
882
+ findings += _f15; notes += _n15
883
+ except Exception as _e:
884
+ notes.append('L15 font : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)' % (type(_e).__name__, _e))
870
885
  # L12 diagram — 타입 JSON 도해가 «지금 JSON» 에서 validate 를 거쳐 구워졌나(2026-09-05 신설).
871
886
  # 차단: 선언된 diagram_source 표면에 한해 지문·validate·해상도·viewBox 폭·여백을 본다.
872
887
  # 영수증 없는 PNG(손그림)는 UNMEASURED 노트로만 — 0 이 아니다.