cardbuddy 0.14.3 → 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/cli.js +0 -0
- package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
- package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/card-editor/runtime/adapter.js +2 -2
- package/dist/vendored/mcp/client.mjs +9 -2
- package/dist/vendored/server/core/ai-chat.js +1702 -134
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/cardValidate.js +16 -5
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
- 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-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
- 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 +233 -61
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- 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/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
- 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/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
- 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 +43 -20
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
- 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 +546 -91
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
- package/dist/preview-editor/assets/index-cbTCHp8J.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
|
@@ -21,17 +21,31 @@
|
|
|
21
21
|
* reused[] 给 existing 名,调用方把引用改绑既有切图(共用切图已语义重命名也认得出)。
|
|
22
22
|
* 判重目录默认 = --reuse-dir... + 工程 src/slices(按脚本位置推算)+ --out 自身。
|
|
23
23
|
* 输出 JSON:{ scale, written[], reused[{name, existing, dir, reason}] }。
|
|
24
|
+
*
|
|
25
|
+
* **落盘前文件名消毒(URL 保留字符,2026-09-21 事故)**:设计稿图层名常自带 `#`(如 `警示-#999`),
|
|
26
|
+
* 直接落盘会让浏览器把 `#` 之后当 fragment 截掉 → 请求变成 `/slices/警示-` → 图片空白但**零报错**。
|
|
27
|
+
* 故落盘名一律过 `sanitizeSliceFileName()`;被改名的条目在 `written[]/reused[]` 里同时给出
|
|
28
|
+
* `originalName` 并在 stderr 响亮提示 —— **调用方必须用返回的 `name` 写 schema 引用**。
|
|
29
|
+
*
|
|
30
|
+
* **`--from-audit` 还会「收养」审计判定存在的资产(2026-09-21 事故)**:spec_audit 按完整 ref
|
|
31
|
+
* + `@Nx` 后缀在 bundle 的 `slices/` 里解析到真实文件(`slices/8.icon/通用/警示-#999@3x.png`),
|
|
32
|
+
* 但那份文件**不在工程的 src/slices/ 里**。只补 missing 的话,模型会拿到一个正确的名字
|
|
33
|
+
* (`警示-#999.png` 消毒成 `警示-999.png`)却在渲染时 404 —— 图片依旧空白。
|
|
34
|
+
* 所以这里把 `audit.slices[].exists === true` 且不在 `--out` 里的资产**原字节复制**进 `--out`,
|
|
35
|
+
* 落盘名同样过消毒 + 同样做像素判重。`--no-adopt` 可关闭。
|
|
36
|
+
* 输出 JSON:{ scale, written[], reused[{name, existing, dir, reason}] }。
|
|
24
37
|
*/
|
|
25
38
|
import fs from 'node:fs'
|
|
26
39
|
import path from 'node:path'
|
|
27
40
|
import { fileURLToPath } from 'node:url'
|
|
28
41
|
|
|
29
|
-
import { cropPng, findPixelTwin, pngPixelSha } from './lib/slice_pixels.mjs'
|
|
42
|
+
import { cropPng, findPixelTwin, pngPixelSha, readPng } from './lib/slice_pixels.mjs'
|
|
43
|
+
import { sanitizeSliceFileName } from './lib/slice_dedupe.mjs'
|
|
30
44
|
|
|
31
45
|
const { PNG } = await import('pngjs')
|
|
32
46
|
|
|
33
47
|
function parseArgs(argv) {
|
|
34
|
-
const out = { design: '', cardWidth: 0, out: '', crops: [], fromAudit: '', reuseDirs: [] }
|
|
48
|
+
const out = { design: '', cardWidth: 0, out: '', crops: [], fromAudit: '', reuseDirs: [], adopt: true }
|
|
35
49
|
for (let i = 0; i < argv.length; i++) {
|
|
36
50
|
const a = argv[i]
|
|
37
51
|
if (a === '--design') out.design = argv[++i] || ''
|
|
@@ -40,8 +54,10 @@ function parseArgs(argv) {
|
|
|
40
54
|
else if (a === '--crop') out.crops.push(argv[++i] || '')
|
|
41
55
|
else if (a === '--from-audit') out.fromAudit = argv[++i] || ''
|
|
42
56
|
else if (a === '--reuse-dir') out.reuseDirs.push(argv[++i] || '')
|
|
57
|
+
else if (a === '--no-adopt') out.adopt = false
|
|
43
58
|
else if (a === '-h' || a === '--help') {
|
|
44
59
|
console.log('Usage: node crop_slices.mjs --design <png> --card-width <n> --out <dir> --crop name@x,y,w,h [...] | --from-audit <audit.json>')
|
|
60
|
+
console.log(' --from-audit 除了补 missing[] 的裁剪,还会把 audit.slices[] 里 exists=true 的资产按消毒名复制进 --out(--no-adopt 关闭)')
|
|
45
61
|
process.exit(0)
|
|
46
62
|
}
|
|
47
63
|
}
|
|
@@ -69,6 +85,8 @@ function main() {
|
|
|
69
85
|
if (![x, y, w, h].every((v) => Number.isFinite(v))) throw new Error(`--crop 坐标非法: "${c}"`)
|
|
70
86
|
jobs.push({ name: c.slice(0, at), x, y, w, h })
|
|
71
87
|
}
|
|
88
|
+
// 审计判定「存在」的资产:文件在 bundle 里而不在工程 src/slices/ —— 收养进来(原字节复制)。
|
|
89
|
+
const adoptions = []
|
|
72
90
|
if (args.fromAudit) {
|
|
73
91
|
const audit = JSON.parse(fs.readFileSync(args.fromAudit, 'utf8'))
|
|
74
92
|
for (const m of audit.missing || []) {
|
|
@@ -76,8 +94,18 @@ function main() {
|
|
|
76
94
|
if (c) jobs.push({ name: c.name || m.name, x: c.x, y: c.y, w: c.w, h: c.h })
|
|
77
95
|
}
|
|
78
96
|
}
|
|
97
|
+
if (args.adopt) {
|
|
98
|
+
const outAbsEarly = path.resolve(args.out)
|
|
99
|
+
for (const s of audit.slices || []) {
|
|
100
|
+
if (!s || !s.exists || !s.file || !s.name) continue
|
|
101
|
+
const srcAbs = path.resolve(s.file)
|
|
102
|
+
if (!fs.existsSync(srcAbs)) continue
|
|
103
|
+
if (path.dirname(srcAbs) === outAbsEarly) continue // 已在工程目录,无需收养
|
|
104
|
+
adoptions.push({ name: sanitizeSliceFileName(s.name), src: srcAbs, originalName: s.originalName || path.basename(srcAbs) })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
79
107
|
}
|
|
80
|
-
if (!jobs.length) {
|
|
108
|
+
if (!jobs.length && !adoptions.length) {
|
|
81
109
|
console.log(JSON.stringify({ written: [] }))
|
|
82
110
|
return
|
|
83
111
|
}
|
|
@@ -100,12 +128,17 @@ function main() {
|
|
|
100
128
|
throw new Error(`裁剪矩形越界: ${j.name} @${j.x},${j.y} ${j.w}x${j.h}(design ${src.width}x${src.height}, scale ${scale})`)
|
|
101
129
|
}
|
|
102
130
|
const o = cropPng(src, cx, cy, cw, ch)
|
|
103
|
-
const fileName =
|
|
131
|
+
const fileName = sanitizeSliceFileName(j.name)
|
|
132
|
+
const originalName = path.basename(String(j.name || ''))
|
|
133
|
+
const renamed = fileName !== originalName
|
|
134
|
+
if (renamed) {
|
|
135
|
+
console.error(`[slices] 文件名含 URL 保留字符/空白,已消毒:${originalName} → ${fileName}(**schema 引用必须用消毒后的名字**,否则浏览器会截断 URL、图片静默不显示)`)
|
|
136
|
+
}
|
|
104
137
|
// ① 批内判重:同批已裁过同内容 → 复用本批文件名,不重复写
|
|
105
138
|
const key = pngPixelSha(o)
|
|
106
139
|
const batchTwin = batch.get(key)
|
|
107
140
|
if (batchTwin) {
|
|
108
|
-
reused.push({ name: fileName, existing: batchTwin, reason: 'batch-identical' })
|
|
141
|
+
reused.push({ name: fileName, ...(renamed ? { originalName } : {}), existing: batchTwin, reason: 'batch-identical' })
|
|
109
142
|
continue
|
|
110
143
|
}
|
|
111
144
|
// ② 既有切图判重:像素同内容(含语义重命名后的共用切图)→ 不写盘,引用改绑既有名
|
|
@@ -117,6 +150,7 @@ function main() {
|
|
|
117
150
|
if (hit) {
|
|
118
151
|
reused.push({
|
|
119
152
|
name: fileName,
|
|
153
|
+
...(renamed ? { originalName } : {}),
|
|
120
154
|
existing: hit.twin,
|
|
121
155
|
dir: path.basename(hit.dir),
|
|
122
156
|
reason: path.resolve(hit.dir) === path.resolve(args.out) ? 'already-derived' : 'content-twin',
|
|
@@ -126,10 +160,62 @@ function main() {
|
|
|
126
160
|
const file = path.join(args.out, fileName)
|
|
127
161
|
fs.writeFileSync(file, PNG.sync.write(o))
|
|
128
162
|
batch.set(key, fileName)
|
|
129
|
-
written.push({ name: path.basename(file), px: [cw, ch], logical: [j.w, j.h] })
|
|
163
|
+
written.push({ name: path.basename(file), ...(renamed ? { originalName } : {}), px: [cw, ch], logical: [j.w, j.h] })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 收养(--from-audit 且未关):把 bundle 里已存在、但不在工程 src/slices/ 的资产按消毒名搬进来。
|
|
167
|
+
// 为什么必须做这一步:审计给出的是**正确的名字**,但文件还在 bundle 里;
|
|
168
|
+
// 模型照着写 `/slices/警示-999.png` 只会 404(图片空白、控制台零报错)。
|
|
169
|
+
for (const a of adoptions) {
|
|
170
|
+
const target = path.join(args.out, a.name)
|
|
171
|
+
if (fs.existsSync(target)) continue // 已在工程目录(同名幂等)
|
|
172
|
+
const buf = fs.readFileSync(a.src)
|
|
173
|
+
const rel = relPathOf(a.src)
|
|
174
|
+
const renamed = a.name !== a.originalName
|
|
175
|
+
if (renamed) {
|
|
176
|
+
console.error(`[slices] 收养时消毒文件名:${a.originalName} → ${a.name}(**schema 引用必须用消毒后的名字**)`)
|
|
177
|
+
}
|
|
178
|
+
const png = readPng(a.src)
|
|
179
|
+
if (png) {
|
|
180
|
+
const key = pngPixelSha(png)
|
|
181
|
+
const batchTwin = batch.get(key)
|
|
182
|
+
if (batchTwin) {
|
|
183
|
+
reused.push({ name: a.name, originalName: a.originalName, existing: batchTwin, reason: 'batch-identical' })
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
let hit = null
|
|
187
|
+
for (const dir of reuseDirs) {
|
|
188
|
+
const twin = findPixelTwin(dir, png, { skip: [a.name] })
|
|
189
|
+
if (twin) { hit = { twin, dir }; break }
|
|
190
|
+
}
|
|
191
|
+
if (hit) {
|
|
192
|
+
reused.push({
|
|
193
|
+
name: a.name,
|
|
194
|
+
originalName: a.originalName,
|
|
195
|
+
existing: hit.twin,
|
|
196
|
+
dir: path.basename(hit.dir),
|
|
197
|
+
reason: 'content-twin',
|
|
198
|
+
})
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
batch.set(key, a.name)
|
|
202
|
+
fs.writeFileSync(target, buf)
|
|
203
|
+
written.push({ name: a.name, originalName: a.originalName, adopted: true, from: rel, px: [png.width, png.height] })
|
|
204
|
+
} else {
|
|
205
|
+
// 非 PNG(svg/jpg/webp):没有像素判据,按名字幂等收养,不去重
|
|
206
|
+
fs.writeFileSync(target, buf)
|
|
207
|
+
written.push({ name: a.name, originalName: a.originalName, adopted: true, from: rel, px: null })
|
|
208
|
+
}
|
|
130
209
|
}
|
|
210
|
+
|
|
131
211
|
for (const r of reused) console.error(`[slices] 裁切判重:${r.name} ≡ ${r.dir}/${r.existing}(${r.reason})→ 不写盘,引用改绑 /slices/${r.existing}`)
|
|
132
212
|
console.log(JSON.stringify({ scale, written, reused }, null, 2))
|
|
133
213
|
}
|
|
134
214
|
|
|
215
|
+
/** 路径输出:在 cwd 内给相对路径,在 cwd 外给绝对路径。 */
|
|
216
|
+
function relPathOf(file) {
|
|
217
|
+
const rel = path.relative(process.cwd(), file)
|
|
218
|
+
return rel && !rel.startsWith('..') ? rel.split(path.sep).join('/') : file
|
|
219
|
+
}
|
|
220
|
+
|
|
135
221
|
main()
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
return "python3";
|
|
7
|
-
}
|
|
2
|
+
// Python 解释器解析已集中到 lib/python.mjs(单一来源:四段环境变量链 + Windows Store
|
|
3
|
+
// 假别名识别)。本文件要的是**候选列表**(逐个试跑,好把真实 stderr 带进报错),
|
|
4
|
+
// 不是「最优那一个」。
|
|
5
|
+
import { probePython, pythonCandidates, resolvePython } from './lib/python.mjs'
|
|
8
6
|
/**
|
|
9
7
|
* expand_ir.mjs — 紧凑 IR(Intermediate Representation)⇄ 完整 schema 双向转换。
|
|
10
8
|
*
|
|
@@ -17,7 +15,8 @@ function findPython() {
|
|
|
17
15
|
*
|
|
18
16
|
* 用法:
|
|
19
17
|
* node expand_ir.mjs --ir <IR.json> --out <dir>/ # IR → schema.json + data.json(写盘并 autofix+validate)
|
|
20
|
-
* [--slices-dir <dir>]
|
|
18
|
+
* [--slices-dir <dir>] [--slices-from <dir,dir>] # 引用切图的来源目录(可多个;
|
|
19
|
+
* # 默认还会查工程 src/slices 与 IR 同目录 slices/)
|
|
21
20
|
* node expand_ir.mjs --to-ir <schema.json> # 完整 schema → 紧凑 IR(stdout,用于验证/二次编辑)
|
|
22
21
|
* node expand_ir.mjs --self-test [--card DepositHolding] # round-trip 无损 + validate + lint 自检
|
|
23
22
|
*
|
|
@@ -34,12 +33,13 @@ function findPython() {
|
|
|
34
33
|
*/
|
|
35
34
|
import fs from 'node:fs'
|
|
36
35
|
import path from 'node:path'
|
|
37
|
-
import { execFileSync } from 'node:child_process'
|
|
36
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
38
37
|
import { fileURLToPath } from 'node:url'
|
|
39
38
|
import { copyCardDesignImage } from './lib/design_image.mjs'
|
|
40
|
-
import { metaDir, skillRoot } from './lib/paths.mjs'
|
|
41
|
-
import { buildContentIndex, rewriteSliceRefs, sha256File } from './lib/slice_dedupe.mjs'
|
|
39
|
+
import { metaDir, projectRoot, skillRoot } from './lib/paths.mjs'
|
|
40
|
+
import { buildContentIndex, rewriteSliceRefs, sha256File, sliceNameMatches } from './lib/slice_dedupe.mjs'
|
|
42
41
|
import { findPixelTwin, readPng } from './lib/slice_pixels.mjs'
|
|
42
|
+
import { normalizeItemAliases } from './lib/scope_alias.mjs'
|
|
43
43
|
|
|
44
44
|
/* ------------------------------------------------------------------ */
|
|
45
45
|
/* 加载 meta → itemId 映射(与 validate_schema.py 同源,零偏离) */
|
|
@@ -96,7 +96,123 @@ function resolveStyleKey(groupKey, childKey) {
|
|
|
96
96
|
if (styleChildId[groupKey]?.[childKey] !== undefined) return childKey
|
|
97
97
|
const resolved = STYLE_ALIAS[childKey]
|
|
98
98
|
if (resolved && styleChildId[groupKey]?.[resolved] !== undefined) return resolved
|
|
99
|
-
return childKey // no match —
|
|
99
|
+
return childKey // no match — 由 collectUnknownStyleKey 记账,写盘前统一响亮失败
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 合法样式键全集(唯一来源 style-meta.json)的紧凑一页表,供报错文本直接回给模型。
|
|
104
|
+
* 实测(2026-09-21 建卡回合):模型不知道 flex 组没有 flexGrow,写了 `flex:{flexGrow:1}` →
|
|
105
|
+
* itemId 空串 → 渲染器静默丢弃 → validate 报「整卡 FAIL」,模型看不出是键名错,
|
|
106
|
+
* 于是 8 次 shell_exec 翻 meta 文件考古 + 6 次把组名写错(size.width 而非 rect.width)。
|
|
107
|
+
*/
|
|
108
|
+
export function styleKeyTableText() {
|
|
109
|
+
return Object.entries(styleChildId)
|
|
110
|
+
.map(([g, kids]) => `${g}: ${Object.keys(kids).join(' ')}`)
|
|
111
|
+
.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 归位之后仍然不存在的样式键 ⇒ 运行时静默丢弃。收集起来,**写盘前**中止(别让它进 schema)。
|
|
116
|
+
* 静默放行是本轮 700 万 token 的主要起点之一:问题的报错信息从「flexGrow 不合法」
|
|
117
|
+
* 退化成下游 validate 的「整卡 FAIL」,模型只能推倒重写整份 IR。
|
|
118
|
+
*/
|
|
119
|
+
/**
|
|
120
|
+
* 作用域别名归一化(**不是报错禁止**)——实现见 `lib/scope_alias.mjs`。
|
|
121
|
+
*
|
|
122
|
+
* 2026-09-24 复盘定论:`$item.xxx` 这类写法**不是模型的问题,是我们自己的问题**。
|
|
123
|
+
* 系统提示词与 ir-format.md 里手写了一条不存在的契约(提交 56e9894「子树内 $item.xxx /
|
|
124
|
+
* $_index」),模型只是老实照做。权威契约来自运行时 `card-editor/runtime/evaluate.js`
|
|
125
|
+
* 的 `makeSlotScope`:`slotScope = 外层 ∪ item 字段逐个展开 ∪ {$$: item, _index: 索引}`
|
|
126
|
+
* —— **没有 `item` 这个键**;全仓 50 张真卡的列表插槽绑定也 100% 用扁平字段名(`$name` /
|
|
127
|
+
* `$label` / `$accountNum`),`$item.` 出现 0 次。
|
|
128
|
+
*
|
|
129
|
+
* 上一版的处理是「写盘前 exit=3 中止 + lint 报 ERROR + 三份文档写满 ⛔ 没有 $item」——
|
|
130
|
+
* 那等于教错了再罚学生:模型必须先背对答案才不被退回,而答案本来就不该由它背。
|
|
131
|
+
*
|
|
132
|
+
* 正确做法是把 IR 当成**编译器输入**:编译器对公认别名做语义保持的归一化即可,
|
|
133
|
+
* schema 里永远只出现规范形(`$字段` / `$$` / `$_index`),模型写哪种都能过。
|
|
134
|
+
*
|
|
135
|
+
* 唯一的例外闸:卡片若真的声明了名为 `item` 的根变量(dictionary.inputParams[].key),
|
|
136
|
+
* 则 `$item.x` 是合法取值,整体不归一化(见 lib 头注释)。
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
const unknownStyleKeys = []
|
|
141
|
+
function resetUnknownStyleKeys() {
|
|
142
|
+
unknownStyleKeys.length = 0
|
|
143
|
+
}
|
|
144
|
+
function collectUnknownStyleKey(nodeLabel, groupKey, childKey) {
|
|
145
|
+
const hit = unknownStyleKeys.find((u) => u.node === nodeLabel && u.group === groupKey && u.key === childKey)
|
|
146
|
+
if (!hit) unknownStyleKeys.push({ node: nodeLabel || '?', group: groupKey, key: childKey })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 非法键的响亮报错文本:逐条点名 + 该组合法子键 + 全局一页表(约 1KB,够模型一次改对) */
|
|
150
|
+
function unknownStyleKeysError() {
|
|
151
|
+
const lines = ['[ir] 非法样式键(运行时会被静默丢弃,已中止写盘,schema 未改动):']
|
|
152
|
+
for (const u of unknownStyleKeys.slice(0, 20)) {
|
|
153
|
+
const kids = Object.keys(styleChildId[u.group] || {})
|
|
154
|
+
const groupNote = kids.length ? ` → ${u.group} 组合法子键只有 ${kids.join('/')}` : `(style-meta 里没有 "${u.group}" 这个组)`
|
|
155
|
+
lines.push(` ${u.node}.s.${u.group}.${u.key}${groupNote}`)
|
|
156
|
+
const owners = styleGroupsOfChild[u.key] || []
|
|
157
|
+
if (owners.length === 1) lines.push(` 纠正:${u.key} 属于 ${owners[0]} 组,写成 ${owners[0]}.${u.key}`)
|
|
158
|
+
else if (owners.length > 1) lines.push(` 纠正:${u.key} 属于 ${owners.join(' / ')} 组`)
|
|
159
|
+
}
|
|
160
|
+
if (unknownStyleKeys.length > 20) lines.push(` …还有 ${unknownStyleKeys.length - 20} 处`)
|
|
161
|
+
lines.push('', '合法样式键全集(style-meta.json):', styleKeyTableText())
|
|
162
|
+
return lines.join('\n')
|
|
163
|
+
}
|
|
164
|
+
// 子键 → 它合法归属的组(style-meta 全局唯一来源)。用来把「键对、组写错」的样式自动归位。
|
|
165
|
+
const styleGroupsOfChild = {}
|
|
166
|
+
for (const g of SM.properties || []) {
|
|
167
|
+
for (const c of g.itemChildren || []) {
|
|
168
|
+
;(styleGroupsOfChild[c.itemKey] ||= []).push(g.itemKey)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 把写错组的样式搬回它该在的组,并丢掉 Root 上非法的 rect 组。
|
|
174
|
+
*
|
|
175
|
+
* 实测(2026-09-21 权益活动建卡):IR 写 `s.rect.position/left/top`,而 style-meta 里
|
|
176
|
+
* position/left/top 属于 position 组、rect 只有 width/height → 展开后 itemId 为空串,
|
|
177
|
+
* validate 一次报 19 个错("运行时静默丢弃该样式")。模型看不出是「组写错」,只会逐个猜。
|
|
178
|
+
* 这里按 style-meta 做机械归位:子键全库只属于另一个组时直接搬过去(不唯一就不猜,交给 validate)。
|
|
179
|
+
* Root 的 rect 组则直接丢:宽度由顶层 view.width 承载,validate 明文禁止
|
|
180
|
+
* (Root_style_rect_forbidden)。
|
|
181
|
+
*/
|
|
182
|
+
function normalizeStyleGroups(s, className, nodeLabel = '') {
|
|
183
|
+
const out = {}
|
|
184
|
+
const put = (gk, ck, cv) => {
|
|
185
|
+
const bucket = out[gk]
|
|
186
|
+
if (bucket === undefined) { out[gk] = { [ck]: cv }; return }
|
|
187
|
+
if (typeof bucket !== 'object' || Array.isArray(bucket)) {
|
|
188
|
+
if (typeof cv === 'object' && cv !== null && !Array.isArray(cv)) out[gk] = { [ck]: cv }
|
|
189
|
+
return // 标量组与对象组撞车:保留先到的那个,validate 会兜底
|
|
190
|
+
}
|
|
191
|
+
bucket[ck] = cv
|
|
192
|
+
}
|
|
193
|
+
for (const [gk, gv] of Object.entries(s || {})) {
|
|
194
|
+
const isObj = gv && typeof gv === 'object' && !Array.isArray(gv)
|
|
195
|
+
if (!isObj) {
|
|
196
|
+
if (className === 'Root' && gk === 'rect') continue
|
|
197
|
+
if (out[gk] === undefined) out[gk] = gv
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
for (const [ck, cv] of Object.entries(gv)) {
|
|
201
|
+
let target = gk
|
|
202
|
+
if (styleChildId[gk]?.[resolveStyleKey(gk, ck)] === undefined) {
|
|
203
|
+
const elsewhere = (styleGroupsOfChild[ck] || []).filter((g) => g !== gk)
|
|
204
|
+
if (elsewhere.length === 1) target = elsewhere[0]
|
|
205
|
+
}
|
|
206
|
+
if (className === 'Root' && target === 'rect') continue
|
|
207
|
+
if (target !== gk) console.log(`[ir] 样式组归位:${gk}.${ck} → ${target}.${ck}`)
|
|
208
|
+
// 归位后仍不在该组:这个名字 style-meta 里根本没有 ⇒ 记下来,写盘前中止
|
|
209
|
+
if (styleChildId[target]?.[resolveStyleKey(target, ck)] === undefined) {
|
|
210
|
+
collectUnknownStyleKey(nodeLabel || className, target, ck)
|
|
211
|
+
}
|
|
212
|
+
put(target, ck, cv)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
100
216
|
}
|
|
101
217
|
// directive itemId + child
|
|
102
218
|
const dirId = {}
|
|
@@ -229,6 +345,9 @@ function ensureRootDefaults(view) {
|
|
|
229
345
|
/** 把 IR 的属性/样式/指令/事件填进节点(不含 children/slots,供 Root 与子节点共用)。 */
|
|
230
346
|
function applyProps(node, ir) {
|
|
231
347
|
const className = node.className
|
|
348
|
+
// 样式组先归位(键对组错的 IR 写法机械纠正),后面的「自动补类型」都按组名判断,
|
|
349
|
+
// 归位后再补才不会漏(例如写在 rect 组里的 paddingTop 也该补上 paddingType)。
|
|
350
|
+
if (ir.s) ir = { ...ir, s: normalizeStyleGroups(ir.s, className, node.componentId || ir.id || className) }
|
|
232
351
|
// 主值 value(t / v)
|
|
233
352
|
if (ir.t !== undefined && ir.v !== undefined) {
|
|
234
353
|
// 实测事故:--set 给已有 t 的节点补 v 后,t 被当主值、from 却按绑定标 "1",
|
|
@@ -246,6 +365,18 @@ function applyProps(node, ir) {
|
|
|
246
365
|
}
|
|
247
366
|
// 图片属性 pi(itemValueFrom=2)
|
|
248
367
|
for (const [k, val] of Object.entries(ir.pi || {})) {
|
|
368
|
+
// 实测事故(demo/helloCard 2026-09-21):pi 是「静态图片/文件值」专用槽,运行时对 "2"
|
|
369
|
+
// 是**原样透传**(property.js onPreview 就一句 out[itemKey] = itemValue),从不对值求值。
|
|
370
|
+
// 把 $ 表达式写进 pi,这串字面量会被直接当图片地址 → background-image:url($icon) →
|
|
371
|
+
// 画布那张图静默空白,而 切图 HTTP 全 200 / validate 全绿 / 控制台无报错,
|
|
372
|
+
// 极易被误判成「src/slices 没托管」。与 t/v 互斥同类,此处响亮报错,别让它在 schema 里落地。
|
|
373
|
+
if (isVar(val)) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`节点 "${ir.id || className}" 的 pi.${k} 收到表达式 ${JSON.stringify(val)}:pi 是静态图片槽(itemValueFrom="2"),` +
|
|
376
|
+
`运行时原样透传当图片地址用,表达式永远渲染成空白。变量绑定改用 p.${k}:"${val}"(自动 itemValueFrom="1");` +
|
|
377
|
+
`静态切图传路径如 "/slices/xxx.png"`,
|
|
378
|
+
)
|
|
379
|
+
}
|
|
249
380
|
node.properties.push(makeProp(compPropId[className]?.[k], k, val, '2'))
|
|
250
381
|
}
|
|
251
382
|
|
|
@@ -344,6 +475,13 @@ function applyProps(node, ir) {
|
|
|
344
475
|
for (const ev of ir.e || []) node.eventactions.push(makeEvent(ev))
|
|
345
476
|
}
|
|
346
477
|
|
|
478
|
+
// 容器类名单:与 validate_schema.py 的 CONTAINER_TYPES(+ Select)对齐。
|
|
479
|
+
// 这些 className 的节点必须始终带 slots(无子组件时兜底空 default slot)。
|
|
480
|
+
const CONTAINER_CLASSNAMES = new Set([
|
|
481
|
+
'Root', 'Row', 'Column', 'AbsoluteLayout', 'MultiColumn',
|
|
482
|
+
'ListLayout', 'GridLayout', 'SingleColumn', 'CheckGroup', 'RadioButton', 'Select',
|
|
483
|
+
])
|
|
484
|
+
|
|
347
485
|
function expandNode(ir, ctx) {
|
|
348
486
|
const className = ir.c
|
|
349
487
|
if (!className) throw new Error('IR 节点缺少 className (c)')
|
|
@@ -374,7 +512,14 @@ function expandNode(ir, ctx) {
|
|
|
374
512
|
components: kids.map((k) => expandNode(k, ctx)),
|
|
375
513
|
})
|
|
376
514
|
}
|
|
377
|
-
if (slotDefs.length)
|
|
515
|
+
if (slotDefs.length) {
|
|
516
|
+
node.slots = slotDefs
|
|
517
|
+
} else if (CONTAINER_CLASSNAMES.has(className)) {
|
|
518
|
+
// 契约(dsl_structure.md §slots / component_rules.md):容器类节点即使无子组件也须带
|
|
519
|
+
// 空 default slot —— 否则 validate_schema.py WARN "has no slots",且下游(check_topology、
|
|
520
|
+
// 预览器)按 slot 结构消费时缺字段。
|
|
521
|
+
node.slots = [{ slotKey: 'default', slotName: '默认插槽', slotSwitch: '1', components: [] }]
|
|
522
|
+
}
|
|
378
523
|
return node
|
|
379
524
|
}
|
|
380
525
|
|
|
@@ -458,6 +603,14 @@ function normalizeInputParams(params) {
|
|
|
458
603
|
* @returns {{schema:object, data:object}}
|
|
459
604
|
*/
|
|
460
605
|
export function expandIR(ir) {
|
|
606
|
+
resetUnknownStyleKeys()
|
|
607
|
+
// 作用域别名归一化(**在扩展开头做**,早于 collectRootBindings 推断 inputParams)。
|
|
608
|
+
// 放在这里而不是 CLI 分支里:iterate.mjs 是直接 import expandIR 在进程内跑的,
|
|
609
|
+
// 只写在 CLI 里会让「AI 建卡主路径」(iterate)完全绕过归一化 —— 历史事故的翻版。
|
|
610
|
+
const knownRoots = new Set(
|
|
611
|
+
((ir.dictionary && ir.dictionary.inputParams) || []).map((p) => p && p.key).filter(Boolean),
|
|
612
|
+
)
|
|
613
|
+
const aliasFixes = normalizeItemAliases(ir, { knownRoots })
|
|
461
614
|
const ctx = { n: 0, nextId: () => `comp_${++ctx.n}` }
|
|
462
615
|
const viewChildren = (ir.children || []).map((k) => expandNode(k, ctx))
|
|
463
616
|
const dictionary = ir.dictionary
|
|
@@ -497,7 +650,7 @@ export function expandIR(ir) {
|
|
|
497
650
|
schema.dictionary.inputParams = normalizeInputParams(schema.dictionary.inputParams)
|
|
498
651
|
const data = {}
|
|
499
652
|
for (const p of schema.dictionary.inputParams || []) data[p.key] = p.defaultValue
|
|
500
|
-
return { schema, data }
|
|
653
|
+
return { schema, data, aliasFixes }
|
|
501
654
|
}
|
|
502
655
|
|
|
503
656
|
/* ------------------------------------------------------------------ */
|
|
@@ -657,6 +810,27 @@ function compareNodes(a, b, p) {
|
|
|
657
810
|
/* ------------------------------------------------------------------ */
|
|
658
811
|
/* CLI */
|
|
659
812
|
/* ------------------------------------------------------------------ */
|
|
813
|
+
/**
|
|
814
|
+
* 递归枚举目录下全部文件。要素包 slices/ 是嵌套目录(如 8.icon/通用/警示-#999@3x.png),
|
|
815
|
+
* 旧版平铺 readdirSync 只扫顶层够不着真身 → exit=1 + fs_list 逐层摸索 + fs_copy 手工搬运
|
|
816
|
+
* (gen-1 实测约 10 次调用白烧,占该回合前半段 38%)。
|
|
817
|
+
*/
|
|
818
|
+
function listSliceFilesDeep(dir) {
|
|
819
|
+
const out = []
|
|
820
|
+
const stack = [dir]
|
|
821
|
+
while (stack.length) {
|
|
822
|
+
const cur = stack.pop()
|
|
823
|
+
let entries
|
|
824
|
+
try { entries = fs.readdirSync(cur, { withFileTypes: true }) } catch { continue }
|
|
825
|
+
for (const ent of entries) {
|
|
826
|
+
const p = path.join(cur, ent.name)
|
|
827
|
+
if (ent.isDirectory()) stack.push(p)
|
|
828
|
+
else if (ent.isFile()) out.push(p)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return out
|
|
832
|
+
}
|
|
833
|
+
|
|
660
834
|
function collectSliceRefs(node, out = new Set()) {
|
|
661
835
|
if (!node || typeof node !== 'object') return out
|
|
662
836
|
for (const p of node.properties || []) {
|
|
@@ -668,15 +842,23 @@ function collectSliceRefs(node, out = new Set()) {
|
|
|
668
842
|
return out
|
|
669
843
|
}
|
|
670
844
|
|
|
671
|
-
export function syncReferencedSlices(schema, irPath,
|
|
845
|
+
export function syncReferencedSlices(schema, irPath, slicesDirs) {
|
|
672
846
|
const result = { copied: 0, reused: 0, rewrites: [] }
|
|
673
847
|
const refs = collectSliceRefs(schema.view)
|
|
674
848
|
if (!refs.size) return result
|
|
675
|
-
|
|
676
|
-
|
|
849
|
+
// 落点必须是**宿主工程根**(cwd / DSL_CARD_PROJECT_ROOT),不能按脚本自身位置往上推:
|
|
850
|
+
// 发布态里脚本在 dist/vendored/skills 下,往上推会把切图写进包内(0.14.1 同类事故)。
|
|
851
|
+
const targetDir = path.join(projectRoot, 'src', 'slices')
|
|
677
852
|
fs.mkdirSync(targetDir, { recursive: true })
|
|
678
|
-
//
|
|
679
|
-
|
|
853
|
+
// 切图来源顺序:--slices-dir / --slices-from 显式指定(要素包 slices/、工程 slices/ 都可给,
|
|
854
|
+
// 逗号分隔多来源)→ 工程 slices/ 自身 → IR 同目录 slices/
|
|
855
|
+
const given = (Array.isArray(slicesDirs) ? slicesDirs : [slicesDirs])
|
|
856
|
+
.flatMap((d) => String(d || '').split(','))
|
|
857
|
+
.map((d) => d.trim())
|
|
858
|
+
.filter(Boolean)
|
|
859
|
+
.map((d) => path.resolve(d))
|
|
860
|
+
const sourceDirs = [...given, targetDir, path.join(path.dirname(path.resolve(irPath)), 'slices')]
|
|
861
|
+
.filter((d, i, arr) => arr.indexOf(d) === i)
|
|
680
862
|
let hitDir = null
|
|
681
863
|
// 落地前判重(slices_convention.md §3.1):内容 sha256 一致即同一张图 → 复用既有文件,
|
|
682
864
|
// 不新增副本,并把 schema 引用改写到既有文件名(避免「同图多名」堆重复素材)。
|
|
@@ -687,9 +869,41 @@ export function syncReferencedSlices(schema, irPath, slicesDir) {
|
|
|
687
869
|
try { name = decodeURIComponent(name) } catch {}
|
|
688
870
|
const target = path.join(targetDir, name)
|
|
689
871
|
if (fs.existsSync(target)) continue
|
|
690
|
-
|
|
872
|
+
let source = sourceDirs.map((d) => path.join(d, name)).find((f) => fs.existsSync(f))
|
|
873
|
+
// 源目录里可能仍存设计稿原名(`警示-#999.png`),而引用是消毒名(`警示-999.png`)
|
|
874
|
+
// → 归一后逐目录再找一次。少了这步会「文件明明在、却报引用不存在」并中断整轮生成。
|
|
875
|
+
if (!source) {
|
|
876
|
+
for (const d of sourceDirs) {
|
|
877
|
+
// 递归枚举(要素包 slices 嵌套子目录);命中判据沿用 sliceNameMatches(口径同源,禁另起一套)
|
|
878
|
+
const hits = listSliceFilesDeep(d).filter(
|
|
879
|
+
(f) => /\.(png|jpe?g|webp|svg)$/i.test(f) && sliceNameMatches(path.basename(f), name),
|
|
880
|
+
)
|
|
881
|
+
// 同名命中多处不猜:报错列出全部命中绝对路径,让调用方把引用改带子目录或挪走多余文件
|
|
882
|
+
if (hits.length > 1) {
|
|
883
|
+
throw new Error(
|
|
884
|
+
`IR 引用的切图 ${ref} 在 ${d} 下命中多个同名文件,不猜——把引用改成带子目录的相对路径,或挪走多余文件后重跑:\n${hits.map((h) => ' - ' + h).join('\n')}`,
|
|
885
|
+
)
|
|
886
|
+
}
|
|
887
|
+
if (hits.length === 1) { source = hits[0]; break }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
691
890
|
if (!source) {
|
|
692
|
-
|
|
891
|
+
// 报错要能一次改对:把搜索目录里「名字沾边」的候选列出来。此前只报「找不到」,
|
|
892
|
+
// 模型只能 fs_list 两个目录来回摸(实测多花 3 步 + 1.5 分钟),其实候选就摆在手边。
|
|
893
|
+
const stem = path.basename(name, path.extname(name)).toLowerCase()
|
|
894
|
+
const words = stem.split(/[^a-z0-9\u4e00-\u9fa5]+/i).filter((w) => w.length > 1)
|
|
895
|
+
const cands = []
|
|
896
|
+
for (const d of sourceDirs) {
|
|
897
|
+
for (const f of listSliceFilesDeep(d)) {
|
|
898
|
+
if (!/\.(png|jpe?g|webp|svg)$/i.test(f)) continue
|
|
899
|
+
const lower = path.basename(f).toLowerCase()
|
|
900
|
+
if (lower === path.basename(name).toLowerCase() || words.some((w) => lower.includes(w))) cands.push(f)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const hint = cands.length
|
|
904
|
+
? `\n 名字沾边的候选(可直接把引用改成它,或把文件按你需要重命名后重跑):\n${cands.slice(0, 8).map((c) => ' - ' + c).join('\n')}`
|
|
905
|
+
: ''
|
|
906
|
+
throw new Error(`IR 引用的切图不存在:${ref}(已查找 ${sourceDirs.join(' / ')};可用 --slices-dir / --slices-from 指向要素包或工程的 slices 目录)${hint}`)
|
|
693
907
|
}
|
|
694
908
|
hitDir = path.dirname(source)
|
|
695
909
|
const sha = sha256File(source)
|
|
@@ -771,17 +985,26 @@ function runPyScript(py, script, args) {
|
|
|
771
985
|
/** autofix(机械修复,exit 1 仅代表「仍有非机械错误」)+ validate(真门禁)。
|
|
772
986
|
* 历史 bug:autofix 的 exit 1 曾被当成致命异常,导致 validate 从未执行、
|
|
773
987
|
* 报出误导性的「validate 未执行(未找到 python)」,模型转而信任无校验的旁路。 */
|
|
988
|
+
export function resolvePythonBin() {
|
|
989
|
+
// 保留 null 语义(调用方靠它区分「没装 Python」):探测不过就明确返回 null,
|
|
990
|
+
// 而不是像 resolvePython() 那样回落到 'python3' 让调用方带着假解释器去试跑。
|
|
991
|
+
const bin = resolvePython()
|
|
992
|
+
return probePython(bin) ? bin : null
|
|
993
|
+
}
|
|
994
|
+
|
|
774
995
|
export function runAutofixAndValidate(schemaPath) {
|
|
775
|
-
const
|
|
776
|
-
const candidates = [PY, 'python3']
|
|
996
|
+
const candidates = pythonCandidates()
|
|
777
997
|
let lastOut = ''
|
|
778
998
|
for (const py of candidates) {
|
|
779
999
|
let autofix
|
|
780
1000
|
try {
|
|
781
1001
|
autofix = runPyScript(py, 'autofix_schema.py', [schemaPath])
|
|
782
1002
|
} catch {
|
|
783
|
-
continue // 该 python
|
|
1003
|
+
continue // 该 python 二进制不存在(ENOENT),换下一个候选
|
|
784
1004
|
}
|
|
1005
|
+
// 假解释器(Windows Store 别名 / 空壳 wrapper)能启动但零产出:不能拿它当「跑过了」,
|
|
1006
|
+
// 否则 autofix 与 validate 都没执行,却被报成 schema 校验失败。
|
|
1007
|
+
if (!autofix.stdout && autofix.code !== 0) continue
|
|
785
1008
|
const v = runPyScript(py, 'validate_schema.py', [schemaPath, '--json'])
|
|
786
1009
|
const out = v.stdout || autofix.stdout
|
|
787
1010
|
if (out) {
|
|
@@ -795,7 +1018,17 @@ export function runAutofixAndValidate(schemaPath) {
|
|
|
795
1018
|
} else lastOut = out.slice(0, 200)
|
|
796
1019
|
}
|
|
797
1020
|
}
|
|
798
|
-
|
|
1021
|
+
// envError:调用方(iterate 门禁)据此判「环境阻塞」而不是「卡片有错」——
|
|
1022
|
+
// 实测这个区别决定模型是停下来告诉用户,还是为一条永远修不好的 error 空转 20 轮。
|
|
1023
|
+
return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
envError: true,
|
|
1026
|
+
errors: [
|
|
1027
|
+
`validate 未执行(没找到可用的 python 解释器:试过 ${candidates.join(' / ')})` +
|
|
1028
|
+
`${lastOut ? `:${lastOut}` : ''}。这不是卡片的错误——装 Python 或设 DSL_CARD_PYTHON=<解释器路径> 后重跑,不要为它改 schema。`,
|
|
1029
|
+
],
|
|
1030
|
+
warnings: [],
|
|
1031
|
+
}
|
|
799
1032
|
}
|
|
800
1033
|
|
|
801
1034
|
function main() {
|
|
@@ -818,7 +1051,7 @@ function main() {
|
|
|
818
1051
|
errs.slice(0, 20).forEach((e) => console.log(' DIFF ' + e))
|
|
819
1052
|
|
|
820
1053
|
// 写临时卡跑 validate + lint
|
|
821
|
-
const repoRoot =
|
|
1054
|
+
const repoRoot = projectRoot
|
|
822
1055
|
const tmpId = '__ir_selftest__'
|
|
823
1056
|
const tmpDir = path.join(repoRoot, 'src', 'cards', tmpId)
|
|
824
1057
|
fs.mkdirSync(tmpDir, { recursive: true })
|
|
@@ -844,7 +1077,24 @@ function main() {
|
|
|
844
1077
|
}
|
|
845
1078
|
if (args.ir && args.out) {
|
|
846
1079
|
const ir = JSON.parse(fs.readFileSync(args.ir, 'utf8'))
|
|
847
|
-
const { schema, data } = expandIR(ir)
|
|
1080
|
+
const { schema, data, aliasFixes } = expandIR(ir)
|
|
1081
|
+
// 归一化是**静默修好**,不是拦下来让模型重写:改一个字符串的字面量而已,
|
|
1082
|
+
// 报一句「已归一化 N 处」让调用方知道发生了什么,但不中断流程。
|
|
1083
|
+
if (aliasFixes && aliasFixes.count) {
|
|
1084
|
+
console.error(
|
|
1085
|
+
`[ir] 已归一化 ${aliasFixes.count} 处列表作用域别名($item.<字段> → $<字段>、$item → $$、$item._index → $_index):schema 里只存规范形,无需重写 IR。`,
|
|
1086
|
+
)
|
|
1087
|
+
for (const s of aliasFixes.samples.slice(0, 3)) {
|
|
1088
|
+
console.error(` ${s.at}: ${s.before} → ${s.after}`)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
// 非法样式键:**写盘前**中止。放行的代价是「schema 写得像成功、渲染却丢样式」,
|
|
1092
|
+
// 下游 validate 只会报整卡 FAIL,模型看不出是键名错 → 推倒重写整份 IR(实测 16 次)。
|
|
1093
|
+
if (unknownStyleKeys.length) {
|
|
1094
|
+
console.error(unknownStyleKeysError())
|
|
1095
|
+
console.error(`\n[ir] 共 ${unknownStyleKeys.length} 处非法样式键,已中止(schema/data 未写盘)。改键名即可,不要重写整份 IR。`)
|
|
1096
|
+
process.exit(3)
|
|
1097
|
+
}
|
|
848
1098
|
fs.mkdirSync(args.out, { recursive: true })
|
|
849
1099
|
const sp = path.join(args.out, 'schema.json')
|
|
850
1100
|
const dp = path.join(args.out, 'data.json')
|
|
@@ -852,7 +1102,7 @@ function main() {
|
|
|
852
1102
|
if (snap) console.log(`[history] 已快照旧 schema → .history/${snap}(覆盖前备份,可回滚)`)
|
|
853
1103
|
fs.writeFileSync(sp, JSON.stringify(schema, null, 2) + '\n')
|
|
854
1104
|
fs.writeFileSync(dp, JSON.stringify(data, null, 2) + '\n')
|
|
855
|
-
const sliceSync = syncReferencedSlices(schema, args.ir, args['slices-dir'])
|
|
1105
|
+
const sliceSync = syncReferencedSlices(schema, args.ir, [args['slices-dir'], args['slices-from']])
|
|
856
1106
|
if (sliceSync.rewrites.length) {
|
|
857
1107
|
// 去重改写发生在 schema 落盘之后 → 回写 schema/data,保证盘上引用与 src/slices 一致
|
|
858
1108
|
rewriteSliceRefs(data, sliceSync.rewrites)
|