@chrono-meta/fh-gate 3.4.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.
- package/.claude-plugin/marketplace.json +9 -3
- package/README.md +2 -2
- package/docs/codex-compat.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +89 -4
- package/package.json +8 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +22 -0
- package/plugins/fh-preprep/.claude-plugin/plugin.json +22 -0
- package/plugins/fh-preprep/README.md +24 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/SKILL.md +75 -3
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/mk_slide_fixtures.py +152 -1
- package/plugins/fh-preprep/skills/preprep/lane_attr_consistency.py +370 -0
- package/plugins/fh-preprep/skills/preprep/lane_geometry.py +339 -0
- package/plugins/fh-preprep/skills/preprep/lane_screen_parity.py +232 -0
- package/plugins/fh-preprep/skills/preprep/oox.py +305 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep.py +52 -4
- package/plugins/fh-preprep/skills/preprep/safe_install.py +369 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/surfaces.example.yaml +29 -0
- package/plugins/fh-preprep/skills/preprep/test_preprep_lanes_rp.py +1417 -0
- package/plugins/fh-preprep/skills/preprep/usage_ledger.py +191 -0
- package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
- package/scripts/fh-run.sh +13 -0
- package/scripts/field_canon_preload.sh +1 -1
- package/scripts/selfcheck.sh +12 -8
- package/scripts/test_fh_run_resolver_lanes.sh +116 -0
- package/scripts/test_files_manifest_shipping_lanes.sh +31 -0
- package/scripts/test_preprep_adjacent_dup_lanes.sh +1 -1
- package/scripts/test_preprep_diagram_lanes.sh +1 -1
- package/scripts/test_preprep_drift_anchor.sh +27 -12
- package/scripts/test_preprep_drift_anchor_lanes.sh +4 -2
- package/scripts/test_preprep_font_lanes.sh +7 -7
- package/scripts/test_preprep_progression_lanes.sh +1 -1
- package/scripts/test_preprep_promise_lanes.sh +1 -1
- package/scripts/test_preprep_retired_lanes.sh +45 -2
- package/scripts/test_preprep_slide_refs_lanes.sh +5 -5
- package/scripts/test_usage_ledger_lanes.sh +243 -0
- package/scripts/usage_report.py +211 -0
- package/plugins/fh-commons/skills/preprep/lane_geometry.py +0 -181
- package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +0 -184
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/README.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/canon_terms.example.yaml +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/diagram_from_json.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/font_revert_probe.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/interslide_deps.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/jargon_terms.example.yaml +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_adjacent_dup.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_diagram.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_font.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_progression.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_promise.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_refs.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_relations.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/c1_baseline.txt +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/gate.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep_wire.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/presentation_checklist.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/test_lane_font.py +0 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""산출물 무결성 게이트 — **편집 결과를 정본에 반영하기 전에** 확인한다.
|
|
3
|
+
|
|
4
|
+
## 왜 있나 (2026-09-10 실사고)
|
|
5
|
+
|
|
6
|
+
pptx 편집 스크립트가 `assert len(paras) == 3` 에서 죽었는데 **뒤따르는 `cp` 가 그대로 돌아**
|
|
7
|
+
120장짜리 정본을 **102장으로 잘린 파일**이 덮었다. git 에서 복구했다.
|
|
8
|
+
|
|
9
|
+
🟥 근본 원인은 assert 가 아니다 — **«만들기»와 «설치»가 한 호출에 묶여 있었다.** 앞이 실패해도
|
|
10
|
+
뒤가 도는 형태이고, 셸에서 `;` 로 이은 줄은 전부 이 형태다. 그래서 처방은 «assert 를 더 잘
|
|
11
|
+
쓰자»가 아니라 **설치를 별도 단계로 떼고, 그 단계가 스스로 대상을 검사하게 하는 것**이다.
|
|
12
|
+
([[feedback_recover_and_destroy_never_in_one_call]] 와 같은 축 — 회수와 파괴를 안 묶는다.)
|
|
13
|
+
|
|
14
|
+
## 무엇을 보나
|
|
15
|
+
|
|
16
|
+
① 후보가 실재하고 비어 있지 않은가
|
|
17
|
+
② 열리는 zip 인가 · `testzip()` 이 통과하는가 (잘린 파일이 여기서 걸린다)
|
|
18
|
+
③ pptx 필수 부품이 있는가 (`[Content_Types].xml` · `ppt/presentation.xml` · 그 .rels)
|
|
19
|
+
④ 장 수가 **기존본보다 줄지 않았는가** — 🟥 이것이 그 사고를 잡는 줄이다
|
|
20
|
+
⑤ `ppt/slides/*.xml` 파일 수 · `sldIdLst` 항목 수 · **rels 로 실제 닿는 서로 다른 슬라이드 수**
|
|
21
|
+
가 **셋 다 같은가** (목록만 잘린 형태 · 같은 장을 두 번 가리키는 형태 · 끊긴 rels 를 잡는다)
|
|
22
|
+
⑥ **검사한 바이트가 곧 설치되는 바이트**다 — 후보를 한 번 읽어 그 바이트로 검사·해시·쓰기를
|
|
23
|
+
전부 하고, 옆의 임시 파일에 쓴 뒤 해시를 다시 확인하고 나서야 정본 자리에 넣는다
|
|
24
|
+
(복사 중 잘려도 정본은 무사하고, 검사 뒤 후보가 바뀌어도 바뀐 바이트는 안 들어간다)
|
|
25
|
+
|
|
26
|
+
🟥 **줄어드는 것이 항상 오류는 아니다** — 일부러 장을 뺄 수 있다. 그때는 `--allow-shrink` 를
|
|
27
|
+
**명시**한다. 기본값을 「거부」로 두는 이유는 이 표면이 «되돌리기 어려운 쪽»이기 때문이다
|
|
28
|
+
(CLAUDE.md §Irreversibility Gates — 비가역 표면은 fail-closed).
|
|
29
|
+
🟥 **기존본을 못 읽으면 반영하지 않는다** — 축소 여부를 판정할 수 없는데 덮어쓰는 것은
|
|
30
|
+
«미측정을 통과로 접는 것»이다. 기존본이 아예 없을 때만 첫 설치로 진행한다.
|
|
31
|
+
⚠️ 이 게이트는 **구조**만 본다. 내용이 옳은지는 안 본다 — 120장이 그대로 120장이면서 전부
|
|
32
|
+
빈 장이 된 경우는 통과한다. 좁아진 것이지 닫힌 게 아니다. 그 자리는 레인과 렌더가 본다.
|
|
33
|
+
|
|
34
|
+
## 쓰는 법
|
|
35
|
+
|
|
36
|
+
python3 safe_install.py <후보> <정본> # 검사하고 통과하면 반영
|
|
37
|
+
python3 safe_install.py <후보> <정본> --dry-run # 검사만
|
|
38
|
+
python3 safe_install.py <후보> <정본> --expect 120 # 장 수를 못 박는다
|
|
39
|
+
python3 safe_install.py <후보> <정본> --allow-shrink --why "38p 삭제"
|
|
40
|
+
|
|
41
|
+
종료코드 0 반영함 · 1 거부(반영 안 함) · 2 판정불가(계기 오류·인자 오류 — PASS 아님)
|
|
42
|
+
· 3 🟥 반영«했는데» 사후 검증 실패 — 정본이 바뀌었다. 이전 정본 바이트를 되돌려 놓았는지는 메시지에 적힌다(R8 S1)
|
|
43
|
+
"""
|
|
44
|
+
import sys, os, re, io, zipfile, hashlib, argparse, posixpath, tempfile, fcntl
|
|
45
|
+
import xml.etree.ElementTree as _ET
|
|
46
|
+
_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _local(tag):
|
|
50
|
+
return tag.rsplit('}', 1)[-1] if '}' in tag else tag.split(':', 1)[-1]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _attr(el, name):
|
|
54
|
+
for k, v in el.attrib.items():
|
|
55
|
+
if _local(k) == name:
|
|
56
|
+
return v
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
from pptx import Presentation as _Presentation # 🟥 R3: 손으로 짠 XML 계수는 S 를 세 라운드째 냈다(접두 q:·깨진 XML·External rel).
|
|
60
|
+
except Exception: # 실제 파서를 오라클로 세우고, 내 계수는 그것과 «맞아야 하는» 두 번째 제공자로 둔다.
|
|
61
|
+
_Presentation = None
|
|
62
|
+
|
|
63
|
+
REQUIRED = ('[Content_Types].xml', 'ppt/presentation.xml', 'ppt/_rels/presentation.xml.rels')
|
|
64
|
+
SLIDE_RE = re.compile(r'^ppt/slides/slide\d+\.xml$')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def probe_bytes(data):
|
|
68
|
+
"""(slide_files, sldIdLst_count, reached_distinct) 또는 예외.
|
|
69
|
+
🟥 셋을 따로 세는 것이 요점이다 — 파일·목록·관계 그래프는 서로 다른 실패를 낸다."""
|
|
70
|
+
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
|
71
|
+
bad = z.testzip()
|
|
72
|
+
if bad is not None:
|
|
73
|
+
raise ValueError(f'zip 안의 «{bad}» 가 깨져 있다')
|
|
74
|
+
names = set(z.namelist())
|
|
75
|
+
missing = [r for r in REQUIRED if r not in names]
|
|
76
|
+
if missing:
|
|
77
|
+
raise ValueError(f'pptx 필수 부품 없음: {missing}')
|
|
78
|
+
files = sorted(n for n in names if SLIDE_RE.match(n))
|
|
79
|
+
pres = z.read('ppt/presentation.xml').decode('utf-8', 'replace')
|
|
80
|
+
lst = re.search(r'<p:sldIdLst\b[^>]*>(.*?)</p:sldIdLst>', pres, re.S)
|
|
81
|
+
entries = re.findall(r'<p:sldId\b[^>]*>', lst.group(1)) if lst else []
|
|
82
|
+
rids = []
|
|
83
|
+
for ent in entries:
|
|
84
|
+
m = re.search(r'\br:id="([^"]+)"', ent)
|
|
85
|
+
if not m:
|
|
86
|
+
raise ValueError(f'sldId 항목에 r:id 가 없다: {ent[:60]}') # R2 S4: r:id 없는 항목이 계수에서 사라졌다
|
|
87
|
+
rids.append(m.group(1))
|
|
88
|
+
rels = z.read('ppt/_rels/presentation.xml.rels').decode('utf-8', 'replace')
|
|
89
|
+
# 속성 순서에 안 기댄다 — Id 와 Target 을 각각 뽑는다
|
|
90
|
+
rel_map = {}
|
|
91
|
+
for tag in re.findall(r'<Relationship\b[^>]*>', rels):
|
|
92
|
+
mid = re.search(r'\bId="([^"]+)"', tag); mt = re.search(r'\bTarget="([^"]+)"', tag)
|
|
93
|
+
if mid and mt:
|
|
94
|
+
if re.search(r'\bTargetMode="External"', tag):
|
|
95
|
+
rel_map[mid.group(1)] = '<external:%s>' % mt.group(1) # R3 S3: 외부 관계는 슬라이드가 아니다
|
|
96
|
+
continue
|
|
97
|
+
_t = mt.group(1)
|
|
98
|
+
rel_map[mid.group(1)] = posixpath.normpath(_t.lstrip('/')) if _t.startswith('/') else posixpath.normpath(posixpath.join('ppt', _t)) # R8 B7: 절대 Target
|
|
99
|
+
reached = []
|
|
100
|
+
for rid in rids:
|
|
101
|
+
tgt = rel_map.get(rid)
|
|
102
|
+
if tgt is None:
|
|
103
|
+
raise ValueError(f'sldId r:id={rid} 가 rels 에 없다 (끊긴 관계)')
|
|
104
|
+
if tgt not in names:
|
|
105
|
+
raise ValueError(f'sldId r:id={rid} → {tgt} 가 zip 에 없다')
|
|
106
|
+
if not SLIDE_RE.match(tgt):
|
|
107
|
+
raise ValueError(f'sldId r:id={rid} → {tgt} 는 슬라이드 부품이 아니다') # R2 S3: zip 에 «있다» ≠ 슬라이드다
|
|
108
|
+
reached.append(tgt)
|
|
109
|
+
dup = len(reached) - len(set(reached))
|
|
110
|
+
if dup:
|
|
111
|
+
raise ValueError(f'sldIdLst 가 같은 슬라이드를 {dup}회 중복해 가리킨다')
|
|
112
|
+
n_struct = (len(files), len(rids), len(set(reached)))
|
|
113
|
+
# 오라클: python-pptx 가 «읽어서» 세는 장 수 — 깨진 슬라이드 XML·외부 관계·다른 접두는 여기서 예외로 올라온다
|
|
114
|
+
if _Presentation is None:
|
|
115
|
+
raise ValueError('python-pptx 가 없다 — 오라클 없이는 판정불가')
|
|
116
|
+
prs = _Presentation(io.BytesIO(data))
|
|
117
|
+
n_pptx = 0
|
|
118
|
+
def _walk(shapes):
|
|
119
|
+
for shp in shapes:
|
|
120
|
+
_ = (shp.shape_id, shp.name, shp.element) # R4 S1: 도형 하나하나 — nvSpPr 없는 도형은 여기서 죽는다
|
|
121
|
+
# R8 A2: «분류» 는 검사가 아니다 — prstGeom 없는 합법 p:sp 에 python-pptx 가 NotImplementedError 를 낸다.
|
|
122
|
+
# 분류 실패는 결함이 아니므로 삼키고, 그룹 판별은 분류가 아니라 태그로 한다.
|
|
123
|
+
try:
|
|
124
|
+
_ = shp.shape_type
|
|
125
|
+
except NotImplementedError:
|
|
126
|
+
pass
|
|
127
|
+
if shp.element.tag.rsplit('}', 1)[-1] == 'grpSp': # GROUP — R5 S1: 자식도 같은 검사(그룹 안의 깨진 도형)
|
|
128
|
+
_walk(shp.shapes)
|
|
129
|
+
for sl in prs.slides:
|
|
130
|
+
_walk(sl.shapes)
|
|
131
|
+
n_pptx += 1
|
|
132
|
+
# 🟥 R6 S1/S2: 종류별 검사(그림 13번만)는 차트·자리표시자 그림(14번)·깨진 이미지 바이트를 놓쳤다.
|
|
133
|
+
# «부품과 관계 전부» 를 걷는다 — 끊긴 관계는 여기서 KeyError, 이미지는 PIL 로 실제 디코드.
|
|
134
|
+
if not (n_struct[0] == n_struct[1] == n_struct[2] == n_pptx):
|
|
135
|
+
raise ValueError(f'구조 계수 파일/목록/도달 {n_struct} 와 python-pptx 장 수 {n_pptx} 가 어긋난다 — 두 제공자가 불일치')
|
|
136
|
+
_verify_rels_in_zip(names, z_bytes=data) # R6 S1: python-pptx 는 끊긴 관계를 로드 시 조용히 버린다 — zip 수준에서 센다
|
|
137
|
+
_verify_package(prs)
|
|
138
|
+
return files, len(rids), len(set(reached))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _verify_rels_in_zip(names, z_bytes):
|
|
142
|
+
"""모든 *.rels 의 내부 Target 이 zip 안에 실재해야 한다(차트·미디어·자리표시자 그림 전부)."""
|
|
143
|
+
# R7 S1: 정규식이 아니라 트리로 읽는다 — `<q:Relationship>`·홑따옴표 속성도 관계다
|
|
144
|
+
with zipfile.ZipFile(io.BytesIO(z_bytes)) as z:
|
|
145
|
+
rel_ids = {} # 부품 → 그 부품 rels 의 Id 집합 (R7 S2)
|
|
146
|
+
ext_ids = {} # 부품 → External 관계 Id (R9 S2)
|
|
147
|
+
for n in names:
|
|
148
|
+
if not n.endswith('.rels'):
|
|
149
|
+
continue
|
|
150
|
+
# R9 S1: .rels 는 반드시 `<부품 디렉터리>/_rels/<부품이름>.rels` 자리다 — 다른 디렉터리의 .rels 는 OPC 가 안 읽는데
|
|
151
|
+
# 여기서 «그 부품의 Id 집합» 으로 쓰이면 엉뚱한 파일이 참조를 만족시킨다. 자리 밖 .rels 는 거부.
|
|
152
|
+
if posixpath.basename(posixpath.dirname(n)) != '_rels':
|
|
153
|
+
raise ValueError(f'{n}: .rels 가 _rels/ 디렉터리 밖에 있다 — 관계 파일 자리가 아니다')
|
|
154
|
+
base = posixpath.dirname(posixpath.dirname(n)) # ppt/slides/_rels/slide1.xml.rels → ppt/slides
|
|
155
|
+
src_part = posixpath.join(base, posixpath.basename(n)[:-len('.rels')]) if posixpath.basename(n) != '.rels' else ''
|
|
156
|
+
ids = set(); exts = set()
|
|
157
|
+
for rel in _ET.fromstring(z.read(n)).iter():
|
|
158
|
+
if _local(rel.tag) != 'Relationship':
|
|
159
|
+
continue
|
|
160
|
+
if _attr(rel, 'Id'):
|
|
161
|
+
ids.add(_attr(rel, 'Id'))
|
|
162
|
+
if _attr(rel, 'TargetMode') == 'External':
|
|
163
|
+
if _attr(rel, 'Id'):
|
|
164
|
+
exts.add(_attr(rel, 'Id'))
|
|
165
|
+
continue
|
|
166
|
+
tgt = _attr(rel, 'Target')
|
|
167
|
+
if not tgt:
|
|
168
|
+
continue
|
|
169
|
+
path = posixpath.normpath(tgt.lstrip('/')) if tgt.startswith('/') else posixpath.normpath(posixpath.join(base, tgt))
|
|
170
|
+
if path not in names:
|
|
171
|
+
raise ValueError(f'{n}: 관계 대상 {path} 가 zip 에 없다 (끊긴 관계)')
|
|
172
|
+
rel_ids[src_part] = ids; ext_ids[src_part] = exts
|
|
173
|
+
# R7 S2: 부품 XML 이 참조하는 r:… 속성(embed·link·id …)은 전부 그 부품 rels 에 있어야 한다
|
|
174
|
+
for n in names:
|
|
175
|
+
if not n.endswith('.xml') or '/_rels/' in n or n == '[Content_Types].xml': # R8 B8: ppt/ 밖 부품(docProps …)도 r:* 를 쓴다
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
root = _ET.fromstring(z.read(n))
|
|
179
|
+
except _ET.ParseError as e:
|
|
180
|
+
# R9 S5: python-pptx 는 범용 XML 부품을 바이트로만 들고 파싱하지 않는다 — «오라클이 본다» 는 거짓이었다
|
|
181
|
+
raise ValueError(f'{n}: XML 이 깨졌다 ({e}) — 부품이 파싱되지 않는다')
|
|
182
|
+
refs = set(); embeds = set()
|
|
183
|
+
for el in root.iter():
|
|
184
|
+
for k, v in el.attrib.items():
|
|
185
|
+
if k.startswith('{' + _REL_NS + '}'):
|
|
186
|
+
refs.add(v) # R9 S3: 빈 값도 «참조» 다 — '' 는 어떤 Id 와도 안 맞아 끊긴 참조로 뜬다
|
|
187
|
+
if k.endswith('}embed'):
|
|
188
|
+
embeds.add(v)
|
|
189
|
+
missing = sorted(refs - rel_ids.get(n, set()))
|
|
190
|
+
if missing:
|
|
191
|
+
raise ValueError(f'{n}: 관계 id {missing} 를 참조하는데 rels 에 없다 (끊긴 참조)')
|
|
192
|
+
bad_ext = sorted(embeds & ext_ids.get(n, set()))
|
|
193
|
+
if bad_ext:
|
|
194
|
+
raise ValueError(f'{n}: r:embed {bad_ext} 가 External 관계를 가리킨다 — 포함(embed) 은 내부 부품이어야 한다') # R9 S2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _verify_package(prs):
|
|
198
|
+
"""패키지의 모든 부품을 읽고, 모든 내부 관계의 대상이 실재하며, 이미지 부품이 디코드되는지 확인한다."""
|
|
199
|
+
pkg = prs.part.package
|
|
200
|
+
seen = 0
|
|
201
|
+
for part in pkg.iter_parts():
|
|
202
|
+
_ = part.blob # 부품 바이트가 실제로 읽힌다
|
|
203
|
+
seen += 1
|
|
204
|
+
for rel in part.rels.values():
|
|
205
|
+
if rel.is_external:
|
|
206
|
+
continue
|
|
207
|
+
_ = rel.target_part # 끊긴 관계 → KeyError
|
|
208
|
+
ct = str(getattr(part, 'content_type', '') or '')
|
|
209
|
+
if ct.startswith('image/') and ct not in ('image/svg+xml', 'image/x-emf', 'image/x-wmf', 'image/emf', 'image/wmf'):
|
|
210
|
+
try:
|
|
211
|
+
from PIL import Image
|
|
212
|
+
except Exception:
|
|
213
|
+
raise ValueError('PIL 이 없다 — 이미지 부품을 검증할 수 없어 판정불가')
|
|
214
|
+
with Image.open(io.BytesIO(part.blob)) as im:
|
|
215
|
+
im.verify() # 헤더·구조
|
|
216
|
+
with Image.open(io.BytesIO(part.blob)) as im:
|
|
217
|
+
im.load() # R7 S3: verify() 는 픽셀을 안 푼다 — 잘린 JPEG 는 load() 에서 죽는다
|
|
218
|
+
if seen == 0:
|
|
219
|
+
raise ValueError('패키지 부품 0 — 판정불가')
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def sha_bytes(data):
|
|
223
|
+
return hashlib.sha256(data).hexdigest()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def parse(argv):
|
|
227
|
+
ap = argparse.ArgumentParser(prog='safe_install.py', add_help=False)
|
|
228
|
+
ap.add_argument('cand'); ap.add_argument('dest')
|
|
229
|
+
ap.add_argument('--dry-run', action='store_true')
|
|
230
|
+
ap.add_argument('--expect', type=int, default=None)
|
|
231
|
+
ap.add_argument('--allow-shrink', action='store_true')
|
|
232
|
+
ap.add_argument('--why', default='')
|
|
233
|
+
try:
|
|
234
|
+
ns = ap.parse_args(argv[1:])
|
|
235
|
+
except SystemExit:
|
|
236
|
+
print(__doc__.split('## 쓰는 법')[1]); return None
|
|
237
|
+
# 🟥 `--why --allow-shrink` 처럼 다음 플래그를 값으로 먹은 경우 — 사유가 아니다
|
|
238
|
+
if ns.why.startswith('--'):
|
|
239
|
+
print(f'🟥 --why 의 값이 플래그처럼 보인다({ns.why!r}) — 사유가 아니다 — 판정불가'); return None
|
|
240
|
+
return ns
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv):
|
|
244
|
+
ns = parse(argv)
|
|
245
|
+
if ns is None:
|
|
246
|
+
return 2
|
|
247
|
+
cand, dest = ns.cand, ns.dest
|
|
248
|
+
|
|
249
|
+
if not os.path.exists(cand) or os.path.getsize(cand) == 0:
|
|
250
|
+
print(f'🟥 후보가 없거나 비었다: {cand} — 반영 안 함')
|
|
251
|
+
return 1
|
|
252
|
+
# R4 S2: 검사와 교체 사이의 창은 «잠금» 으로만 닫힌다 — 같은 게이트를 쓰는 다른 설치는 여기서 직렬화된다.
|
|
253
|
+
# 🟥 잔여(이름으로): 이 잠금을 안 쓰는 임의의 쓰기 주체(cp·에디터)는 못 막는다. 그건 파일시스템의 성질이다.
|
|
254
|
+
lock_path = dest + '.safe_install.lock'
|
|
255
|
+
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
256
|
+
try:
|
|
257
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
258
|
+
return _install_locked(ns, cand, dest)
|
|
259
|
+
finally:
|
|
260
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
261
|
+
os.close(lock_fd)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _install_locked(ns, cand, dest):
|
|
265
|
+
# ⑥ 한 번 읽는다 — 이 바이트가 검사되고, 이 바이트가 설치된다
|
|
266
|
+
with open(cand, 'rb') as f:
|
|
267
|
+
data = f.read()
|
|
268
|
+
try:
|
|
269
|
+
c_files, c_listed, c_reached = probe_bytes(data)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
print(f'🟥 후보를 못 읽는다 ({type(e).__name__}: {e}) — 반영 안 함')
|
|
272
|
+
return 1
|
|
273
|
+
|
|
274
|
+
print(f'후보 : {cand}')
|
|
275
|
+
print(f' 슬라이드 파일 {len(c_files)}개 · sldIdLst 항목 {c_listed}개 · rels 도달 {c_reached}개')
|
|
276
|
+
# ⑤ 셋이 같아야 한다 — 목록이 0 이어도 «검사 생략» 이 아니라 «어긋남» 이다
|
|
277
|
+
if not (len(c_files) == c_listed == c_reached):
|
|
278
|
+
print(f'🟥 파일 수({len(c_files)}) · 목록 수({c_listed}) · 도달 수({c_reached}) 가 어긋난다 — 반영 안 함')
|
|
279
|
+
return 1
|
|
280
|
+
if len(c_files) == 0:
|
|
281
|
+
print('🟥 슬라이드가 0장인 덱 — 반영 안 함')
|
|
282
|
+
return 1
|
|
283
|
+
|
|
284
|
+
# ── 기존본과의 비교 ────────────────────────────────────────────────────────
|
|
285
|
+
# R3 S5: 끊긴 심링크는 exists() 가 False 라 «첫 설치» 로 흘렀다 — 링크 여부는 존재와 무관하게 먼저 본다
|
|
286
|
+
if os.path.islink(dest):
|
|
287
|
+
print(f'🟥 정본 경로가 심링크다({dest}) — 반영 안 함')
|
|
288
|
+
return 1
|
|
289
|
+
d_hash = None
|
|
290
|
+
if os.path.exists(dest):
|
|
291
|
+
try:
|
|
292
|
+
with open(dest, 'rb') as f:
|
|
293
|
+
d_bytes = f.read()
|
|
294
|
+
d_hash = sha_bytes(d_bytes)
|
|
295
|
+
d_files, d_listed, d_reached = probe_bytes(d_bytes)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
# 🟥 축소 판정을 못 하는데 덮어쓰면 «미측정 → 통과» 다. 거부.
|
|
298
|
+
print(f'🟥 기존본을 못 읽는다 ({type(e).__name__}: {e}) — 축소 여부 판정 불가 — 반영 안 함')
|
|
299
|
+
return 1
|
|
300
|
+
if not (len(d_files) == d_listed == d_reached):
|
|
301
|
+
# R2 S1: 기존본이 «구조적으로 안 세어지면»(예: 슬라이드 부품 이름이 관례 밖) 0장으로 접혀 축소가 통과했다
|
|
302
|
+
print(f'🟥 기존본의 파일 수({len(d_files)}) · 목록 수({d_listed}) · 도달 수({d_reached}) 가 어긋난다 — 축소 여부 판정 불가 — 반영 안 함')
|
|
303
|
+
return 1
|
|
304
|
+
print(f'정본 : {dest}\n 슬라이드 {len(d_files)}개')
|
|
305
|
+
if len(c_files) < len(d_files):
|
|
306
|
+
if not ns.allow_shrink:
|
|
307
|
+
print(f'🟥 장이 {len(d_files)} → {len(c_files)} 로 **줄었다**. '
|
|
308
|
+
f'의도한 것이면 --allow-shrink --why "<사유>" 로 명시하라 — 반영 안 함')
|
|
309
|
+
return 1
|
|
310
|
+
if not ns.why.strip():
|
|
311
|
+
print('🟥 --allow-shrink 에는 --why 가 필요하다 — 사유 없는 축소는 거부 — 반영 안 함')
|
|
312
|
+
return 1
|
|
313
|
+
print(f'⚠️ 축소를 명시 승인으로 반영한다 ({len(d_files)} → {len(c_files)}): {ns.why}')
|
|
314
|
+
else:
|
|
315
|
+
print(f'⚠️ 정본이 아직 없다({dest}) — 장 수 대조 UNMEASURED (첫 설치로 진행, 비교 대상 없음)')
|
|
316
|
+
|
|
317
|
+
if ns.expect is not None and len(c_files) != ns.expect:
|
|
318
|
+
print(f'🟥 --expect {ns.expect} 인데 후보는 {len(c_files)}장 — 반영 안 함')
|
|
319
|
+
return 1
|
|
320
|
+
|
|
321
|
+
if ns.dry_run:
|
|
322
|
+
print('✅ 검사 통과 — --dry-run 이라 반영은 안 했다')
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
# ⑥ 검사한 바이트를 «옆에» 쓰고 확인한 뒤에 정본 자리에 넣는다 — 정본은 마지막 순간까지 무사하다
|
|
326
|
+
src_hash = sha_bytes(data)
|
|
327
|
+
# R2 S2: 예측 가능한 임시 경로는 미리 심어 둔 심링크가 정본을 가리킬 수 있다 — mkstemp(O_EXCL) 로 «내 것» 만 연다
|
|
328
|
+
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(dest) + '.safe_install.', dir=os.path.dirname(os.path.abspath(dest)) or '.')
|
|
329
|
+
try:
|
|
330
|
+
with os.fdopen(fd, 'wb') as f:
|
|
331
|
+
f.write(data)
|
|
332
|
+
with open(tmp, 'rb') as f:
|
|
333
|
+
if sha_bytes(f.read()) != src_hash:
|
|
334
|
+
print('🟥 임시 파일 해시가 다르다 — 쓰기 실패. 정본은 건드리지 않았다')
|
|
335
|
+
return 1
|
|
336
|
+
# R3 S4: 검사한 정본이 «지금도 그 정본» 인가 — 그 사이 다른 손이 덮었으면 축소 판정이 낡았다. 중단.
|
|
337
|
+
if os.path.islink(dest):
|
|
338
|
+
print('🟥 정본 경로가 그 사이 심링크가 됐다 — 반영 안 함'); return 1
|
|
339
|
+
now_hash = sha_bytes(open(dest, 'rb').read()) if os.path.exists(dest) else None
|
|
340
|
+
if now_hash != d_hash:
|
|
341
|
+
print('🟥 정본이 검사 뒤에 바뀌었다(다른 쓰기) — 판정이 낡았다 — 반영 안 함. 다시 돌려라')
|
|
342
|
+
return 1
|
|
343
|
+
os.replace(tmp, dest)
|
|
344
|
+
finally:
|
|
345
|
+
if os.path.exists(tmp):
|
|
346
|
+
os.remove(tmp)
|
|
347
|
+
with open(dest, 'rb') as f:
|
|
348
|
+
if sha_bytes(f.read()) != src_hash:
|
|
349
|
+
# R8 S1: 여기서 rc=1(«반영 안 함») 을 내면 호출자가 복구를 건너뛴다 — 정본은 이미 바뀌었다.
|
|
350
|
+
# 이전 정본 바이트가 손에 있으면 되돌려 놓고, 종료코드는 «반영했는데 실패» 로 따로 낸다.
|
|
351
|
+
if d_hash is not None:
|
|
352
|
+
try:
|
|
353
|
+
fd2, tmp2 = tempfile.mkstemp(prefix=os.path.basename(dest) + '.safe_install.restore.', dir=os.path.dirname(os.path.abspath(dest)) or '.')
|
|
354
|
+
with os.fdopen(fd2, 'wb') as g:
|
|
355
|
+
g.write(d_bytes)
|
|
356
|
+
os.replace(tmp2, dest)
|
|
357
|
+
restored = sha_bytes(open(dest, 'rb').read()) == d_hash
|
|
358
|
+
except Exception as e:
|
|
359
|
+
restored = False
|
|
360
|
+
print(f'🟥 반영 후 해시가 다르다 — 정본이 바뀌었다. 이전 정본({d_hash[:16]}…) 복원 {"성공" if restored else "실패 — 즉시 손으로 확인하라"}')
|
|
361
|
+
else:
|
|
362
|
+
print('🟥 반영 후 해시가 다르다 — 첫 설치라 되돌릴 이전 정본이 없다. 즉시 손으로 확인하라')
|
|
363
|
+
return 3
|
|
364
|
+
print(f'✅ 반영함 — {len(c_files)}장 · sha256 {src_hash[:16]}…')
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == '__main__':
|
|
369
|
+
sys.exit(main(sys.argv))
|
|
@@ -165,6 +165,35 @@ adjacent_dup:
|
|
|
165
165
|
max_dup_chars: 40 # 인접 두 절의 중복 자수가 이보다 크면 낸다. 지우면 «측정만»
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
# ── P1/P3 geometry — 넘길 때 튐 · 인접 어긋남 ─────────────────────────────────
|
|
169
|
+
# 🟥 `baseline`(이전 판 pptx)을 주면 **편집 전/후 델타**로 돈다 — 「지금 몇 건이냐」가 아니라
|
|
170
|
+
# «내가 방금 뭘 어긋냈냐». P3 는 기저 오탐이 수십 건이라 절대 목록으로는 못 쓴다.
|
|
171
|
+
geometry:
|
|
172
|
+
jitter_emu: 200000 # 이보다 큰 이동은 «의도한 이동»으로 보고 안 낸다 (1pt = 12,700)
|
|
173
|
+
align_emu: 63500 # 5pt. 이보다 큰 틈/겹침은 «의도한 여백»으로 본다
|
|
174
|
+
# baseline: "이전판.pptx"
|
|
175
|
+
# 🟥 «알고도 남긴 것»을 선언하는 자리 (SKILL §방법론 ⓕ). 안 적으면 다음 감사자가 다시
|
|
176
|
+
# «발견»하고 «고친다» — 2026-09-10 에 실제로 그렇게 고쳤다가 되돌렸다.
|
|
177
|
+
# 면제는 «도형»이 아니라 «그 변화»에 걸린다: attrs 말고 다른 게 바뀌면 그대로 뜬다.
|
|
178
|
+
# `why` 가 비면 면제가 아니라 **오류**다. 면제된 것은 «면제됨»으로 출력된다.
|
|
179
|
+
# intended:
|
|
180
|
+
# - slides: [49, 50]
|
|
181
|
+
# shapes: ["gate_bar_l", "gate_bar_r"]
|
|
182
|
+
# attrs: [x, cx]
|
|
183
|
+
# why: "격리 막대 얇음→두꺼움. 「격리가 세지는」 연출"
|
|
184
|
+
|
|
185
|
+
# ── P4 attr-consistency — 덱 자신의 분포가 판정 기준 ──────────────────────────
|
|
186
|
+
# 축 넷: dash(같은 점선 굵기) · algn(같은 줄 정렬) · mirror(좌우 대칭 크기) · echo(같은 문장)
|
|
187
|
+
# ⚠️ «다르다»만 본다. «틀렸다»는 사람이 판정한다 — advisory 고정.
|
|
188
|
+
attr_consistency:
|
|
189
|
+
mirror_tol_in: 0.4 # 거울 자리 허용 오차(인치). 덱 폭에 맞춰 다시 재라
|
|
190
|
+
min_echo_chars: 8 # 이보다 짧은 문장은 echo 축이 안 본다(라벨·번호 소음)
|
|
191
|
+
# axes: [dash, algn, mirror, echo] # 일부만 돌리고 싶을 때
|
|
192
|
+
# intended:
|
|
193
|
+
# - axis: mirror
|
|
194
|
+
# slides: [6, 7]
|
|
195
|
+
# why: "빌드 강조 교체 — 크기가 강조를 나른다"
|
|
196
|
+
|
|
168
197
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
169
198
|
# L15 font — 서체가 배포 템플릿이 정한 집합을 벗어났나 (2026-09-11 신설).
|
|
170
199
|
#
|