cardbuddy 0.9.1 → 0.11.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 (64) hide show
  1. package/README.md +21 -6
  2. package/dist/cli.js +17 -6
  3. package/dist/config.js +20 -2
  4. package/dist/portGuard.js +293 -0
  5. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  6. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  9. package/dist/server-core/ai-chat.js +3710 -0
  10. package/dist/server-core/assetProxy.js +3 -22
  11. package/dist/server-core/buildDataFromSchema.js +36 -4
  12. package/dist/server-core/cardFiles.js +95 -2
  13. package/dist/server-core/cardImage.js +14 -0
  14. package/dist/server-core/cardListCache.js +0 -17
  15. package/dist/server-core/cardSave.js +2 -0
  16. package/dist/server-core/cardValidate.js +3 -0
  17. package/dist/server-core/index.js +6 -5
  18. package/dist/server-core/logStream.js +1 -6
  19. package/dist/server-core/sliceUpload.js +8 -1
  20. package/dist/standalone.js +24 -8
  21. package/dist/vendored/App.vue +405 -125
  22. package/dist/vendored/api.js +4 -4
  23. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  24. package/dist/vendored/components/AiSettings.vue +392 -0
  25. package/dist/vendored/components/CardGallery.vue +51 -5
  26. package/dist/vendored/components/ChatComposer.vue +413 -0
  27. package/dist/vendored/components/ChatMessage.vue +383 -0
  28. package/dist/vendored/components/ChatPanel.vue +937 -0
  29. package/dist/vendored/components/ComponentPalette.vue +1 -1
  30. package/dist/vendored/components/DebugPanel.vue +732 -90
  31. package/dist/vendored/components/Dropdown.vue +4 -2
  32. package/dist/vendored/components/MarkdownBody.vue +64 -0
  33. package/dist/vendored/components/NewCardDialog.vue +132 -0
  34. package/dist/vendored/components/NumberStepper.vue +125 -0
  35. package/dist/vendored/components/ProgressSlider.vue +9 -1
  36. package/dist/vendored/components/RemotePreview.vue +1 -1
  37. package/dist/vendored/components/StructureTree.vue +2 -2
  38. package/dist/vendored/main.js +1 -0
  39. package/dist/vendored/runtime/adapter.js +1 -1
  40. package/dist/vendored/runtime/ai-api.js +165 -0
  41. package/dist/vendored/runtime/clipboard-node.js +0 -5
  42. package/dist/vendored/runtime/markdown.js +140 -0
  43. package/dist/vendored/skills/.manifest.json +1101 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  46. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  49. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  50. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  51. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  52. package/dist/vendored/styles/ai-chat.css +1850 -0
  53. package/dist/vendored/styles/app.css +725 -213
  54. package/dist/vendored/styles/card-gallery.css +48 -10
  55. package/dist/vendored/styles/card-select.css +13 -6
  56. package/dist/vendored/styles/dropdown.css +1 -2
  57. package/dist/vendored/styles/json-tree.css +3 -3
  58. package/dist/vendored/styles/node-inspector.css +13 -289
  59. package/dist/vendored/styles/setters.css +48 -49
  60. package/dist/vendored/styles/structure-tree.css +6 -11
  61. package/package.json +1 -1
  62. package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
  63. package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
  64. package/dist/vendored/assets/logo.png +0 -0
@@ -0,0 +1,678 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """check_topology.py — 基于人力卡统计规则库(references/handcrafted/patterns.json)
4
+ 对生成的 schema 做拓扑合规性检查。
5
+
6
+ 设计原则(关键):
7
+ - **纯建议层**:只产出 hints 清单(INFO 级),不抛 errors、不阻断 validate。
8
+ 生成器与人工可以照常发布;本工具是「对比人力卡范式的偏离度报告」。
9
+ - **不打分排序**:每条 hint 是一句话建议 + 证据 + 推荐做法,下游自行决定是否采纳。
10
+ - **无业务词**:全部基于结构特征(className / itemKey / slotKey / parent-child 组合),
11
+ 不引用任何具体业务字段名。
12
+
13
+ hint 类型:
14
+ - "skeleton_uncommon": 父节点的子节点类型序列在人力卡中频次很低(<3 张卡支持)
15
+ - "slot unconventional": 该 className 在人力卡中 slot 约定与此不同
16
+ - "missing_required_style": 该 className 节点缺人力卡 ≥90% 都有的 style itemKey
17
+ - "var_name_uncommon": inputParams 的 key 不在人力卡 top-N 高频命名集
18
+ - "binding_low_expr_ratio": 文本绑定 literal:$expr 比例偏离人力卡均值(≥0.65 $expr)
19
+
20
+ 用法:
21
+ python check_topology.py <schema.json> # 检查一张卡
22
+ python check_topology.py <schema.json> --quiet # 只输出 hints 数量
23
+ python check_topology.py <schema.json> --patterns <path> # 指定规则库
24
+ 默认规则库 = <skill>/references/handcrafted/patterns.json。
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ SCRIPT_DIR = Path(__file__).resolve().parent
33
+ SKILL_DIR = SCRIPT_DIR.parent
34
+ DEFAULT_PATTERNS = SKILL_DIR / "references" / "handcrafted" / "patterns.json"
35
+
36
+ # 阈值(可调)
37
+ SKELETON_MIN_SUPPORT = 3 # 低于此支持度的骨架触发 uncommon hint
38
+ MISSING_REQUIRED_RATIO = 0.9 # 与 patterns.json 中 REQUIRED_RATIO 对齐
39
+ VAR_NAME_TOP_N = 60 # inputParams 推荐集大小(与 patterns.json 一致)
40
+ EXPR_RATIO_BASELINE = 0.65 # 文本绑定 $expr 比例的人力卡基线
41
+ EXPR_RATIO_DEVIATION = 0.2 # 偏离基线 ±20% 触发 hint
42
+
43
+
44
+ def load_patterns(path):
45
+ if not Path(path).is_file():
46
+ return None
47
+ try:
48
+ with open(path, encoding="utf-8") as f:
49
+ return json.load(f)
50
+ except Exception as e:
51
+ print(f"[WARN] 无法加载规则库 {path}: {e}", file=sys.stderr)
52
+ return None
53
+
54
+
55
+ def iter_children(node):
56
+ """统一提取直接子节点(兼容 view.components 与 comp.slots[].components)。"""
57
+ kids = []
58
+ if isinstance(node, dict):
59
+ comps = node.get("components")
60
+ if isinstance(comps, list):
61
+ kids.extend(c for c in comps if isinstance(c, dict))
62
+ slots = node.get("slots")
63
+ if isinstance(slots, list):
64
+ for sl in slots:
65
+ if isinstance(sl, dict) and isinstance(sl.get("components"), list):
66
+ kids.extend(c for c in sl["components"] if isinstance(c, dict))
67
+ return kids
68
+
69
+
70
+ def collect_style_itemkeys(node):
71
+ out = set()
72
+ style = node.get("style") or {}
73
+ for p in style.get("properties") or []:
74
+ if isinstance(p, dict):
75
+ ik = p.get("itemKey")
76
+ if ik:
77
+ out.add(ik)
78
+ return out
79
+
80
+
81
+ def collect_slot_specs(node):
82
+ out = []
83
+ for sl in node.get("slots") or []:
84
+ if isinstance(sl, dict):
85
+ out.append((sl.get("slotKey", "default"), bool(sl.get("slotScope", False))))
86
+ return out
87
+
88
+
89
+ def text_value_binding(node):
90
+ for p in node.get("properties") or []:
91
+ if isinstance(p, dict) and p.get("itemKey") == "value":
92
+ iv = p.get("itemValue")
93
+ ivf = p.get("itemValueFrom")
94
+ if isinstance(iv, str):
95
+ return "$expr" if "$" in iv else "literal"
96
+ if isinstance(ivf, str) and "$" in ivf:
97
+ return "$expr"
98
+ return None
99
+
100
+
101
+ def walk_components(view):
102
+ """yield (node, depth, parent_type, parent_node)."""
103
+ def rec(c, depth, ptype, pnode):
104
+ if not isinstance(c, dict):
105
+ return
106
+ cn = c.get("className", "") or ""
107
+ yield (c, depth, ptype, pnode)
108
+ for k in iter_children(c):
109
+ yield from rec(k, depth + 1, cn, c)
110
+ yield from rec(view, 0, None, None)
111
+
112
+
113
+ def check_skeletons(view, patterns):
114
+ """检查父节点的子节点类型序列是否落在人力卡常见骨架。
115
+ 去重粒度:(parent_type, sibling_signature)。同卡片内同种拓扑模式只报一次(避免噪声),
116
+ 但 hint 里给出出现次数供下游判断严重度。
117
+ """
118
+ hints = []
119
+ skeletons = patterns.get("topologySkeletons", {})
120
+ idx = {}
121
+ for parent, entries in skeletons.items():
122
+ idx[parent] = {(tuple(e["children"]), e["support"]) for e in entries}
123
+ # 第一次扫描:统计每个 (parent_type, sibling_signature) 在卡片内出现次数
124
+ counts = {} # (parent_type, sig) -> count
125
+ queue = [view]
126
+ while queue:
127
+ node = queue.pop(0)
128
+ if not isinstance(node, dict):
129
+ continue
130
+ cn = node.get("className", "") or ""
131
+ kids = iter_children(node)
132
+ if kids and cn:
133
+ sibs = tuple(k.get("className", "?") for k in kids)
134
+ key = (cn, sibs)
135
+ counts[key] = counts.get(key, 0) + 1
136
+ for k in iter_children(node):
137
+ queue.append(k)
138
+ # 第二次:对每种出现的 (parent, sig) 输出一次 hint
139
+ for (cn, sibs), in_card_count in counts.items():
140
+ if cn not in idx:
141
+ continue
142
+ supports = [sup for (k, sup) in idx[cn] if k == sibs]
143
+ sup = supports[0] if supports else 0
144
+ if sup < SKELETON_MIN_SUPPORT:
145
+ hints.append({
146
+ "type": "skeleton_uncommon",
147
+ "severity": "info",
148
+ "parent": cn,
149
+ "children": list(sibs),
150
+ "support_in_handcrafted": sup,
151
+ "occurrences_in_card": in_card_count,
152
+ "suggestion": (
153
+ f"父节点 {cn} 的子节点序列 {list(sibs)} 在人力卡中仅出现在 {sup} 张"
154
+ f"(阈值 ≥{SKELETON_MIN_SUPPORT}),本卡片内出现 {in_card_count} 次。"
155
+ f"可参考 patterns.json['topologySkeletons']['{cn}'] 的高频骨架。"
156
+ f"若确为新形态请忽略。"
157
+ ),
158
+ })
159
+ return hints
160
+
161
+
162
+ def check_slots(view, patterns):
163
+ """检查 className 的 slot 约定是否与人力卡多数派一致。"""
164
+ hints = []
165
+ conv = patterns.get("slotConventions", {})
166
+ seen = set()
167
+ for c, depth, ptype, pnode in walk_components(view):
168
+ cn = c.get("className", "") or ""
169
+ if cn not in conv:
170
+ continue
171
+ slots_here = collect_slot_specs(c)
172
+ if not slots_here:
173
+ continue
174
+ comp_id = c.get("componentId", "") or id(c)
175
+ if (cn, comp_id) in seen:
176
+ continue
177
+ seen.add((cn, comp_id))
178
+ default = conv[cn].get("default", {})
179
+ d_sk = default.get("slotKey", "default")
180
+ d_ss = default.get("slotScope", False)
181
+ for (sk, ss) in slots_here:
182
+ if sk != d_sk or ss != d_ss:
183
+ hints.append({
184
+ "type": "slot_unconventional",
185
+ "severity": "info",
186
+ "className": cn,
187
+ "actual": {"slotKey": sk, "slotScope": ss},
188
+ "expected": {"slotKey": d_sk, "slotScope": d_ss},
189
+ "suggestion": (
190
+ f"{cn} 的 slot={sk} scope={ss} 与人力卡多数派不一致"
191
+ f"(人力卡 {conv[cn].get('support',0)}/张默认 slotKey={d_sk} slotScope={d_ss})。"
192
+ f"若无理由请对齐。"
193
+ ),
194
+ })
195
+ return hints
196
+
197
+
198
+ def check_required_styles(view, patterns):
199
+ """检查每个 className 节点是否齐全人力卡 ≥90% 都有的 style itemKey。"""
200
+ hints = []
201
+ style_req = patterns.get("styleRequired", {})
202
+ seen = set()
203
+ for c, depth, ptype, pnode in walk_components(view):
204
+ cn = c.get("className", "") or ""
205
+ if cn not in style_req:
206
+ continue
207
+ required = style_req[cn].get("required", [])
208
+ if not required:
209
+ continue
210
+ comp_id = c.get("componentId", "") or id(c)
211
+ if (cn, comp_id) in seen:
212
+ continue
213
+ seen.add((cn, comp_id))
214
+ actual_keys = collect_style_itemkeys(c)
215
+ missing = [k for k in required if k not in actual_keys]
216
+ if missing:
217
+ hints.append({
218
+ "type": "missing_required_style",
219
+ "severity": "info",
220
+ "className": cn,
221
+ "componentId": c.get("componentId", ""),
222
+ "missing": missing,
223
+ "suggestion": (
224
+ f"{cn} 节点缺失人力卡 ≥{int(MISSING_REQUIRED_RATIO*100)}% 都有的 style itemKey: {missing}。"
225
+ f"可能导致样式无法对齐平台默认渲染。"
226
+ ),
227
+ })
228
+ return hints
229
+
230
+
231
+ def check_var_naming(schema, patterns):
232
+ """检查 inputParams 的 key 是否在人力卡高频命名集或符合中性命名习惯。"""
233
+ hints = []
234
+ var_naming = patterns.get("variableNaming", {})
235
+ top_keys = {x["key"] for x in var_naming.get("topParamKeys", [])[:VAR_NAME_TOP_N]}
236
+ list_keys = {x["key"] for x in var_naming.get("listItemFieldKeys", [])[:VAR_NAME_TOP_N]}
237
+ params = schema.get("dictionary", {}).get("inputParams", [])
238
+ for p in params:
239
+ k = p.get("key", "")
240
+ if not k:
241
+ continue
242
+ # 中性命名(不以业务实体名命名):白名单 = 字母/数字/下划线 + 通用语义后缀
243
+ # 这里简化:只要不在 top_keys 且明显含业务拼音/汉字,给提示
244
+ if k in top_keys:
245
+ continue # 命中人力卡高频集,跳过
246
+ # 若是 list children 字段名则跳过
247
+ if k in list_keys:
248
+ continue
249
+ # 中性命名规则:纯 ASCII 字母数字下划线
250
+ is_neutral = all(ch.isalnum() or ch in "_-" for ch in k)
251
+ if not is_neutral:
252
+ hints.append({
253
+ "type": "var_name_non_ascii",
254
+ "severity": "info",
255
+ "key": k,
256
+ "suggestion": (
257
+ f"inputParams key='{k}' 含非 ASCII 字符。人力卡变量名均为 ASCII(snake_case / camelCase)。"
258
+ f"若是中文业务字段,建议改为英文语义命名或参考 patterns.json['variableNaming']['topParamKeys']。"
259
+ ),
260
+ })
261
+ return hints
262
+
263
+
264
+ def check_text_binding_ratio(view, patterns):
265
+ """检查 Text 节点的字面量 vs $expr 比例是否偏离人力卡基线。"""
266
+ hints = []
267
+ baseline = patterns.get("textBindingParadigm", {}).get("exprRatio", EXPR_RATIO_BASELINE)
268
+ expr_count = 0
269
+ literal_count = 0
270
+ for c, depth, ptype, pnode in walk_components(view):
271
+ if (c.get("className", "")) != "Text":
272
+ continue
273
+ b = text_value_binding(c)
274
+ if b == "$expr":
275
+ expr_count += 1
276
+ elif b == "literal":
277
+ literal_count += 1
278
+ total = expr_count + literal_count
279
+ if total < 5:
280
+ return hints # 样本太小不判
281
+ actual_ratio = expr_count / total
282
+ if abs(actual_ratio - baseline) > EXPR_RATIO_DEVIATION:
283
+ direction = "低于" if actual_ratio < baseline else "高于"
284
+ hints.append({
285
+ "type": "binding_ratio_deviation",
286
+ "severity": "info",
287
+ "expr_count": expr_count,
288
+ "literal_count": literal_count,
289
+ "actual_ratio": round(actual_ratio, 3),
290
+ "baseline_ratio": baseline,
291
+ "suggestion": (
292
+ f"Text 节点 $expr 比例 {actual_ratio:.0%}({expr_count}/总{total}){direction}人力卡基线 {baseline:.0%}。"
293
+ f"人力卡偏绑变量;过低意味着大量文本写死,可能不利于多状态/多语言扩展。"
294
+ f"若该卡片文本确实全静态可忽略。"
295
+ ),
296
+ })
297
+ return hints
298
+
299
+
300
+ def check_all(schema_path, patterns_path=DEFAULT_PATTERNS):
301
+ patterns = load_patterns(patterns_path)
302
+ # v2 抽象规律库(与 patterns.json 同目录)
303
+ recipes_path = Path(patterns_path).parent / "recipes_v2.json"
304
+ recipes = load_patterns(recipes_path) if recipes_path.is_file() else None
305
+
306
+ try:
307
+ with open(schema_path, encoding="utf-8") as f:
308
+ schema = json.load(f)
309
+ except Exception as e:
310
+ return [], f"无法读取 schema: {e}"
311
+
312
+ view = schema.get("view")
313
+ if not isinstance(view, dict):
314
+ return [], "schema 缺 view 树"
315
+
316
+ hints = []
317
+
318
+ # v2 抽象层检查(新权威)
319
+ if recipes:
320
+ hints.extend(check_v2_intent_coverage(view, schema, recipes))
321
+ hints.extend(check_v2_param_roles(view, schema, recipes))
322
+ hints.extend(check_v2_style_contracts(view, recipes))
323
+ # v1 频率统计检查(fallback,仅当 v2 不可用时启用)
324
+ if patterns and not recipes:
325
+ hints.extend(check_skeletons(view, patterns))
326
+ hints.extend(check_slots(view, patterns))
327
+ hints.extend(check_required_styles(view, patterns))
328
+ hints.extend(check_var_naming(schema, patterns))
329
+ hints.extend(check_text_binding_ratio(view, patterns))
330
+ return hints, None
331
+
332
+
333
+ # ====================================================================
334
+ # v2 抽象层检查器
335
+ # ====================================================================
336
+ def _iter_children(node):
337
+ if not isinstance(node, dict):
338
+ return []
339
+ kids = []
340
+ comps = node.get("components")
341
+ if isinstance(comps, list):
342
+ kids.extend(c for c in comps if isinstance(c, dict))
343
+ slots = node.get("slots")
344
+ if isinstance(slots, list):
345
+ for sl in slots:
346
+ if isinstance(sl, dict) and isinstance(sl.get("components"), list):
347
+ kids.extend(c for c in sl["components"] if isinstance(c, dict))
348
+ return kids
349
+
350
+
351
+ def _walk(node, depth=0):
352
+ if not isinstance(node, dict):
353
+ return
354
+ yield node, depth
355
+ for c in _iter_children(node):
356
+ yield from _walk(c, depth + 1)
357
+
358
+
359
+ def _get_text_value(node):
360
+ for p in node.get("properties") or []:
361
+ if isinstance(p, dict) and p.get("itemKey") == "value":
362
+ iv = p.get("itemValue")
363
+ if isinstance(iv, str):
364
+ return iv
365
+ return ""
366
+
367
+
368
+ def _get_font_size(node):
369
+ for grp in (node.get("style") or {}).get("properties") or []:
370
+ if isinstance(grp, dict) and grp.get("itemKey") == "font":
371
+ for c in (grp.get("itemChildren") or {}).get("properties") or []:
372
+ if isinstance(c, dict) and c.get("itemKey") == "fontSize":
373
+ try:
374
+ return int(c.get("itemValue"))
375
+ except (TypeError, ValueError):
376
+ return None
377
+ return None
378
+
379
+
380
+ def _has_event_action(node, event_key=None, action_type=None):
381
+ for ea in node.get("eventactions") or []:
382
+ if not isinstance(ea, dict):
383
+ continue
384
+ ek = (ea.get("event") or {}).get("eventKey")
385
+ at = (ea.get("action") or {}).get("actionType")
386
+ if event_key and ek != event_key:
387
+ continue
388
+ if action_type and at != action_type:
389
+ continue
390
+ return True
391
+ return False
392
+
393
+
394
+ def _get_custom_action_codes(node):
395
+ codes = []
396
+ for ea in node.get("eventactions") or []:
397
+ if not isinstance(ea, dict):
398
+ continue
399
+ if (ea.get("action") or {}).get("actionType") != "customAction":
400
+ continue
401
+ for p in (ea.get("action") or {}).get("properties") or []:
402
+ if isinstance(p, dict) and p.get("itemKey") == "function":
403
+ iv = p.get("itemValue")
404
+ if isinstance(iv, dict) and iv.get("code"):
405
+ codes.append(iv["code"])
406
+ return codes
407
+
408
+
409
+ def check_v2_intent_coverage(view, schema, recipes):
410
+ """检查生成卡命中哪些布局意图,给出 canonical 拓扑对照建议。
411
+
412
+ 与 v1 check_skeletons 不同:v1 报「父子序列在人力卡中频次低」,
413
+ v2 报「这张卡命中哪些意图 + 是否符合 canonical 拓扑 + 是否漏抽关键参数」。
414
+ """
415
+ hints = []
416
+ layout = recipes.get("layoutIntents", {})
417
+
418
+ # 复用 extract_handcrafted 的意图识别逻辑(简单复制,避免循环依赖)
419
+ import re
420
+ intents = []
421
+
422
+ root_children = _iter_children(view)
423
+ root_classes = [c.get("className", "") for c in root_children]
424
+ if root_classes and root_classes[0] == "Column":
425
+ intents.append("single_column_flow")
426
+
427
+ has_list = any(c.get("className") == "ListLayout" for c, _ in _walk(view))
428
+ if has_list:
429
+ intents.append("list_with_sections")
430
+
431
+ # form_rows
432
+ form_rows = 0
433
+ for c, _ in _walk(view):
434
+ if c.get("className") != "Row":
435
+ continue
436
+ kids = _iter_children(c)
437
+ kc = [k.get("className", "") for k in kids]
438
+ if (len(kc) >= 2 and kc[0] == "Text" and kc[1] in ("Text", "Input")):
439
+ form_rows += 1
440
+ elif (len(kc) == 2 and kc[0] == "Text" and kc[1] in ("Image", "Icon")):
441
+ form_rows += 1
442
+ if form_rows >= 2:
443
+ intents.append("form_rows")
444
+
445
+ # canvas_chart
446
+ for c, _ in _walk(view):
447
+ if c.get("className") == "Canvas" and _has_event_action(c, "onMeasured"):
448
+ intents.append("canvas_chart")
449
+ break
450
+
451
+ # tab_switch
452
+ input_params = schema.get("dictionary", {}).get("inputParams", [])
453
+ has_active_tab = any(p.get("key") == "activeTab" for p in input_params)
454
+ vshow_count = sum(
455
+ 1 for c, _ in _walk(view)
456
+ for d in c.get("directives") or []
457
+ if isinstance(d, dict) and d.get("itemKey") == "v-show"
458
+ )
459
+ if has_active_tab and vshow_count >= 2:
460
+ intents.append("tab_switch")
461
+
462
+ # 输出:每个命中的意图给一个 hint(含 canonical 拓扑对照)
463
+ for ik in intents:
464
+ spec = layout.get(ik, {})
465
+ if not spec:
466
+ continue
467
+ hints.append({
468
+ "type": "intent_matched",
469
+ "severity": "info",
470
+ "intent": ik,
471
+ "desc": spec.get("desc", ""),
472
+ "canonical_topology": spec.get("canonical_topology", ""),
473
+ "param_recipe": spec.get("param_recipe", ""),
474
+ "support": spec.get("support", 0),
475
+ "suggestion": (
476
+ f"命中布局意图 {ik}({spec.get('desc', '')})。"
477
+ f"人力卡 {spec.get('support', 0)} 张同构,canonical 拓扑:"
478
+ f"{spec.get('canonical_topology', '')}。"
479
+ + (f" 参数配方:{spec.get('param_recipe', '')}。" if spec.get("param_recipe") else "")
480
+ + "若实际拓扑偏离 canonical,请确认是否有充分理由。"
481
+ ),
482
+ })
483
+
484
+ # 漏抽提示:命中的意图对应的关键参数角色在 inputParams 中是否缺失
485
+ INTENT_REQUIRED_ROLES = {
486
+ "list_with_sections": [("items", "array"), ("showAll", "boolean")],
487
+ "amount_header": [("primaryAmount", "number")],
488
+ "form_rows": [], # 表单行参数按位置派生,不强求
489
+ "tab_switch": [("activeTab", "string")],
490
+ }
491
+ input_keys = {p.get("key", "") for p in input_params}
492
+ for ik in intents:
493
+ for role, _ in INTENT_REQUIRED_ROLES.get(ik, []):
494
+ # 简单检查:inputParams 含同名 key(不含则提示)
495
+ if role not in input_keys and role != "showAll":
496
+ hints.append({
497
+ "type": "param_role_missing",
498
+ "severity": "info",
499
+ "intent": ik,
500
+ "missing_role": role,
501
+ "suggestion": (
502
+ f"命中意图 {ik} 但 inputParams 未含角色 `{role}`。"
503
+ f"若该卡确需此角色(如列表数据/Tab 状态),请补抽;"
504
+ f"若该卡不适用此角色(如静态展示),可忽略。"
505
+ ),
506
+ })
507
+
508
+ # 未命中任何意图的提示
509
+ if not intents:
510
+ hints.append({
511
+ "type": "no_intent_matched",
512
+ "severity": "info",
513
+ "suggestion": (
514
+ "未命中任何已知布局意图(single_column_flow / list_with_sections / "
515
+ "form_rows / tab_switch / canvas_chart / amount_header / accordion_groups / badge_overlay)。"
516
+ "可能是新型形态;若确为新范式,建议沉淀到 recipes_v2.json。"
517
+ ),
518
+ })
519
+
520
+ return hints
521
+
522
+
523
+ def check_v2_param_roles(view, schema, recipes):
524
+ """检查参数角色是否符合规范(按 v2 抽象规则)。"""
525
+ hints = []
526
+ param_roles = recipes.get("paramRoles", {})
527
+
528
+ # 检查 list_items 角色:ListLayout 必须有 slotScope=true
529
+ for c, _ in _walk(view):
530
+ if c.get("className") != "ListLayout":
531
+ continue
532
+ slots = c.get("slots") or []
533
+ if not slots:
534
+ continue
535
+ ss = bool(slots[0].get("slotScope", False)) if isinstance(slots[0], dict) else False
536
+ if not ss:
537
+ hints.append({
538
+ "type": "param_role_violation",
539
+ "severity": "info",
540
+ "role": "list_items",
541
+ "violation": "ListLayout slotScope != true",
542
+ "suggestion": (
543
+ "ListLayout 的 slotScope 必须为 true(v2 styleContract: ListLayout_slotScope_true, hard 级)。"
544
+ "否则列表项作用域无法隔离。"
545
+ ),
546
+ })
547
+
548
+ return hints
549
+
550
+
551
+ def check_v2_style_contracts(view, recipes):
552
+ """检查生成卡是否违反 v2 样式硬契约。"""
553
+ hints = []
554
+ contracts = recipes.get("styleContracts", {})
555
+
556
+ # Text_font_required
557
+ text_nodes = [c for c, _ in _walk(view) if c.get("className") == "Text"]
558
+ if text_nodes:
559
+ missing_font = 0
560
+ for t in text_nodes:
561
+ style_keys = set()
562
+ for p in (t.get("style") or {}).get("properties") or []:
563
+ if isinstance(p, dict):
564
+ style_keys.add(p.get("itemKey"))
565
+ if "font" not in style_keys:
566
+ missing_font += 1
567
+ if missing_font:
568
+ hints.append({
569
+ "type": "style_contract_violation",
570
+ "severity": "info",
571
+ "contract": "Text_font_required",
572
+ "violations": missing_font,
573
+ "total_text": len(text_nodes),
574
+ "suggestion": (
575
+ f"{missing_font}/{len(text_nodes)} 个 Text 节点缺 font 组(v2 styleContract: Text_font_required, hard 级)。"
576
+ f"渲染器会回退浏览器默认样式(无大小无颜色)。请补 font 组(fontSize + color 至少)。"
577
+ ),
578
+ })
579
+
580
+ # Canvas_onMeasured_required
581
+ for c, _ in _walk(view):
582
+ if c.get("className") == "Canvas":
583
+ if not _has_event_action(c, "onMeasured"):
584
+ hints.append({
585
+ "type": "style_contract_violation",
586
+ "severity": "info",
587
+ "contract": "Canvas_onMeasured_required",
588
+ "suggestion": (
589
+ "Canvas 节点缺 onMeasured 事件(v2 styleContract: Canvas_onMeasured_required, hard 级)。"
590
+ "缺则 Canvas 不绘制(一片空白)。"
591
+ ),
592
+ })
593
+
594
+ # Root_style_rect_forbidden:根节点宽度由顶层 view.width 承载,
595
+ # style 里不应再放 rect 组(width)——放重复宽度是冗余/冲突(铁律见 rules.md §九)
596
+ root_style_keys = set()
597
+ for p in (view.get("style") or {}).get("properties") or []:
598
+ if isinstance(p, dict):
599
+ root_style_keys.add(p.get("itemKey"))
600
+ if "rect" in root_style_keys:
601
+ hints.append({
602
+ "type": "style_contract_violation",
603
+ "severity": "info",
604
+ "contract": "Root_style_rect_forbidden",
605
+ "suggestion": (
606
+ "Root(view)style 含 rect 组(width)——根节点宽度由顶层 view.width 承载,"
607
+ "style 里不应再放 rect.width(v2 styleContract: Root_style_rect_forbidden, hard 级)。"
608
+ "删除根节点的 rect 组,保留 view.width 即可。"
609
+ ),
610
+ })
611
+
612
+ # Root_outer_radius_required:纯色卡片最外层外圆角应设在 Root(rules.md §九)。
613
+ # 判定:Root 有纯色背景(backgroundType=01)且 Root 无 borderRadius(或为 0),
614
+ # 但第一层主容器却设了 borderRadius>0 → 外圆角放错了层级(应上提 Root,内部归零)。
615
+ def _radius_of(node):
616
+ for gp in (node.get("style") or {}).get("properties") or []:
617
+ if isinstance(gp, dict) and gp.get("itemKey") == "borderRadius":
618
+ for c in (gp.get("itemChildren") or {}).get("properties") or []:
619
+ if isinstance(c, dict) and c.get("itemKey") == "borderRadius":
620
+ return c.get("itemValue")
621
+ return None
622
+
623
+ root_bg_type = None
624
+ for gp in (view.get("style") or {}).get("properties") or []:
625
+ if isinstance(gp, dict) and gp.get("itemKey") == "background":
626
+ for c in (gp.get("itemChildren") or {}).get("properties") or []:
627
+ if isinstance(c, dict) and c.get("itemKey") == "backgroundType":
628
+ root_bg_type = c.get("itemValue")
629
+ root_radius = _radius_of(view)
630
+ first_children = (view.get("components") or []) or ((view.get("slots") or [{}])[0].get("components") or [])
631
+ if root_bg_type == "01" and (not root_radius or str(root_radius) == "0") and first_children:
632
+ child_radius = _radius_of(first_children[0])
633
+ if child_radius and str(child_radius) != "0":
634
+ hints.append({
635
+ "type": "style_contract_violation",
636
+ "severity": "info",
637
+ "contract": "Root_outer_radius_required",
638
+ "suggestion": (
639
+ f"纯色卡(Root backgroundType=01)最外层外圆角应设在 Root(view.style.borderRadius),"
640
+ f"但 Root 无 borderRadius 而第一层容器设了 {child_radius}——重复/错层圆角(rules.md §九)。"
641
+ f"把外圆角上提至 Root,第一层容器 borderRadius 归 0。"
642
+ ),
643
+ })
644
+
645
+ return hints
646
+
647
+
648
+ def main():
649
+ ap = argparse.ArgumentParser(description="基于人力卡规则库的拓扑合规性检查(纯建议层,v2 抽象规律优先)")
650
+ ap.add_argument("schema", help="schema.json 路径")
651
+ ap.add_argument("--patterns", default=str(DEFAULT_PATTERNS),
652
+ help=f"规则库 JSON 路径(默认 {DEFAULT_PATTERNS},v2 自动在同目录找 recipes_v2.json)")
653
+ ap.add_argument("--quiet", action="store_true", help="只输出 hints 数量与摘要")
654
+ args = ap.parse_args()
655
+
656
+ hints, err = check_all(args.schema, args.patterns)
657
+ if err:
658
+ print(f"[ERR] {err}", file=sys.stderr)
659
+ sys.exit(1)
660
+
661
+ by_type = {}
662
+ for h in hints:
663
+ by_type.setdefault(h["type"], 0)
664
+ by_type[h["type"]] += 1
665
+
666
+ if args.quiet:
667
+ print(f"hints={len(hints)} by_type={by_type}")
668
+ return
669
+
670
+ print(f"\n[TOPOLOGY CHECK] {args.schema}: {len(hints)} hint(s)")
671
+ for h in hints:
672
+ print(f" [{h['type']}] {h.get('suggestion', '')}")
673
+ if by_type:
674
+ print(f"\n Summary: {by_type}")
675
+
676
+
677
+ if __name__ == "__main__":
678
+ main()