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.
- package/README.md +21 -6
- package/dist/cli.js +17 -6
- package/dist/config.js +20 -2
- package/dist/portGuard.js +293 -0
- package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
- package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/_vendor/finmall-env.mjs +4 -4
- package/dist/server-core/ai-chat.js +3710 -0
- package/dist/server-core/assetProxy.js +3 -22
- package/dist/server-core/buildDataFromSchema.js +36 -4
- package/dist/server-core/cardFiles.js +95 -2
- package/dist/server-core/cardImage.js +14 -0
- package/dist/server-core/cardListCache.js +0 -17
- package/dist/server-core/cardSave.js +2 -0
- package/dist/server-core/cardValidate.js +3 -0
- package/dist/server-core/index.js +6 -5
- package/dist/server-core/logStream.js +1 -6
- package/dist/server-core/sliceUpload.js +8 -1
- package/dist/standalone.js +24 -8
- package/dist/vendored/App.vue +405 -125
- package/dist/vendored/api.js +4 -4
- package/dist/vendored/assets/svgIcons/icons.svg +28 -0
- package/dist/vendored/components/AiSettings.vue +392 -0
- package/dist/vendored/components/CardGallery.vue +51 -5
- package/dist/vendored/components/ChatComposer.vue +413 -0
- package/dist/vendored/components/ChatMessage.vue +383 -0
- package/dist/vendored/components/ChatPanel.vue +937 -0
- package/dist/vendored/components/ComponentPalette.vue +1 -1
- package/dist/vendored/components/DebugPanel.vue +732 -90
- package/dist/vendored/components/Dropdown.vue +4 -2
- package/dist/vendored/components/MarkdownBody.vue +64 -0
- package/dist/vendored/components/NewCardDialog.vue +132 -0
- package/dist/vendored/components/NumberStepper.vue +125 -0
- package/dist/vendored/components/ProgressSlider.vue +9 -1
- package/dist/vendored/components/RemotePreview.vue +1 -1
- package/dist/vendored/components/StructureTree.vue +2 -2
- package/dist/vendored/main.js +1 -0
- package/dist/vendored/runtime/adapter.js +1 -1
- package/dist/vendored/runtime/ai-api.js +165 -0
- package/dist/vendored/runtime/clipboard-node.js +0 -5
- package/dist/vendored/runtime/markdown.js +140 -0
- package/dist/vendored/skills/.manifest.json +1101 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
- package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
- package/dist/vendored/styles/ai-chat.css +1850 -0
- package/dist/vendored/styles/app.css +725 -213
- package/dist/vendored/styles/card-gallery.css +48 -10
- package/dist/vendored/styles/card-select.css +13 -6
- package/dist/vendored/styles/dropdown.css +1 -2
- package/dist/vendored/styles/json-tree.css +3 -3
- package/dist/vendored/styles/node-inspector.css +13 -289
- package/dist/vendored/styles/setters.css +48 -49
- package/dist/vendored/styles/structure-tree.css +6 -11
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
- package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
- 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()
|