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
@@ -15,7 +15,17 @@
15
15
  * 输出(stdout JSON):
16
16
  * { card, design:{scale}, texts[], shapes[], slices[], bands[], gaps{}, colors[], missing[],
17
17
  * bindHints[], listCandidates[] }
18
+ * - shapes[] 每条 = { x,y,w,h, bg, gradient, bgImage, border, radius, opacity }
19
+ * · **gradient / border / bgImage 必须带出来**:只给 bg 时,渐变底板与描边层在模型眼里
20
+ * 就是一个「什么都没有的空节点」,会被整块丢掉(2026-09-21 helloCard:顶部渐变区 343×154、
21
+ * 两块产品底板 311×62、红标签 0.5px #F6434F 描边、311×0.5 分割线,四处全丢)。
22
+ * 没有视觉能力的模型只能靠这份清单知道「这里有个东西要画」。
23
+ * · gradient 是 CSS 串(spec 里已是该格式),可直接喂 expand_ir 的 backgroundGradient。
18
24
  * - slices[].verdict ∈ full-asset / missing-file / reused-differs / reused-identical / content-twin
25
+ * · ref = 设计稿图层**完整**引用(含 `8.icon/通用/` 这类分组路径);name = **消毒后的落盘/引用名**,
26
+ * 两者不同时另带 originalName。存在性判定按完整 ref + `@2x/@3x` 后缀查
27
+ * (MeaXure 常态落点是 `slices/<分组路径>/<名>@3x.png`)——只按 basename 找扁平文件会把
28
+ * **真实存在**的资产报成 missing-file,下游白重裁或模型改用图层名当引用(浏览器 404)。
19
29
  * · content-twin:导出资产缺文件,但**既有切图里已有像素同内容**(共用/语义重命名/跨画板)→ 直接复用,不裁切
20
30
  * · crops[] 按**指纹分组**产出:同组(内容相同)的多个矩形共用一张派生图(alsoRects 列同组其余矩形),
21
31
  不再「每个矩形一个 _altN」;派生前先对既有切图做像素级判重,命中给 reuseOf(引用改绑既有名,不裁切)
@@ -30,6 +40,7 @@ import path from 'node:path'
30
40
 
31
41
  const { PNG } = await import('pngjs')
32
42
  import { cropPng, findPixelTwin } from './lib/slice_pixels.mjs'
43
+ import { sanitizeSliceFileName } from './lib/slice_dedupe.mjs'
33
44
 
34
45
  function parseArgs(argv) {
35
46
  const out = { spec: '', card: 1, slicesDir: '', design: '', projectSlices: '' }
@@ -41,10 +52,13 @@ function parseArgs(argv) {
41
52
  else if (a === '--design') out.design = argv[++i] || ''
42
53
  else if (a === '--project-slices') out.projectSlices = argv[++i] || ''
43
54
  else if (a === '--save') out.save = argv[++i] || ''
55
+ else if (a === '--no-save') out.noSave = true
44
56
  else if (a === '-h' || a === '--help') {
45
- console.log('Usage: node spec_audit.mjs --spec <spec.json> [--card 1] [--slices-dir <dir>] [--design <card-N.png>] [--save <path.json>]')
46
- console.log(' --save:把整份审计 JSON 原样落盘(供 crop_slices --from-audit 直接消费)。')
57
+ console.log('Usage: node spec_audit.mjs --spec <spec.json> [--card 1] [--slices-dir <dir>] [--design <card-N.png>] [--save <path.json>] [--no-save]')
58
+ console.log(' --save:整份审计 JSON 落盘路径;不给就默认落 <工程根>/.cache/spec-audit/<spec 名>-card<N>.json。')
47
59
  console.log(' 历史事故:模型用手抄的 156 字节「审计摘要」喂 crop_slices,切图名/矩形全错;审计必须整份落盘复用,禁止手抄。')
60
+ console.log(' --no-save:不落盘(只在明确不需要 crop_slices --from-audit 时才用)。')
61
+ console.log(' stdout 一律是紧凑 JSON 审计简报;超过 9000 字会改出「摘要 + savedTo」。')
48
62
  process.exit(0)
49
63
  }
50
64
  }
@@ -103,7 +117,6 @@ function analyzeSlice(file) {
103
117
  return { px: [png.width, png.height], bg, label: rows > 0 && labelRows / rows > 0.2 }
104
118
  }
105
119
 
106
- /** 切图名可能不带扩展名(spec 里存的是图层名),逐个候选扩展名找文件 */
107
120
  /**
108
121
  * manifest 别名表:语义重命名(slices_normalize / 手工改名)后,导出原名 → 新名的映射。
109
122
  * 读各目录 manifest.json 的 renamedFrom / mergedFrom(§1.1「改名要成套做」的机器可读痕迹)。
@@ -123,18 +136,59 @@ function buildAliasMap(dirs) {
123
136
  return aliases
124
137
  }
125
138
 
126
- function resolveSliceFile(dirs, name, aliases = null) {
127
- const base = path.extname(name) ? name : `${name}.png`
128
- const names = [name]
129
- const alias = aliases && (aliases.get(name) || aliases.get(base))
130
- if (alias) names.push(alias)
131
- for (const dir of dirs) {
132
- for (const nm of names) {
133
- const b = path.extname(nm) ? nm : `${nm}.png`
134
- for (const cand of [b, nm, `${nm}.jpg`, `${nm}.jpeg`, `${nm}.webp`]) {
135
- const f = path.join(dir, cand)
136
- if (fs.existsSync(f)) return f
137
- }
139
+ /** 路径输出:在 cwd 内给相对路径(好读、可复现),在 cwd 外给绝对路径(否则一串 `../../..`)。 */
140
+ function relOrAbs(file) {
141
+ const rel = path.relative(process.cwd(), file)
142
+ return rel && !rel.startsWith('..') ? rel.split(path.sep).join('/') : file
143
+ }
144
+
145
+ /**
146
+ * 解析切图引用 → 实际文件。
147
+ *
148
+ * **必须吃全引用路径(含子目录)与 `@Nx` 倍图后缀**。历史事故(2026-09-21 helloCard):
149
+ * 旧实现 `path.basename(ref)` 后再拼 `<名>.png` 找一个扁平文件,而 MeaXure 导出把切图放在
150
+ * `slices/<图层分组路径>/<名字>@3x.png` —— 于是**磁盘上明明存在**的资产被判成 `missing-file`,
151
+ * 下游要么白重裁一遍(丢原图品质),要么模型改用设计稿图层名当引用、浏览器 404(图片空白零报错)。
152
+ *
153
+ * 候选优先级:完整 ref → basename → manifest 别名;每层再补 `@3x/@2x` 与常见扩展名。
154
+ * 循环以「名字」为外层、「目录」为内层:**精确 ref 命中优先于模糊命中**,
155
+ * 避免 bundle 里的 `@3x` 猜中而把工程里真名同名的扁平切图挤掉。
156
+ */
157
+ function resolveSliceFile(dirs, ref, aliases = null) {
158
+ const raw = String(ref || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()
159
+ if (!raw) return null
160
+ const base = path.basename(raw)
161
+ const dirPart = path.dirname(raw) === '.' ? '' : path.dirname(raw)
162
+ const stripExt = (s) => (path.extname(s) ? s.slice(0, -path.extname(s).length) : s)
163
+ const alias = aliases && (aliases.get(raw) || aliases.get(base) || aliases.get(stripExt(base)))
164
+ const rels = [...new Set([raw, base, ...(alias ? [alias, path.basename(alias)] : [])].filter(Boolean))]
165
+ const names = []
166
+ for (const rel of rels) {
167
+ const ext = path.extname(rel)
168
+ const core = stripExt(rel)
169
+ const exts = ext ? [ext] : ['.png', '.jpg', '.jpeg', '.webp', '.svg']
170
+ for (const e of exts) names.push(`${core}${e}`)
171
+ // 倍图后缀:MeaXure 常态是 `<名>@3x.png`,仅当引用本身没带 @Nx 时补
172
+ if (!/@\dx$/i.test(core)) {
173
+ for (const e of exts) names.push(`${core}@3x${e}`, `${core}@2x${e}`)
174
+ }
175
+ }
176
+ for (const nm of names) {
177
+ for (const dir of dirs) {
178
+ const f = path.join(dir, nm)
179
+ if (fs.existsSync(f)) return f
180
+ }
181
+ }
182
+ // 兜底:ref 带子目录但导出时把目录压平了(少数导出脚本行为),按 basename 在子目录里搜一层
183
+ if (dirPart) {
184
+ for (const dir of dirs) {
185
+ const sub = path.join(dir, dirPart)
186
+ if (!fs.existsSync(sub)) continue
187
+ try {
188
+ for (const f of fs.readdirSync(sub)) {
189
+ if (stripExt(f) === stripExt(base)) return path.join(sub, f)
190
+ }
191
+ } catch {}
138
192
  }
139
193
  }
140
194
  return null
@@ -210,21 +264,74 @@ function main() {
210
264
  })
211
265
  } else if (n.type === 'slice') {
212
266
  const raw = typeof n.slices === 'string' ? n.slices : (n.slices && (n.slices.name || n.slices.path)) || n.name || ''
213
- sliceEls.push({ name: path.basename(String(raw)), rect })
267
+ // ref 保留**完整**引用(含 `8.icon/通用/` 这类分组路径)——basename 会让存在性判定失准;
268
+ // name 给的是**消毒后的落盘名**,模型必须拿它写 /slices 引用(含 `#`/`%`/`&` 会被浏览器截断)。
269
+ const ref = String(raw).replace(/\\/g, '/').replace(/^\/+/, '').trim()
270
+ sliceEls.push({ ref, name: sanitizeSliceFileName(path.basename(ref)), rect })
214
271
  } else if (n.type === 'shape') {
215
272
  const st = n.style || {}
216
- const bg = (st.background && st.background.color) || (st.fill && st.fill.color) || null
217
- shapes.push({ ...rect, bg, radius: st.radius ?? null, opacity: st.opacity ?? 1 })
273
+ const fill = st.fill || {}
274
+ const background = st.background || {}
275
+ const bd = st.border || {}
276
+ shapes.push({
277
+ ...rect,
278
+ // background 优先(与设计工具的语义一致:background = 容器底色,fill = 形状填色)
279
+ bg: background.color || fill.color || null,
280
+ // 渐变层必须显式带出来。只给 bg:null 时模型看到的是「一个什么都没有的空节点」,
281
+ // 于是渐变底板 / 顶部渐变区会被整块丢掉(helloCard 实测丢 3 处:顶部 343×154 + 两块 311×62 底板)。
282
+ // spec 里 gradient 已经是 CSS 串,原样透传(与 expand_ir 的 backgroundGradient 同格式)。
283
+ gradient: background.gradient || fill.gradient || null,
284
+ bgImage: background.image || fill.image || null,
285
+ // 描边同理:红标签的 0.5px #F6434F 边框不报出来,模型就只会写一个裸 Text。
286
+ border: bd.isBorder ? { color: bd.color || null, width: bd.width ?? null } : null,
287
+ radius: st.radius ?? null,
288
+ opacity: st.opacity ?? 1,
289
+ })
218
290
  }
219
291
  ;(n.children || []).forEach(walk)
220
292
  }
221
293
  ;(card.elements || []).forEach(walk)
222
294
 
295
+ /**
296
+ * 聚合文本节点剔除(导出侧冗余层)。
297
+ *
298
+ * MeaXure 导出会在**同一行**同时给出「合并文案」与「拆分文案」。实测 2026-09-24 收支分析卡:
299
+ * `总收入(元)` (x32 w70) 与 `总收入` (x32 w45) + `(元)` (x77 w25) 同行并存;
300
+ * `转账-张扬 (银行号转账)` (x32 w148) 与 `转账-张扬` (x32 w75) + `(银行号转账)` (x107 w74) 同行并存。
301
+ * 对设计稿做像素采样可证可见真值是**拆分节点**(主文案 #111111 + 后缀 #999999 两色),
302
+ * 合并节点的 textStyle 只有一种颜色 ⇒ 它是导出侧的聚合层,画不出来。
303
+ *
304
+ * 不剔除的代价:① texts 真值集虚高(该卡 29 条 vs 视觉 20 条),bindHints 与 listCandidates
305
+ * 一并被污染;② 模型若挑中合并节点,会把灰色后缀渲染成主色(颜色偏差)。
306
+ *
307
+ * 判据只用几何 + 文本,不含任何业务词:同行(|Δy| ≤ 2)且 x 区间被包含的 ≥2 条文本,
308
+ * 按 x 排序拼接后与本体的非空白内容相同 ⇒ 本体是聚合层。
309
+ */
310
+ const redundantTexts = []
311
+ const normText = (s) => String(s || '').replace(/\s+/g, '')
312
+ const keptTexts = texts.filter((t, i) => {
313
+ const parts = texts.filter(
314
+ (o, j) => j !== i && Math.abs(o.y - t.y) <= 2 && o.x >= t.x - 1 && o.x + o.w <= t.x + t.w + 1,
315
+ )
316
+ if (parts.length < 2) return true
317
+ const joined = parts.slice().sort((a, b) => a.x - b.x).map((p) => p.text).join('')
318
+ if (normText(joined) !== normText(t.text)) return true
319
+ redundantTexts.push({
320
+ text: t.text,
321
+ rect: { x: t.x, y: t.y, w: t.w, h: t.h },
322
+ parts: parts.map((p) => p.text),
323
+ })
324
+ return false
325
+ })
326
+ texts.length = 0
327
+ texts.push(...keptTexts)
328
+
223
329
  // 切图清单:存在性 / 复用 / 烘焙内容 / 复用但内容不同
224
- const byName = new Map()
330
+ // 按**完整 ref** 分组(不是 basename):分组路径是图层身份的一部分,两个不同分组的同名图层是两张图。
331
+ const byRef = new Map()
225
332
  for (const el of sliceEls) {
226
- if (!byName.has(el.name)) byName.set(el.name, [])
227
- byName.get(el.name).push(el.rect)
333
+ if (!byRef.has(el.ref)) byRef.set(el.ref, [])
334
+ byRef.get(el.ref).push(el.rect)
228
335
  }
229
336
  const slicesDir = args.slicesDir || path.join(path.dirname(args.spec), 'slices')
230
337
  // 派生切图的唯一落点是 src/slices/:已派生过的缺口不应再报 missing(否则每轮都建议重裁)
@@ -244,7 +351,8 @@ function main() {
244
351
  })
245
352
  return groups
246
353
  }
247
- /** 设计稿矩形区域 ↔ 既有切图的像素级判重(sha 快路径 + 容差兜底,slice_pixels.mjs)。
354
+ /**
355
+ * 设计稿矩形区域 ↔ 既有切图的像素级判重(sha 快路径 + 容差兜底,slice_pixels.mjs)。
248
356
  * 覆盖「共用切图已语义重命名 / 在别的画板已派生过」——名字对不上也能认出来。 */
249
357
  const regionTwin = (rect) => {
250
358
  if (!design) return null
@@ -255,11 +363,15 @@ function main() {
255
363
  }
256
364
  return null
257
365
  }
258
- for (const [name, rects] of byName) {
259
- let file = resolveSliceFile(sliceDirs, name, aliases)
366
+ for (const [ref, rects] of byRef) {
367
+ // `ref` = 设计稿里的完整图层引用(含分组路径);`name` = 消毒后的落盘/引用名。
368
+ // 两者都报出来:模型按 name 写 /slices 引用,按 ref 回查设计稿图层。
369
+ const originalName = path.basename(ref)
370
+ const name = sanitizeSliceFileName(originalName)
371
+ const stem = path.extname(name) ? name.slice(0, -path.extname(name).length) : name
372
+ let file = resolveSliceFile(sliceDirs, ref, aliases)
260
373
  let exists = !!file
261
374
  let info = exists ? analyzeSlice(file) : null
262
- const logical = info ? [Math.round(info.px[0] / (info.px[0] / rects[0].w)), Math.round(info.px[1] / (info.px[1] / rects[0].h))] : null
263
375
  let verdict = exists ? 'full-asset' : 'missing-file'
264
376
  let reuseOf = null
265
377
  let crops = null
@@ -274,17 +386,16 @@ function main() {
274
386
  file = path.join(twin0.dir, twin0.twin)
275
387
  info = analyzeSlice(file)
276
388
  } else {
277
- const base0 = path.parse(name).name
278
389
  crops = []
279
390
  groups.forEach((g, gi) => {
280
391
  const rep = rects[g.idxs[0]]
281
- const cn = gi === 0 ? `${base0}.png` : `${base0}_alt${gi + 1}.png`
392
+ const cn = sanitizeSliceFileName(gi === 0 ? stem : `${stem}_alt${gi + 1}`)
282
393
  const twin = gi === 0 ? twin0 : regionTwin(rep)
283
394
  const entry = { name: cn, exists: !!resolveSliceFile(sliceDirs, cn, aliases), ...rep }
284
395
  if (g.idxs.length > 1) entry.alsoRects = g.idxs.slice(1).map((i) => rects[i])
285
396
  if (twin) { entry.exists = true; entry.reuseOf = twin.twin; entry.from = path.basename(twin.dir) }
286
397
  crops.push(entry)
287
- if (!entry.exists) missing.push({ reason: 'slice-file-missing', name, crop: entry })
398
+ if (!entry.exists) missing.push({ reason: 'slice-file-missing', name, originalName, crop: entry })
288
399
  })
289
400
  if (twin0) { verdict = 'content-twin-partial'; reuseOf = twin0.twin }
290
401
  }
@@ -299,11 +410,10 @@ function main() {
299
410
  // 历史事故②(2026-09-18 ynet-card):旧实现只跟 rects[0] 比指纹、每矩形一个 _altN,
300
411
  // 内容相同的 5 个矩形裁出 5 份字节级重复副本;且不做既有切图判重,共用图标已重命名
301
412
  // 落地仍被重裁。现按指纹分组 + 像素级复用根治。
302
- const baseN = path.parse(name).name
303
413
  crops = []
304
414
  for (let gi = 1; gi < groups.length; gi++) {
305
415
  const rep = rects[groups[gi].idxs[0]]
306
- const cn = `${baseN}_alt${gi + 1}.png`
416
+ const cn = sanitizeSliceFileName(`${stem}_alt${gi + 1}`)
307
417
  const entry = { name: cn, exists: !!resolveSliceFile(sliceDirs, cn, aliases), ...rep }
308
418
  if (groups[gi].idxs.length > 1) entry.alsoRects = groups[gi].idxs.slice(1).map((i) => rects[i])
309
419
  if (!entry.exists) {
@@ -311,13 +421,19 @@ function main() {
311
421
  if (twin) { entry.exists = true; entry.reuseOf = twin.twin; entry.from = path.basename(twin.dir) }
312
422
  }
313
423
  crops.push(entry)
314
- if (!entry.exists) missing.push({ reason: 'reused-slice-content-differs', name, crop: entry })
424
+ if (!entry.exists) missing.push({ reason: 'reused-slice-content-differs', name, originalName, crop: entry })
315
425
  }
316
426
  }
317
427
  }
318
428
  slices.push({
319
429
  name,
430
+ // ref = 设计稿图层完整引用;引用名与设计稿图层名不一致时模型才查得回去
431
+ ref,
432
+ ...(name !== originalName ? { originalName } : {}),
320
433
  exists,
434
+ // 解析到的实际文件路径:crop_slices --from-audit 据此把「审计判定存在」的资产
435
+ // 按消毒名搬进工程 src/slices/(否则模型拿着正确名字仍然 404 —— 图片空白零报错)。
436
+ file: file ? relOrAbs(file) : null,
321
437
  from: file ? path.basename(path.dirname(file)) : null,
322
438
  reuseOf,
323
439
  rects,
@@ -347,11 +463,22 @@ function main() {
347
463
  } else bands.push({ y: it.y, h: it.h, items: [it] })
348
464
  }
349
465
  bands.sort((a, b) => a.y - b.y)
350
- for (const b of bands) b.items.sort((a, c) => a.x - c.x)
466
+ // 带内排序必须与下面「网格行合并」用的口径**完全一致**(y 为主、x 为辅)。
467
+ // 2026-09-24 实测事故:这里原来只按 x 排,合并那里按 (y,x) 排 ⇒ 同构的三条交易行
468
+ // (每条自身两行:主文案+金额 / 账户+日期)签名被打乱成两种(t15|t12|t13|t12|t15 与
469
+ // t15|t13|t15|t12|t12),「同构连续行」的 run 检测只认出 2 条,真列表漏掉一行。
470
+ // 单行带(y 全同)下两种口径等价,改口径不会动已有单行判定。
471
+ for (const b of bands) b.items.sort((a, c) => a.y - c.y || a.x - c.x)
351
472
  const rowGaps = bands.slice(1).map((b, i) => b.y - (bands[i].y + bands[i].h))
352
473
 
353
- const xs = items.map((i) => i.x)
354
- const xes = items.map((i) => i.x + i.w)
474
+ // 左右内缩只看**内容**的外沿:整宽装饰(行底色矩形 / 通栏分割线)的 local x 恒为 0,
475
+ // 掺进来会让 padLeft / padRight 永远是 0。实测 2026-09-24 收支分析卡:三个交易行的底色
476
+ // 矩形 343×72 从 x=0 起,padLeft 报 0,而真实内容内缩是 16(文案 x=16)——
477
+ // 模型照 0 排版就直接贴边。整宽/近整宽(≥ W−4)的装饰单独屏蔽。
478
+ const insetItems = items.filter((i) => i.w < W - 4)
479
+ const padSource = insetItems.length ? insetItems : items
480
+ const xs = padSource.map((i) => i.x)
481
+ const xes = padSource.map((i) => i.x + i.w)
355
482
  const gaps = {
356
483
  padLeft: Math.min(...xs),
357
484
  padRight: W - Math.max(...xes),
@@ -388,7 +515,23 @@ function main() {
388
515
  const i = (Math.round(y * scale) * design.width + Math.round(4 * scale)) * 4
389
516
  return { hex: hex(design.data[i], design.data[i + 1], design.data[i + 2]), a: design.data[i + 3] }
390
517
  }
391
- const fill = colors.length ? colors[3]?.hex || colors[0].hex : '#FFFFFF'
518
+ // 卡底色 = 角部 + 右缘 gutter 的众数(量化色距聚类取最大簇)。
519
+ // **绝不能用 center 采样点**:卡面正中常被图表/大图占据(实测雷达卡 center=#F8B46A 恰是
520
+ // 雷达图中心橙,被当成卡底色后 bgGradientHint 产出「白→橙 264px」毒提示,模型照涂整卡,
521
+ // 像素 match 直接掉 4pp)。center 点仍保留在 colors 里供人看,但不参与底色投票。
522
+ const fillVotes = colors.filter((c) => c.label !== 'center').map((c) => c.hex)
523
+ const fill = (() => {
524
+ if (!fillVotes.length) return '#FFFFFF'
525
+ // 贪心聚类:色距 <12 视为同簇(与渐变检测的 dist 阈值一致)
526
+ const clusters = []
527
+ for (const hexV of fillVotes) {
528
+ const hit = clusters.find((c) => dist(c.ref, hexV) < 12)
529
+ if (hit) hit.n++
530
+ else clusters.push({ ref: hexV, n: 1 })
531
+ }
532
+ clusters.sort((a, b) => b.n - a.n)
533
+ return clusters[0].ref
534
+ })()
392
535
  // 首个不透明且明显异于卡底色的采样点 = 渐变带起点
393
536
  let startY = -1
394
537
  let top = null
@@ -457,24 +600,43 @@ function main() {
457
600
  const bandSig = (items) => items.filter((i) => i.kind !== 'shape').map((i) => (i.kind === 'text' ? `t${i.fs ?? ''}` : i.kind)).join('|')
458
601
  const listCandidates = []
459
602
  const pushCandidate = (layout, rows, source) => {
460
- const values = rows.map((r) => r.map((it) => (it.kind === 'text' ? it.text : it.name || '')))
603
+ // 值矩阵只保留内容项(text / slice):shape 是行底色、分割线这类纯装饰,行与行之间数量不等
604
+ // (2026-09-24 实测:三条交易行的 items 数是 6/7/8,多出来的全是 shape)—— 掺进来会让
605
+ // 「列」与「字段」整体错位,模型照着错位的矩阵命名会得到一堆空列。
606
+ const contentRows = rows.map((r) => r.filter((it) => it.kind !== 'shape'))
607
+ if (!contentRows.length || !contentRows[0].length) return
608
+ const width = Math.max(...contentRows.map((r) => r.length))
609
+ const values = contentRows.map((r) => {
610
+ const v = r.map((it) => (it.kind === 'text' ? it.text : it.name || ''))
611
+ while (v.length < width) v.push('') // 同构行理论等长;不足者补空占位,避免列数不齐
612
+ return v
613
+ })
461
614
  // 每行值完全相同 = 装饰性重复(分割线 / 同款图标行),不是数据行,不值得绑数组
462
615
  if (values.every((v) => JSON.stringify(v) === JSON.stringify(values[0]))) return
616
+ // 以最全的一行作字段基准(同构行等长,取最长更稳)
617
+ const base = contentRows.reduce((a, b) => (b.length > a.length ? b : a), contentRows[0])
463
618
  listCandidates.push({
464
619
  layout,
465
620
  rows: source,
466
- fields: rows[0].map((it, pos) => ({ pos, kind: it.kind, first: it.kind === 'text' ? it.text : it.name || '' })),
621
+ fields: base.map((it, pos) => ({ pos, kind: it.kind, first: it.kind === 'text' ? it.text : it.name || '' })),
467
622
  values,
468
623
  })
469
624
  }
470
625
  // 网格行合并:tile 内部(图标行 → 标题行 → 副标题行)的行距远小于 tile 之间的行距,
471
626
  // 按「小于中位行距 60%」把相邻 bands 粘成一行 tile;不合并的话列表候选会被拆成碎片行
627
+ //
628
+ // ⚠ 签名相同的相邻带**不粘**(2026-09-24 实测事故):tile 的各层靠签名区分(图标行 / 标题 /
629
+ // 副标题),签名相同的两条带是**并列的兄弟行**,不是同一 tile 的上下层。收支分析卡的三个交易行
630
+ // 内容高度正好顶满 72px 行高 ⇒ 带与带的 gap 为 0 ⇒ 旧逻辑把它们粘成一条带,于是
631
+ // 「同构连续行」的 run 检测再也看不到它们,真列表漏报;同时残留的那条巨带又喂出了
632
+ // 一个假候选(space-between 两端标签被当成「同构 tile 行」)。
472
633
  const rowGapMed = rowGaps.length ? [...rowGaps].sort((a, b) => a - b)[Math.floor(rowGaps.length / 2)] : 0
473
634
  const merged = []
474
635
  for (const b of bands) {
475
636
  const last = merged[merged.length - 1]
476
637
  const gap = last ? b.y - (last.y + last.h) : Infinity
477
- if (last && rowGapMed > 0 && gap >= 0 && gap < rowGapMed * 0.8) {
638
+ const sameSig = last && bandSig(b.items) === bandSig(last.items)
639
+ if (last && !sameSig && rowGapMed > 0 && gap >= 0 && gap < rowGapMed * 0.8) {
478
640
  last.items = [...last.items, ...b.items].sort((a, c) => a.y - c.y || a.x - c.x)
479
641
  last.h = Math.max(last.y + last.h, b.y + b.h) - last.y
480
642
  } else {
@@ -508,15 +670,32 @@ function main() {
508
670
  cur.push(its[i])
509
671
  }
510
672
  groups.push(cur)
511
- if (groups.length >= 2 && groups.every((g) => g.length === groups[0].length && bandSig(g) === bandSig(groups[0]))) {
673
+ // 同构 tile 组判定 + 排除「space-between 两端标签」这个高频假阳性(2026-09-24 实测:
674
+ // 收支分析卡 band1 的「总收入(元)…」/「总支出(元)…」两端标签被当成一个 2 组同构行候选,
675
+ // 模型得先在正文里推翻它才敢按真结构写 IR,白烧一轮推理)。
676
+ // 区分点:真 tile 是**等距列**(tile 之间有间隔但不吞掉大半行);两端标签是**一整条空档**。
677
+ // 故 2 组时要求列间空档不超过本带内容跨度的 40%;≥3 组不再额外限制(3 组及以上几乎不可能是两端标签)。
678
+ const span = its.reduce((m, i) => Math.max(m, i.x + i.w), 0) - its.reduce((m, i) => Math.min(m, i.x), Infinity)
679
+ const splitThreshold = Math.max(12, med * 2)
680
+ const wideSplit = gaps.some((g) => g > splitThreshold && span > 0 && g > span * 0.4)
681
+ if (
682
+ groups.length >= 2 &&
683
+ groups.every((g) => g.length === groups[0].length && bandSig(g) === bandSig(groups[0])) &&
684
+ (groups.length >= 3 || !wideSplit)
685
+ ) {
512
686
  pushCandidate('row', groups, [bi])
513
687
  }
514
688
  }
515
689
 
516
690
  const brief = {
691
+ ...(process.env.SPEC_AUDIT_DEBUG
692
+ ? { _debug: { merged: merged.map((m, i) => ({ i, y: m.y, h: m.h, sig: bandSig(m.items), n: m.items.length })) } }
693
+ : {}),
517
694
  card: { index: args.card, name: card.name || '', rect: { w: W, h: H }, fill: card.cardFill || null },
518
695
  design: design ? { path: args.design, scale: Number(scale.toFixed(3)) } : null,
519
696
  texts,
697
+ // 被剔除的导出侧聚合层(合并文案 + 其拆分件)。本行同内容只保留拆分件,模型不必再猜该用哪个。
698
+ redundantTexts: redundantTexts.length ? redundantTexts : undefined,
520
699
  shapes,
521
700
  bgLayers,
522
701
  slices,
@@ -531,19 +710,66 @@ function main() {
531
710
  missing,
532
711
  bindHints,
533
712
  listCandidates,
713
+ // 列表绑定的作用域写法,只在真有候选时出现(避免每份审计都塞一段与当前卡无关的说明)。
714
+ // 为什么必须在这里给:模型是在读 listCandidates 的这一刻决定「这是列表、要绑数组」的,
715
+ // 此刻它手边没有作用域契约,容易照直觉写 $item.<字段>(求值恒 undefined 且全链路静默)。
716
+ // 现在它写错也不会出事 —— expand_ir 在扩展开头会归一化别名(lib/scope_alias.mjs)。
717
+ // 这里给契约是为了让它一次写对、少一轮往返,而不是「提醒它别犯错」。
718
+ // 文案只讲契约,不涉及任何具体字段名 / 卡名。
719
+ ...(listCandidates.length
720
+ ? {
721
+ listBindingRule:
722
+ 'listScope 插槽内 item 的字段已**逐个展开**到作用域,规范形三种:' +
723
+ '① `$<字段名>`(字段名由你对 fields[].first 的值语义化命名,禁 text1 / val2 这类占位名);' +
724
+ '② `$$` = 整个 item 对象;③ `$_index` = 行索引。' +
725
+ '(写成 `$item.<字段>` / `$item` / `$item._index` 也不会出错:expand_ir 会在扩展开头自动归一化到上面三种规范形。作用域里本来就没有 `item` 这个键。)' +
726
+ 'fields[] 一列一字段,values[] 的每一行与 fields[] 同序(shape 装饰项已剔除)。' +
727
+ '契约全文:skills/dsl-card-generator/references/expression_engine.md §3。',
728
+ }
729
+ : {}),
534
730
  }
535
- if (args.save) {
731
+ // 默认落盘:审计是「整份复用」的产物(crop_slices --from-audit 直接吃),
732
+ // 靠模型每次记得加 --save 是不可靠的;不给路径就落到工程根 .cache/spec-audit/ 下。
733
+ // 默认路径要在工程根下(cwd 即工程根),别按脚本位置推。
734
+ const savePath = args.noSave ? '' : args.save || defaultAuditPath(args.spec, args.card)
735
+ if (savePath) {
536
736
  try {
537
- fs.mkdirSync(path.dirname(path.resolve(args.save)), { recursive: true })
538
- fs.writeFileSync(args.save, JSON.stringify(brief, null, 2) + '\n')
539
- brief.savedTo = args.save
540
- console.error(`[spec_audit] 审计已落盘 ${args.save}(crop_slices --from-audit 直接用它,禁止手抄摘要)`)
737
+ fs.mkdirSync(path.dirname(path.resolve(savePath)), { recursive: true })
738
+ fs.writeFileSync(savePath, JSON.stringify(brief, null, 2) + '\n')
739
+ brief.savedTo = savePath
740
+ console.error(`[spec_audit] 审计已落盘 ${savePath}(crop_slices --from-audit 直接用它,禁止手抄摘要)`)
541
741
  } catch (e) {
542
- console.error(`[spec_audit] --save 失败:${e.message}`)
742
+ console.error(`[spec_audit] 落盘失败:${e.message}`)
543
743
  }
544
744
  }
545
745
  // 紧凑输出:审计简报的读者是模型,缩进纯浪费 token(也更容易整份进 12000 字回喂窗口)
546
- console.log(JSON.stringify(brief))
746
+ const compact = JSON.stringify(brief)
747
+ if (compact.length <= STDOUT_MAX_CHARS) {
748
+ console.log(compact)
749
+ } else {
750
+ // 超长时改出摘要:**每一个顶层键都在、可决策的信息一个不少**,只把「按位置复述内容」的
751
+ // bands[].items(它是 texts/slices 的重排,纯冗余)折成计数,完整版指向 savedTo。
752
+ // 为什么不做「截断 stdout」:截断会把 slices/missing 这类靠后的关键字段整段切掉,
753
+ // 模型既不知道少了什么、又会为了补读再跑一遍 40s 的解析。
754
+ const digest = {
755
+ ...brief,
756
+ bands: (brief.bands || []).map((b) => ({ y: b.y, h: b.h, itemCount: (b.items || []).length })),
757
+ stdoutDigest: true,
758
+ note: `stdout 已折成摘要(完整审计 ${compact.length} 字在 ${brief.savedTo || '(本次未落盘)'}):bands[].items 只是 texts/slices 的位置重排,需要时用 fs_read 读 savedTo;crop_slices --from-audit 一律用 savedTo,禁止手抄。`,
759
+ }
760
+ console.log(JSON.stringify(digest))
761
+ }
762
+ }
763
+
764
+ /** stdout 预算:审计简报要喂给模型,超过这个长度就折摘要(完整版落盘) */
765
+ const STDOUT_MAX_CHARS = 9000
766
+
767
+ /** 默认落盘路径:工程根 .cache/spec-audit/<要素包目录名>-card<N>.json(cwd 即工程根)。
768
+ * 用目录名而不是文件名:所有要素包里的文件都叫 spec.json,只取 basename 会互相覆盖。 */
769
+ function defaultAuditPath(spec, card) {
770
+ const dir = path.basename(path.dirname(path.resolve(String(spec))))
771
+ const base = dir || path.basename(String(spec)).replace(/\.json$/i, '') || 'spec'
772
+ return path.join('.cache', 'spec-audit', `${base}-card${card}.json`)
547
773
  }
548
774
 
549
775
  main()