cardbuddy 0.14.2 → 0.15.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-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
- package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/mcp/client.mjs +8 -1
- package/dist/vendored/server/core/ai-chat.js +1521 -124
- 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/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 +196 -195
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
- 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 +14 -0
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -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 +4 -3
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -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 +644 -42
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -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 +5443 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
- 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 +186 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
|
@@ -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,9 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
/**
|
|
3
|
+
* 跨平台 Python 解释器探测:DSL_CARD_PYTHON > WORKBUDDY_PYTHON > 真探测 python3 / python / py。
|
|
4
|
+
*
|
|
5
|
+
* 为什么必须真跑一次 --version(而不是「能不能 spawn」):Windows 上 `python3` 常是
|
|
6
|
+
* Microsoft Store 的应用执行别名 —— 进程能启动,却立刻以 9009 退出,**不是 ENOENT**。
|
|
7
|
+
* 只按 spawn 异常判断就会钉死这个假解释器,autofix / validate 全部静默不执行,
|
|
8
|
+
* 只回一句「validate 未执行(python 不可用)」,调用方(iterate / expand_ir)看到的是一个
|
|
9
|
+
* **永远修不好**的 validate error —— 实测模型为它空转 20 轮、每轮都重跑 iterate。
|
|
10
|
+
*/
|
|
11
|
+
function probePython(bin) {
|
|
12
|
+
try {
|
|
13
|
+
const r = spawnSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
14
|
+
if (r.status !== 0) return false;
|
|
15
|
+
return /Python \d/i.test(`${r.stdout || ''}${r.stderr || ''}`);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pythonCandidates() {
|
|
22
|
+
const out = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const push = (bin) => {
|
|
25
|
+
const b = String(bin || '').trim();
|
|
26
|
+
if (!b || seen.has(b)) return;
|
|
27
|
+
seen.add(b);
|
|
28
|
+
out.push(b);
|
|
29
|
+
};
|
|
30
|
+
// 显式指定的解释器优先(可用就只认它;探测不过也保留一次机会——可能是带参数的包装器)
|
|
31
|
+
for (const env of [process.env.DSL_CARD_PYTHON, process.env.WORKBUDDY_PYTHON]) {
|
|
32
|
+
if (!env) continue;
|
|
33
|
+
if (probePython(env)) { push(env); return out; }
|
|
34
|
+
push(env);
|
|
35
|
+
}
|
|
36
|
+
for (const bin of ['python3', 'python', 'py']) if (probePython(bin)) push(bin);
|
|
37
|
+
// 探测全失败:仍按常见名字逐一试跑,让报错里能看到真实失败原因(stderr 原样带出)
|
|
38
|
+
for (const bin of ['python3', 'python']) push(bin);
|
|
39
|
+
return out;
|
|
7
40
|
}
|
|
8
41
|
/**
|
|
9
42
|
* expand_ir.mjs — 紧凑 IR(Intermediate Representation)⇄ 完整 schema 双向转换。
|
|
@@ -17,7 +50,8 @@ function findPython() {
|
|
|
17
50
|
*
|
|
18
51
|
* 用法:
|
|
19
52
|
* node expand_ir.mjs --ir <IR.json> --out <dir>/ # IR → schema.json + data.json(写盘并 autofix+validate)
|
|
20
|
-
* [--slices-dir <dir>]
|
|
53
|
+
* [--slices-dir <dir>] [--slices-from <dir,dir>] # 引用切图的来源目录(可多个;
|
|
54
|
+
* # 默认还会查工程 src/slices 与 IR 同目录 slices/)
|
|
21
55
|
* node expand_ir.mjs --to-ir <schema.json> # 完整 schema → 紧凑 IR(stdout,用于验证/二次编辑)
|
|
22
56
|
* node expand_ir.mjs --self-test [--card DepositHolding] # round-trip 无损 + validate + lint 自检
|
|
23
57
|
*
|
|
@@ -34,11 +68,11 @@ function findPython() {
|
|
|
34
68
|
*/
|
|
35
69
|
import fs from 'node:fs'
|
|
36
70
|
import path from 'node:path'
|
|
37
|
-
import { execFileSync } from 'node:child_process'
|
|
71
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
38
72
|
import { fileURLToPath } from 'node:url'
|
|
39
73
|
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'
|
|
74
|
+
import { metaDir, projectRoot, skillRoot } from './lib/paths.mjs'
|
|
75
|
+
import { buildContentIndex, rewriteSliceRefs, sha256File, sliceNameMatches } from './lib/slice_dedupe.mjs'
|
|
42
76
|
import { findPixelTwin, readPng } from './lib/slice_pixels.mjs'
|
|
43
77
|
|
|
44
78
|
/* ------------------------------------------------------------------ */
|
|
@@ -96,7 +130,102 @@ function resolveStyleKey(groupKey, childKey) {
|
|
|
96
130
|
if (styleChildId[groupKey]?.[childKey] !== undefined) return childKey
|
|
97
131
|
const resolved = STYLE_ALIAS[childKey]
|
|
98
132
|
if (resolved && styleChildId[groupKey]?.[resolved] !== undefined) return resolved
|
|
99
|
-
return childKey // no match —
|
|
133
|
+
return childKey // no match — 由 collectUnknownStyleKey 记账,写盘前统一响亮失败
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 合法样式键全集(唯一来源 style-meta.json)的紧凑一页表,供报错文本直接回给模型。
|
|
138
|
+
* 实测(2026-09-21 建卡回合):模型不知道 flex 组没有 flexGrow,写了 `flex:{flexGrow:1}` →
|
|
139
|
+
* itemId 空串 → 渲染器静默丢弃 → validate 报「整卡 FAIL」,模型看不出是键名错,
|
|
140
|
+
* 于是 8 次 shell_exec 翻 meta 文件考古 + 6 次把组名写错(size.width 而非 rect.width)。
|
|
141
|
+
*/
|
|
142
|
+
export function styleKeyTableText() {
|
|
143
|
+
return Object.entries(styleChildId)
|
|
144
|
+
.map(([g, kids]) => `${g}: ${Object.keys(kids).join(' ')}`)
|
|
145
|
+
.join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 归位之后仍然不存在的样式键 ⇒ 运行时静默丢弃。收集起来,**写盘前**中止(别让它进 schema)。
|
|
150
|
+
* 静默放行是本轮 700 万 token 的主要起点之一:问题的报错信息从「flexGrow 不合法」
|
|
151
|
+
* 退化成下游 validate 的「整卡 FAIL」,模型只能推倒重写整份 IR。
|
|
152
|
+
*/
|
|
153
|
+
const unknownStyleKeys = []
|
|
154
|
+
function resetUnknownStyleKeys() {
|
|
155
|
+
unknownStyleKeys.length = 0
|
|
156
|
+
}
|
|
157
|
+
function collectUnknownStyleKey(nodeLabel, groupKey, childKey) {
|
|
158
|
+
const hit = unknownStyleKeys.find((u) => u.node === nodeLabel && u.group === groupKey && u.key === childKey)
|
|
159
|
+
if (!hit) unknownStyleKeys.push({ node: nodeLabel || '?', group: groupKey, key: childKey })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 非法键的响亮报错文本:逐条点名 + 该组合法子键 + 全局一页表(约 1KB,够模型一次改对) */
|
|
163
|
+
function unknownStyleKeysError() {
|
|
164
|
+
const lines = ['[ir] 非法样式键(运行时会被静默丢弃,已中止写盘,schema 未改动):']
|
|
165
|
+
for (const u of unknownStyleKeys.slice(0, 20)) {
|
|
166
|
+
const kids = Object.keys(styleChildId[u.group] || {})
|
|
167
|
+
const groupNote = kids.length ? ` → ${u.group} 组合法子键只有 ${kids.join('/')}` : `(style-meta 里没有 "${u.group}" 这个组)`
|
|
168
|
+
lines.push(` ${u.node}.s.${u.group}.${u.key}${groupNote}`)
|
|
169
|
+
const owners = styleGroupsOfChild[u.key] || []
|
|
170
|
+
if (owners.length === 1) lines.push(` 纠正:${u.key} 属于 ${owners[0]} 组,写成 ${owners[0]}.${u.key}`)
|
|
171
|
+
else if (owners.length > 1) lines.push(` 纠正:${u.key} 属于 ${owners.join(' / ')} 组`)
|
|
172
|
+
}
|
|
173
|
+
if (unknownStyleKeys.length > 20) lines.push(` …还有 ${unknownStyleKeys.length - 20} 处`)
|
|
174
|
+
lines.push('', '合法样式键全集(style-meta.json):', styleKeyTableText())
|
|
175
|
+
return lines.join('\n')
|
|
176
|
+
}
|
|
177
|
+
// 子键 → 它合法归属的组(style-meta 全局唯一来源)。用来把「键对、组写错」的样式自动归位。
|
|
178
|
+
const styleGroupsOfChild = {}
|
|
179
|
+
for (const g of SM.properties || []) {
|
|
180
|
+
for (const c of g.itemChildren || []) {
|
|
181
|
+
;(styleGroupsOfChild[c.itemKey] ||= []).push(g.itemKey)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 把写错组的样式搬回它该在的组,并丢掉 Root 上非法的 rect 组。
|
|
187
|
+
*
|
|
188
|
+
* 实测(2026-09-21 权益活动建卡):IR 写 `s.rect.position/left/top`,而 style-meta 里
|
|
189
|
+
* position/left/top 属于 position 组、rect 只有 width/height → 展开后 itemId 为空串,
|
|
190
|
+
* validate 一次报 19 个错("运行时静默丢弃该样式")。模型看不出是「组写错」,只会逐个猜。
|
|
191
|
+
* 这里按 style-meta 做机械归位:子键全库只属于另一个组时直接搬过去(不唯一就不猜,交给 validate)。
|
|
192
|
+
* Root 的 rect 组则直接丢:宽度由顶层 view.width 承载,validate 明文禁止
|
|
193
|
+
* (Root_style_rect_forbidden)。
|
|
194
|
+
*/
|
|
195
|
+
function normalizeStyleGroups(s, className, nodeLabel = '') {
|
|
196
|
+
const out = {}
|
|
197
|
+
const put = (gk, ck, cv) => {
|
|
198
|
+
const bucket = out[gk]
|
|
199
|
+
if (bucket === undefined) { out[gk] = { [ck]: cv }; return }
|
|
200
|
+
if (typeof bucket !== 'object' || Array.isArray(bucket)) {
|
|
201
|
+
if (typeof cv === 'object' && cv !== null && !Array.isArray(cv)) out[gk] = { [ck]: cv }
|
|
202
|
+
return // 标量组与对象组撞车:保留先到的那个,validate 会兜底
|
|
203
|
+
}
|
|
204
|
+
bucket[ck] = cv
|
|
205
|
+
}
|
|
206
|
+
for (const [gk, gv] of Object.entries(s || {})) {
|
|
207
|
+
const isObj = gv && typeof gv === 'object' && !Array.isArray(gv)
|
|
208
|
+
if (!isObj) {
|
|
209
|
+
if (className === 'Root' && gk === 'rect') continue
|
|
210
|
+
if (out[gk] === undefined) out[gk] = gv
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
for (const [ck, cv] of Object.entries(gv)) {
|
|
214
|
+
let target = gk
|
|
215
|
+
if (styleChildId[gk]?.[resolveStyleKey(gk, ck)] === undefined) {
|
|
216
|
+
const elsewhere = (styleGroupsOfChild[ck] || []).filter((g) => g !== gk)
|
|
217
|
+
if (elsewhere.length === 1) target = elsewhere[0]
|
|
218
|
+
}
|
|
219
|
+
if (className === 'Root' && target === 'rect') continue
|
|
220
|
+
if (target !== gk) console.log(`[ir] 样式组归位:${gk}.${ck} → ${target}.${ck}`)
|
|
221
|
+
// 归位后仍不在该组:这个名字 style-meta 里根本没有 ⇒ 记下来,写盘前中止
|
|
222
|
+
if (styleChildId[target]?.[resolveStyleKey(target, ck)] === undefined) {
|
|
223
|
+
collectUnknownStyleKey(nodeLabel || className, target, ck)
|
|
224
|
+
}
|
|
225
|
+
put(target, ck, cv)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
100
229
|
}
|
|
101
230
|
// directive itemId + child
|
|
102
231
|
const dirId = {}
|
|
@@ -229,6 +358,9 @@ function ensureRootDefaults(view) {
|
|
|
229
358
|
/** 把 IR 的属性/样式/指令/事件填进节点(不含 children/slots,供 Root 与子节点共用)。 */
|
|
230
359
|
function applyProps(node, ir) {
|
|
231
360
|
const className = node.className
|
|
361
|
+
// 样式组先归位(键对组错的 IR 写法机械纠正),后面的「自动补类型」都按组名判断,
|
|
362
|
+
// 归位后再补才不会漏(例如写在 rect 组里的 paddingTop 也该补上 paddingType)。
|
|
363
|
+
if (ir.s) ir = { ...ir, s: normalizeStyleGroups(ir.s, className, node.componentId || ir.id || className) }
|
|
232
364
|
// 主值 value(t / v)
|
|
233
365
|
if (ir.t !== undefined && ir.v !== undefined) {
|
|
234
366
|
// 实测事故:--set 给已有 t 的节点补 v 后,t 被当主值、from 却按绑定标 "1",
|
|
@@ -246,6 +378,18 @@ function applyProps(node, ir) {
|
|
|
246
378
|
}
|
|
247
379
|
// 图片属性 pi(itemValueFrom=2)
|
|
248
380
|
for (const [k, val] of Object.entries(ir.pi || {})) {
|
|
381
|
+
// 实测事故(demo/helloCard 2026-09-21):pi 是「静态图片/文件值」专用槽,运行时对 "2"
|
|
382
|
+
// 是**原样透传**(property.js onPreview 就一句 out[itemKey] = itemValue),从不对值求值。
|
|
383
|
+
// 把 $ 表达式写进 pi,这串字面量会被直接当图片地址 → background-image:url($icon) →
|
|
384
|
+
// 画布那张图静默空白,而 切图 HTTP 全 200 / validate 全绿 / 控制台无报错,
|
|
385
|
+
// 极易被误判成「src/slices 没托管」。与 t/v 互斥同类,此处响亮报错,别让它在 schema 里落地。
|
|
386
|
+
if (isVar(val)) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`节点 "${ir.id || className}" 的 pi.${k} 收到表达式 ${JSON.stringify(val)}:pi 是静态图片槽(itemValueFrom="2"),` +
|
|
389
|
+
`运行时原样透传当图片地址用,表达式永远渲染成空白。变量绑定改用 p.${k}:"${val}"(自动 itemValueFrom="1");` +
|
|
390
|
+
`静态切图传路径如 "/slices/xxx.png"`,
|
|
391
|
+
)
|
|
392
|
+
}
|
|
249
393
|
node.properties.push(makeProp(compPropId[className]?.[k], k, val, '2'))
|
|
250
394
|
}
|
|
251
395
|
|
|
@@ -458,6 +602,7 @@ function normalizeInputParams(params) {
|
|
|
458
602
|
* @returns {{schema:object, data:object}}
|
|
459
603
|
*/
|
|
460
604
|
export function expandIR(ir) {
|
|
605
|
+
resetUnknownStyleKeys()
|
|
461
606
|
const ctx = { n: 0, nextId: () => `comp_${++ctx.n}` }
|
|
462
607
|
const viewChildren = (ir.children || []).map((k) => expandNode(k, ctx))
|
|
463
608
|
const dictionary = ir.dictionary
|
|
@@ -668,15 +813,23 @@ function collectSliceRefs(node, out = new Set()) {
|
|
|
668
813
|
return out
|
|
669
814
|
}
|
|
670
815
|
|
|
671
|
-
export function syncReferencedSlices(schema, irPath,
|
|
816
|
+
export function syncReferencedSlices(schema, irPath, slicesDirs) {
|
|
672
817
|
const result = { copied: 0, reused: 0, rewrites: [] }
|
|
673
818
|
const refs = collectSliceRefs(schema.view)
|
|
674
819
|
if (!refs.size) return result
|
|
675
|
-
|
|
676
|
-
|
|
820
|
+
// 落点必须是**宿主工程根**(cwd / DSL_CARD_PROJECT_ROOT),不能按脚本自身位置往上推:
|
|
821
|
+
// 发布态里脚本在 dist/vendored/skills 下,往上推会把切图写进包内(0.14.1 同类事故)。
|
|
822
|
+
const targetDir = path.join(projectRoot, 'src', 'slices')
|
|
677
823
|
fs.mkdirSync(targetDir, { recursive: true })
|
|
678
|
-
//
|
|
679
|
-
|
|
824
|
+
// 切图来源顺序:--slices-dir / --slices-from 显式指定(要素包 slices/、工程 slices/ 都可给,
|
|
825
|
+
// 逗号分隔多来源)→ 工程 slices/ 自身 → IR 同目录 slices/
|
|
826
|
+
const given = (Array.isArray(slicesDirs) ? slicesDirs : [slicesDirs])
|
|
827
|
+
.flatMap((d) => String(d || '').split(','))
|
|
828
|
+
.map((d) => d.trim())
|
|
829
|
+
.filter(Boolean)
|
|
830
|
+
.map((d) => path.resolve(d))
|
|
831
|
+
const sourceDirs = [...given, targetDir, path.join(path.dirname(path.resolve(irPath)), 'slices')]
|
|
832
|
+
.filter((d, i, arr) => arr.indexOf(d) === i)
|
|
680
833
|
let hitDir = null
|
|
681
834
|
// 落地前判重(slices_convention.md §3.1):内容 sha256 一致即同一张图 → 复用既有文件,
|
|
682
835
|
// 不新增副本,并把 schema 引用改写到既有文件名(避免「同图多名」堆重复素材)。
|
|
@@ -687,9 +840,36 @@ export function syncReferencedSlices(schema, irPath, slicesDir) {
|
|
|
687
840
|
try { name = decodeURIComponent(name) } catch {}
|
|
688
841
|
const target = path.join(targetDir, name)
|
|
689
842
|
if (fs.existsSync(target)) continue
|
|
690
|
-
|
|
843
|
+
let source = sourceDirs.map((d) => path.join(d, name)).find((f) => fs.existsSync(f))
|
|
844
|
+
// 源目录里可能仍存设计稿原名(`警示-#999.png`),而引用是消毒名(`警示-999.png`)
|
|
845
|
+
// → 归一后逐目录再找一次。少了这步会「文件明明在、却报引用不存在」并中断整轮生成。
|
|
846
|
+
if (!source) {
|
|
847
|
+
for (const d of sourceDirs) {
|
|
848
|
+
let entries = []
|
|
849
|
+
try { entries = fs.readdirSync(d) } catch { continue }
|
|
850
|
+
const hit = entries.find((f) => /\.(png|jpe?g|webp|svg)$/i.test(f) && sliceNameMatches(f, name))
|
|
851
|
+
if (hit) { source = path.join(d, hit); break }
|
|
852
|
+
}
|
|
853
|
+
}
|
|
691
854
|
if (!source) {
|
|
692
|
-
|
|
855
|
+
// 报错要能一次改对:把搜索目录里「名字沾边」的候选列出来。此前只报「找不到」,
|
|
856
|
+
// 模型只能 fs_list 两个目录来回摸(实测多花 3 步 + 1.5 分钟),其实候选就摆在手边。
|
|
857
|
+
const stem = path.basename(name, path.extname(name)).toLowerCase()
|
|
858
|
+
const words = stem.split(/[^a-z0-9\u4e00-\u9fa5]+/i).filter((w) => w.length > 1)
|
|
859
|
+
const cands = []
|
|
860
|
+
for (const d of sourceDirs) {
|
|
861
|
+
let entries = []
|
|
862
|
+
try { entries = fs.readdirSync(d) } catch { continue }
|
|
863
|
+
for (const f of entries) {
|
|
864
|
+
if (!/\.(png|jpe?g|webp|svg)$/i.test(f)) continue
|
|
865
|
+
const lower = f.toLowerCase()
|
|
866
|
+
if (lower === path.basename(name).toLowerCase() || words.some((w) => lower.includes(w))) cands.push(path.join(d, f))
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const hint = cands.length
|
|
870
|
+
? `\n 名字沾边的候选(可直接把引用改成它,或把文件按你需要重命名后重跑):\n${cands.slice(0, 8).map((c) => ' - ' + c).join('\n')}`
|
|
871
|
+
: ''
|
|
872
|
+
throw new Error(`IR 引用的切图不存在:${ref}(已查找 ${sourceDirs.join(' / ')};可用 --slices-dir / --slices-from 指向要素包或工程的 slices 目录)${hint}`)
|
|
693
873
|
}
|
|
694
874
|
hitDir = path.dirname(source)
|
|
695
875
|
const sha = sha256File(source)
|
|
@@ -771,17 +951,24 @@ function runPyScript(py, script, args) {
|
|
|
771
951
|
/** autofix(机械修复,exit 1 仅代表「仍有非机械错误」)+ validate(真门禁)。
|
|
772
952
|
* 历史 bug:autofix 的 exit 1 曾被当成致命异常,导致 validate 从未执行、
|
|
773
953
|
* 报出误导性的「validate 未执行(未找到 python)」,模型转而信任无校验的旁路。 */
|
|
954
|
+
export function resolvePythonBin() {
|
|
955
|
+
for (const bin of pythonCandidates()) if (probePython(bin)) return bin
|
|
956
|
+
return null
|
|
957
|
+
}
|
|
958
|
+
|
|
774
959
|
export function runAutofixAndValidate(schemaPath) {
|
|
775
|
-
const
|
|
776
|
-
const candidates = [PY, 'python3']
|
|
960
|
+
const candidates = pythonCandidates()
|
|
777
961
|
let lastOut = ''
|
|
778
962
|
for (const py of candidates) {
|
|
779
963
|
let autofix
|
|
780
964
|
try {
|
|
781
965
|
autofix = runPyScript(py, 'autofix_schema.py', [schemaPath])
|
|
782
966
|
} catch {
|
|
783
|
-
continue // 该 python
|
|
967
|
+
continue // 该 python 二进制不存在(ENOENT),换下一个候选
|
|
784
968
|
}
|
|
969
|
+
// 假解释器(Windows Store 别名 / 空壳 wrapper)能启动但零产出:不能拿它当「跑过了」,
|
|
970
|
+
// 否则 autofix 与 validate 都没执行,却被报成 schema 校验失败。
|
|
971
|
+
if (!autofix.stdout && autofix.code !== 0) continue
|
|
785
972
|
const v = runPyScript(py, 'validate_schema.py', [schemaPath, '--json'])
|
|
786
973
|
const out = v.stdout || autofix.stdout
|
|
787
974
|
if (out) {
|
|
@@ -795,7 +982,17 @@ export function runAutofixAndValidate(schemaPath) {
|
|
|
795
982
|
} else lastOut = out.slice(0, 200)
|
|
796
983
|
}
|
|
797
984
|
}
|
|
798
|
-
|
|
985
|
+
// envError:调用方(iterate 门禁)据此判「环境阻塞」而不是「卡片有错」——
|
|
986
|
+
// 实测这个区别决定模型是停下来告诉用户,还是为一条永远修不好的 error 空转 20 轮。
|
|
987
|
+
return {
|
|
988
|
+
ok: false,
|
|
989
|
+
envError: true,
|
|
990
|
+
errors: [
|
|
991
|
+
`validate 未执行(没找到可用的 python 解释器:试过 ${candidates.join(' / ')})` +
|
|
992
|
+
`${lastOut ? `:${lastOut}` : ''}。这不是卡片的错误——装 Python 或设 DSL_CARD_PYTHON=<解释器路径> 后重跑,不要为它改 schema。`,
|
|
993
|
+
],
|
|
994
|
+
warnings: [],
|
|
995
|
+
}
|
|
799
996
|
}
|
|
800
997
|
|
|
801
998
|
function main() {
|
|
@@ -818,7 +1015,7 @@ function main() {
|
|
|
818
1015
|
errs.slice(0, 20).forEach((e) => console.log(' DIFF ' + e))
|
|
819
1016
|
|
|
820
1017
|
// 写临时卡跑 validate + lint
|
|
821
|
-
const repoRoot =
|
|
1018
|
+
const repoRoot = projectRoot
|
|
822
1019
|
const tmpId = '__ir_selftest__'
|
|
823
1020
|
const tmpDir = path.join(repoRoot, 'src', 'cards', tmpId)
|
|
824
1021
|
fs.mkdirSync(tmpDir, { recursive: true })
|
|
@@ -845,6 +1042,13 @@ function main() {
|
|
|
845
1042
|
if (args.ir && args.out) {
|
|
846
1043
|
const ir = JSON.parse(fs.readFileSync(args.ir, 'utf8'))
|
|
847
1044
|
const { schema, data } = expandIR(ir)
|
|
1045
|
+
// 非法样式键:**写盘前**中止。放行的代价是「schema 写得像成功、渲染却丢样式」,
|
|
1046
|
+
// 下游 validate 只会报整卡 FAIL,模型看不出是键名错 → 推倒重写整份 IR(实测 16 次)。
|
|
1047
|
+
if (unknownStyleKeys.length) {
|
|
1048
|
+
console.error(unknownStyleKeysError())
|
|
1049
|
+
console.error(`\n[ir] 共 ${unknownStyleKeys.length} 处非法样式键,已中止(schema/data 未写盘)。改键名即可,不要重写整份 IR。`)
|
|
1050
|
+
process.exit(3)
|
|
1051
|
+
}
|
|
848
1052
|
fs.mkdirSync(args.out, { recursive: true })
|
|
849
1053
|
const sp = path.join(args.out, 'schema.json')
|
|
850
1054
|
const dp = path.join(args.out, 'data.json')
|
|
@@ -852,7 +1056,7 @@ function main() {
|
|
|
852
1056
|
if (snap) console.log(`[history] 已快照旧 schema → .history/${snap}(覆盖前备份,可回滚)`)
|
|
853
1057
|
fs.writeFileSync(sp, JSON.stringify(schema, null, 2) + '\n')
|
|
854
1058
|
fs.writeFileSync(dp, JSON.stringify(data, null, 2) + '\n')
|
|
855
|
-
const sliceSync = syncReferencedSlices(schema, args.ir, args['slices-dir'])
|
|
1059
|
+
const sliceSync = syncReferencedSlices(schema, args.ir, [args['slices-dir'], args['slices-from']])
|
|
856
1060
|
if (sliceSync.rewrites.length) {
|
|
857
1061
|
// 去重改写发生在 schema 落盘之后 → 回写 schema/data,保证盘上引用与 src/slices 一致
|
|
858
1062
|
rewriteSliceRefs(data, sliceSync.rewrites)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gate.mjs — 卡片校验门禁一条命令(2026-09-21 优化方案 1.3)。
|
|
4
|
+
*
|
|
5
|
+
* 把 Workflow A 步骤 4 的 5 条门禁命令合并成一次调用,消除 4 次 LLM 工具
|
|
6
|
+
* 调用往返。依次执行(任一步 FAIL 立即短路后续非修复步骤):
|
|
7
|
+
*
|
|
8
|
+
* 1. autofix python autofix_schema.py <schema> 机械修复(写回)
|
|
9
|
+
* 2. validate python validate_schema.py <schema> 结构合法性(0 ERROR 0 WARN)
|
|
10
|
+
* 3. data python derive_data_json.py <schema> --check data.json 一致性
|
|
11
|
+
* 4. lint node lint_card.mjs --id <id> 行为规则 0 ERROR
|
|
12
|
+
* 5. preflight node preflight.mjs --spec <spec> --id <id> 13 项机械检查(给了 --spec 才跑)
|
|
13
|
+
*
|
|
14
|
+
* 用法:
|
|
15
|
+
* node scripts/gate.mjs --id <卡id> [--spec UI/<画板>/spec.json] [--json] [--project-root <dir>]
|
|
16
|
+
*
|
|
17
|
+
* 退出码:0 = 全部通过;1 = 任一门禁 FAIL;2 = 用法/路径错误。
|
|
18
|
+
* --json 输出机器可读汇总:{ ok, steps: { autofix: {ok, output}, validate: {...}, ... }, summary }
|
|
19
|
+
*
|
|
20
|
+
* 自包含:只依赖 Node 内置模块 + Python3(validate/autofix/derive 是 .py)。
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { cardSchemaPath, cardsDir, skillRoot } from './lib/paths.mjs'
|
|
25
|
+
|
|
26
|
+
// ---------- CLI ----------
|
|
27
|
+
const argv = process.argv.slice(2)
|
|
28
|
+
function argOf(name) {
|
|
29
|
+
const i = argv.indexOf(name)
|
|
30
|
+
return i >= 0 ? argv[i + 1] : undefined
|
|
31
|
+
}
|
|
32
|
+
const hasFlag = (name) => argv.includes(name)
|
|
33
|
+
|
|
34
|
+
const id = argOf('--id')
|
|
35
|
+
const specPath = argOf('--spec')
|
|
36
|
+
const asJson = hasFlag('--json')
|
|
37
|
+
const projectRoot = argOf('--project-root')
|
|
38
|
+
|
|
39
|
+
if (!id || argv.includes('--help') || argv.includes('-h')) {
|
|
40
|
+
console.error('Usage: node scripts/gate.mjs --id <卡id> [--spec UI/<画板>/spec.json] [--json] [--project-root <dir>]')
|
|
41
|
+
process.exit(2)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const schema = cardSchemaPath(id)
|
|
45
|
+
let fs
|
|
46
|
+
try {
|
|
47
|
+
fs = await import('node:fs')
|
|
48
|
+
if (!fs.existsSync(schema)) {
|
|
49
|
+
console.error(`[gate] schema not found: ${schema}`)
|
|
50
|
+
process.exit(2)
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// fs import always succeeds; placeholder for symmetry
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (projectRoot) process.env.DSL_CARD_PROJECT_ROOT = projectRoot
|
|
57
|
+
|
|
58
|
+
const scriptsDir = path.join(skillRoot, 'scripts')
|
|
59
|
+
const py = process.env.PYTHON || 'python3'
|
|
60
|
+
|
|
61
|
+
/** 跑一步门禁。返回 { ok, code, output };output 只保留尾部的关键行(错误/摘要)。 */
|
|
62
|
+
function runStep(name, cmd, args) {
|
|
63
|
+
const res = spawnSync(cmd, args, {
|
|
64
|
+
cwd: projectRoot || process.cwd(),
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
67
|
+
timeout: 120_000,
|
|
68
|
+
})
|
|
69
|
+
const out = `${res.stdout || ''}${res.stderr || ''}`.trim()
|
|
70
|
+
const ok = res.status === 0
|
|
71
|
+
// 只保留含 ERROR/WARN/✗/failed/关键字样的行 + 最后 8 行,控制输出体积
|
|
72
|
+
const lines = out.split('\n')
|
|
73
|
+
const key = lines.filter((l) =>
|
|
74
|
+
/ERROR|WARN|✗|✘|FAIL|failed|not found|missing|Invalid|invalid|error/i.test(l)
|
|
75
|
+
)
|
|
76
|
+
const brief = [...new Set([...key, ...lines.slice(-8)])].slice(-24).join('\n')
|
|
77
|
+
return { step: name, ok, code: res.status, output: brief || out.slice(0, 2000), full: out }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------- 门禁序列 ----------
|
|
81
|
+
const steps = []
|
|
82
|
+
|
|
83
|
+
steps.push(
|
|
84
|
+
runStep('autofix', py, [path.join(scriptsDir, 'autofix_schema.py'), schema])
|
|
85
|
+
)
|
|
86
|
+
steps.push(
|
|
87
|
+
runStep('validate', py, [path.join(scriptsDir, 'validate_schema.py'), schema])
|
|
88
|
+
)
|
|
89
|
+
steps.push(
|
|
90
|
+
runStep('data', py, [path.join(scriptsDir, 'derive_data_json.py'), schema, '--check'])
|
|
91
|
+
)
|
|
92
|
+
steps.push(
|
|
93
|
+
runStep('lint', process.execPath, [path.join(scriptsDir, 'lint_card.mjs'), '--id', id])
|
|
94
|
+
)
|
|
95
|
+
if (specPath) {
|
|
96
|
+
steps.push(
|
|
97
|
+
runStep('preflight', process.execPath, [
|
|
98
|
+
path.join(scriptsDir, 'preflight.mjs'),
|
|
99
|
+
'--spec',
|
|
100
|
+
specPath,
|
|
101
|
+
'--id',
|
|
102
|
+
id,
|
|
103
|
+
])
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const failed = steps.filter((s) => !s.ok)
|
|
108
|
+
const ok = failed.length === 0
|
|
109
|
+
|
|
110
|
+
// ---------- 输出 ----------
|
|
111
|
+
if (asJson) {
|
|
112
|
+
const summary = {
|
|
113
|
+
id,
|
|
114
|
+
schema,
|
|
115
|
+
ok,
|
|
116
|
+
failedSteps: failed.map((s) => s.step),
|
|
117
|
+
stepCount: steps.length,
|
|
118
|
+
}
|
|
119
|
+
console.log(
|
|
120
|
+
JSON.stringify(
|
|
121
|
+
{ ok, summary, steps: Object.fromEntries(steps.map((s) => [s.step, { ok: s.ok, code: s.code, output: s.output }])) },
|
|
122
|
+
null,
|
|
123
|
+
2
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
} else {
|
|
127
|
+
console.log(`\n=== gate: ${id} ===`)
|
|
128
|
+
for (const s of steps) {
|
|
129
|
+
const mark = s.ok ? '✓' : '✗'
|
|
130
|
+
console.log(`${mark} ${s.step}${s.ok ? '' : ` (exit ${s.code})`}`)
|
|
131
|
+
}
|
|
132
|
+
if (ok) {
|
|
133
|
+
console.log(`\n[PASS] 全部 ${steps.length} 项门禁通过 → 可进入渲染/视觉闭环`)
|
|
134
|
+
} else {
|
|
135
|
+
for (const f of failed) {
|
|
136
|
+
console.log(`\n--- ${f.step} 输出(关键行)---`)
|
|
137
|
+
console.log(f.output)
|
|
138
|
+
}
|
|
139
|
+
console.log(`\n[FAIL] ${failed.length}/${steps.length} 项未过:${failed.map((f) => f.step).join(', ')}`)
|
|
140
|
+
console.log('按 lint/validate 输出的规则号逐条修复后重跑本命令;修复手法见规则号对应文档(rules.md / generation_pitfalls.md / component_rules.md)。')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
process.exit(ok ? 0 : 1)
|