@chrono-meta/fh-gate 3.2.0 → 3.5.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 (106) hide show
  1. package/.claude/registry/agent_cards.json +1 -1
  2. package/.claude/rules/fh_4axis_gate.md +25 -0
  3. package/.claude-plugin/marketplace.json +9 -3
  4. package/AGENTS.md +2 -2
  5. package/CATALOG.md +4 -4
  6. package/CHEATSHEET.md +1 -1
  7. package/CLAUDE.md +4 -4
  8. package/README.md +2 -2
  9. package/docs/OUTPUT_EVIDENCE.md +1 -1
  10. package/docs/STANDARDS_ALIGNMENT.md +1 -1
  11. package/docs/codex-compat.md +2 -2
  12. package/knowledge/shared/harness-core/agents_md_runtime_details.md +2 -2
  13. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +30 -3
  14. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +1 -1
  15. package/knowledge/shared/harness-core/skill_quality_rubric.md +1 -1
  16. package/knowledge/shared/learnings/subagent_invocations_log.yaml +120 -4
  17. package/knowledge/shared/rules/modes_and_value.md +2 -2
  18. package/package.json +9 -1
  19. package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
  20. package/plugins/fh-commons/README.md +38 -0
  21. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  22. package/plugins/fh-meta/CHANGELOG.md +117 -0
  23. package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
  24. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +40 -0
  25. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +1 -1
  26. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +43 -5
  27. package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL.md +75 -3
  28. package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL_detail.md +2 -2
  29. package/plugins/fh-meta/skills/harvest-loop/SKILL_detail.md +2 -2
  30. package/plugins/fh-meta/skills/install-doctor/SKILL.md +1 -1
  31. package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
  32. package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
  33. package/plugins/fh-meta/skills/pipeline-conductor/SKILL.md +1 -1
  34. package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +1 -1
  35. package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
  36. package/plugins/fh-preprep/.claude-plugin/plugin.json +22 -0
  37. package/plugins/fh-preprep/README.md +24 -0
  38. package/plugins/{fh-commons → fh-preprep}/skills/preprep/SKILL.md +75 -3
  39. package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/mk_slide_fixtures.py +152 -1
  40. package/plugins/fh-preprep/skills/preprep/lane_attr_consistency.py +370 -0
  41. package/plugins/fh-preprep/skills/preprep/lane_geometry.py +339 -0
  42. package/plugins/fh-preprep/skills/preprep/lane_screen_parity.py +232 -0
  43. package/plugins/fh-preprep/skills/preprep/oox.py +305 -0
  44. package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep.py +52 -4
  45. package/plugins/fh-preprep/skills/preprep/safe_install.py +369 -0
  46. package/plugins/{fh-commons → fh-preprep}/skills/preprep/surfaces.example.yaml +29 -0
  47. package/plugins/fh-preprep/skills/preprep/test_preprep_lanes_rp.py +1417 -0
  48. package/plugins/fh-preprep/skills/preprep/usage_ledger.py +191 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  50. package/scripts/fh-run.sh +13 -0
  51. package/scripts/field_canon_preload.sh +1 -1
  52. package/scripts/finding_fleet.sh +391 -13
  53. package/scripts/finding_pipeline.sh +370 -11
  54. package/scripts/finding_verifier.sh +31 -2
  55. package/scripts/finding_verify.py +198 -13
  56. package/scripts/frontier_digest_autopilot.sh +3 -3
  57. package/scripts/gate_shape_scan.sh +16 -2
  58. package/scripts/selfcheck.sh +12 -8
  59. package/scripts/test_fh_run_resolver_lanes.sh +116 -0
  60. package/scripts/test_files_manifest_shipping_lanes.sh +31 -0
  61. package/scripts/test_finding_pipeline_lanes.sh +1556 -4
  62. package/scripts/test_gate_shape_scan_lanes.sh +11 -0
  63. package/scripts/test_marker_crossfamily_lanes.sh +75 -3
  64. package/scripts/test_marker_standpoint_lanes.sh +31 -6
  65. package/scripts/test_preprep_adjacent_dup_lanes.sh +1 -1
  66. package/scripts/test_preprep_diagram_lanes.sh +1 -1
  67. package/scripts/test_preprep_drift_anchor.sh +27 -12
  68. package/scripts/test_preprep_drift_anchor_lanes.sh +4 -2
  69. package/scripts/test_preprep_font_lanes.sh +7 -7
  70. package/scripts/test_preprep_progression_lanes.sh +1 -1
  71. package/scripts/test_preprep_promise_lanes.sh +1 -1
  72. package/scripts/test_preprep_retired_lanes.sh +45 -2
  73. package/scripts/test_preprep_slide_refs_lanes.sh +5 -5
  74. package/scripts/test_usage_ledger_lanes.sh +243 -0
  75. package/scripts/usage_report.py +211 -0
  76. package/templates/.git-hooks/pre-commit +152 -6
  77. package/templates/local_fh_context.md +1 -1
  78. package/templates/regression_guard.sh +1 -1
  79. package/plugins/fh-commons/skills/preprep/lane_geometry.py +0 -181
  80. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +0 -184
  81. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/README.md +0 -0
  82. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/canon_terms.example.yaml +0 -0
  83. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/diagram_from_json.py +0 -0
  84. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_negative.md +0 -0
  85. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_positive.md +0 -0
  86. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  87. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  88. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/font_revert_probe.py +0 -0
  89. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_negative.md +0 -0
  90. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_positive.md +0 -0
  91. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_negative.md +0 -0
  92. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_positive.md +0 -0
  93. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/interslide_deps.py +0 -0
  94. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/jargon_terms.example.yaml +0 -0
  95. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_adjacent_dup.py +0 -0
  96. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_diagram.py +0 -0
  97. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_font.py +0 -0
  98. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_progression.py +0 -0
  99. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_promise.py +0 -0
  100. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_refs.py +0 -0
  101. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_relations.py +0 -0
  102. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/c1_baseline.txt +0 -0
  103. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/gate.py +0 -0
  104. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep_wire.py +0 -0
  105. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/presentation_checklist.md +0 -0
  106. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/test_lane_font.py +0 -0
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """P4 attr-consistency — **덱 자신의 분포가 판정 기준이다.**
3
+
4
+ ## 왜 이 레인이 생겼나 (2026-09-10, 실사고에서 역산)
5
+
6
+ 「120장 전수 육안 검수를 했다」고 보고했는데 운영자가 *"전체적으로 제대로 본 게 맞는지 심히
7
+ 의심된다"* 고 했고, **측정이 운영자 쪽이 맞다는 것을 보였다** — 그 뒤 이 방식으로 스캔하자
8
+ 눈으로도 운영자 지적으로도 안 나온 결함이 **5건** 나왔다:
9
+
10
+ s54 같은 장 안에서 같은 종류 점선이 1.0pt 와 3.0pt
11
+ s58·59 노란 점선 박스의 위/아래 여백이 0.55" 와 1.20"
12
+ s59 좌우 대칭 한 쌍이 30pt ↔ 32pt
13
+ s70 「반박」 32pt ↔ 거울 자리 「나온 것」 28pt
14
+ s29 열 제목 28↔32 · 1행 32↔26
15
+
16
+ 🟥 **원인은 성실함이 아니라 «누가 채점표를 썼나»였다.** 나는 내가 만든 체크리스트로 나를
17
+ 채점했고, 그러면 내가 안 적은 축은 구조적으로 안 보인다. ⇒ 이 레인은 채점표를 **저자에게서
18
+ 덱 자신에게** 옮긴다: 임계를 코드에 박지 않고, **같은 역할의 도형들이 실제로 무슨 값을 쓰고
19
+ 있는가**를 덱에서 뽑아 그 안의 «혼자 다른 것»만 낸다.
20
+
21
+ ## 형태 — 축 넷이 전부 같은 한 문장이다
22
+
23
+ «덱 자신이 정의하는 역할 키»로 묶고 → 그 무리 안에서 «한 속성»이 갈리는 것을 낸다
24
+
25
+ dash 키=(장, 점선 종류) 속성=선 굵기 「같은 점선인데 굵기가 다르다」
26
+ algn 키=(장, 폭, y) 속성=정렬 방식 「같은 줄인데 정렬이 다르다」
27
+ mirror 키=(장, y, 화면 중심 거울) 속성=글자 크기 「좌우 대칭인데 크기가 다르다」
28
+ echo 키=같은 문장(덱 전체) 속성=글자 크기·x 「같은 말인데 매번 다르게 생겼다」
29
+
30
+ 축을 늘리는 것이 **코드가 아니라 표 한 줄**이 되도록 이 모양으로 짰다. `echo` 는 나머지 셋과
31
+ 달리 **장을 가로지르는** 키를 쓴다 — 같은 문장이 여러 장에 나올 때 크기·자리가 갈리는가.
32
+ (실측: 방법론 이름 (a)(b)(c) 셋이 8장에 걸쳐 **네 가지 배치**로 나온다.)
33
+
34
+ ## 🟥 정직한 한계 — 인용 전에 읽어라
35
+
36
+ - **이 레인은 «다르다»만 본다. «틀렸다»는 안 본다.** 크기 대비가 강조를 나르는 자리는 정상이고
37
+ (실측: s6/s7 의 44↔32pt 는 빌드 강조 교체다), 그건 `attr_consistency.intended` 로 **선언**한다 —
38
+ 기계는 «선언되었나»만 보고 «옳은가»는 안 본다(CLAUDE.md §Mechanization Boundary).
39
+ - **advisory 고정.** 오탐의 최저비용 무마가 «다른 쪽을 같이 맞춰 버리는 것»이라, 종료코드에
40
+ 태우면 덱을 평평하게 미는 압력이 된다. 후보를 내고 **판정은 사람이 한다.**
41
+ - **정렬·거울 축은 키가 엄격해서(정확히 같은 폭·같은 y) 못 보는 자리가 많다.** 한 픽셀 어긋난
42
+ «같은 줄»은 이 레인에 안 잡힌다. 좁아진 것이지 닫힌 게 아니다.
43
+ - **크기 축은 명시 `sz` 만 읽는다.** 플레이스홀더/테마에서 물려받는 크기는 XML 에 안 적히므로
44
+ **UNMEASURED 다, 「같다」가 아니다.** 그런 도형은 통째로 뺀다.
45
+ - **n=1 코퍼스(120장 한국어 덱)에서 벼려졌다.** 다른 덱에 옮기면 거울 허용 오차와
46
+ `min_echo_chars` 를 자기 분포에서 다시 재라.
47
+ """
48
+ import zipfile, re, os, collections, html
49
+
50
+ EMU_IN = 914400.0
51
+ EMU_PT = 12700.0
52
+ AXES = ('dash', 'algn', 'mirror', 'echo')
53
+
54
+
55
+ # ── 추출 ─────────────────────────────────────────────────────────────────────
56
+
57
+ import oox as _oox
58
+
59
+
60
+ def _slide_order(z):
61
+ return _oox.slide_order(z)
62
+
63
+
64
+ def _slide_size_in(z):
65
+ sz = _oox.slide_size(z)
66
+ return (sz[0] / EMU_IN, sz[1] / EMU_IN) if sz else (13.333, 7.5)
67
+
68
+
69
+ def _shapes(z, sn):
70
+ """한 장의 도형 전부(그룹은 풀어서 절대 좌표). 🟥 lane_geometry.shapes() 와 달리 **이름 중복도 버리지 않는다** —
71
+ 이 레인은 «같은 이름을 짝지어 대조»하는 게 아니라 «한 장 안의 분포»를 보기 때문이다. 대신 «한 장 안에서
72
+ 같은 이름이 둘» 이면 `dup=True` 로 표시해 면제 조회가 그것을 모호로 취급한다(R5 A6 · R6 A5)."""
73
+ out = []
74
+ walked = _oox.walk_slide(z, sn)
75
+ # R7 A7: 이름 중복은 «장 위의 모든 도형» 에서 센다 — xfrm 없는 자리표시자를 걸러낸 뒤 세면 그것과 겹치는 이름을 못 본다
76
+ cnt = collections.Counter(s_['name'] for s_ in walked if s_['name'] is not None)
77
+ for s in walked:
78
+ if s['kind'] not in ('sp', 'cxnSp', 'pic') or s['name'] is None or s['x'] is None:
79
+ continue
80
+ paras = s['paras']
81
+ szs = tuple(sz for p in paras for _t, sz in p['runs'] if sz is not None)
82
+ # R3 A10: 크기는 «글자 구간» 에 붙는다 — 인접 같은 크기 런을 합친 (글자수, 크기). R6 B7: 글자수는 공백 정규화 후.
83
+ # R7 A6 → R8 A4: 공백 정규화는 «문단 전체» 에서 한 번. 🟥 그리고 공백은 자기 크기를 갖지 않는다 —
84
+ # «앞 글자» 의 크기를 물려받는다(문단 첫 공백은 뒤 글자). R7 은 공백 런의 크기를 그대로 세어
85
+ # "abc "@28+"def"@32 와 "abc"@28+" def"@32(같은 화면) 가 갈렸고, <a:br/> 은 «1자@Nonept» 라는 거짓 증거를 냈다.
86
+ # 잔여(이름으로): 공백 «만» 다른 크기인 런(codex R7 A6) 은 이 레인이 못 본다 — 그것은 글자 크기가 아니라 «틈 너비» 다.
87
+ spans = []
88
+ for p in paras:
89
+ chars = [(ch, sz) for t, sz in p['runs'] for ch in t]
90
+ norm = []
91
+ for ch, sz in chars:
92
+ if ch.isspace():
93
+ if norm and norm[-1][0] == ' ':
94
+ continue
95
+ norm.append((' ', None))
96
+ else:
97
+ norm.append((ch, sz))
98
+ while norm and norm[0][0] == ' ':
99
+ norm.pop(0)
100
+ while norm and norm[-1][0] == ' ':
101
+ norm.pop()
102
+ prev_sz = None
103
+ for k_, (ch, sz) in enumerate(norm):
104
+ if ch == ' ':
105
+ norm[k_] = (' ', prev_sz if prev_sz is not None else next((z for _c, z in norm[k_ + 1:] if _c != ' '), None))
106
+ else:
107
+ prev_sz = sz
108
+ for ch, sz in norm:
109
+ if spans and spans[-1][1] == sz:
110
+ spans[-1] = (spans[-1][0] + 1, sz)
111
+ else:
112
+ spans.append((1, sz))
113
+ out.append(dict(
114
+ slide=sn, name=s['name'],
115
+ x=s['x'] / EMU_IN, y=s['y'] / EMU_IN, w=s['cx'] / EMU_IN, h=s['cy'] / EMU_IN,
116
+ szs=szs,
117
+ algn=tuple((p['algn'] or '(기본)') for p in paras) or ('(기본)',),
118
+ spans=tuple(spans),
119
+ lnw=round(s['ln_w'] / EMU_PT, 2) if s['ln_w'] is not None else None, dash=s['dash'],
120
+ text=_oox.shape_text(paras), dup=cnt[s['name']] > 1))
121
+ return out
122
+
123
+
124
+ def load(path):
125
+ z = zipfile.ZipFile(path)
126
+ order = _slide_order(z)
127
+ sw, _sh = _slide_size_in(z)
128
+ S = [_shapes(z, sn) for sn in order]
129
+ # 🟥 «몇 장» 은 발표 순서(위치)지 파일 번호가 아니다 — slide9.xml 이 1번째면 1p 다(codex 09-11).
130
+ for pos, sh in enumerate(S, 1):
131
+ for sp in sh:
132
+ sp['slide'] = pos
133
+ return S, sw
134
+
135
+
136
+ # ── 면제 채널 (P1 과 같은 규율: 선언되었나만 본다 · 사유 필수 · 조용히 안 삼킨다) ──────
137
+
138
+ def _intent_index(intended):
139
+ idx, errs = collections.defaultdict(list), []
140
+ for n, e in enumerate(intended or [], 1):
141
+ if not isinstance(e, dict):
142
+ errs.append(f'attr_consistency.intended[{n}] : 매핑이 아니다 — 면제 안 함')
143
+ continue
144
+ ax, sl = e.get('axis'), e.get('slides') or []
145
+ why = (e.get('why') or '').strip()
146
+ if ax not in AXES:
147
+ errs.append(f'attr_consistency.intended[{n}] : axis 가 {AXES} 중 하나여야 한다 — 면제 안 함')
148
+ continue
149
+ if not sl:
150
+ errs.append(f'attr_consistency.intended[{n}] : slides 가 비었다 — 면제 안 함')
151
+ continue
152
+ if not isinstance(sl, list) or (e.get('shapes') is not None and not isinstance(e.get('shapes'), list)):
153
+ errs.append(f'attr_consistency.intended[{n}] : slides·shapes 는 목록이어야 한다 (문자열 "ab" 는 a·b 로 읽힌다) — 면제 안 함') # R3 A9
154
+ continue
155
+ if e.get('shapes') is not None and not all(isinstance(v, str) for v in e.get('shapes')):
156
+ errs.append(f'attr_consistency.intended[{n}] : shapes 원소는 문자열이어야 한다 (받은 값 {e.get("shapes")!r}) — 면제 안 함') # R8 A3: 섞이면 sorted 가 TypeError 로 레인 전체를 UNMEASURED 로 접었다
157
+ continue
158
+ if not all(isinstance(v, int) and not isinstance(v, bool) for v in sl):
159
+ errs.append(f'attr_consistency.intended[{n}] : slides 원소는 정수여야 한다 (받은 값 {sl!r} — 1.9·true 는 1 이 아니다) — 면제 안 함') # R4 A7
160
+ continue
161
+ if not why:
162
+ errs.append(f'attr_consistency.intended[{n}] : 🟥 why 가 비었다 — 사유 없는 면제는 오류다')
163
+ continue
164
+ for s in sl:
165
+ idx[(ax, int(s))].append((tuple(sorted(e.get('shapes') or [])), why, n))
166
+ return idx, errs
167
+
168
+
169
+ AMBIG = '\u2205' # «같은 장 안에 같은 이름 둘» — 선언으로 못 가르는 표식(R5 A6). 어떤 선언과도 안 맞는다.
170
+
171
+
172
+ def _mark_ambig(shapes_):
173
+ """도형 목록 → 이름 목록. 그중 하나라도 «자기 장 안에 같은 이름이 또 있으면»(dup) AMBIG 를 덧붙여 면제를 막는다.
174
+ R6 A5: 후보 쌍만 보면 «그 밖의» 같은 이름을 못 본다 — dup 은 장 전체에서 센 값이다."""
175
+ names = [s_['name'] for s_ in shapes_]
176
+ if any(s_.get('dup') for s_ in shapes_):
177
+ names.append(AMBIG)
178
+ return names
179
+
180
+
181
+ def _why_all(idx, axis, slide, names):
182
+ """이 장·축·도형 집합에 걸리는 «모든» 선언 [(why, n) …]. R3 B14: 첫 선언만 고르면 순서 의존이 된다.
183
+ R5 A6: 같은 이름이 둘이면 «어느 것» 인지 선언으로 못 가른다 — 면제 불가(집합화가 셋째 도형을 지웠다)."""
184
+ if AMBIG in names:
185
+ return []
186
+ got = sorted(set(names))
187
+ return [(why, n) for want, why, n in idx.get((axis, int(slide)), []) if not want or list(want) == got]
188
+
189
+
190
+ def _why(idx, axis, slide, names):
191
+ """면제는 «축 + 장 + 도형 집합»에 걸린다. `shapes` 를 안 적으면 그 장의 그 축 전체."""
192
+ r = _why_all(idx, axis, slide, names)
193
+ return r[0] if r else None
194
+
195
+
196
+ # ── 축 넷 ────────────────────────────────────────────────────────────────────
197
+
198
+ def ax_dash(S, sw, cfg):
199
+ """같은 장 안에서 «같은 종류 점선»의 굵기가 갈리는가."""
200
+ for sn, sh in enumerate(S, 1):
201
+ g = collections.defaultdict(dict)
202
+ for a in sh:
203
+ if a['dash'] and a['lnw'] is not None:
204
+ g[a['dash']].setdefault(a['lnw'], []).append(a)
205
+ for d, byw in g.items():
206
+ if len(byw) > 1:
207
+ detail = ' ↔ '.join(f"{w}pt({','.join(n['name'][:10] for n in ns[:2])})"
208
+ for w, ns in sorted(byw.items()))
209
+ yield sn, _mark_ambig([a_ for ns in byw.values() for a_ in ns]), \
210
+ f"{sn:>3}p [dash] «{d}» 굵기가 갈린다 — {detail}"
211
+
212
+
213
+ def ax_algn(S, sw, cfg):
214
+ """같은 장에서 «같은 폭 · 같은 y»(= 한 줄)인데 정렬 방식이 갈리는가."""
215
+ for sn, sh in enumerate(S, 1):
216
+ rows = collections.defaultdict(list)
217
+ for a in sh:
218
+ if a['text']: # 글자 있는 도형만이 «정렬»을 가진다 — 크기가 상속이어도 정렬은 있다
219
+ rows[(round(a['w'], 2), round(a['y'], 1))].append(a)
220
+ for (w, y), v in rows.items():
221
+ if len(v) > 1 and len({a['algn'] for a in v}) > 1:
222
+ detail = ' · '.join(f"{a['name'][:12]}={'/'.join(a['algn'])}" for a in v)
223
+ yield sn, _mark_ambig(v), \
224
+ f"{sn:>3}p [algn] 폭 {w}\" y {y}\" 한 줄인데 정렬이 갈린다 — {detail}"
225
+
226
+
227
+ def _size_seq(spans):
228
+ return tuple(sz for _n, sz in spans)
229
+
230
+
231
+ def ax_mirror(S, sw, cfg):
232
+ """좌우 대칭 쌍(같은 y · 화면 중심 기준 거울)의 글자 크기가 갈리는가."""
233
+ tol = float(cfg.get('mirror_tol_in', 0.4))
234
+ cx0 = sw / 2.0
235
+ for sn, sh in enumerate(S, 1):
236
+ A = [a for a in sh if a['szs'] and a['w'] < sw * 0.9]
237
+ for i, a in enumerate(A):
238
+ for b in A[i + 1:]:
239
+ if abs(a['y'] - b['y']) > 0.05:
240
+ continue
241
+ ca, cb = a['x'] + a['w'] / 2, b['x'] + b['w'] / 2
242
+ if abs((ca - cx0) + (cb - cx0)) > tol: # 거울 자리인가
243
+ continue
244
+ if abs(ca - cb) < 1.0: # 너무 붙어 있으면 쌍이 아니다
245
+ continue
246
+ # R4 B8: 거울 쌍은 글자가 다른 게 정상이라 «글자 수» 는 비교 대상이 아니다 — 크기 «순열» 만.
247
+ # R5 A5: 단, 글자가 «같으면» 구간 전체를 비교한다(AB@28+CD@32 vs A@28+BCD@32 — B 가 커졌다)
248
+ differs = (a['spans'] != b['spans']) if a['text'] == b['text'] else (_size_seq(a['spans']) != _size_seq(b['spans']))
249
+ if differs:
250
+ yield sn, _mark_ambig([a, b]), (
251
+ f"{sn:>3}p [mirror] 좌우 대칭인데 크기가 갈린다 — "
252
+ f"{a['name'][:12]} {'/'.join(map(str, a['szs']))}pt «{a['text'][:14]}» ↔ "
253
+ f"{b['name'][:12]} {'/'.join(map(str, b['szs']))}pt «{b['text'][:14]}»")
254
+
255
+
256
+ def ax_echo(S, sw, cfg):
257
+ """**장을 가로지르는 축** — 같은 문장이 여러 장에서 다른 크기·다른 x 로 나오는가.
258
+
259
+ 🟥 «같은 말을 할 때 같은 자리에 있는가»는 넘길 때만 보이고 한 장짜리 렌더에는 구조적으로
260
+ 안 나타난다(SKILL §방법론 ⓑ). 실측: 방법론 이름 셋이 8장에 네 가지 배치로 나왔다."""
261
+ mn = int(cfg.get('min_echo_chars', 8))
262
+ byt = collections.defaultdict(list)
263
+ for sh in S:
264
+ for a in sh:
265
+ t = a['text']
266
+ if a['szs'] and len(t) >= mn:
267
+ byt[t].append(a)
268
+ for t, v in sorted(byt.items()):
269
+ slides = sorted({a['slide'] for a in v})
270
+ if len(slides) < 2:
271
+ continue
272
+ szset = {a['spans'] for a in v} # R3 A10: 구간 단위
273
+ xset = {round(a['x'], 1) for a in v}
274
+ if len(szset) < 2 and len(xset) < 2:
275
+ continue
276
+ what = []
277
+ if len(szset) > 1:
278
+ what.append('크기 ' + ' ↔ '.join('+'.join(f'{n_}자@{sz}pt' for n_, sz in s) for s in sorted(szset, key=str)))
279
+ if len(xset) > 1:
280
+ what.append('x ' + ' ↔ '.join(f'{x}"' for x in sorted(xset)))
281
+ yield tuple(slides), _mark_ambig(v), ( # R2 A7 · R5 A6(장 안 중복만 모호)
282
+ f"{'·'.join(str(s) for s in slides[:6])}p [echo] «{t[:30]}» 가 "
283
+ f"{len(slides)}장에서 갈린다 — {' · '.join(what)}")
284
+
285
+
286
+ AXIS_FN = {'dash': ax_dash, 'algn': ax_algn, 'mirror': ax_mirror, 'echo': ax_echo}
287
+
288
+
289
+ # ── 진입점 ───────────────────────────────────────────────────────────────────
290
+
291
+ def collect(path, cfg):
292
+ """(lines, suppressed, errors, stats)"""
293
+ S, sw = load(path)
294
+ idx, errors = _intent_index(cfg.get('intended'))
295
+ want = [a for a in (cfg.get('axes') or AXES) if a in AXIS_FN]
296
+ unknown = [a for a in (cfg.get('axes') or []) if a not in AXIS_FN]
297
+ errors += [f'attr_consistency.axes : 모르는 축 «{a}» — 무시 (있는 축: {AXES})' for a in unknown]
298
+ lines, suppressed = [], []
299
+ stats = collections.Counter()
300
+ # 명시 크기가 없는 도형은 크기 축에서 UNMEASURED — 0 으로 접지 않고 센다
301
+ stats['shapes'] = sum(len(sh) for sh in S)
302
+ stats['sized'] = sum(1 for sh in S for a in sh if a['szs'])
303
+ hits = collections.Counter()
304
+ for ax in want:
305
+ for slide, names, line in AXIS_FN[ax](S, sw, cfg):
306
+ slides_all = list(slide) if isinstance(slide, tuple) else [slide]
307
+ # 그룹이 여러 장에 걸치면(echo) 전부 «같은 선언 n» 에 걸려야 면제 — R3 B14: 장마다 첫 선언을 고르지 말고
308
+ # 장 전체에 공통인 n 을 찾는다(부분 선언이 완전 선언을 가리지 않게)
309
+ per = [dict((n_, w_) for w_, n_ in _why_all(idx, ax, sl_, names)) for sl_ in slides_all]
310
+ common = set(per[0]) if per else set()
311
+ for d_ in per[1:]:
312
+ common &= set(d_)
313
+ hit = (per[0][min(common)], min(common)) if common else None
314
+ if hit is not None:
315
+ suppressed.append(line + f' ← 선언: {hit[0]}')
316
+ stats['suppressed'] += 1
317
+ hits[hit[1]] += 1
318
+ else:
319
+ lines.append(line)
320
+ stats[ax] += 1
321
+ # 🟥 죽은 선언 보고 — 아무 후보도 안 거는 intended 항목(덱 세션 실측 09-11: 유령 후보에 붙은 why)
322
+ for n in sorted({n for v in idx.values() for _w, _y, n in v}):
323
+ if hits.get(n, 0) == 0:
324
+ errors.append(f'attr_consistency.intended[{n}] : 🟥 죽은 선언 — 이 덱에서 아무 후보도 면제하지 않는다. 지우거나 고쳐라')
325
+ stats['dead_intended'] += 1
326
+ return lines, suppressed, errors, dict(stats)
327
+
328
+
329
+ def _resolve(root, p):
330
+ return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
331
+
332
+
333
+ def scan(cfg, root):
334
+ """preprep.py 가 부르는 레인 진입점. 🟥 **advisory 고정** — findings 를 안 낸다."""
335
+ deck_s = (cfg.get('surfaces_by_id') or {}).get('built_deck')
336
+ if not deck_s:
337
+ return [], ['P4 attr-consistency : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
338
+ deck = _resolve(root, deck_s['path'])
339
+ if not os.path.exists(deck):
340
+ return [], [f'P4 attr-consistency : built_deck 실물 없음({deck}) — UNMEASURED (0 아님)']
341
+ spec = cfg.get('attr_consistency') or {}
342
+ try:
343
+ lines, sup, errs, st = collect(deck, spec)
344
+ except Exception as e:
345
+ return [], [f'P4 attr-consistency : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
346
+ per = ' · '.join(f'{a} {st.get(a, 0)}' for a in AXES if a in (spec.get('axes') or AXES))
347
+ notes = [f'P4 attr-consistency : 후보 {len(lines)}건 ({per}) — 🟥 advisory, '
348
+ f'«다르다»지 «틀렸다»가 아니다. 판정은 사람이 한다',
349
+ f' 대상 도형 {st.get("shapes", 0)}개 중 명시 크기 있는 것 {st.get("sized", 0)}개 '
350
+ f'— 나머지는 크기 축에서 UNMEASURED (0 아님)']
351
+ notes += [' ' + l for l in lines]
352
+ notes += [' 🟥 ' + e for e in errs]
353
+ notes += [' ~ (면제)' + l for l in sup]
354
+ if sup:
355
+ notes.append(f' ~ 선언된 것 {len(sup)}건을 면제했다 — 「없다」가 아니라 「알고 남겼다」')
356
+ return [], notes
357
+
358
+
359
+ if __name__ == '__main__':
360
+ import sys, json
361
+ p = sys.argv[1]
362
+ conf = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
363
+ L, SU, ER, ST = collect(p, conf)
364
+ print(f'후보 {len(L)}건 · 면제 {len(SU)} · 선언오류 {len(ER)} · {ST}')
365
+ for l in L:
366
+ print(' ', l)
367
+ for e in ER:
368
+ print(' 🟥', e)
369
+ for s in SU:
370
+ print(' ~', s)