cardbuddy 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
  2. package/dist/preview-editor/assets/{index-CIIfMlwI.js → index-Bro07DTa.js} +91 -88
  3. package/dist/preview-editor/index.html +2 -2
  4. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  5. package/dist/vendored/mcp/client.mjs +1 -1
  6. package/dist/vendored/server/core/ai-chat.js +251 -80
  7. package/dist/vendored/server/core/cardValidate.js +16 -5
  8. package/dist/vendored/skills/card-skill-trainer/SKILL.md +61 -6
  9. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  10. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  11. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  12. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  13. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  14. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  15. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  16. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  17. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
  18. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
  19. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  20. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  21. package/dist/vendored/skills/dsl-card-generator/SKILL.md +93 -27
  22. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  23. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  24. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +169 -44
  25. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +87 -3
  26. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +35 -6
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +28 -27
  28. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +30 -8
  29. package/dist/vendored/skills/dsl-card-generator/references/rules.md +3 -2
  30. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +5 -2
  31. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +2 -3
  32. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +1 -4
  33. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  34. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +88 -0
  35. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +143 -42
  36. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  37. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +99 -53
  38. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +5 -2
  39. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +388 -29
  40. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  41. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  42. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  43. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  44. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +325 -41
  54. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +112 -6
  55. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +0 -6
  56. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +119 -78
  57. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -50
  58. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  59. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  60. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +5 -10
  61. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +122 -8
  62. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +3 -1
  63. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  64. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  65. package/dist/vendored/skills/shared/spec-contract.md +118 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +26 -9
  67. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +3 -3
  68. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +0 -8
  69. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  70. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +91 -47
  71. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +0 -5
  72. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +75 -15
  73. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +40 -34
  74. package/package.json +1 -1
  75. package/dist/preview-editor/assets/index-DQn0zQBP.css +0 -1
  76. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  77. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
@@ -20,9 +20,9 @@
20
20
  */
21
21
  import fs from 'node:fs'
22
22
  import path from 'node:path'
23
- import { spawnSync } from 'node:child_process'
24
23
  import { fileURLToPath } from 'node:url'
25
24
  import { hostSkillsDir, projectRoot } from './lib/paths.mjs'
25
+ import { resolvePython } from './lib/python.mjs'
26
26
 
27
27
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp'])
28
28
  /** 已知入口文件:判定交给它所在目录(index.html=MeaXure 导出;manifest/clusters/spec/prompt=要素包;schema=卡片目录)。 */
@@ -94,14 +94,9 @@ function shortPath(abs) {
94
94
  return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : abs
95
95
  }
96
96
 
97
+ /** Python 解释器:解析集中在 lib/python.mjs(单一来源,含 Windows Store 假别名识别)。 */
97
98
  function pythonBin() {
98
- if (process.env.DSL_CARD_PYTHON) return process.env.DSL_CARD_PYTHON
99
- // py 是 Windows 官方启动器(python3 在 Windows 常是 Store 假别名:能启动、立刻 9009 退出)
100
- for (const bin of ['python3', 'python', 'py']) {
101
- const probe = spawnSync(bin, ['--version'], { stdio: 'ignore' })
102
- if (probe.status === 0) return bin
103
- }
104
- return 'python3'
99
+ return resolvePython()
105
100
  }
106
101
 
107
102
  /** Sketch MeaXure 标注导出判定:index.html 的 data 签名是硬证据,preview/ assets/ links/ 是佐证。 */
@@ -341,7 +336,7 @@ function route(inputAbs) {
341
336
  `node ${shortPath(path.join(hostSkillsDir, 'dsl-card-generator', 'scripts', 'spec_tree.mjs'))} --list-cards ${shortPath(inputAbs)}`,
342
337
  SELECTION_GATE,
343
338
  ]),
344
- `# 再按 SKILL.md §8 Workflow A 六步闭环生成 schema(scaffold_card → add_component/expand_ir → validate+lint → render_diff)`,
339
+ `# 再按 SKILL.md §8 Workflow A 生成 schema(整树 IR 一次下发 → expand_ir → readiness → gate → iterate 视觉闭环);add_component 仅改卡用`,
345
340
  ],
346
341
  note: bundle.artboardBundle
347
342
  ? '输入已是单画板要素包,无需前处理,直接走 Workflow A 生成本卡。'
@@ -378,7 +373,7 @@ function route(inputAbs) {
378
373
  evidence: [`单张图片 ${path.basename(inputAbs)}(同目录无 spec.json)`],
379
374
  skillChain: ['dsl-card-generator'],
380
375
  workflow: 'Workflow 0 入口③ → Workflow A(无标注,视觉推断)',
381
- next: [`# 直接多模态读图 → scaffold_card 出骨架 → validate+lint → render_diff 视觉闭环`],
376
+ next: [`# 直接多模态读图 → 整树 IR(AI 判定布局)→ expand_ir → readiness → gate → iterate 视觉闭环`],
382
377
  note: '无 MeaXure 标注时全靠 AI 视觉推断,间距/圆角从图上目视估算;若这张图其实来自某份标注导出,请改传导出目录以走入口①。',
383
378
  }
384
379
  }
@@ -292,6 +292,40 @@ function main() {
292
292
  }
293
293
  ;(card.elements || []).forEach(walk)
294
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
+
295
329
  // 切图清单:存在性 / 复用 / 烘焙内容 / 复用但内容不同
296
330
  // 按**完整 ref** 分组(不是 basename):分组路径是图层身份的一部分,两个不同分组的同名图层是两张图。
297
331
  const byRef = new Map()
@@ -429,11 +463,22 @@ function main() {
429
463
  } else bands.push({ y: it.y, h: it.h, items: [it] })
430
464
  }
431
465
  bands.sort((a, b) => a.y - b.y)
432
- 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)
433
472
  const rowGaps = bands.slice(1).map((b, i) => b.y - (bands[i].y + bands[i].h))
434
473
 
435
- const xs = items.map((i) => i.x)
436
- 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)
437
482
  const gaps = {
438
483
  padLeft: Math.min(...xs),
439
484
  padRight: W - Math.max(...xes),
@@ -470,7 +515,23 @@ function main() {
470
515
  const i = (Math.round(y * scale) * design.width + Math.round(4 * scale)) * 4
471
516
  return { hex: hex(design.data[i], design.data[i + 1], design.data[i + 2]), a: design.data[i + 3] }
472
517
  }
473
- 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
+ })()
474
535
  // 首个不透明且明显异于卡底色的采样点 = 渐变带起点
475
536
  let startY = -1
476
537
  let top = null
@@ -539,24 +600,43 @@ function main() {
539
600
  const bandSig = (items) => items.filter((i) => i.kind !== 'shape').map((i) => (i.kind === 'text' ? `t${i.fs ?? ''}` : i.kind)).join('|')
540
601
  const listCandidates = []
541
602
  const pushCandidate = (layout, rows, source) => {
542
- 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
+ })
543
614
  // 每行值完全相同 = 装饰性重复(分割线 / 同款图标行),不是数据行,不值得绑数组
544
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])
545
618
  listCandidates.push({
546
619
  layout,
547
620
  rows: source,
548
- 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 || '' })),
549
622
  values,
550
623
  })
551
624
  }
552
625
  // 网格行合并:tile 内部(图标行 → 标题行 → 副标题行)的行距远小于 tile 之间的行距,
553
626
  // 按「小于中位行距 60%」把相邻 bands 粘成一行 tile;不合并的话列表候选会被拆成碎片行
627
+ //
628
+ // ⚠ 签名相同的相邻带**不粘**(2026-09-24 实测事故):tile 的各层靠签名区分(图标行 / 标题 /
629
+ // 副标题),签名相同的两条带是**并列的兄弟行**,不是同一 tile 的上下层。收支分析卡的三个交易行
630
+ // 内容高度正好顶满 72px 行高 ⇒ 带与带的 gap 为 0 ⇒ 旧逻辑把它们粘成一条带,于是
631
+ // 「同构连续行」的 run 检测再也看不到它们,真列表漏报;同时残留的那条巨带又喂出了
632
+ // 一个假候选(space-between 两端标签被当成「同构 tile 行」)。
554
633
  const rowGapMed = rowGaps.length ? [...rowGaps].sort((a, b) => a - b)[Math.floor(rowGaps.length / 2)] : 0
555
634
  const merged = []
556
635
  for (const b of bands) {
557
636
  const last = merged[merged.length - 1]
558
637
  const gap = last ? b.y - (last.y + last.h) : Infinity
559
- 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) {
560
640
  last.items = [...last.items, ...b.items].sort((a, c) => a.y - c.y || a.x - c.x)
561
641
  last.h = Math.max(last.y + last.h, b.y + b.h) - last.y
562
642
  } else {
@@ -590,15 +670,32 @@ function main() {
590
670
  cur.push(its[i])
591
671
  }
592
672
  groups.push(cur)
593
- 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
+ ) {
594
686
  pushCandidate('row', groups, [bi])
595
687
  }
596
688
  }
597
689
 
598
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
+ : {}),
599
694
  card: { index: args.card, name: card.name || '', rect: { w: W, h: H }, fill: card.cardFill || null },
600
695
  design: design ? { path: args.design, scale: Number(scale.toFixed(3)) } : null,
601
696
  texts,
697
+ // 被剔除的导出侧聚合层(合并文案 + 其拆分件)。本行同内容只保留拆分件,模型不必再猜该用哪个。
698
+ redundantTexts: redundantTexts.length ? redundantTexts : undefined,
602
699
  shapes,
603
700
  bgLayers,
604
701
  slices,
@@ -613,6 +710,23 @@ function main() {
613
710
  missing,
614
711
  bindHints,
615
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
+ : {}),
616
730
  }
617
731
  // 默认落盘:审计是「整份复用」的产物(crop_slices --from-audit 直接吃),
618
732
  // 靠模型每次记得加 --save 是不可靠的;不给路径就落到工程根 .cache/spec-audit/ 下。
@@ -25,6 +25,7 @@ import fs from 'node:fs'
25
25
  import path from 'node:path'
26
26
  import { spawnSync } from 'node:child_process'
27
27
  import { fileURLToPath } from 'node:url'
28
+ import { resolvePython } from './lib/python.mjs'
28
29
 
29
30
  const HERE = path.dirname(fileURLToPath(import.meta.url))
30
31
  const SCRIPTS = HERE
@@ -64,7 +65,8 @@ const workRoot = path.join(REPO, '.cache', 'v3-regression')
64
65
  const workCards = path.join(workRoot, 'src', 'cards')
65
66
  const baselineDir = path.join(REPO, 'training', 'pass1', 'baseline')
66
67
  const node = process.execPath
67
- const py = process.env.PYTHON || 'python3'
68
+ // 同 gate.mjs:改为统一解析(此前 `process.env.PYTHON || 'python3'` 不认 DSL_CARD_PYTHON、不探测)
69
+ const py = resolvePython()
68
70
 
69
71
  fs.rmSync(workRoot, { recursive: true, force: true })
70
72
  fs.mkdirSync(workCards, { recursive: true })
@@ -377,7 +377,8 @@ def check_style_props(style, cn, loc, errors, warnings, style_ids=None):
377
377
  f"'{cv}'. Use one of {sorted(FLEX_ENUMS[ck])}")
378
378
 
379
379
 
380
- def check_components(components, errors, warnings, depth=0, style_ids=None, seen_ids=None, stats=None):
380
+ def check_components(components, errors, warnings, depth=0, style_ids=None, seen_ids=None, stats=None,
381
+ sibling_index=None):
381
382
  if style_ids is None:
382
383
  style_ids, _ = load_meta()
383
384
  load_style_maps()
@@ -389,6 +390,10 @@ def check_components(components, errors, warnings, depth=0, style_ids=None, seen
389
390
  warnings.append("Component tree depth exceeds 30, possible infinite nesting?")
390
391
  return
391
392
  for i, comp in enumerate(components):
393
+ # 递归下钻时按「单个子节点」传列表,enumerate 的 i 恒为 0;sibling_index 携带
394
+ # 真实兄弟下标,保证 "depth N[i]" 可区分同层兄弟(定位仍以 #componentId 为准)。
395
+ if sibling_index is not None:
396
+ i = sibling_index
392
397
  if not isinstance(comp, dict):
393
398
  errors.append(f"Component at depth {depth}[{i}] is not an object")
394
399
  continue
@@ -430,26 +435,29 @@ def check_components(components, errors, warnings, depth=0, style_ids=None, seen
430
435
  errors.append(f"Property '{pk}' in '{cn}' at depth {depth}[{i}] has itemValueFrom="
431
436
  f"'{prop.get('itemValueFrom')}' but no itemDefaultValue")
432
437
 
438
+ # Slots / children —— 检查须在下方 tag_messages 之前 append,"has no slots" /
439
+ # "has child components" 才能吃到本组件的 #cid 定位标记(此前检查在 tag 之后,
440
+ # 消息漏挂标记,只剩裸 depth[i])
441
+ slots = comp.get("slots", [])
442
+ if cn in CONTAINER_TYPES or cn == "Select":
443
+ if not slots:
444
+ warnings.append(f"Container '{cn}' at depth {depth}[{i}] has no slots")
445
+ else:
446
+ for slot in slots:
447
+ if slot.get("components"):
448
+ warnings.append(f"Leaf component '{cn}' at depth {depth}[{i}] has child components")
449
+
433
450
  # 到这里为止都是「本组件自身」的问题,统一挂上定位标记;子组件的消息由下面递归时
434
451
  # 各自挂自己的标记,不能被父级污染(否则点一下会选到父节点)
435
452
  tag_messages(errors, err_mark, cid_tag(cid))
436
453
  tag_messages(warnings, warn_mark, cid_tag(cid))
437
454
 
438
- # Slots / children
455
+ # 子组件下钻:enumerate 携带真实兄弟下标(sibling_index),否则嵌套层 i 恒为 0
439
456
  if cn in CONTAINER_TYPES or cn == "Select":
440
- slots = comp.get("slots", [])
441
- if not slots:
442
- warnings.append(f"Container '{cn}' at depth {depth}[{i}] has no slots")
443
- else:
444
- for slot in slots:
445
- for child in slot.get("components", []):
446
- check_components([child], errors, warnings, depth + 1, style_ids, seen_ids)
447
- else:
448
- slots = comp.get("slots", [])
449
- if slots:
450
- for slot in slots:
451
- if slot.get("components"):
452
- warnings.append(f"Leaf component '{cn}' at depth {depth}[{i}] has child components")
457
+ for slot in slots:
458
+ for idx, child in enumerate(slot.get("components", [])):
459
+ check_components([child], errors, warnings, depth + 1, style_ids, seen_ids,
460
+ stats=stats, sibling_index=idx)
453
461
 
454
462
 
455
463
  def check_data_json(schema, path, warnings):
@@ -0,0 +1,79 @@
1
+ # 脚本退出码语义(单一权威)
2
+
3
+ > 适用范围:`skills/dsl-card-generator/scripts/` 与 `skills/sketch-spec-to-card/scripts/` 下的
4
+ > 全部脚本(`.mjs` / `.py`)。**本文件是退出码语义的唯一权威**,其它文档只引用、不复述。
5
+ >
6
+ > 服务端记账表在 `server/core/ai-chat.js` 的 `SKILL_EXIT_SEMANTICS`;**本文件是它的来源**,
7
+ > 改动顺序永远是「先改本文件 → 再改记账表 → 跑 `tools/tests/exit-code-contract.test.mjs`」。
8
+
9
+ ## 0. 两条总则
10
+
11
+ 1. **0 = 成功,1 = 未特别声明时的通用失败**。只声明这两条是不够的 —— 见总则 2。
12
+ 2. **退出码语义按脚本查表,不跨脚本外推**。同一个数字在不同脚本里含义可以不同:
13
+ `2` 在 `iterate.mjs` 是「门禁未达标」,在 `gate.mjs` / `ir_merge.mjs` 是「用法错误」。
14
+ **永远按 (脚本名, code) 二元组查表,不要只看数字。**
15
+
16
+ 为什么这条总则值钱:模型(与服务端)天然会把**一切非 0 读成「工具坏了」**,于是去修一个
17
+ 不存在的问题、或原样重试。本链路里大量非 0 其实是**脚本正常跑完、只是结论是负面的**。
18
+
19
+ ## 1. 语义分类(kind)
20
+
21
+ | kind | 含义 | 模型该做什么 | 计入「连续失败」短路 |
22
+ |---|---|---|---|
23
+ | `diagnostic` | 正常跑完,产出就是差异/偏差清单 | 按报告逐项修 | **否** |
24
+ | `gate-fail` | 正常跑完,门禁未达标 | 按 `gate.failed` 逐项改,**不要原样重试** | 是 |
25
+ | `not-ready` | 正常跑完,输入内容尚未就绪 | 补内容后重跑(不是脚本故障) | 是 |
26
+ | `needs-user` | 正常跑完,**必须由用户做选择** | 把清单交给用户,停下等回复 | 是 |
27
+ | `fail` | 真失败(脚本报错 / 用法错误 / IO 失败) | 修调用或修脚本 | 是 |
28
+
29
+ 「计入连续失败」的判定在 `ai-chat.js:1917 skillExecFailure()`:**只有 `diagnostic` 豁免**
30
+ (连 `gate-fail` 都计入 —— 连续三次门禁不过且报错没变,短路提示比再撞一次墙更有用;
31
+ `--ir` 文件变了会换 key,所以「模型确实在改」的连续失败不会被误短路)。
32
+
33
+ ## 2. 必须登记的「非 fail」退出码
34
+
35
+ 下表每一行都会在服务端生成 `exit=N [kind:note]` 首行标签。**漏登记 = 被标成 `fail`**,
36
+ 模型会把「结论负面」当成「工具故障」。
37
+
38
+ | 脚本 | code | kind | 语义 |
39
+ |---|---|---|---|
40
+ | `readiness.mjs` | 3 | not-ready | 内容未就绪(要素 / 变量绑定 / 图片地址 / 切图落盘四类缺口),**不是脚本报错**,按报告逐项补内容后重跑 |
41
+ | `route_input.mjs` | 3 | needs-user | 输入形态判为 `unknown` —— 先问用户这是哪类输入,**不要猜、不要自己挑** |
42
+ | `parse_meaxure.py` | 4 | needs-user | 多画板选择门禁(`rules.md §十三`):脚本故意打印 1..n + 全部清单后退出,**Agent 必须把清单交给用户、停下等回复**,再用 `--pick` / `--scope-all` 重跑 |
43
+ | `iterate.mjs` | 2 | gate-fail | 门禁未达标,报告照常回喂 —— 按 `gate.failed` 逐项改 |
44
+ | `gate.mjs` | 1 | gate-fail | 门禁未过(`autofix → validate → lint → preflight` 任一步 FAIL),按规则号逐条修后重跑 |
45
+ | `preflight.mjs` | 1 | diagnostic | 正常结果:列出若干 Pre-flight 问题,按 code 逐条修 |
46
+ | `check_fidelity.mjs` | 1 | diagnostic | 正常结果:报出文本缺失 / 字号 / 颜色偏差,逐项修 |
47
+ | `lint_card.mjs` | 1 | diagnostic | 正常结果:有 lint 问题,按规则号逐项修 |
48
+ | `diff_bands.mjs` | 1 | diagnostic | 正常结果:渲染与设计的逐带差异非空 |
49
+ | `check_magic_values.mjs` | 70 | diagnostic | 正常结果:发现魔法值,改用 `lib/layout-tokens.mjs` 的 `T.*`(`70` 是历史取值,勿按数字猜语义) |
50
+ | `render_baseline.mjs` | 70 | diagnostic | 同 `check_magic_values`,历史取值 |
51
+
52
+ ## 3. 其余常见退出码(按 `fail` 处理,无需单独登记)
53
+
54
+ | 脚本 | code | 语义 |
55
+ |---|---|---|
56
+ | `expand_ir.mjs` | 1 | `validate` 未通过(报错文本写了具体项) |
57
+ | `expand_ir.mjs` | 3 | IR 含非法字段(报错已给合法值全集),改键名即可,**不要重写整份 IR** |
58
+ | `ir_restore.mjs` | 1 / 3 | 参数或文件不对 / 快照不是合法 JSON |
59
+ | `extract_subtree.mjs` | 3 | 写文件失败(IO) |
60
+ | `gate.mjs` / `ir_merge.mjs` / `describe_card.mjs` / `diff_card.mjs` / `apply_opacity_pattern.mjs` | 2 | 用法 / 参数错误 |
61
+ | `readiness.mjs` / `route_input.mjs` / `ir_merge.mjs` | 1 | 用法 / 参数错误 |
62
+
63
+ ## 4. 为什么 `iterate.mjs` 的「门禁未达标」是 `2` 而不是 `5`
64
+
65
+ 2026-09-24 的链路审计报告曾建议「统一为 `5 = 门禁未收敛`」。**评估后不采纳**,理由:
66
+
67
+ 1. **已被生产链路消费**:`SKILL_EXIT_SEMANTICS['iterate.mjs'][2]` 已是 `gate-fail`,
68
+ 语义标签与"是否计入连续失败"都按它工作;改数字是纯粹的破坏性变更,收益为零。
69
+ 2. **服务端按 (脚本, code) 查表**,不按数字跨脚本解释 —— 总则 2 已经解决了"同一个 2 两个含义"
70
+ 的问题,不需要靠换数字来解决。
71
+ 3. 报告的真实目标(「避免把收敛失败/未就绪记成工具故障」)**已由 kind 机制达成**;
72
+ 本轮做的是把它补全(§2 表里 `readiness` / `route_input` / `parse_meaxure` 三行此前缺失)。
73
+
74
+ ## 5. 新增脚本时的清单
75
+
76
+ 1. 决定了非 0 语义 → **先加进本文件 §2**(kind 四选一)。
77
+ 2. 同步加进 `server/core/ai-chat.js` 的 `SKILL_EXIT_SEMANTICS`。
78
+ 3. `npm test` 跑 `tools/tests/exit-code-contract.test.mjs`,它会断言「本文件 §2 每行都在记账表里」。
79
+ 4. 改完 `ai-chat.js` **必须重启 `npm run dev`**(后端无热重载,旧进程会继续跑旧代码)。
@@ -0,0 +1,118 @@
1
+ # spec.json 契约(sketch-spec-to-card ↔ dsl-card-generator)
2
+
3
+ > **单一权威**:本文件是 `UI/<画板>/spec.json` 的唯一契约定义。`references/spec-schema.md`(上游)只描述字段来源与判定逻辑,**字段的增删改以本文件为准**。
4
+ >
5
+ > - **生产者**:`skills/sketch-spec-to-card`(唯一写出方 = `scripts/parse_meaxure.py`;视觉判定出自 `vision.py`,列表/意图出自 `build_prompt.py`)
6
+ > - **消费者**:`skills/dsl-card-generator`(生产建卡路径)、`skills/card-skill-trainer`(训练/回归)、`skills/card-doctor`
7
+ > - **机械校验**:`tools/tests/spec-contract.test.mjs` —— 从 `UI/**/spec.json` 聚合实际字段路径,未在本文件登记的路径会让测试失败。**改产物字段必须同步改本文件**。
8
+
9
+ ## 状态口径
10
+
11
+ | 状态 | 含义 | 改动要求 |
12
+ |---|---|---|
13
+ | `active` | 两端或多端在用,**是接口** | 破坏性变更需同步改所有消费者 |
14
+ | `upstream-only` | 只有上游(sketch skill)自用;下游 0 读点 | 下游可无视;改动不通知下游 |
15
+ | `write-only` | **全仓无读点**(只写不读) | 删除候选。删之前先按 §「消重纪律」跑一轮全量确认无外部消费(如训练脚本、外部接入方) |
16
+ | `removed` | 上游已停写(2026-09-24 起) | **禁止在新代码读它**。存量旧产物仍带此字段(无害,不必重跑);登记行保留是为了门禁对存量产物放行 |
17
+
18
+ ## 顶层字段
19
+
20
+ | 字段路径 | 类型 | 生产者 | 消费者 | 状态 | 缺失 / 为 null 时的口径 |
21
+ |---|---|---|---|---|---|
22
+ | `canvas` | string | parse_meaxure | build_prompt、generate_cards、v3_assemble、build_dataset | active | 画板标识,同时是 `UI/<canvas>/` 目录名;同名画板第 2 个起追加 `-2`/`-3`(见 `spec_tree.mjs` 展示名取 `spec.canvas`);不可为空 |
23
+ | `schemaVersion` | string | parse_meaxure | **无** | removed | **已删(2026-09-24 统计定案)**:spec 产物恒 `"1.0"` 零信息量、静态读点 0 + 金标全链路运行时访问 0 → 上游已停写。存量旧产物仍带此字段,无害,不必重跑。注意 manifest.json 的 schemaVersion 是另一处(manifest 自描述),保留 |
24
+ | `size` | {width,height} | parse_meaxure | v3_extract、v3_assemble、spec_tree、iterate | active | 下游回落 `{width:375,height:812}` 并告警 |
25
+ | `cards` | array | vision.detect_cards → parse_meaxure | v3_extract、spec_tree、generate_cards、trainer | active | `[]` = 本画板无卡(`text_only` / 全弹层);下游必须跳过而非报错 |
26
+ | `rejectedCards` | array | vision.detect_cards | build_dataset(训练集构造) | upstream-only | 缺失 = 无拒绝记录;训练侧据此做负样本分析 |
27
+ | `conversationFlow` | object\|null | vision.detect_conversation_flow | **无** | write-only | **保留(2026-09-24 统计)**:96.6% 写入且 10 种真实值(inputDock 几何/bubbleCount/stackedBubblePairs,非对话卡 null)——上游花算力提取的结构化信息,值有信息量;会话流判定当前由 `artboardType=text_only` + `rejectedCards` 承载,此字段是未来对话卡生成的原料。勿与 text_only 判定混谈 |
28
+ | `focusSkipReason` | string | parse_meaxure | build_dataset | upstream-only | 非空 = **跳过了常规候选检测**(含原因)。⚠ **不是「0 卡」的同义词**:`bottom_sheet_geometry` 与 `cards` 非空可同时成立(底部抽屉由面板几何直取,不走候选检测) |
29
+ | `elements` | array | parse_meaxure | v3_extract、spec_tree、trainer | active | **必需**;全画板展平图层(含系统区/背景) |
30
+ | `styleSummary` | object | parse_meaxure | build_prompt | upstream-only | 下游不读:**样式以 `elements[].style` 为准**,禁止从摘要反推单元素样式 |
31
+ | `styleSummary.*` | {colors,gradients,fonts,borderRadius,shadows} | parse_meaxure | build_prompt | upstream-only | 五个子键均为聚合摘要,不进下游 |
32
+ | `artboardType` | string | parse_meaxure.classify_artboard | generate_cards、v3_extract、spec_tree、iterate | active | 取值 `card`/`overlay_dialog`/`subcard`/`text_only`;缺失回落 `card` |
33
+ | `interaction` | string | parse_meaxure | build_prompt、cluster_states | upstream-only | `card` 时为空串 |
34
+ | `jsapiInteractions` | array | parse_meaxure | build_prompt、cluster_states | upstream-only | 缺失 = `[]` |
35
+ | `annotationDefects` | array | parse_meaxure | card-skill-trainer(diagnose_case / plan_next_round / scoring) | active(训练侧) | 缺失 = 视为无标注缺陷 |
36
+ | `slices` | array&lt;string&gt; | parse_meaxure | v3_assemble、v3_extract | active | 本画板 `slices/` 下的相对路径(posix);空数组 = 无切图 |
37
+ | `skippedReason` | string | parse_meaxure | build_prompt | upstream-only | 仅 `cards=[]` 时存在 |
38
+
39
+ ## `cards` 空 / 非空的合法性(按 `artboardType` 分流)
40
+
41
+ `cards` 空与非空**都不代表对错** —— 上游按 `artboardType` 收口,每类的产出与建卡路径如下。下游遇到空 `cards` 时按类型分流,**不要**判成「上游自相矛盾」,也不要在下游补卡:
42
+
43
+ | artboardType | 含义 | `cards[]` | `card-N.png` | 建卡路径(Workflow A.5 口径) | 证据 |
44
+ |---|---|---|---|---|---|
45
+ | text_only | 纯文本画板(对话 / 问答 / 提示),整板无卡 | `[]` | 不产 | **不建卡**(上游按类型收口,勿人工绕过) | `classify_artboard`;`test_text_only_artboard.py` 用例 1 |
46
+ | overlay_dialog | 系统弹框 | `[]` | 视觉 scrim 判定类不产;名称兜底判定类产 fallback `card-1.png` | **不建卡** —— 由父卡 alertDialog 调用承载 | 用例 5 |
47
+ | subcard | 视觉 bottom-sheet 子卡(底部抽屉) | **1 张**(面板几何直取,**不走候选检测**) | 产 `card-1.png`(面板 rect + 顶圆角遮罩) | **建独立 Dialog 卡** —— 与普通卡同构:`rect` / `mask.radius` / 卡内 `elements` / `segments` 齐备 | `detect_artboard_cards` sheet 分支;`vision._bottom_sheet_rect`;用例 4 |
48
+ | card | 0 卡 · 结构丰富:卡片被整体导成一张切片(`vision._candidate_visuals` 跳过 `is_slice`,故检不出) | `[]` | 产 fallback `card-1.png`(去系统 chrome) | 建卡:以该兜底图 + `elements[]` 为准(**有图无坐标**) | `parse_meaxure.py:645`;`board_tag`(`:1768`);用例 2/3/6 |
49
+
50
+ > 注:本表首列**刻意不加反引号** —— `spec-contract.test.mjs` 以「首列反引号 + 整行 6 列」识别字段契约表,加反引号会让本表被误当成字段登记。
51
+
52
+ > **`subcard` 产卡(2026-09-24 修)**:此前 `bottom_sheet` 只写 `focusSkipReason` 不产 `cards`,导致 `cluster_states` 承认它是独立逻辑卡(`pageId=card-N`、`isDialog=true`)而 spec 层给不出任何卡级数据。现由 `detect_artboard_cards` 的 sheet 分支把面板几何(`vision._bottom_sheet_rect` 早已算出 rect + 顶圆角)落成 1 张卡;`focusSkipReason` **保持不变**(`classify_artboard` 靠它判 `subcard`,改动会连带打翻 `is_text_only_artboard` / `board_verdict` / trainer 的 `is_popup_case`)。**旧版存量 spec 仍可能是 `subcard` + `cards=[]` → 重跑上游,不要在下游补卡。**
53
+
54
+ > `readiness.mjs` 门 **F**:`cards=[]` 的三类(text_only / overlay_dialog / card)只在 `checks.spec.notes` 记说明,**均不进 `blockers`**;`cards` 非空时只校验 `rect` / `mask` 是否缺失(产卡与否由 Workflow A.5 决定,不是契约缺失)。
55
+ > 门 F 真正拦的只有:`cards` 非空时 `cards[i].rect` / `cards[i].mask` 缺失(旧版产物)、`--card` 序号越界、spec 解析失败。
56
+
57
+ ## `cards[]`
58
+
59
+ | 字段路径 | 类型 | 生产者 | 消费者 | 状态 | 缺失 / 为 null 时的口径 |
60
+ |---|---|---|---|---|---|
61
+ | `cards[].index` | int | parse_meaxure | v3_extract(定位 `card-N.png`)、trainer | active | **1 起**;与 `card-N.png` 的 N 严格同源 |
62
+ | `cards[].rect` | {x,y,width,height} | vision.detect_cards | v3_extract、v3_assemble、spec_tree | active | **`cards` 非空时必需**;画板坐标系设计 px |
63
+ | `cards[].image` | string | parse_meaxure | v3_assemble、render_card、迭代对比 | active | 裁切失败可缺失 → 下游按 `cards[].index` 自行拼 `card-N.png` 并容忍不存在 |
64
+ | `cards[].cardFill` | string\|null | parse_meaxure | v3_extract、v3_assemble | active | `null`(无纯色填充,如渐变/图片底)→ 下游用组件默认底色,**不得从截图像素反推** |
65
+ | `cards[].mask` | object | parse_meaxure(`:584`,**无条件产出**) | spec_tree、v3_extract | active | **必需**。存量 spec 缺失 `mask` 是**旧版产物**(在 `focusMetrics`/`mask`/`cardFill` 批次字段引入前生成)→ 重跑上游 `parse_meaxure.py --build` 即补全;`readiness.mjs` 的 spec 契约检查会拦 |
66
+ | `cards[].mask.type` | `"geometry"` | parse_meaxure | spec_tree(诊断打印) | upstream-only | 固定值;遮罩完全来自图层几何(rect + borderRadius),**不依赖背景颜色** |
67
+ | `cards[].mask.radius` | [tl,tr,br,bl]\|null | parse_meaxure `_card_mask_info` | **v3_extract → v3_assemble** | active | **`null` = 上游没找到带圆角的卡壳**(`_card_mask_info` 返回 `(None, fill)`)。下游 `v3_assemble.mjs:366` 取 `T.radius('card')` 默认圆角 —— **这是契约规定的兜底,不是 bug**。取用时用 `??` 而非 `\|\|`,否则 `radius[0]==0`(真·直角卡)会被误判成"无圆角" |
68
+ | `cards[].mask.shapeCount` | int | parse_meaxure | **无** | write-only | **保留(2026-09-24 统计)**:92% 写入、3 种值(1/2/4,mask 复杂度有变化)。无代码读点,但删除收益为零、有校验价值 |
69
+ | `cards[].focusMetrics` | object | vision.card_focus_metrics | card-skill-trainer | upstream-only | 缺失 = 该卡未参与焦点评分 |
70
+ | `cards[].focusMetrics.*` | {areaRatio,aspectRatio,heightRatio,textCount,richComponentCount,sliceCount,symbolCount,shapeCount} | vision.card_focus_metrics | card-skill-trainer | upstream-only | 训练侧打分用 |
71
+ | `cards[].segments` | array | parse_meaxure.detect_segments | **v3_assemble(真消费)**、build_prompt | active | 空数组 = 未分段;**下游不得自算几何兜底**(上游已是权威,重复判定会给出两个答案) |
72
+ | `cards[].segments[].y` | float | parse_meaxure | v3_assemble | active | 卡内相对坐标 |
73
+ | `cards[].segments[].height` | float | parse_meaxure | v3_assemble | active | 卡内相对坐标 |
74
+ | `cards[].segments[].hint` | string | parse_meaxure | build_prompt | upstream-only | 分段语义提示 |
75
+ | `cards[].segments[].items` | array | parse_meaxure | build_prompt | upstream-only | 段内元素引用 |
76
+ | `cards[].segments[].gap_top` | float | parse_meaxure | **无** | removed | **已删(2026-09-24 定案)**:纯冗余推导值——首段 `gap_top === y` 完全重复,后续段 = 相邻 `y/height` 之差,可无损推导;读点 0 + 金标运行时访问 0 → 上游已停写。间距看 `segments[].y`。存量旧产物仍带此字段,无害。(勘误:初版统计「0/609 写入」是扫描脚本 path 漏 `cards[]` 前缀的误报,真实为 48% 写入) |
77
+ | `cards[].detectedList` | object\|absent | build_prompt | build_prompt | upstream-only | 仅用于生成 prompt 文本,**不进 spec 下游链路**;v3 自行重判列表 |
78
+ | `cards[].flexLayout` | object\|absent | vision.infer_flex_layout | build_prompt、spec_tree(诊断打印) | upstream-only | **不进生成**:flex 方向由 AI 视觉主判 / IR 层决定 |
79
+ | `cards[].cardFillSampled` | string | parse_meaxure | **无** | write-only | **保留(2026-09-24 统计)**:92.3% 写入、4 种色值——是 `cardFillConflict` 的对照基线,删了 conflict 标记就失去参照 |
80
+ | `cards[].cardFillConflict` | true | parse_meaxure | **读 spec 的人/模型**(代码无读点;写入同时打 stderr) | write-only | **保留(2026-09-24 定案)**:30.8% 卡写(恒 true,仅在冲突时写)。它的消费者是**读 spec 的智能体**而非代码——警示「fill 标注不可信,按视觉裁决」;删掉会让冲突静默 |
81
+ | `cards[].trainingEligible` | bool | parse_meaxure | build_dataset、spec_tree、run_round | upstream-only | `false` = 不进训练集 |
82
+ | `cards[].elements` | array | parse_meaxure | v3_extract | active | 归属该卡的图层;结构与顶层 `elements[]` 同构 |
83
+
84
+ ## `elements[]`
85
+
86
+ | 字段路径 | 类型 | 生产者 | 消费者 | 状态 | 缺失 / 为 null 时的口径 |
87
+ |---|---|---|---|---|---|
88
+ | `elements[].name` | string | parse_meaxure | spec_tree、v3_extract | active | 原始 Sketch 层名;仅附属信息,**不参与保留/剔除判定** |
89
+ | `elements[].type` | string | parse_meaxure | v3_extract、spec_tree | active | `symbol`/`shape`/`group`/`slice`/`text` |
90
+ | `elements[].rect` | {x,y,width,height} | parse_meaxure | v3_extract、v3_assemble、spec_tree | active | 画板坐标系设计 px |
91
+ | `elements[].style` | object | parse_meaxure | v3_extract、v3_assemble | active | 平台对齐样式参考(`fill`/`border`/`radius`/`shadow`/`opacity`/`gradient`…),**样式值只从这里读** |
92
+ | `elements[].text` | string\|null | parse_meaxure | build_prompt、v3_extract | active | 仅 text 层;附属记录,不参与判定 |
93
+ | `elements[].textStyle` | object\|null | parse_meaxure | v3_extract | active | 仅 text 层(`fontSize`/`fontFace`/`color`/`textAlign`/`lineHeight`);非 text 层为 `null` |
94
+ | `elements[].slices` | string\|null | parse_meaxure | v3_assemble | active | 仅 slice 层:原始层名(切图匹配溯源);非 slice 层为 `null` |
95
+ | `elements[].annotationDefect` | object\|absent | parse_meaxure(`:1200` / `:1264`) | v3_extract、v3_assemble、v3_shared、card-skill-trainer | active | **仅当该元素的标注有缺陷时才存在**。下游据此决定切图补偿方式;**缺失 ≠ 有缺陷,而是「标注完整」**,所以判定必须用 `el.annotationDefect?.type` 而非存在性反推 |
96
+ | `elements[].annotationDefect.*` | {type,name,reason,approximateSolidColor,resolution,cropSlice} | parse_meaxure | 同上 | active | `type`(如 `missing-slice`)是判定键;`cropSlice` 是「从画板裁切补图」的产物路径 —— 为 `null` 时 `v3_shared.mjs:381` 判定无法补偿,切图会悬空 |
97
+
98
+ ## 消重纪律(v3 与上游的判定边界)
99
+
100
+ 上游已经算过的事,下游**不得自算第二遍**(同一张卡出现两个答案时无裁决机制)。当前已知的越界自算(清理中,需先埋点统计 fallback 触发率,< 5% 才删):
101
+
102
+ | 能力 | 权威(上游) | 下游越界自算 |
103
+ |---|---|---|
104
+ | 分段 segments | `parse_meaxure.detect_segments`(`:294`) | `v3_extract.mjs:277-356, 2000-2068` 几何兜底 |
105
+ | 列表检测 | `build_prompt.detect_list_in_card` → `cards[].detectedList` | `v3_extract.mjs:838-856, 2403-2472` 自行重判(且不读 `detectedList`) |
106
+ | 卡底颜色 | `cards[].cardFill` | `v3_assemble.mjs:5303-5314` 对设计稿二次像素校验 |
107
+ | 圆角 | `cards[].mask.radius` | 自算 `radiusMax`(`v3_shared.mjs:276`) |
108
+
109
+ **已正确划界(下游只消费不重做)**:keyboard / overlay_dialog / 聊天气泡 / 系统 chrome / `annotationDefects` / `artboardType`。
110
+
111
+ ## 存量产物漂移
112
+
113
+ `UI/` 下的 spec.json 是历史产物,与当前代码产出的字段集**可能不一致**:
114
+
115
+ - 缺 `mask` / `focusMetrics` / `cardFill` / `cardFillSampled` / `trainingEligible` 的 = **旧版产物**(上述批次字段引入前生成)。
116
+ - 判定口径:字段出现次数 < `cards[]` 总数即存在旧产物。
117
+ - 处置:**重跑上游**(`parse_meaxure.py --src <导出> --draft <稿名> --build`)而非手工补字段。
118
+ - 不要为了兼容旧产物在下游写 `if (!card.mask)` 分支 —— 契约规定 `mask` 必需,兼容分支会让"上游漏产"永远不被发现。