@lark-apaas/coding-steering 0.1.31 → 0.1.32-beta.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.
@@ -0,0 +1,907 @@
1
+ #!/usr/bin/env python3
2
+ """风格包 v2 机器门禁(镜像 ../v2-format-spec.md §5 十四行:V2-1..V2-13 + V2-R5)。
3
+
4
+ 用法: check_v2.py <包目录>
5
+ 只做 v2 追加校验;check_v1 的规则不重复实现,调用方须先跑 check_v1.py。
6
+ 无 FAIL 退出码 0(WARN 不阻塞),有 FAIL 退出码 1。
7
+ 规则以 v2-format-spec.md §5 为唯一事实源,此处为镜像。
8
+ """
9
+ import os
10
+ import re
11
+ import sys
12
+
13
+ # —— §1 frontmatter 键序清单(V2-9)——
14
+ KEY_ORDER = [
15
+ 'version', 'name', 'name_zh', 'description', 'colors', 'typography',
16
+ 'spacing', 'rounded', 'components', 'omitted',
17
+ 'anchors', 'gaps', 'exceptions', 'canvas',
18
+ 'canvas-source', 'themes', 'default-theme', 'theme-mechanism', 'color-confidence',
19
+ 'assets', 'layouts', 'safe-area',
20
+ ]
21
+
22
+ # —— §2 / §3 封闭枚举(V2-4)——
23
+ ENUM_KIND = ['logo', 'slogan', 'background', 'texture', 'icon']
24
+ ENUM_MARK = ['primary', 'secondary', 'icon', 'wordmark', 'lockup']
25
+ ENUM_ON_BG = ['light', 'dark']
26
+ ENUM_ASSET_ROLE = ['cover', 'content', 'section', 'closing', 'accent']
27
+ ENUM_LAYOUT_ROLE = ['cover', 'section', 'content', 'quote', 'closing', 'blank', 'custom']
28
+ ENUM_SLOT_TYPE = ['title', 'subtitle', 'body', 'pic', 'table', 'chart', 'media',
29
+ 'slide-number', 'footer']
30
+ ENUM_CONFIDENCE = ['high', 'medium', 'low']
31
+
32
+ # —— V2-7 花括号禁引段名 ——
33
+ V2_SECTIONS = ['themes', 'default-theme', 'theme-mechanism', 'color-confidence',
34
+ 'assets', 'layouts', 'safe-area', 'canvas', 'canvas-source']
35
+
36
+ # —— V2-11 YAML 1.1 布尔字面量(PyYAML 会把这些键名解析成 True/False)——
37
+ BOOL_LITERALS = {'y', 'yes', 'n', 'no', 'true', 'false', 'on', 'off'}
38
+
39
+ # —— V2-6 体积上限(D5b 修订 2026-08-05;KB = 1024)——
40
+ ASSET_WARN_SINGLE = 500 * 1024 # 压缩图单张(受检对象 = 条目 path 指向的包内文件)
41
+ ASSET_MAX_TOTAL = 20 * 1024 * 1024 # 包内资产总量(assets/** ∪ 条目 path/full 并集)
42
+
43
+ # §5 V2-R5 行的排除项:sidecar 自身载荷键,design.md 侧同名键是指针不是冲突
44
+ SIDECAR_OWN_KEYS = {'layouts', 'layouts-file', 'canvas'} # canvas 的家在 sidecar(§1.1)
45
+
46
+ # —— V2-3 出血容差 ——
47
+ BLEED = 0.05
48
+
49
+ KEY_RE = re.compile(r'^"?([A-Za-z0-9_@.\-]+)"?\s*:(?:\s+(.*?))?\s*$')
50
+
51
+
52
+ # ============================ 轻量 YAML 子集解析 ============================
53
+ # 环境无 PyYAML(已探测:ModuleNotFoundError),手写覆盖本 schema 用到的形态:
54
+ # 块映射 / 块序列 / 流映射 / 流序列 / 引号标量 / 折叠标量(>- |)/ 行尾注释。
55
+
56
+ def _strip_comment(line):
57
+ """去掉行尾 # 注释(引号内的 # 不算,如 "#0A0E1E")。"""
58
+ out, quote = [], None
59
+ for i, ch in enumerate(line):
60
+ if quote:
61
+ out.append(ch)
62
+ if ch == quote:
63
+ quote = None
64
+ continue
65
+ if ch in '"\'':
66
+ quote = ch
67
+ out.append(ch)
68
+ continue
69
+ if ch == '#' and (i == 0 or line[i - 1] in ' \t'):
70
+ break
71
+ out.append(ch)
72
+ return ''.join(out).rstrip()
73
+
74
+
75
+ def _scalar(text):
76
+ text = text.strip()
77
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in '"\'':
78
+ return text[1:-1]
79
+ if text in ('', '~', 'null', 'Null', 'NULL'):
80
+ return None
81
+ if re.fullmatch(r'-?\d+', text):
82
+ return int(text)
83
+ if re.fullmatch(r'-?\d+\.\d+', text):
84
+ return float(text)
85
+ return text
86
+
87
+
88
+ def _read_flow_token(text, i, stops):
89
+ """读到 stops 里的字符(引号内不算),返回 (原文, 新位置)。"""
90
+ start, quote = i, None
91
+ while i < len(text):
92
+ ch = text[i]
93
+ if quote:
94
+ if ch == quote:
95
+ quote = None
96
+ elif ch in '"\'':
97
+ quote = ch
98
+ elif ch in stops:
99
+ break
100
+ i += 1
101
+ return text[start:i], i
102
+
103
+
104
+ def _parse_flow(text, i, keys_out, lineno):
105
+ """解析流式 { } / [ ] / 标量,返回 (值, 新位置)。"""
106
+ while i < len(text) and text[i] == ' ':
107
+ i += 1
108
+ if i >= len(text):
109
+ return None, i
110
+ if text[i] == '{':
111
+ out, i = {}, i + 1
112
+ while i < len(text):
113
+ while i < len(text) and text[i] in ' ,':
114
+ i += 1
115
+ if i < len(text) and text[i] == '}':
116
+ i += 1
117
+ break
118
+ raw_key, i = _read_flow_token(text, i, ':,}')
119
+ key = raw_key.strip().strip('"\'')
120
+ if i < len(text) and text[i] == ':':
121
+ i += 1
122
+ value, i = _parse_flow(text, i, keys_out, lineno)
123
+ else:
124
+ value = None
125
+ if key:
126
+ keys_out.append((key, lineno))
127
+ out[key] = value
128
+ return out, i
129
+ if text[i] == '[':
130
+ out, i = [], i + 1
131
+ while i < len(text):
132
+ while i < len(text) and text[i] in ' ,':
133
+ i += 1
134
+ if i < len(text) and text[i] == ']':
135
+ i += 1
136
+ break
137
+ value, i = _parse_flow(text, i, keys_out, lineno)
138
+ out.append(value)
139
+ return out, i
140
+ raw, i = _read_flow_token(text, i, ',}]')
141
+ return _scalar(raw), i
142
+
143
+
144
+ def _parse_inline(text, keys_out, lineno):
145
+ text = text.strip()
146
+ if text[:1] in ('{', '['):
147
+ value, _ = _parse_flow(text, 0, keys_out, lineno)
148
+ return value
149
+ return _scalar(text)
150
+
151
+
152
+ class YamlLite:
153
+ """块结构解析器;同时记录所有出现过的映射键名与行号(V2-9 / V2-11 用)。"""
154
+
155
+ def __init__(self, text):
156
+ self.lines = [(n, _strip_comment(raw))
157
+ for n, raw in enumerate(text.split('\n'), 1)]
158
+ self.pos = 0
159
+ self.keys = [] # [(键名, 行号)] —— 全量,含流映射内的键
160
+ self.root_keys = [] # [(键名, 行号)] —— 仅顶层
161
+ self.anomalies = [] # 解析器看不懂的行
162
+
163
+ @staticmethod
164
+ def _indent(text):
165
+ return len(text) - len(text.lstrip(' '))
166
+
167
+ def _peek(self):
168
+ while self.pos < len(self.lines):
169
+ lineno, text = self.lines[self.pos]
170
+ if text.strip() == '':
171
+ self.pos += 1
172
+ continue
173
+ return lineno, text
174
+ return None
175
+
176
+ def parse(self):
177
+ head = self._peek()
178
+ if head is None:
179
+ return {}
180
+ return self._block(self._indent(head[1]), root=True)
181
+
182
+ def _block(self, indent, root=False):
183
+ head = self._peek()
184
+ if head is None:
185
+ return None
186
+ if head[1].strip() == '-' or head[1].strip().startswith('- '):
187
+ return self._seq(indent)
188
+ return self._map(indent, root=root)
189
+
190
+ def _map(self, indent, root=False):
191
+ out = {}
192
+ while True:
193
+ head = self._peek()
194
+ if head is None:
195
+ break
196
+ lineno, text = head
197
+ cur = self._indent(text)
198
+ if cur < indent:
199
+ break
200
+ body = text.strip()
201
+ if cur > indent or body.startswith('- '):
202
+ self.anomalies.append((lineno, text))
203
+ self.pos += 1
204
+ continue
205
+ match = KEY_RE.match(body)
206
+ if not match:
207
+ self.anomalies.append((lineno, text))
208
+ self.pos += 1
209
+ continue
210
+ key, rest = match.group(1), match.group(2)
211
+ self.keys.append((key, lineno))
212
+ if root:
213
+ self.root_keys.append((key, lineno))
214
+ self.pos += 1
215
+ out[key] = self._value(rest, indent, lineno)
216
+ return out
217
+
218
+ def _value(self, rest, indent, lineno):
219
+ rest = (rest or '').strip()
220
+ if rest in ('>', '>-', '>+', '|', '|-', '|+'):
221
+ return self._block_scalar(indent, rest[0])
222
+ if rest:
223
+ return _parse_inline(rest, self.keys, lineno)
224
+ head = self._peek()
225
+ if head is None:
226
+ return None
227
+ child = self._indent(head[1])
228
+ if child > indent:
229
+ return self._block(child)
230
+ if child == indent and head[1].strip().startswith('- '):
231
+ return self._seq(indent)
232
+ return None
233
+
234
+ def _block_scalar(self, indent, style):
235
+ parts = []
236
+ while self.pos < len(self.lines):
237
+ _, text = self.lines[self.pos]
238
+ if text.strip() != '' and self._indent(text) <= indent:
239
+ break
240
+ parts.append(text.strip())
241
+ self.pos += 1
242
+ return (' ' if style == '>' else '\n').join(p for p in parts if p != '')
243
+
244
+ def _seq(self, indent):
245
+ out = []
246
+ while True:
247
+ head = self._peek()
248
+ if head is None:
249
+ break
250
+ lineno, text = head
251
+ if self._indent(text) != indent:
252
+ break
253
+ body = text.strip()
254
+ if body != '-' and not body.startswith('- '):
255
+ break
256
+ item = body[1:].strip()
257
+ item_col = text.find(item, self._indent(text)) if item else indent + 2
258
+ self.pos += 1
259
+ if item == '':
260
+ nxt = self._peek()
261
+ out.append(self._block(self._indent(nxt[1]))
262
+ if nxt and self._indent(nxt[1]) > indent else None)
263
+ continue
264
+ match = KEY_RE.match(item) if item[:1] not in ('{', '[', '"', "'") else None
265
+ if match:
266
+ key, rest = match.group(1), match.group(2)
267
+ self.keys.append((key, lineno))
268
+ entry = {key: self._value(rest, item_col, lineno)}
269
+ nxt = self._peek()
270
+ if nxt and self._indent(nxt[1]) == item_col:
271
+ entry.update(self._map(item_col))
272
+ out.append(entry)
273
+ else:
274
+ out.append(_parse_inline(item, self.keys, lineno))
275
+ return out
276
+
277
+
278
+ # ================================ 包加载 ================================
279
+
280
+ FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n?(.*)$', re.S)
281
+ YAML_FENCE_RE = re.compile(r'^```ya?ml\n(.*?)^```', re.S | re.M)
282
+
283
+
284
+ class MdFile:
285
+ """一个 md 文件:frontmatter + 正文 + 正文里的 ```yaml 围栏块。"""
286
+
287
+ def __init__(self, path):
288
+ self.path = path
289
+ self.name = os.path.basename(path)
290
+ with open(path, encoding='utf-8') as handle:
291
+ self.text = handle.read()
292
+ match = FRONTMATTER_RE.match(self.text)
293
+ fm_text, self.body = (match.group(1), match.group(2)) if match else ('', self.text)
294
+ self.fm_offset = 1 if match else 0
295
+ parser = YamlLite(fm_text)
296
+ self.data = parser.parse() or {}
297
+ self.keys = [(k, n + self.fm_offset) for k, n in parser.keys]
298
+ self.root_keys = [(k, n + self.fm_offset) for k, n in parser.root_keys]
299
+ self.anomalies = [(n + self.fm_offset, t) for n, t in parser.anomalies]
300
+ self.fenced = []
301
+ for fence in YAML_FENCE_RE.finditer(self.body):
302
+ offset = self.text[:self.text.index(fence.group(0))].count('\n')
303
+ sub = YamlLite(fence.group(1))
304
+ self.fenced.append(sub.parse() or {})
305
+ self.keys += [(k, n + offset) for k, n in sub.keys]
306
+
307
+ def section(self, key):
308
+ """先取 frontmatter,再取正文围栏块(claude-design 的 layouts 在围栏块里)。"""
309
+ if isinstance(self.data.get(key), (dict, list)):
310
+ return self.data[key]
311
+ for block in self.fenced:
312
+ if isinstance(block.get(key), (dict, list)):
313
+ return block[key]
314
+ return self.data.get(key)
315
+
316
+
317
+ class Pack:
318
+ def __init__(self, root):
319
+ self.root = os.path.abspath(root)
320
+ self.notes = []
321
+ design_path = os.path.join(self.root, 'design.md')
322
+ if not os.path.isfile(design_path):
323
+ raise SystemExit(f'FATAL: 包目录缺 design.md: {design_path}')
324
+ self.design = MdFile(design_path)
325
+ self.files = [self.design]
326
+
327
+ # layouts sidecar 指针:规范定名 layouts;layouts-file 已废弃但仍解析,
328
+ # 否则整份 sidecar 消失会级联出一堆假 FAIL——废弃键本身由 V2-9 判 FAIL。
329
+ self.layout_pointer = None
330
+ for key in ('layouts', 'layouts-file'):
331
+ value = self.design.data.get(key)
332
+ if isinstance(value, str) and value.strip().endswith('.md'):
333
+ self.layout_pointer = (key, value.strip())
334
+ break
335
+ self.layouts_file = None
336
+ candidate = self.layout_pointer[1] if self.layout_pointer else 'layouts.md'
337
+ path = os.path.join(self.root, candidate)
338
+ if os.path.isfile(path):
339
+ self.layouts_file = MdFile(path)
340
+ self.files.append(self.layouts_file)
341
+ self.legacy_layout_keys = [(md.name, lineno) for md in self.files
342
+ for key, lineno in md.root_keys if key == 'layouts-file']
343
+
344
+ # 各段(V2-1/V2-2 要求跨 design.md + layouts.md 求并集)
345
+ self.assets = {}
346
+ self.layouts = {}
347
+ for md in self.files:
348
+ block = md.section('assets')
349
+ if isinstance(block, dict):
350
+ for key, value in block.items():
351
+ self.assets.setdefault(key, (value, md))
352
+ block = md.section('layouts')
353
+ if isinstance(block, dict):
354
+ for key, value in block.items():
355
+ self.layouts.setdefault(key, (value, md))
356
+ self.safe_area = None
357
+ for md in self.files:
358
+ block = md.section('safe-area')
359
+ if isinstance(block, dict):
360
+ self.safe_area = (block, md)
361
+ break
362
+ self.themes = []
363
+ for md in self.files:
364
+ value = md.section('themes')
365
+ if isinstance(value, list):
366
+ for theme in value:
367
+ if theme not in self.themes:
368
+ self.themes.append(theme)
369
+ self.canvas = None
370
+ for md in self.files:
371
+ value = md.data.get('canvas')
372
+ if isinstance(value, str):
373
+ match = re.fullmatch(r'\s*(\d+)\s*[xX×]\s*(\d+)\s*', value)
374
+ if match:
375
+ self.canvas = (int(match.group(1)), int(match.group(2)), md)
376
+ break
377
+ self.colors = self.design.data.get('colors') or {}
378
+
379
+
380
+ # ================================ 校验规则 ================================
381
+
382
+ class Result:
383
+ def __init__(self, rule, title):
384
+ self.rule, self.title = rule, title
385
+ self.fails, self.warns, self.notes = [], [], []
386
+
387
+ @property
388
+ def level(self):
389
+ return 'FAIL' if self.fails else ('WARN' if self.warns else 'PASS')
390
+
391
+
392
+ def _iter_slots(pack):
393
+ for name, (layout, md) in pack.layouts.items():
394
+ if not isinstance(layout, dict):
395
+ continue
396
+ for slot in layout.get('slots') or []:
397
+ if isinstance(slot, dict):
398
+ yield name, layout, slot, md
399
+
400
+
401
+ def _asset_refs(value):
402
+ """layouts 的 asset/background 值 → [(资产 id, 主题或 None)];{color: x} 单独识别。"""
403
+ if isinstance(value, str):
404
+ return [(value, None)], None
405
+ if isinstance(value, dict):
406
+ if set(value) == {'color'}:
407
+ return [], value['color']
408
+ return [(v, k) for k, v in value.items() if isinstance(v, str)], None
409
+ return [], None
410
+
411
+
412
+ def rule_v2_1(pack):
413
+ """V2-1 path/url 引用断链(含 slots 的 by-theme 嵌套形态)"""
414
+ res = Result('V2-1', 'path/url 引用断链(含 by-theme 嵌套)')
415
+ checked = 0
416
+ for aid, (entry, md) in sorted(pack.assets.items()):
417
+ if not isinstance(entry, dict):
418
+ continue
419
+ path = entry.get('path')
420
+ url = entry.get('url')
421
+ full = entry.get('full')
422
+ if isinstance(path, str):
423
+ checked += 1
424
+ if not os.path.isfile(os.path.join(pack.root, path)):
425
+ res.fails.append(f'assets.{aid}.path 断链:包内不存在 {path}')
426
+ if isinstance(full, str): # 方案甲原图(§2),同样是包内路径引用
427
+ checked += 1
428
+ if not os.path.isfile(os.path.join(pack.root, full)):
429
+ res.fails.append(f'assets.{aid}.full 断链:包内不存在 {full}')
430
+ if isinstance(url, str):
431
+ checked += 1
432
+ if not re.match(r'https?://', url):
433
+ res.fails.append(f'assets.{aid}.url 非 http(s) 地址:{url}')
434
+ else:
435
+ res.notes.append(f'assets.{aid}.url 为远端地址,离线不可验活:{url}')
436
+ # layouts 里的 asset/background 引用必须指向已声明资产
437
+ for name, layout, slot, md in _iter_slots(pack):
438
+ refs, color = _asset_refs(slot.get('asset'))
439
+ for aid, theme in refs:
440
+ checked += 1
441
+ if aid not in pack.assets:
442
+ where = f'layouts.{name}.slots[{slot.get("role")}].asset'
443
+ where += f'.{theme}' if theme else ''
444
+ res.fails.append(f'{where} 断链:未声明资产 `{aid}`')
445
+ if color is not None:
446
+ res.warns.append(
447
+ f'layouts.{name}.slots[{slot.get("role")}].asset 用了 {{color: ...}} 形态,'
448
+ '§3 未定义(asset 仅 <asset-id> / {<theme>: <asset-id>} 两形态)')
449
+ for name, (layout, md) in sorted(pack.layouts.items()):
450
+ if not isinstance(layout, dict):
451
+ continue
452
+ refs, color = _asset_refs(layout.get('background'))
453
+ for aid, theme in refs:
454
+ checked += 1
455
+ if aid not in pack.assets:
456
+ where = f'layouts.{name}.background' + (f'.{theme}' if theme else '')
457
+ res.fails.append(f'{where} 断链:未声明资产 `{aid}`')
458
+ if color is not None:
459
+ token = str(color).strip('{}')
460
+ token = token[len('colors.'):] if token.startswith('colors.') else token
461
+ # {color: <colors-token>} 是 §3 三形态之一,合法形态不告警;
462
+ # 与 {<theme>: <asset-id>} 的歧义由 color 是保留键(主题名禁用)在键名层消解。
463
+ if token not in pack.colors:
464
+ res.fails.append(
465
+ f'layouts.{name}.background.color 断链:colors 未定义 `{token}`')
466
+ # sidecar 指针本身也是一条包内路径引用
467
+ if pack.layout_pointer:
468
+ key, target = pack.layout_pointer
469
+ checked += 1
470
+ if not os.path.isfile(os.path.join(pack.root, target)):
471
+ res.fails.append(f'{key} 指针断链:包内不存在 {target}')
472
+ if checked == 0:
473
+ res.notes.append('包内无 path/url/资产引用,无适用对象')
474
+ else:
475
+ res.notes.append(f'共校验 {checked} 处引用')
476
+ return res
477
+
478
+
479
+ def rule_v2_2(pack):
480
+ """V2-2 孤儿资产"""
481
+ res = Result('V2-2', '孤儿资产(声明但无处引用)')
482
+ if not pack.assets:
483
+ res.notes.append('包内无 assets 段,无适用对象')
484
+ return res
485
+ referenced = set()
486
+ for name, layout, slot, md in _iter_slots(pack):
487
+ referenced.update(aid for aid, _ in _asset_refs(slot.get('asset'))[0])
488
+ for name, (layout, md) in pack.layouts.items():
489
+ if isinstance(layout, dict):
490
+ referenced.update(aid for aid, _ in _asset_refs(layout.get('background'))[0])
491
+ for md in pack.files: # 正文反引号裸 id 也算引用(§1 引用硬规则)
492
+ referenced.update(re.findall(r'`([\w\-./]+)`', md.body))
493
+ orphans = [aid for aid in sorted(pack.assets) if aid not in referenced]
494
+ for aid in orphans:
495
+ res.warns.append(f'资产 `{aid}` 声明后未被 layouts 或正文引用')
496
+ res.notes.append(f'{len(pack.assets) - len(orphans)}/{len(pack.assets)} 个资产被引用')
497
+ # 孤儿判定的粒度是「资产 id」,不是文件:条目的 full(原图)挂在已声明 id 下,
498
+ # 只要该 id 被引用就随之被引用,不单独算孤儿(§5 V2-2 行 + §2 方案甲)。
499
+ withfull = [aid for aid, (e, _) in sorted(pack.assets.items())
500
+ if isinstance(e, dict) and isinstance(e.get('full'), str)]
501
+ if withfull:
502
+ res.notes.append(f'{len(withfull)} 个条目带 full(原图):'
503
+ f'{", ".join(withfull)}——随其 id 判定,不单独算孤儿')
504
+ return res
505
+
506
+
507
+ def rule_v2_3(pack):
508
+ """V2-3 坐标出 canvas ±5% 出血容差"""
509
+ res = Result('V2-3', f'坐标出 canvas ±{int(BLEED * 100)}% 出血容差')
510
+ boxes = []
511
+ for name, layout, slot, md in _iter_slots(pack):
512
+ if isinstance(slot.get('box'), list):
513
+ boxes.append((f'layouts.{name}.slots[{slot.get("role")}].box', slot['box']))
514
+ for aid, (entry, md) in sorted(pack.assets.items()):
515
+ if isinstance(entry, dict) and isinstance(entry.get('boxes'), list):
516
+ for idx, box in enumerate(entry['boxes']):
517
+ boxes.append((f'assets.{aid}.boxes[{idx}]', box))
518
+ if not boxes:
519
+ res.notes.append('包内无坐标,无适用对象')
520
+ return res
521
+ if not pack.canvas:
522
+ res.fails.append('存在坐标但 frontmatter 无可解析的 canvas(见 V2-10)')
523
+ return res
524
+ width, height, _ = pack.canvas
525
+ max_x, max_y = width * (1 + BLEED), height * (1 + BLEED)
526
+ min_x, min_y = -width * BLEED, -height * BLEED
527
+ for where, box in boxes:
528
+ if not (isinstance(box, list) and len(box) == 4
529
+ and all(isinstance(v, (int, float)) for v in box)):
530
+ res.fails.append(f'{where} 不是 4 个数字的 [x, y, w, h]:{box}')
531
+ continue
532
+ x, y, w, h = box
533
+ if x < min_x or y < min_y or x + w > max_x or y + h > max_y:
534
+ res.fails.append(
535
+ f'{where} = {box} 出界(canvas {width}x{height},'
536
+ f'容许 x∈[{min_x:.0f},{max_x:.0f}] y∈[{min_y:.0f},{max_y:.0f}])')
537
+ res.notes.append(f'canvas {width}x{height},校验 {len(boxes)} 个 box')
538
+ return res
539
+
540
+
541
+ def _check_enum(res, where, value, allowed):
542
+ if value is None:
543
+ return
544
+ if value not in allowed:
545
+ res.fails.append(f'{where} = `{value}` 不在枚举 {"|".join(map(str, allowed))}')
546
+
547
+
548
+ def rule_v2_4(pack):
549
+ """V2-4 kind/mark/on-bg/role/theme/type/confidence 枚举合法"""
550
+ res = Result('V2-4', 'kind/mark/on-bg/role/theme/type/confidence 枚举合法')
551
+ themes = pack.themes or ENUM_ON_BG
552
+ count = 0
553
+ for aid, (entry, md) in sorted(pack.assets.items()):
554
+ if not isinstance(entry, dict):
555
+ continue
556
+ count += 1
557
+ _check_enum(res, f'assets.{aid}.kind', entry.get('kind'), ENUM_KIND)
558
+ _check_enum(res, f'assets.{aid}.mark', entry.get('mark'), ENUM_MARK)
559
+ _check_enum(res, f'assets.{aid}.on-bg', entry.get('on-bg'), ENUM_ON_BG)
560
+ _check_enum(res, f'assets.{aid}.confidence', entry.get('confidence'), ENUM_CONFIDENCE)
561
+ _check_enum(res, f'assets.{aid}.theme', entry.get('theme'), themes)
562
+ if entry.get('kind') == 'background':
563
+ _check_enum(res, f'assets.{aid}.role', entry.get('role'), ENUM_ASSET_ROLE)
564
+ for name, (layout, md) in sorted(pack.layouts.items()):
565
+ if not isinstance(layout, dict):
566
+ continue
567
+ count += 1
568
+ _check_enum(res, f'layouts.{name}.role', layout.get('role'), ENUM_LAYOUT_ROLE)
569
+ _check_enum(res, f'layouts.{name}.confidence', layout.get('confidence'), ENUM_CONFIDENCE)
570
+ for theme in layout.get('themes') or []:
571
+ if theme not in themes:
572
+ res.fails.append(f'layouts.{name}.themes 含未声明主题 `{theme}`')
573
+ for name, layout, slot, md in _iter_slots(pack):
574
+ count += 1
575
+ # slots.role 是开放枚举(§3「slots.*.role 开放不校验」),只校验 type
576
+ _check_enum(res, f'layouts.{name}.slots[{slot.get("role")}].type',
577
+ slot.get('type'), ENUM_SLOT_TYPE)
578
+ if pack.safe_area:
579
+ _check_enum(res, 'safe-area.confidence', pack.safe_area[0].get('confidence'),
580
+ ENUM_CONFIDENCE)
581
+ conf = pack.design.data.get('color-confidence')
582
+ if isinstance(conf, dict):
583
+ _check_enum(res, 'color-confidence.level', conf.get('level'), ENUM_CONFIDENCE)
584
+ if count == 0:
585
+ res.notes.append('包内无 assets/layouts 段,无适用对象')
586
+ else:
587
+ res.notes.append(f'校验 {count} 个带枚举字段的条目')
588
+ return res
589
+
590
+
591
+ def rule_v2_5(pack):
592
+ """V2-5 推断段缺 confidence"""
593
+ res = Result('V2-5', '推断段缺 confidence')
594
+ count = 0
595
+ # assets 的 confidence 属审计字段,落 ref/audit.yaml,不受本检(§5 V2-5 行)
596
+ for name, (layout, md) in sorted(pack.layouts.items()):
597
+ if isinstance(layout, dict):
598
+ count += 1
599
+ if 'confidence' not in layout:
600
+ res.fails.append(f'layouts.{name} 缺 confidence')
601
+ if pack.safe_area:
602
+ count += 1
603
+ if 'confidence' not in pack.safe_area[0]:
604
+ res.fails.append('safe-area 缺 confidence')
605
+ if count == 0:
606
+ res.notes.append('包内无推断段,无适用对象')
607
+ else:
608
+ res.notes.append(f'校验 {count} 个推断段条目')
609
+ return res
610
+
611
+
612
+ def _pack_asset_files(pack):
613
+ """随包下发的资产文件清单 → {包内相对路径: 字节数}。
614
+
615
+ 口径(§5 V2-6 行)= `assets/**` 下所有文件 ∪ 条目声明的 path/full 并集,
616
+ 并集是为了防「未声明的大文件」和「声明在 assets/ 之外的大文件」两头绕过。
617
+ """
618
+ files = {}
619
+ assets_dir = os.path.join(pack.root, 'assets')
620
+ for base, _, names in os.walk(assets_dir):
621
+ for fname in names:
622
+ abspath = os.path.join(base, fname)
623
+ files[os.path.relpath(abspath, pack.root)] = os.path.getsize(abspath)
624
+ for aid, (entry, md) in pack.assets.items():
625
+ if not isinstance(entry, dict):
626
+ continue
627
+ for field in ('path', 'full'):
628
+ rel = entry.get(field)
629
+ if isinstance(rel, str) and os.path.isfile(os.path.join(pack.root, rel)):
630
+ files[os.path.normpath(rel)] = os.path.getsize(os.path.join(pack.root, rel))
631
+ return files
632
+
633
+
634
+ def rule_v2_6(pack):
635
+ """V2-6 压缩图单张 >500KB WARN;包内资产总量 >20MB FAIL(D5b 修订)"""
636
+ res = Result('V2-6', '压缩图单张 >500KB WARN;包内资产总量 >20MB FAIL')
637
+ # 500KB 只管压缩图 = 条目 path 指向的包内文件;
638
+ # full(原图)天然大,豁免单张 WARN 但计入总量;url 条目包内无文件,不适用。
639
+ compressed, url_borne = {}, []
640
+ for aid, (entry, md) in sorted(pack.assets.items()):
641
+ if not isinstance(entry, dict):
642
+ continue
643
+ if isinstance(entry.get('url'), str):
644
+ url_borne.append(aid)
645
+ continue
646
+ rel = entry.get('path')
647
+ if isinstance(rel, str) and os.path.isfile(os.path.join(pack.root, rel)):
648
+ compressed[os.path.normpath(rel)] = os.path.getsize(os.path.join(pack.root, rel))
649
+ for rel, size in sorted(compressed.items()):
650
+ if size > ASSET_WARN_SINGLE:
651
+ res.warns.append(f'{rel} = {size / 1024:.1f}KB > 500KB(压缩图单张上限)')
652
+
653
+ files = _pack_asset_files(pack)
654
+ if not files:
655
+ res.notes.append('包内无资产文件,总量检查无适用对象')
656
+ else:
657
+ total = sum(files.values())
658
+ if total > ASSET_MAX_TOTAL:
659
+ res.fails.append(f'包内资产总量 {total / 1024 / 1024:.2f}MB > 20MB'
660
+ f'({len(files)} 个文件:assets/** ∪ 条目 path/full)')
661
+ res.notes.append(f'包内资产 {len(files)} 个文件,总 {total / 1024:.1f}KB,'
662
+ f'最大 {max(files.values()) / 1024:.1f}KB')
663
+ res.notes.append(f'受 500KB 检查的压缩图(条目 path){len(compressed)} 个'
664
+ + (f';url 承载条目 {len(url_borne)} 个不适用体积检查' if url_borne else ''))
665
+ return res
666
+
667
+
668
+ def rule_v2_7(pack):
669
+ """V2-7 花括号引用新段显式拦截"""
670
+ res = Result('V2-7', '花括号引用新段({assets.*} 等)')
671
+ pattern = re.compile(r'\{(' + '|'.join(map(re.escape, V2_SECTIONS)) + r')\.([\w\-]+)\}')
672
+ for md in pack.files:
673
+ for lineno, line in enumerate(md.text.split('\n'), 1):
674
+ for section, token in pattern.findall(line):
675
+ res.fails.append(
676
+ f'{md.name}:{lineno} 花括号引用新段 {{{section}.{token}}}'
677
+ '(check_v1 会判未知命名空间 broken-ref,须改反引号裸 id)')
678
+ res.notes.append(f'扫描 {len(pack.files)} 个 md 全文')
679
+ return res
680
+
681
+
682
+ def rule_v2_8(pack):
683
+ """V2-8 themes 声明主题缺可用 logo on-bg 变体"""
684
+ res = Result('V2-8', 'themes 每主题需有可用 logo on-bg 变体')
685
+ if not pack.themes:
686
+ res.notes.append('未声明 themes,无适用对象')
687
+ return res
688
+ have = {entry.get('on-bg') for entry, _ in pack.assets.values()
689
+ if isinstance(entry, dict) and entry.get('kind') == 'logo'}
690
+ for theme in pack.themes:
691
+ if theme not in have:
692
+ res.warns.append(f'themes 声明 `{theme}`,但无 kind: logo 且 on-bg: {theme} 的资产')
693
+ res.notes.append(f'themes = {pack.themes},logo on-bg 覆盖 = {sorted(x for x in have if x)}')
694
+ return res
695
+
696
+
697
+ def rule_v2_9(pack):
698
+ """V2-9 frontmatter 键序不符 §1 清单"""
699
+ res = Result('V2-9', 'frontmatter 键名/键序符合 §1 清单')
700
+ order_index = {key: i for i, key in enumerate(KEY_ORDER)}
701
+ seen, unknown = [], []
702
+ for key, lineno in pack.design.root_keys:
703
+ if key in order_index:
704
+ seen.append((key, order_index[key], lineno))
705
+ else:
706
+ unknown.append((key, lineno))
707
+ positions = [p for _, p, _ in seen]
708
+ if positions != sorted(positions):
709
+ actual = ' → '.join(k for k, _, _ in seen)
710
+ expect = ' → '.join(k for k, _, _ in sorted(seen, key=lambda t: t[1]))
711
+ res.warns.append(f'键序乱:实际 {actual};应为 {expect}')
712
+ for name, lineno in pack.legacy_layout_keys: # 键名收紧(§3 键名统一 layouts)
713
+ res.fails.append(
714
+ f'{name}:{lineno} 键 `layouts-file` 已废弃,规范定名 `layouts`'
715
+ '(值为 string 且 .md 结尾 = sidecar 指针,值为 map = 内联)')
716
+ for key, lineno in unknown:
717
+ if key == 'layouts-file': # 已由上面判 FAIL,不重复报 WARN
718
+ continue
719
+ res.warns.append(f'design.md:{lineno} 键 `{key}` 不在 §1 键序清单')
720
+ res.notes.append(f'顶层键 {len(pack.design.root_keys)} 个,识别 {len(seen)} 个')
721
+ return res
722
+
723
+
724
+ def rule_v2_10(pack):
725
+ """V2-10 含 layouts/safe-area 缺 canvas"""
726
+ res = Result('V2-10', '含 layouts/safe-area 必有 canvas')
727
+ triggers = []
728
+ if pack.layouts:
729
+ triggers.append('layouts')
730
+ if pack.safe_area:
731
+ triggers.append('safe-area')
732
+ if not triggers:
733
+ res.notes.append('无 layouts / safe-area 段,无适用对象')
734
+ return res
735
+ if not pack.canvas:
736
+ res.fails.append(f'包内有 {"/".join(triggers)} 但无可解析的 canvas: <W>x<H>')
737
+ return res
738
+ width, height, md = pack.canvas
739
+ res.notes.append(f'触发段 {"/".join(triggers)};canvas {width}x{height}(来自 {md.name})')
740
+ return res
741
+
742
+
743
+ def rule_v2_11(pack):
744
+ """V2-11 键名命中 YAML 1.1 布尔字面量"""
745
+ res = Result('V2-11', '键名不得命中 YAML 1.1 布尔字面量')
746
+ total = 0
747
+ for md in pack.files:
748
+ for key, lineno in md.keys:
749
+ total += 1
750
+ if str(key).lower() in BOOL_LITERALS:
751
+ res.fails.append(
752
+ f'{md.name}:{lineno} 键名 `{key}` 命中 YAML 1.1 布尔字面量'
753
+ '(PyYAML 会解析成 True/False,须改名,如 on → on-bg)')
754
+ res.notes.append(f'扫描 {total} 个键名(含流映射内的键)')
755
+ return res
756
+
757
+
758
+ def rule_v2_12(pack):
759
+ """V2-12 assets 条目 path/url/color 三态互斥"""
760
+ res = Result('V2-12', 'assets 条目 path/url/color 恰好一个;full 仅随 path;color 仅 background')
761
+ if not pack.assets:
762
+ res.notes.append('包内无 assets 段,无适用对象')
763
+ return res
764
+ for aid, (entry, md) in sorted(pack.assets.items()):
765
+ if not isinstance(entry, dict):
766
+ res.fails.append(f'assets.{aid} 不是映射')
767
+ continue
768
+ present = [k for k in ('path', 'url', 'color') if entry.get(k) is not None]
769
+ if len(present) == 0:
770
+ res.fails.append(f'assets.{aid} 三态全缺(path/url/color 必须恰好一个)')
771
+ elif len(present) > 1:
772
+ res.fails.append(f'assets.{aid} 三态双源:同时存在 {" + ".join(present)}')
773
+ if 'color' in present and entry.get('kind') != 'background':
774
+ res.fails.append(
775
+ f'assets.{aid} 用 color 但 kind = `{entry.get("kind")}`(仅 background 允许)')
776
+ if entry.get('full') is not None and entry.get('path') is None:
777
+ res.fails.append(
778
+ f'assets.{aid} 有 full 但无 path(§2 方案甲:full 指原图,只允许与 path 共存;'
779
+ f'当前承载 = {" + ".join(present) or "三态全缺"})')
780
+ res.notes.append(f'校验 {len(pack.assets)} 个资产条目')
781
+ return res
782
+
783
+
784
+ def rule_v2_13(pack):
785
+ """V2-13 声明多主题(themes 长度 >1)但缺 default-theme"""
786
+ res = Result('V2-13', '多主题(themes 长度 >1)须有 default-theme')
787
+ themes = pack.design.data.get('themes')
788
+ if not isinstance(themes, list) or len(themes) <= 1:
789
+ res.notes.append(f'design.md frontmatter themes = {themes!r},非多主题,无适用对象')
790
+ return res
791
+ default = pack.design.data.get('default-theme')
792
+ if default is None:
793
+ res.warns.append(f'design.md 声明 themes = {themes}({len(themes)} 个)但缺 default-theme,'
794
+ '消费侧无从判断默认渲染哪一套')
795
+ else:
796
+ res.notes.append(f'themes = {themes},default-theme = `{default}`')
797
+ return res
798
+
799
+
800
+ def rule_sidecar_dup(pack):
801
+ """V2-R5 sidecar 不得重复 design.md 已有顶层键(规则正文在 §3 sidecar 容器形态)"""
802
+ res = Result('V2-R5', 'sidecar frontmatter 不重复 design.md 顶层键(§3)')
803
+ sidecars = [md for md in pack.files if md is not pack.design]
804
+ if not sidecars:
805
+ res.notes.append('包内无 sidecar 文件,无适用对象')
806
+ return res
807
+ design_keys = {key for key, _ in pack.design.root_keys}
808
+ checked = 0
809
+ for md in sidecars:
810
+ if not md.root_keys:
811
+ res.notes.append(f'{md.name} 无 frontmatter,无适用对象')
812
+ continue
813
+ for key, lineno in md.root_keys:
814
+ if key in SIDECAR_OWN_KEYS: # sidecar 自身载荷,design.md 侧是指针不是冲突
815
+ continue
816
+ checked += 1
817
+ if key in design_keys:
818
+ res.warns.append(
819
+ f'{md.name}:{lineno} 顶层键 `{key}` 与 design.md 重复'
820
+ '(冲突以 design.md 为准,sidecar 侧应删除)')
821
+ res.notes.append(f'{len(sidecars)} 个 sidecar,校验 {checked} 个顶层键')
822
+ return res
823
+
824
+
825
+ AUDIT_TOP_KEYS = ('canvas-source', 'theme-mechanism', 'color-confidence')
826
+ AUDIT_ASSET_FIELDS = ('boxes', 'aspect', 'mark', 'confidence')
827
+
828
+
829
+ def rule_audit_fields(pack):
830
+ """V2-R6 审计元数据不进 design.md(§1 / §2:应移 ref/audit.yaml 或 layouts.md)"""
831
+ res = Result('V2-R6', '审计元数据不进 design.md(应移 ref/audit.yaml / layouts.md)')
832
+ design_keys = {key: lineno for key, lineno in pack.design.root_keys}
833
+ for key in AUDIT_TOP_KEYS:
834
+ if key in design_keys:
835
+ res.warns.append(
836
+ f'design.md:{design_keys[key]} 顶层键 `{key}` 是审计元数据,应移 ref/audit.yaml')
837
+ sidecars = [md for md in pack.files if md is not pack.design and md.root_keys]
838
+ if 'canvas' in design_keys and sidecars:
839
+ res.warns.append(
840
+ f'design.md:{design_keys["canvas"]} `canvas` 应移 layouts sidecar 的 frontmatter 首键'
841
+ '(仅内联/退化态才留在 design.md)')
842
+ hit = 0
843
+ for aid, (entry, md) in sorted(pack.assets.items()):
844
+ if not isinstance(entry, dict):
845
+ continue
846
+ extras = [f for f in AUDIT_ASSET_FIELDS if f in entry]
847
+ if extras:
848
+ hit += 1
849
+ res.warns.append(
850
+ f'assets.{aid} 含审计字段 {"/".join(extras)},应移 ref/audit.yaml'
851
+ '(条目只留消费字段:path/url/color/full/kind/role/theme/on-bg/recipe)')
852
+ if not res.warns:
853
+ res.notes.append('design.md 无审计元数据残留')
854
+ return res
855
+
856
+
857
+ def rule_layouts_pointer(pack):
858
+ """V2-R7 sidecar 指针必须在正文有呼应(弱指针 = 消费者到不了版式数据)"""
859
+ res = Result('V2-R7', 'layouts sidecar 指针在正文有呼应(§2.5 Usage)')
860
+ pointer = pack.design.data.get('layouts')
861
+ if not (isinstance(pointer, str) and pointer.endswith('.md')):
862
+ res.notes.append('无 sidecar 指针(内联或无 layouts),无适用对象')
863
+ return res
864
+ mentions = pack.design.body.count(pointer)
865
+ if mentions == 0:
866
+ res.warns.append(
867
+ f'frontmatter 指向 `{pointer}` 但正文零次提及——消费模型不会主动打开它;'
868
+ '在 ## Usage 第一步写明「搭页前先读 layouts.md 选页型」')
869
+ else:
870
+ res.notes.append(f'正文提及 `{pointer}` {mentions} 次')
871
+ return res
872
+
873
+
874
+ RULES = [rule_v2_1, rule_v2_2, rule_v2_3, rule_v2_4, rule_v2_5, rule_v2_6,
875
+ rule_v2_7, rule_v2_8, rule_v2_9, rule_v2_10, rule_v2_11, rule_v2_12,
876
+ rule_v2_13, rule_sidecar_dup, rule_audit_fields, rule_layouts_pointer]
877
+
878
+
879
+ def main():
880
+ if len(sys.argv) != 2:
881
+ print('用法: check_v2.py <包目录>')
882
+ sys.exit(2)
883
+ pack = Pack(sys.argv[1])
884
+ print(f'包: {pack.root}')
885
+ print('文件: ' + ', '.join(md.name for md in pack.files))
886
+ for md in pack.files:
887
+ for lineno, text in md.anomalies:
888
+ print(f'PARSE-WARN: {md.name}:{lineno} 解析器跳过无法识别的行: {text.strip()[:60]}')
889
+ print('-' * 72)
890
+ failed = 0
891
+ for rule in RULES:
892
+ res = rule(pack)
893
+ print(f'{res.rule:<6} {res.level:<4} {res.title}')
894
+ for line in res.fails:
895
+ print(f' FAIL {line}')
896
+ for line in res.warns:
897
+ print(f' WARN {line}')
898
+ for line in res.notes:
899
+ print(f' · {line}')
900
+ failed += len(res.fails)
901
+ print('-' * 72)
902
+ print(f'结论: {"FAIL" if failed else "PASS"}({failed} 条 FAIL)')
903
+ sys.exit(1 if failed else 0)
904
+
905
+
906
+ if __name__ == '__main__':
907
+ main()