cardbuddy 0.15.0 → 0.16.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/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
- package/dist/preview-editor/assets/{index-CIIfMlwI.js → index-Bro07DTa.js} +91 -88
- package/dist/preview-editor/index.html +2 -2
- package/dist/vendored/card-editor/runtime/adapter.js +2 -2
- package/dist/vendored/mcp/client.mjs +1 -1
- package/dist/vendored/server/core/ai-chat.js +251 -80
- package/dist/vendored/server/core/cardValidate.js +16 -5
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +61 -6
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
- package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +93 -27
- package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
- package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +169 -44
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +87 -3
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +35 -6
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +28 -27
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +30 -8
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +3 -2
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +5 -2
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +2 -3
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +1 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +88 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +143 -42
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +99 -53
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +5 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +388 -29
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +325 -41
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +112 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +0 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +119 -78
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -50
- package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +5 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +122 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +3 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
- package/dist/vendored/skills/shared/exit-codes.md +79 -0
- package/dist/vendored/skills/shared/spec-contract.md +118 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +26 -9
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +3 -3
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +0 -8
- package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +91 -47
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +0 -5
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +75 -15
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +40 -34
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-DQn0zQBP.css +0 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
- package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* component_contract.mjs — 组件契约(className 白名单 + 属性键白名单 + 样式组/子键白名单)单一来源。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(2026-09-24 gen-1 实测事故):
|
|
5
|
+
* 模型给 Image 节点写了 `itemKey: "image"`,而 Image 组件元数据里**只有一个属性键 `value`**
|
|
6
|
+
* (itemName「图片地址」,itemTypeRange "image,var")。运行时按 itemKey 绑定组件 props,
|
|
7
|
+
* 不存在的键**被整条静默丢弃** ⇒ value 为空 ⇒ 图片走 DEFAULT_IMAGE_URL ⇒ 渲染成破图。
|
|
8
|
+
* 而当时的四道闸**全部放行**:
|
|
9
|
+
* - validate_schema.py:只查结构,属性键不是它的管辖范围
|
|
10
|
+
* - lint_card.mjs:没有属性键规则
|
|
11
|
+
* - preflight.mjs:Check 3 / Check 5 都用 `getProp(n,'value')`,取不到就直接 `continue`
|
|
12
|
+
* —— 键名写错反而把整条检查链**短路**了
|
|
13
|
+
* - iterate 的 elements.missing:只比对文本,不比对图片
|
|
14
|
+
* 全仓 82 处 Image 里 81 处写 `value`,唯一写 `image` 的就是这一处 ⇒ 这是模型偶发编造键名,
|
|
15
|
+
* 必须有机械兜底,不能指望它每次都对。
|
|
16
|
+
*
|
|
17
|
+
* 口径同源:preflight / lint_card / readiness / gate 一律 import 本模块,
|
|
18
|
+
* **禁止各写一份白名单**(否则出现「文档教 A、门禁判 A 违规」的死结)。
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 渲染器内建、但不在 component-meta.json 里的类。
|
|
25
|
+
*
|
|
26
|
+
* `Root` = 卡片根容器。card-editor/runtime/node-model.js 明写:
|
|
27
|
+
* 「根节点 className "Root" 不在组件 meta 里,这里合成一份:无属性、无插槽、样式全开」。
|
|
28
|
+
* 所以:className 合法、样式组照常校验、**属性键不校验**(ROOT_META 里根本没有 properties 概念,
|
|
29
|
+
* 也就不存在「非法属性键」)。全仓 75 张卡的根节点实测 0 张带 properties。
|
|
30
|
+
*/
|
|
31
|
+
const BUILTIN_CLASSES = new Set(['Root'])
|
|
32
|
+
|
|
33
|
+
/** 不做属性键校验的类(见 BUILTIN_CLASSES 说明)。 */
|
|
34
|
+
const NO_PROP_CHECK = new Set(['Root'])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 复现器(repeater)判定:**由元数据推导,不写类名**。
|
|
38
|
+
*
|
|
39
|
+
* 为什么需要:`slots[].slotKey` 实测**恒为 `default`**(全仓 1010 个 slot 无一例外,
|
|
40
|
+
* Row 597 / Column 328 / ListLayout 54 / AbsoluteLayout 20 / GridLayout 11),
|
|
41
|
+
* 所以「子节点存在 slot.components 里」**不等于**「处在列表项作用域里」——
|
|
42
|
+
* 按 slotKey 或按「有没有 slot」判断会把整棵树的引用都当成列表项字段(实测 gen-1 21 条引用
|
|
43
|
+
* 被全部误判进槽位,变量绑定门禁因此整条失效)。
|
|
44
|
+
*
|
|
45
|
+
* 判据 = 同时满足两条:① 元数据声明了 `value` 属性(数组数据源);② 有 `default` 插槽。
|
|
46
|
+
* 命中者即「按数据源循环渲染子节点」的容器,其插槽内的 `$field` 才是 item 字段。
|
|
47
|
+
*/
|
|
48
|
+
function deriveRepeaterClasses(cm) {
|
|
49
|
+
const out = new Set()
|
|
50
|
+
for (const [cls, def] of Object.entries(cm?.components || {})) {
|
|
51
|
+
const hasValueProp = (def?.properties || []).some((p) => p && p.itemKey === 'value')
|
|
52
|
+
const slots = def?.slots || def?.slotList || []
|
|
53
|
+
const hasDefaultSlot = slots.some((s) => String(s?.slotKey || s?.key || s) === 'default')
|
|
54
|
+
if (hasValueProp && hasDefaultSlot) out.add(cls)
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 读取并编译契约。
|
|
61
|
+
*
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {string} opts.metaDir 元数据目录(含 component-meta.json / style-meta.json)
|
|
64
|
+
* @returns {{
|
|
65
|
+
* classes: Set<string>,
|
|
66
|
+
* propKeys: Map<string, Set<string>>,
|
|
67
|
+
* propMeta: Map<string, Map<string, object>>,
|
|
68
|
+
* styleGroups: Map<string, Set<string>>,
|
|
69
|
+
* repeaterClasses: Set<string>,
|
|
70
|
+
* loaded: { components: boolean, styles: boolean },
|
|
71
|
+
* }}
|
|
72
|
+
*/
|
|
73
|
+
export function loadContract({ metaDir }) {
|
|
74
|
+
const out = {
|
|
75
|
+
classes: new Set(BUILTIN_CLASSES),
|
|
76
|
+
propKeys: new Map(),
|
|
77
|
+
propMeta: new Map(),
|
|
78
|
+
styleGroups: new Map(),
|
|
79
|
+
repeaterClasses: new Set(),
|
|
80
|
+
loaded: { components: false, styles: false },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 组件契约 ──
|
|
84
|
+
const cmPath = path.join(metaDir, 'component-meta.json')
|
|
85
|
+
try {
|
|
86
|
+
const cm = JSON.parse(fs.readFileSync(cmPath, 'utf8'))
|
|
87
|
+
for (const [cls, def] of Object.entries(cm?.components || {})) {
|
|
88
|
+
out.classes.add(cls)
|
|
89
|
+
const keys = new Set()
|
|
90
|
+
const metaByKey = new Map()
|
|
91
|
+
for (const p of def?.properties || []) {
|
|
92
|
+
const k = p?.itemKey
|
|
93
|
+
// 关键:**跳过没有 itemKey 的属性项**。
|
|
94
|
+
// Text / Button 的元数据里有「常用样式」这类 UI 预置项,只有 itemId + itemType,没有 itemKey
|
|
95
|
+
// —— 它们不参与运行时绑定。全仓实测 443(Text) + 21(Button) 条这种键,若不跳过会造成大面积误报。
|
|
96
|
+
if (typeof k !== 'string' || !k) continue
|
|
97
|
+
keys.add(k)
|
|
98
|
+
metaByKey.set(k, p)
|
|
99
|
+
}
|
|
100
|
+
out.propKeys.set(cls, keys)
|
|
101
|
+
out.propMeta.set(cls, metaByKey)
|
|
102
|
+
}
|
|
103
|
+
out.repeaterClasses = deriveRepeaterClasses(cm)
|
|
104
|
+
out.loaded.components = out.classes.size > 0
|
|
105
|
+
} catch {
|
|
106
|
+
/* 拿不到组件元数据:契约退化为「空」,调用方据此跳过检查而不是误报 */
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 样式契约(13 组 + 每组合法子键)──
|
|
110
|
+
const smPath = path.join(metaDir, 'style-meta.json')
|
|
111
|
+
try {
|
|
112
|
+
const sm = JSON.parse(fs.readFileSync(smPath, 'utf8'))
|
|
113
|
+
for (const g of sm?.properties || []) {
|
|
114
|
+
const gk = g?.itemKey
|
|
115
|
+
if (typeof gk !== 'string' || !gk) continue
|
|
116
|
+
const childKeys = new Set()
|
|
117
|
+
for (const c of g?.itemChildren || []) {
|
|
118
|
+
const ck = c?.itemKey
|
|
119
|
+
if (typeof ck === 'string' && ck) childKeys.add(ck)
|
|
120
|
+
}
|
|
121
|
+
out.styleGroups.set(gk, childKeys)
|
|
122
|
+
}
|
|
123
|
+
out.loaded.styles = out.styleGroups.size > 0
|
|
124
|
+
} catch {
|
|
125
|
+
/* 同上 */
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 该组件的合法属性键集合(未知组件返回 null,表示「无从判断」而非「全部非法」)。 */
|
|
132
|
+
export function legalPropKeys(contract, className) {
|
|
133
|
+
if (NO_PROP_CHECK.has(className)) return null
|
|
134
|
+
return contract.propKeys.has(className) ? contract.propKeys.get(className) : null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 编辑距离(只用于给「你是不是想写 X」的建议,不是判定依据)。
|
|
139
|
+
* 上限 2:键名普遍很短,距离 >2 的建议没有参考价值。
|
|
140
|
+
*/
|
|
141
|
+
function nearestKey(target, candidates) {
|
|
142
|
+
const t = String(target || '')
|
|
143
|
+
let best = null
|
|
144
|
+
let bestD = Infinity
|
|
145
|
+
for (const c of candidates) {
|
|
146
|
+
const d = levenshtein(t, c)
|
|
147
|
+
if (d < bestD) {
|
|
148
|
+
bestD = d
|
|
149
|
+
best = c
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return bestD <= 2 ? best : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function levenshtein(a, b) {
|
|
156
|
+
if (a === b) return 0
|
|
157
|
+
const m = a.length
|
|
158
|
+
const n = b.length
|
|
159
|
+
if (!m) return n
|
|
160
|
+
if (!n) return m
|
|
161
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j)
|
|
162
|
+
for (let i = 1; i <= m; i++) {
|
|
163
|
+
const cur = [i]
|
|
164
|
+
for (let j = 1; j <= n; j++) {
|
|
165
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
166
|
+
}
|
|
167
|
+
prev = cur
|
|
168
|
+
}
|
|
169
|
+
return prev[n]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 遍历 schema 里所有节点(含 slots 子树,与渲染器一致)。
|
|
174
|
+
*
|
|
175
|
+
* @param {object} schema 卡片 schema
|
|
176
|
+
* @param {(node: object, where: string, parent: object|null) => void} cb
|
|
177
|
+
*/
|
|
178
|
+
export function walkSchemaNodes(schema, cb) {
|
|
179
|
+
const visit = (node, where, parent) => {
|
|
180
|
+
if (!node || typeof node !== 'object') return
|
|
181
|
+
cb(node, where, parent)
|
|
182
|
+
const kids = node.components || []
|
|
183
|
+
kids.forEach((c, i) => visit(c, `${where}>c${i}`, node))
|
|
184
|
+
;(node.slots || []).forEach((s, si) => {
|
|
185
|
+
;(s?.components || []).forEach((c, ci) => visit(c, `${where}>slot:${s?.slotKey || si}[${ci}]`, node))
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
if (schema?.view) visit(schema.view, 'view', null)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 采集组件契约违规。
|
|
193
|
+
*
|
|
194
|
+
* 返回项字段:
|
|
195
|
+
* code 'unknown-class' | 'unknown-prop-key' | 'unknown-style-group' | 'unknown-style-key'
|
|
196
|
+
* severity 'error' | 'warn'
|
|
197
|
+
* componentId 节点 id(缺省 '(root)')
|
|
198
|
+
* className 节点类型
|
|
199
|
+
* where 节点在树中的路径(定位用)
|
|
200
|
+
* key 违规键名
|
|
201
|
+
* legal 该处合法键数组(供调用方直接展示)
|
|
202
|
+
* suggestion 近似合法键(编辑距离 ≤2 才有;用于「你是不是想写 value」)
|
|
203
|
+
*
|
|
204
|
+
* @param {object} schema
|
|
205
|
+
* @param {ReturnType<typeof loadContract>} contract
|
|
206
|
+
* @returns {Array<object>}
|
|
207
|
+
*/
|
|
208
|
+
export function collectContractViolations(schema, contract) {
|
|
209
|
+
const out = []
|
|
210
|
+
if (!contract || !contract.loaded.components) return out
|
|
211
|
+
|
|
212
|
+
walkSchemaNodes(schema, (node, where) => {
|
|
213
|
+
const cls = node.className
|
|
214
|
+
if (!cls) return
|
|
215
|
+
const cid = node.componentId || '(root)'
|
|
216
|
+
|
|
217
|
+
// 1) className 本身不在元数据里(渲染空白)——与 preflight Check 2 同源,这里也给一份,
|
|
218
|
+
// 让 readiness 能独立跑出完整结论。
|
|
219
|
+
if (!contract.classes.has(cls)) {
|
|
220
|
+
out.push({ code: 'unknown-class', severity: 'error', componentId: cid, className: cls, where, key: cls, legal: [...contract.classes].sort() })
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2) 属性键白名单 —— 本次事故的正主
|
|
225
|
+
const legal = legalPropKeys(contract, cls)
|
|
226
|
+
if (legal) {
|
|
227
|
+
for (const p of node.properties || []) {
|
|
228
|
+
const k = p?.itemKey
|
|
229
|
+
// 无 itemKey 的属性项是「样式预置」类 UI 载体,不参与绑定,跳过(全仓实测 464 条)
|
|
230
|
+
if (typeof k !== 'string' || !k) continue
|
|
231
|
+
if (legal.has(k)) continue
|
|
232
|
+
out.push({
|
|
233
|
+
code: 'unknown-prop-key',
|
|
234
|
+
severity: 'error',
|
|
235
|
+
componentId: cid,
|
|
236
|
+
className: cls,
|
|
237
|
+
where,
|
|
238
|
+
key: k,
|
|
239
|
+
legal: [...legal].sort(),
|
|
240
|
+
suggestion: nearestKey(k, legal),
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 3) 样式组 / 组内子键白名单
|
|
246
|
+
if (contract.loaded.styles) {
|
|
247
|
+
for (const g of node.style?.properties || []) {
|
|
248
|
+
const gk = g?.itemKey
|
|
249
|
+
if (typeof gk !== 'string' || !gk) continue
|
|
250
|
+
if (!contract.styleGroups.has(gk)) {
|
|
251
|
+
// 空组(没有任何带值的子键)运行时本来就什么也不做 ⇒ 只是噪音,报 warn;
|
|
252
|
+
// 带值的未知组才是真丢样式,报 error。这条区分避免「模型随手写错一个空组」也把门禁打红。
|
|
253
|
+
const carrying = (g?.itemChildren?.properties || []).filter((c) => {
|
|
254
|
+
const v = c?.itemValue
|
|
255
|
+
if (v === undefined || v === null || v === '') return false
|
|
256
|
+
if (typeof v === 'object' && !Array.isArray(v)) return Object.keys(v).length > 0
|
|
257
|
+
return true
|
|
258
|
+
})
|
|
259
|
+
out.push({
|
|
260
|
+
code: 'unknown-style-group',
|
|
261
|
+
severity: carrying.length ? 'error' : 'warn',
|
|
262
|
+
componentId: cid,
|
|
263
|
+
className: cls,
|
|
264
|
+
where,
|
|
265
|
+
key: gk,
|
|
266
|
+
legal: [...contract.styleGroups.keys()].sort(),
|
|
267
|
+
suggestion: nearestKey(gk, [...contract.styleGroups.keys()]),
|
|
268
|
+
carrying: carrying.length,
|
|
269
|
+
})
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
const childLegal = contract.styleGroups.get(gk)
|
|
273
|
+
for (const c of g?.itemChildren?.properties || []) {
|
|
274
|
+
const ck = c?.itemKey
|
|
275
|
+
if (typeof ck !== 'string' || !ck) continue
|
|
276
|
+
if (childLegal.has(ck)) continue
|
|
277
|
+
out.push({
|
|
278
|
+
code: 'unknown-style-key',
|
|
279
|
+
severity: 'error',
|
|
280
|
+
componentId: cid,
|
|
281
|
+
className: cls,
|
|
282
|
+
where: `${where}>style.${gk}`,
|
|
283
|
+
key: ck,
|
|
284
|
+
legal: [...childLegal].sort(),
|
|
285
|
+
suggestion: nearestKey(ck, childLegal),
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
return out
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 把一条违规渲染成一行可执行的中文说明(脚本输出是程序文本,不放具体卡片/业务字段名)。 */
|
|
296
|
+
export function describeViolation(v) {
|
|
297
|
+
const at = v.where ? `${v.where} ` : ''
|
|
298
|
+
const suffix = v.suggestion ? `(是不是想写 "${v.suggestion}"?)` : ''
|
|
299
|
+
switch (v.code) {
|
|
300
|
+
case 'unknown-class':
|
|
301
|
+
return `${at}${v.className} "${v.componentId}" 不在组件元数据白名单里 → 渲染空白。合法类型:${v.legal.join(' / ')}`
|
|
302
|
+
case 'unknown-prop-key':
|
|
303
|
+
return (
|
|
304
|
+
`${at}${v.className} "${v.componentId}" 的属性键 "${v.key}" 不在该组件的合法键里 → ` +
|
|
305
|
+
`运行时按 itemKey 绑定,**不存在的键被整条静默丢弃**,组件拿到空值(如 Image 会渲染成破图)。` +
|
|
306
|
+
`该组件合法键:${v.legal.length ? v.legal.join(' / ') : '(无:该组件不接受任何属性)'}${suffix}`
|
|
307
|
+
)
|
|
308
|
+
case 'unknown-style-group':
|
|
309
|
+
return `${at}${v.className} "${v.componentId}" 的样式组 "${v.key}" 不是 13 个合法组名之一 → 整组被丢弃。合法组:${v.legal.join(' / ')}${suffix}`
|
|
310
|
+
case 'unknown-style-key':
|
|
311
|
+
return `${at}${v.className} "${v.componentId}" 在样式组 "${String(v.where).split('.').pop()}" 下写了非法子键 "${v.key}" → 该条属性被静默丢弃。该组合法子键:${v.legal.join(' / ')}${suffix}`
|
|
312
|
+
default:
|
|
313
|
+
return `${at}${v.className} "${v.componentId}" 契约违规:${v.key}`
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -54,7 +54,7 @@ export const TOKEN = {
|
|
|
54
54
|
COLOR: {
|
|
55
55
|
pageBg: '#F5F6F8', // 页面/画布背景
|
|
56
56
|
cardBg: '#FFFFFF', // 卡片背景
|
|
57
|
-
primary: '#00B578', // 品牌主色(绿,
|
|
57
|
+
primary: '#00B578', // 品牌主色(绿,v3)
|
|
58
58
|
primaryBlue: '#0079F2', // legacy 生成器历史主色(蓝)
|
|
59
59
|
fallbackBg: '#F5F5F5', // pickBg 兜底色
|
|
60
60
|
danger: '#F6434F', // 危险/未读红点
|
|
@@ -49,6 +49,29 @@ export const slicesDir =
|
|
|
49
49
|
export const baselinesDir =
|
|
50
50
|
process.env.DSL_CARD_BASELINES_DIR || path.join(projectRoot, '.card-baselines')
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 机器产物目录 —— **门禁只认这里的落盘产物,不认模型自述**。
|
|
54
|
+
*
|
|
55
|
+
* 三类文件同住于此,路径必须单源(历史事故:`check_fidelity` 自己拼了
|
|
56
|
+
* `<cardDir>/.audit.json`,而真实位置是 `.cache/ai-scratch/<id>.audit.json` ⇒
|
|
57
|
+
* 切图覆盖数恒为 null,`.audit.json` 从未存在过却没人发现):
|
|
58
|
+
* - `.cache/ai-scratch/<id>.audit.json` S2 审计全文(crop_slices --from-audit 与 readiness 都吃它)
|
|
59
|
+
* - `.cache/ai-scratch/<id>.iterate.json` 最近一轮迭代报告(--gate 的唯一依据)
|
|
60
|
+
* - `.cache/ai-scratch/<id>.ir.rNN.json` 每轮 IR 快照(回退用)
|
|
61
|
+
*/
|
|
62
|
+
export const scratchDir =
|
|
63
|
+
process.env.DSL_CARD_SCRATCH_DIR || path.join(projectRoot, '.cache', 'ai-scratch')
|
|
64
|
+
|
|
65
|
+
/** S2 审计落盘路径(spec_audit.mjs --save 的默认目标)。 */
|
|
66
|
+
export function cardAuditPath(id) {
|
|
67
|
+
return path.join(scratchDir, `${id}.audit.json`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 最近一轮迭代报告路径。 */
|
|
71
|
+
export function cardIteratePath(id) {
|
|
72
|
+
return path.join(scratchDir, `${id}.iterate.json`)
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
/** 兼容旧脚本的别名:宿主项目根目录。 */
|
|
53
76
|
export const repoRoot = projectRoot
|
|
54
77
|
|
|
@@ -15,15 +15,11 @@ paths.py — skill 内部路径与宿主项目路径单一来源(Python 侧)
|
|
|
15
15
|
DSL_CARD_PREVIEW_DIR 自包含预览目录(默认 <skill>/vendor/preview)
|
|
16
16
|
DSL_CARD_BASELINES_DIR 渲染基线目录(默认 <project>/.card-baselines)
|
|
17
17
|
"""
|
|
18
|
-
import json
|
|
19
18
|
import os
|
|
20
19
|
from pathlib import Path
|
|
21
20
|
|
|
22
21
|
_HERE = Path(__file__).resolve().parent
|
|
23
22
|
|
|
24
|
-
# 占位 sentinel:与任何用户传入的 fallback 都不会相同的内部对象。
|
|
25
|
-
_MISSING = object()
|
|
26
|
-
|
|
27
23
|
# 当前 skill 根目录:scripts/lib -> scripts -> dsl-card-generator
|
|
28
24
|
skill_root = _HERE.parent.parent
|
|
29
25
|
|
|
@@ -93,36 +89,3 @@ host_skills_dir = _host_skills_dir()
|
|
|
93
89
|
def card_dir(card_id: str) -> Path:
|
|
94
90
|
"""解析卡片目录的绝对路径:`<cards_dir>/<id>`。"""
|
|
95
91
|
return cards_dir / card_id
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def card_schema_path(card_id: str) -> Path:
|
|
99
|
-
"""解析卡片 schema.json 的绝对路径。"""
|
|
100
|
-
return cards_dir / card_id / "schema.json"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def card_data_path(card_id: str) -> Path:
|
|
104
|
-
"""解析卡片 data.json 的绝对路径。"""
|
|
105
|
-
return cards_dir / card_id / "data.json"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def read_json(p: Path, fallback=_MISSING):
|
|
109
|
-
"""
|
|
110
|
-
安全读取 JSON(不存在或解析失败时返回 fallback)。
|
|
111
|
-
|
|
112
|
-
Args:
|
|
113
|
-
p: 路径
|
|
114
|
-
fallback: 不存在/解析失败时的返回值。默认抛错。
|
|
115
|
-
Returns:
|
|
116
|
-
解析后的 Python 值
|
|
117
|
-
"""
|
|
118
|
-
p = Path(p)
|
|
119
|
-
if not p.is_file():
|
|
120
|
-
if fallback is _MISSING:
|
|
121
|
-
raise FileNotFoundError(f"File not found: {p}")
|
|
122
|
-
return fallback
|
|
123
|
-
try:
|
|
124
|
-
return json.loads(p.read_text(encoding="utf-8"))
|
|
125
|
-
except Exception as e:
|
|
126
|
-
if fallback is _MISSING:
|
|
127
|
-
raise RuntimeError(f"Cannot parse JSON {p}: {e}") from e
|
|
128
|
-
return fallback
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* python.mjs — 跨平台 Python 解释器解析的**单一来源**(Node 侧)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须真跑一次 `--version`(而不是只看「能不能 spawn」):Windows 上 `python3` 常是
|
|
5
|
+
* Microsoft Store 的**应用执行别名** —— 进程能启动,却立刻以 9009 退出,这不是 ENOENT。
|
|
6
|
+
* 只按 spawn 异常判断就会钉死这个假解释器,autofix / validate 全部静默不执行,调用方
|
|
7
|
+
* 收到的是一个**永远修不好**的 validate error(实测模型为它空转 20 轮,每轮重跑 iterate)。
|
|
8
|
+
*
|
|
9
|
+
* 为什么集中到本文件:同一件事此前在 5 个脚本里各写一遍,能力互不相同 ——
|
|
10
|
+
* · `expand_ir.mjs` 真探测 + `py`(原本最完整,本文件的基准)
|
|
11
|
+
* · `route_input.mjs` 真探测 + `py`,但不认 `WORKBUDDY_PYTHON`
|
|
12
|
+
* · `add_component.mjs` 不真探测、不认 `py`
|
|
13
|
+
* · `gate.mjs` / `v3_regression.mjs` 只读 `process.env.PYTHON || 'python3'`(**完全不探测**)
|
|
14
|
+
* · `register_card_template.mjs` 只读 `DSL_PYTHON`、不认 `py`
|
|
15
|
+
* 后果:同一台机器上 `gate.mjs` 判定「没有 Python」而 `expand_ir.mjs` 跑得通。
|
|
16
|
+
*
|
|
17
|
+
* 环境变量链(前者优先,命中即停止回退到自动探测):
|
|
18
|
+
* `DSL_CARD_PYTHON`(权威)> `WORKBUDDY_PYTHON` > `DSL_PYTHON` > `PYTHON`
|
|
19
|
+
* 后两项为历史兼容(分别在 register_card_template / gate / v3_regression 里出现过),
|
|
20
|
+
* **新代码一律用 `DSL_CARD_PYTHON`**。
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
|
|
24
|
+
/** 显式指定的解释器,按优先级排列;命中即不再做自动探测。 */
|
|
25
|
+
export const PYTHON_ENV_KEYS = ['DSL_CARD_PYTHON', 'WORKBUDDY_PYTHON', 'DSL_PYTHON', 'PYTHON']
|
|
26
|
+
|
|
27
|
+
/** 权威环境变量名(文档 / 报错提示里要用户设置的那个)。 */
|
|
28
|
+
export const PYTHON_ENV_PRIMARY = 'DSL_CARD_PYTHON'
|
|
29
|
+
|
|
30
|
+
/** 自动探测顺序:`py` 是 Windows 官方启动器,`python3` 可能是 Store 假别名故不设优先级。 */
|
|
31
|
+
export const PYTHON_AUTO_BINS = ['python3', 'python', 'py']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 真跑一次 `--version` 判断解释器可用(识别 Windows Store 假别名)。
|
|
35
|
+
* @param {string} bin
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function probePython(bin) {
|
|
39
|
+
const b = String(bin || '').trim()
|
|
40
|
+
if (!b) return false
|
|
41
|
+
try {
|
|
42
|
+
const r = spawnSync(b, ['--version'], {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
45
|
+
timeout: 5000,
|
|
46
|
+
})
|
|
47
|
+
if (r.status !== 0) return false
|
|
48
|
+
return /Python \d/i.test(`${r.stdout || ''}${r.stderr || ''}`)
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 候选解释器列表(去重、保持优先级)。
|
|
56
|
+
*
|
|
57
|
+
* 语义与 `expand_ir.mjs` 原实现一致:显式指定且探测通过 → 只返回它;显式指定但探测不过
|
|
58
|
+
* → 仍保留一次机会(可能是带参数的包装器);全探测失败 → 回落到常见名字,让报错里能带出
|
|
59
|
+
* 真实 stderr,而不是一句笼统的「python 不可用」。
|
|
60
|
+
*
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function pythonCandidates() {
|
|
64
|
+
const out = []
|
|
65
|
+
const seen = new Set()
|
|
66
|
+
const push = (bin) => {
|
|
67
|
+
const b = String(bin || '').trim()
|
|
68
|
+
if (!b || seen.has(b)) return
|
|
69
|
+
seen.add(b)
|
|
70
|
+
out.push(b)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const key of PYTHON_ENV_KEYS) {
|
|
74
|
+
const val = process.env[key]
|
|
75
|
+
if (!val) continue
|
|
76
|
+
if (probePython(val)) {
|
|
77
|
+
push(val)
|
|
78
|
+
return out // 显式指定且可用:不再回退到其它解释器
|
|
79
|
+
}
|
|
80
|
+
push(val) // 探测不过也留一次机会(包装器 / 路径含参数)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const bin of PYTHON_AUTO_BINS) if (probePython(bin)) push(bin)
|
|
84
|
+
for (const bin of ['python3', 'python']) push(bin)
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 最优先的可用解释器(总是返回可用的命令名,探测全失败时返回 `python3` 以便报错原样带出)。
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function resolvePython() {
|
|
93
|
+
return pythonCandidates()[0] || 'python3'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 当前环境是否**有**可用 Python(供「没装就别跑」的早退分支用)。 */
|
|
97
|
+
export function hasPython() {
|
|
98
|
+
return probePython(resolvePython())
|
|
99
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules/adaptive-width.mjs —— 宽度自适应契约的机械判据(ir-format.md §2f)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-09-25 汇率卡事故):容器类节点把「设计稿测量宽」写死成 px
|
|
5
|
+
* (如第一层主容器 rect.width="343"),卡片在非设计宽度的宿主容器里
|
|
6
|
+
* 无法自适应——Root 撑满了,内容层却锁死在 343px。
|
|
7
|
+
*
|
|
8
|
+
* 渲染契约(component-meta.json / components.css 实证):
|
|
9
|
+
* - 全部容器组件的 meta 默认 rect.width 就是 "100%"(覆盖默认 = 显式声明固定);
|
|
10
|
+
* - adapter.doSize 对 '%' 值原样透传(不做 px 换算);
|
|
11
|
+
* - 纵向流(Root/Column/AbsoluteLayout)里子项交叉轴默认 stretch。
|
|
12
|
+
* 因此「占满语义」永远不该写成 px:写 100%(显式)或不写(默认)都自适应,
|
|
13
|
+
* 且在以卡面宽为基准的渲染/截图环境下与原 px 值逐像素等价(父内容盒宽 == 原 px)。
|
|
14
|
+
*
|
|
15
|
+
* 判定口径(全部是格式/几何/结构特征,与任何业务无关):
|
|
16
|
+
* 1. 节点类型 ∈ 容器白名单(meta 默认 100% 的那批);
|
|
17
|
+
* 2. rect.width 是纯数字(px 字面量;'100%'/'auto'/表达式不在此列);
|
|
18
|
+
* 3. 父内容盒宽可计算(Root 直系 = view.width;父定宽 = 父宽 − 父水平 padding;
|
|
19
|
+
* 父 100%/无宽则透传,透传链不可达时跳过);
|
|
20
|
+
* 4. 宽度占满父内容盒(98% ~ 103%,容差 2px 量级的标注/取整误差);
|
|
21
|
+
* **超出型不报**(ratio > 1.03:绝对层故意越出卡面的形态,属另一话题);
|
|
22
|
+
* 5. 父为纵向流(Root/Column/AbsoluteLayout 或显式 flexDirection=column)——
|
|
23
|
+
* Row/MultiColumn 主轴语境里并列子项定宽是 space-between 等范式的合法组成
|
|
24
|
+
* (ir-format.md R-FIXEDW / R-COMPARE),不在此规则射程。
|
|
25
|
+
*/
|
|
26
|
+
import { hit } from './walk.mjs'
|
|
27
|
+
|
|
28
|
+
const FULLWIDTH_CONTAINERS = new Set([
|
|
29
|
+
'Row', 'Column', 'MultiColumn', 'ListLayout', 'GridLayout', 'ViewContainer',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/** 纵向流父级:显式 flexDirection=column 优先,否则按渲染器默认方向推断。 */
|
|
33
|
+
function isColumnAxis(node) {
|
|
34
|
+
const fl = (node?.style?.properties || []).find((g) => g.itemKey === 'flex')
|
|
35
|
+
const dir = (fl?.itemChildren?.properties || []).find((c) => c.itemKey === 'flexDirection')?.itemValue
|
|
36
|
+
if (dir === 'column') return true
|
|
37
|
+
if (dir === 'row') return false
|
|
38
|
+
const cls = node?.className
|
|
39
|
+
return cls === 'Root' || cls === 'Column' || cls === 'AbsoluteLayout'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** rect.width 纯数字(px 字面量)→ 数值;无/百分比/auto/表达式 → null。 */
|
|
43
|
+
function fixedPxWidth(node) {
|
|
44
|
+
const rect = (node?.style?.properties || []).find((g) => g.itemKey === 'rect')
|
|
45
|
+
const child = (rect?.itemChildren?.properties || []).find((c) => c.itemKey === 'width')
|
|
46
|
+
if (!child) return null
|
|
47
|
+
const raw = String(child.itemValue ?? '').trim()
|
|
48
|
+
if (!/^-?\d+(\.\d+)?$/.test(raw)) return null
|
|
49
|
+
const n = Number(raw)
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 水平 padding 合计(单值 padding 或 paddingLeft+paddingRight)。 */
|
|
54
|
+
function horizontalPadding(node) {
|
|
55
|
+
const g = (node?.style?.properties || []).find((x) => x.itemKey === 'padding')
|
|
56
|
+
let all = 0
|
|
57
|
+
let left = 0
|
|
58
|
+
let right = 0
|
|
59
|
+
for (const c of g?.itemChildren?.properties || []) {
|
|
60
|
+
if (c.itemKey === 'padding') all = Number(c.itemValue) || 0
|
|
61
|
+
if (c.itemKey === 'paddingLeft') left = Number(c.itemValue) || 0
|
|
62
|
+
if (c.itemKey === 'paddingRight') right = Number(c.itemValue) || 0
|
|
63
|
+
}
|
|
64
|
+
return (left || all) + (right || all)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function collectFullwidthFixedPx(view) {
|
|
68
|
+
const out = []
|
|
69
|
+
if (!view || typeof view !== 'object') return out
|
|
70
|
+
const viewW = Number(String(view.width ?? '').replace(/[^0-9.\-]/g, '')) || 0
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param node 当前节点
|
|
74
|
+
* @param parentContent 父节点的内容盒宽(父解析宽 − 父水平 padding;Root = view.width − Root padding)
|
|
75
|
+
* —— 子节点 rect.width:"100%" 的解析基准就是这个值(doSize 对 % 原样透传)。
|
|
76
|
+
* 父为 '100%'/无宽时解析宽 = 透传 parentContent;父定宽 px 时 = 定宽值。
|
|
77
|
+
*/
|
|
78
|
+
const walk = (node, parentContent, isRoot) => {
|
|
79
|
+
if (!node || typeof node !== 'object') return
|
|
80
|
+
const selfResolved = isRoot ? viewW : (() => {
|
|
81
|
+
const w = fixedPxWidth(node)
|
|
82
|
+
return w !== null ? w : parentContent
|
|
83
|
+
})()
|
|
84
|
+
const selfContent = Math.max(0, selfResolved - horizontalPadding(node))
|
|
85
|
+
const kids = [
|
|
86
|
+
...(node.components || []),
|
|
87
|
+
...((node.slots || []).flatMap((s) => (s && s.components) || [])),
|
|
88
|
+
]
|
|
89
|
+
for (const child of kids) {
|
|
90
|
+
if (!child || typeof child !== 'object') continue
|
|
91
|
+
const w = fixedPxWidth(child)
|
|
92
|
+
if (selfContent > 0 && w !== null && FULLWIDTH_CONTAINERS.has(child.className)
|
|
93
|
+
&& isColumnAxis(node)) {
|
|
94
|
+
const ratio = w / selfContent
|
|
95
|
+
if (ratio >= 0.98 && ratio <= 1.03) {
|
|
96
|
+
out.push(hit('r66-fullwidth-px', 'error', child.componentId || '',
|
|
97
|
+
`${child.className} "${child.componentId || child.name || ''}" 在纵向流父级里把占满语义写死成 rect.width="${w}"(父内容盒宽 ≈ ${Math.round(selfContent)})——宿主容器宽度一旦偏离设计基准,内容层锁死不自适应(ir-format.md §2f)。修法:改 width:"100%"(或不写 width,meta 默认即 100%),与设计宽逐像素等价且随容器自适应;真固定宽元素(图标/头像/徽章/图表画布)保留 px。注意 100% 节点禁叠水平 margin(rules.md §十六 R63),水平内缩写父容器 padding。`))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
walk(child, selfContent, false)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
walk(view, 0, true)
|
|
104
|
+
return out
|
|
105
|
+
}
|