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.
Files changed (108) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
  3. package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  7. package/dist/vendored/mcp/client.mjs +9 -2
  8. package/dist/vendored/server/core/ai-chat.js +1702 -134
  9. package/dist/vendored/server/core/browser.js +12 -3
  10. package/dist/vendored/server/core/cardFiles.js +3 -0
  11. package/dist/vendored/server/core/cardHistory.js +1 -0
  12. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  13. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  14. package/dist/vendored/server/core/cardSave.js +1 -0
  15. package/dist/vendored/server/core/cardSync.js +3 -0
  16. package/dist/vendored/server/core/cardValidate.js +16 -5
  17. package/dist/vendored/server/core/codeBridge.js +5 -1
  18. package/dist/vendored/server/core/logStream.js +1 -0
  19. package/dist/vendored/server/core/sliceStatic.js +24 -4
  20. package/dist/vendored/server/core/sliceUpload.js +1 -0
  21. package/dist/vendored/server/core/turnJournal.js +6 -0
  22. package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
  23. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  24. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  26. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  34. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
  37. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  38. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  39. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
  40. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
  41. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  43. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
  44. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  45. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
  46. package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
  47. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
  50. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
  51. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  52. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  53. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
  54. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
  55. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  56. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  57. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  58. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
  59. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
  60. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  61. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  62. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  63. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
  64. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  65. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  66. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  67. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  68. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  69. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  70. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  71. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  72. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  73. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  74. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  75. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  76. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  77. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  78. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  79. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  80. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  81. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
  82. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
  83. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
  85. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
  86. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  88. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  89. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
  90. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
  91. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  92. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  94. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  95. package/dist/vendored/skills/shared/spec-contract.md +118 -0
  96. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
  97. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
  98. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
  99. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  100. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
  101. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
  102. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
  103. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
  104. package/package.json +1 -1
  105. package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
  106. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
  107. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  108. 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 = path.extname(j.name) ? j.name : `${j.name}.png`
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
- /** Cross-platform Python detection: DSL_CARD_PYTHON > WORKBUDDY_PYTHON > python3 */
3
- function findPython() {
4
- if (process.env.DSL_CARD_PYTHON) return process.env.DSL_CARD_PYTHON;
5
- if (process.env.WORKBUDDY_PYTHON) return process.env.WORKBUDDY_PYTHON;
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>] # 引用切图的来源目录(默认 IR 同目录 slices/)
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 — let it pass through (validate will catch)
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) node.slots = slotDefs
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, slicesDir) {
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
- const repoRoot = path.resolve(skillRoot, '..', '..')
676
- const targetDir = path.join(repoRoot, 'src', 'slices')
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
- // 切图来源:--slices-dir 显式指定(要素包 slices/)优先,其次 IR 同目录 slices/
679
- const sourceDirs = [...(slicesDir ? [path.resolve(slicesDir)] : []), path.join(path.dirname(path.resolve(irPath)), 'slices')]
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
- const source = sourceDirs.map((d) => path.join(d, name)).find((f) => fs.existsSync(f))
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
- throw new Error(`IR 引用的切图不存在:${ref}(已查找 ${sourceDirs.join(' / ')};可用 --slices-dir 指向要素包的 slices 目录)`)
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 PY = findPython()
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
- return { ok: false, errors: [`validate 未执行(python 不可用${lastOut ? `:${lastOut}` : ''})`], warnings: [] }
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 = path.resolve(skillRoot, '..', '..')
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)