cardbuddy 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
  3. package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/mcp/client.mjs +8 -1
  7. package/dist/vendored/server/core/ai-chat.js +1521 -124
  8. package/dist/vendored/server/core/browser.js +12 -3
  9. package/dist/vendored/server/core/cardFiles.js +3 -0
  10. package/dist/vendored/server/core/cardHistory.js +1 -0
  11. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  12. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  13. package/dist/vendored/server/core/cardSave.js +1 -0
  14. package/dist/vendored/server/core/cardSync.js +3 -0
  15. package/dist/vendored/server/core/codeBridge.js +5 -1
  16. package/dist/vendored/server/core/logStream.js +1 -0
  17. package/dist/vendored/server/core/sliceStatic.js +24 -4
  18. package/dist/vendored/server/core/sliceUpload.js +1 -0
  19. package/dist/vendored/server/core/turnJournal.js +6 -0
  20. package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
  21. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  22. package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
  23. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
  24. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
  25. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
  26. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
  28. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  29. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
  30. package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
  31. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  33. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
  34. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  36. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
  37. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
  38. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
  41. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  43. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
  54. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
  55. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  56. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
  57. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
  58. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  59. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
  60. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
  61. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
  62. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
  63. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
  64. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
  65. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
  67. package/package.json +1 -1
  68. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
@@ -165,7 +165,7 @@
165
165
 
166
166
  ### 13.1 标准选择交互(列 `1..n + 全部`;默认「列清单 + 提问后停下等序号」)
167
167
 
168
- 1. **先列全量清单(两种选择方式都要做)**:机械取画板(见 13.2),按 **`画板序号.卡片序号`** 编号逐行列出(序号 **1 起**,每行 `画板序号.卡片序号. 画板名 — 类型 / 尺寸`),清单末尾追加一项 **`全部`**。清单是用户的完整选项池,**不得只列节选**。**多卡画板展开为子项**(见 13.4):一个画板含 N 张卡片时列出 N 行(如画板有 2 张卡则 `1.1` 和 `1.2`),同一画板共享一张画板预览图但每张卡有各自的 `card-N.png` 聚焦图。**逐行配图(强制)**:每个卡片行下面紧跟**它自己那张**聚焦图绝对路径(`图: <绝对路径>`,`card-N.png`,无单独聚焦图时降级用画板 `artboard.png`),缺图该行显式写 `图: (预览图缺失)`;**禁止把所有预览图统一堆在清单末尾**——部分卡片缺图时堆末尾会让序号与图错位,用户选不准。
168
+ 1. **先列全量清单(两种选择方式都要做)**:机械取画板(见 13.2),按 **`画板序号.卡片序号`** 编号逐行列出(序号 **1 起**,每行 `画板序号.卡片序号. 画板名 — 类型 / 尺寸`),清单末尾追加一项 **`全部`**。清单是用户的完整选项池,**不得只列节选**。**多卡画板展开为子项**(见 13.4):一个画板含 N 张卡片时列出 N 行(如画板有 2 张卡则 `1.1` 和 `1.2`),同一画板共享一张画板预览图但每张卡有各自的 `card-N.png` 聚焦图。**逐行配图(强制)**:每个卡片行下面紧跟**它自己那张**聚焦图绝对路径(`图: <绝对路径>`,`card-N.png`,无单独聚焦图时降级用画板 `artboard.png`),缺图该行显式写 `图: (预览图缺失)`;**禁止把所有预览图统一堆在清单末尾**——部分卡片缺图时堆末尾会让序号与图错位,用户选不准。**路径照抄完整绝对路径**(`/Users/…` 或 `C:\…` 打头):**禁止用 `…` / `...` 省略前缀、禁止把路径包进反引号或代码块**——实测 `…/积分助手-4-xx.png` 这种缩写渲染端取不到图,那几行只剩一串路径=配图全丢(服务端已加文件名反查兜底,但那是补救,首轮就该写全)。
169
169
  2. **再按环境能力选交互方式(优先级从高到低)**:
170
170
  - **A|仅当工具清单里确实列出了原生弹窗工具时才用**(如宿主自带的交互式提问工具)→ 先输出上面的完整 `1..n + 全部` 清单,**紧接着调用该工具弹选择**。该工具每题最多 3 个选项、客户端会自动追加自由输入 Other,故:选项放**最具代表性的 2 个画板 + `全部`**(代表性优先 `artboardType=card` 且 `status=generated`),问题文案里写明「其余画板请在 Other 里填清单序号或画板名」;返回空答案时**不反复追问**,降级到 B。**注意:编辑器助手 / 本仓库助手的工具集里没有这个工具**(不存在 `request_user_input`)——不要去调用、也不要去寻找,直接走 B。
171
171
  - **B|默认路径**→ 输出清单 + **一句纯文本提问**(如「请回复要处理的画板序号,或回复 `全部`」),然后**停止输出、结束本轮,等用户回序号**——不得在同一轮顺手产卡,不得自行挑画板。
@@ -173,9 +173,10 @@
173
173
  3. **拿到序号才继续**:
174
174
  - 回复单个序号 / 多个序号 → 只处理对应卡片;序号格式 `画板序号.卡片序号`(如 `1.2` = 第 1 个画板的第 2 张卡),单独回画板序号(如 `1`)时默认取该画板第 1 张卡(`1.1`);产卡时一轮只写一个 `src/cards/<id>/`。
175
175
  - 回复 `全部` → **生成设计稿 UI 产物(sketch-spec-to-card 管线)允许**,整份导出全量跑(不加 `--only/--indices`);**产卡**时视为用户**显式批量授权**,按批量流程逐卡产出,交付里说明产了几张卡、分别对应哪些画板。
176
- 4. **序号换算**:`parse_meaxure.py --list` 与门禁清单**展示统一 1 起**(与 `manifest.canvases[].order` 同口径,直接对齐);用户回的 1 起序号 k 传 `--indices` 时 **-1(0 起)**。
176
+ 4. **序号换算(优先照抄,不要换算)**:`parse_meaxure.py` 的清单**每行行尾自带可复制的 `--pick`**(板号 1 起、卡号 1 起:`--pick 8` / `--pick 8.1` / 同板连卡 `--pick 8.1~8.2` / 连板 `--pick 7~9`,多个逗号分隔),**用户回「8.1」就把这个串原样放进 args**——实测手工把 1 起序号减成 0 起 `--indices` 减错一位,建了错的画板(白跑 34s 解析 + 一步 LLM 才发现)。
177
+ `--indices` 仍是合法老写法(0 起),只在没有 `--pick` 可用时才手工换算:`--list` 与门禁清单**展示统一 1 起**(与 `manifest.canvases[].order` 同口径),用户回的 1 起序号 k 传 `--indices` 时 **-1(0 起)**。
177
178
  5. **「全部」只能是用户的回答,不是 Agent 的默认值**:「UI 产物允许全量生成」指用户回复 `全部` 后可整份导出全跑;用户消息里没指定画板时,**包括「生成产物 / 前处理 / 跑管线」在内的任何请求都必须先过第 1–3 步**,Agent 不得替用户选「全部」。
178
- 6. **机械卡点(脚本层兜底)**:`sketch-spec-to-card/scripts/parse_meaxure.py` 不带 `--only/--indices/--sample/--scope-all` 的生成运行会打印 1..n + 全部 清单并 **exit 4**(不生成任何东西);拿到用户回答后序号 k → `--indices <k-1>`、「全部」→ `--scope-all`。无人值守训练(`card-skill-trainer`)属显式批量例外,直接带 `--scope-all`(或 `SKETCH_SPEC_SCOPE_ALL=1`)。
179
+ 6. **机械卡点(脚本层兜底)**:`sketch-spec-to-card/scripts/parse_meaxure.py` 不带 `--pick/--only/--indices/--sample/--scope-all` 的生成运行会打印 1..n + 全部 清单并 **exit 4**(不生成任何东西);拿到用户回答后**照抄该行行尾的 `--pick`**(等价老写法序号 k → `--indices <k-1>`)、「全部」→ `--scope-all`。无人值守训练(`card-skill-trainer`)属显式批量例外,直接带 `--scope-all`(或 `SKETCH_SPEC_SCOPE_ALL=1`)。
179
180
 
180
181
  ### 13.2 清单来源(机械产出,不靠记忆)
181
182
 
@@ -187,11 +188,12 @@
187
188
 
188
189
  - 清单**全量列名称**(几十个也列全,可按业务流分组),并标注哪些**不产卡**:`overlay_dialog` → 不生成独立卡,补到父卡事件(见 `dsl-card-generator/SKILL.md §8 Workflow A.5`)。
189
190
  - 同一卡有多状态画板时,选择单位是 **cardKey(卡)**,并说明 `clusters.json.recommendedBase` 建议哪张作基线;用户选定卡但未指定状态时用 recommendedBase 并明确告知。
190
- - 序号基准要对齐:`--list` 与 `manifest.canvases[].order` **展示都是 1 起**(同口径,可直接对齐);只有 `parse_meaxure.py --indices` **传参是 0 起**,用户回的序号 k → `--indices <k-1>`,避免选错画板。
191
+ - 序号基准要对齐:`--list` 与 `manifest.canvases[].order` **展示都是 1 起**(同口径,可直接对齐);只有 `parse_meaxure.py --indices` **传参是 0 起**,用户回的序号 k → `--indices <k-1>`,避免选错画板。清单行尾的 `--pick` 就是为免掉这次换算而加的——**能照抄就照抄**。
191
192
 
192
193
  ### 13.3 落盘纪律
193
194
 
194
195
  - 一轮生成只写**一个卡片目录** `src/cards/<id>/`;批量仅在用户显式要求时进行。
196
+ - **批量建卡(用户一次点了多张 / 回了「全部」)走「一个回合多张卡」**:一次 `parse_meaxure.py --pick a.b,c.d`(或 `--indices`)出**全部**要素包,然后**在同一个回合里**按「S2→S5 走完一张,再走下一张」连续推进,各卡各写 IR、各跑 expand + iterate。**禁止每张卡各起一个回合**——每个新回合首步都要把整段对话重发一遍(实测 1.3–3 万 token/步),还要把同一份要素包重读一次,纯浪费。
195
197
  - **显式批量例外**:用户点名跑无人值守训练(`card-skill-trainer`)或明确说「全部生成 / 批量建卡」时按批量执行——训练产物落隔离 run 目录、不写 `src/cards/`,交付时说明产了几张卡。
196
198
  - 选定画板后走 `dsl-card-generator` Workflow A 六步闭环;`route_input.mjs` / MCP `card_route_input` 的形态判定仍是第一步。
197
199
 
@@ -260,3 +262,20 @@ Canvas 是**即时模式**绘制——后画的压先画的。所以「绘制层
260
262
  - 数据标记半透明(spec `opacity<1`)时把透明度**乘进颜色/渐变 stop**(代理 ctx 白名单**无 `globalAlpha`**),底层参考线才能透出来;设计稿「网格透过多边形可见」正是这个效果(assetAllocRadar 雷达 opacity 0.8)。
261
263
  - 机械保证:`spec_to_schema_v3.mjs` 的 `chartBarCanvasCode` 用 `chartPaintLayers` 强制分层(任一维 ≤2px 的细线 = 参考线;覆盖簇 bbox 一半以上的填充 = 绘图区底板;其余 = 数据标记),出码顺序 = 底板 → 参考线 → 数据标记 → 文字标签;回归护栏 `tools/tests/chart-layer-order.test.mjs`。手写图表(`recipes.md §15/§18`、`recipe_snippets/canvas-radar.json`)同口径。
262
264
  - 数据取值导致的图形差异(多边形形状、柱高、扇区角度)不在本铁律管辖内——层序只约束「参考线 / 数据标记 / 文字标签」三类图层的先后。
265
+
266
+ ## 十六、水平 margin 不叠加 width:100%(铁律 R63)
267
+
268
+ `rect.width:"100%"` 展开成 CSS `width:100%`(`adapter.doSize` 见 `%` 原样透传),此时元素宽度**已经等于父容器内容盒宽度**;再写 `marginLeft/Right`(或全边 `margin` + `marginType:"0"`)叠加的就是**外框**宽度 → 超出父容器、向右溢出,被 `overflow:hidden`(Root 与组件默认值)裁掉。生成侧最常见的低级错误:想表达「父容器水平内缩」,却把间距写在子节点上。
269
+
270
+ **lint `r63-width100-with-hmargin`(ERROR)的判定边界**:
271
+
272
+ - **报(必溢出)**:父容器按**列**排布时,水平方向是**交叉轴**,定宽不可缩——`Root` / `Column` / `SingleColumn` / 显式 `flexDirection:"column"` / `AbsoluteLayout`(子节点 `position:absolute` 脱标,`width:100%` 对定位包含块解析,margin 完全不参与收缩)。含 `marginType:"0"` 的全边 `margin`(四边同值,左右也吃)。
273
+ - **不报(主轴)**:父容器 `flex-direction:row`(`Row` / `MultiColumn` / ListLayout·GridLayout 的 item 壳)里 `flexShrink` 默认 1,会把节点缩回「容器宽 − margin」,margin 被吸收。存量 4 处这种写法(accountCreditedlist / memberLevelNone / pointsDetail / assetOverview)实测都不溢出——**故意不报主轴**,避免误报。
274
+
275
+ **正路(按设计意图三选一)**:
276
+
277
+ 1. **间距放父容器**:`paddingLeft/paddingRight`(或父容器 `gap`),子节点保持 `width:100%`;
278
+ 2. **改 `width:"auto"` 靠 stretch**:流式父容器(`Column`/`Root` 且 `alignItems` 为空/`stretch`)把 `rect.width` 从 `"100%"` 改成 `"auto"`,元素自动补满「容器宽 − margin」,视觉等价且不溢出。**必须显式写 `auto`**——Text/MultiText 有组件样式 `width:fit-content`,**只删 width 不会撑满**(实测:同一 375 宽父容器里 `auto` = 369px、删 width = 86.67px)。**AbsoluteLayout 子节点不适用**:abspos 的 `width:auto` 是 fit-content 不是 stretch,改走 ① 或显式像素宽;
279
+ 3. **并排分栏**:左 `width:100% + flexShrink:1`、右 `width:auto + flexShrink:0`(quickref「space-between 三要素公式」)。
280
+
281
+ 回归护栏:`tools/tests/card-gen-gates.test.mjs`(3 种命中形态 + 3 种不报形态)。
@@ -0,0 +1,51 @@
1
+ # 校验脚本全集
2
+
3
+ > 从 SKILL.md §10 外迁(2026-09-21 优化方案 P0-3)。SKILL.md 只留高频命令;本文件是全集。详细参数见各脚本头注释。
4
+
5
+ ## 全量命令表
6
+
7
+ ```bash
8
+ node scripts/gate.mjs --id <卡id> [--spec <spec.json>] [--json] # ⚡ 门禁一条命令:autofix→validate→derive→lint→preflight 汇总
9
+ node scripts/route_input.mjs <输入路径> [--json] # Workflow 0 输入路由(0=已路由/3=unknown/1=用法错)
10
+ node scripts/spec_tree.mjs --spec <spec.json> [--card 1] [--compact] # spec 层级树(AI 读图)
11
+ node scripts/scaffold_card.mjs --intent <…> --id <卡id> --name <名称> [--from <模板卡id>] # 骨架(blank/克隆;常规建卡走 IR)
12
+ node scripts/add_component.mjs --id <卡id> --parent <cid> --className <X> [--text "…"] [--click] [--set-prop k=v] # 插组件(改卡专用,只增不改)
13
+ node scripts/expand_ir.mjs --ir <IR.json> --out src/cards/<卡id>/ # 紧凑 IR→合法 schema/data(切图自动汇聚);契约见 ir-format.md
14
+ node scripts/ir_merge.mjs --ir <IR.json> --part <片段.json> # IR 分片合并(幂等+原子;每片 +40-140s,小卡禁拆;支持 {parts:[…]} 捆绑包一次合并)
15
+ node scripts/iterate.mjs --ir <IR.json> --out <dir> --design <png> [--set 'path=value'] # 一条龙闭环:expand→lint→render→pixel-diff(JSON 摘要,比逐脚本快 5×)
16
+ node scripts/ir_restore.mjs --list|--best|--round … # 迭代快照回退(见 iteration_protocol.md)
17
+ node scripts/lint_card.mjs --id <卡id> | --all [--json] # 行为规则体检(46 条规则)
18
+ python3 scripts/validate_schema.py <schema.json> [--topology] # 结构合法性(CI 硬卡点)
19
+ python3 scripts/autofix_schema.py <schema.json> [--check] # 机械修复 itemValueFrom/重复 id/data 漂移
20
+ node scripts/preflight.mjs --spec <spec.json> --id <卡id> # 13 项机械 Pre-flight
21
+ node scripts/diff_card.mjs --id <卡id> [--baseline <path>] # 变更对比(铁律机械兜底,整体替换后必跑)
22
+ node scripts/check_fidelity.mjs --spec <spec.json> --schema <schema.json> # 保真度门禁(元素级对齐断言,npm run check:fidelity)
23
+ node scripts/render_card.mjs --id <卡id> [--width 375] [--out render.png] [--keep-alive] # 离线截图(vendor 产物+Chrome CDP,不占端口)
24
+ node scripts/render_diff.mjs --card <卡id> --design <设计图> # 并排视觉对比(--width 自动读 schema)
25
+ node scripts/render_baseline.mjs --snapshot|--diff [--id <卡id>] # 渲染回归基线(第四层校验)
26
+ node scripts/apply_opacity_pattern.mjs --id <卡id> [--check] # R57 透明度表达式机械改写
27
+ node scripts/diff_bands.mjs --id <卡id> --design <png> [--tol 2] # 量化偏差比对(dy/dh/主色直方图)
28
+ node scripts/check_magic_values.mjs # 魔法值门禁(改生成器后必跑,exit 70 供 CI)
29
+ node scripts/v3_regression.mjs [--update-baseline] [--cases <path>] # v3 改动回归门禁(金标清单 training/pass1/cases.json,skill 不持业务路径)
30
+ python3 scripts/derive_data_json.py <schema.json> --check|--overwrite # data.json 派生/检查
31
+ python3 scripts/inventory_schemas.py src/cards --examples # 盘点 DSL 面(同步新导出后先跑)
32
+ python3 scripts/extract_handcrafted.py # 刷新人力卡统计规则库 patterns.json
33
+ node scripts/build_card_index.mjs / build_card_templates.mjs # 重建卡片索引 / 模板库
34
+ node scripts/extract_subtree.mjs --id <卡id> --component-id <cid> # 抽子树为 recipe snippet
35
+ node scripts/describe_card.mjs --id <卡id> [--json] # 卡片反向描述机器初稿
36
+ node scripts/slices_normalize.mjs [--write] # 切图副本归一化(默认 dry-run)
37
+ ```
38
+
39
+ ## MCP 等价工具(P5,见 mcp/,无需 dev server)
40
+
41
+ `card_scaffold` / `card_add_component`(复用 lib/scaffold.mjs,插组件自带离线硬校验)/ `card_route_input`(复用 routeInput())/ `card_write({id, schema, verify:true})`(写卡+validate+render_status)/ `card_read` / `card_node_models` / `card_screenshot` / `ir_patch`(IR 点路径原子修补)。
42
+
43
+ ## r057 保真规则(标注优先,8 条)
44
+
45
+ **属生成器与校验器内部规则(P2)**,改 `spec_to_schema_v3.mjs` 或 `check_fidelity.mjs` 时才读,常规生成不需要:孤立媒体间距 / 文字底壳全覆盖 / 透明形状 / 全卡渐变形 / 语义前缀外置 / 行内值几何三规则 r147 / 文字壳三态 r147 / 占位行保留行操作 r146。
46
+
47
+ ## 四层校验自洽
48
+
49
+ validate(结构合法,CI 硬卡点)→ lint(体验达标)→ diff(铁律保留)→ render_baseline(像素回归)。四者互补不重叠,底层共用 `lib/paths.{mjs,py}`,目录重构无需同步多处。
50
+
51
+ 校验器不证明设计器里的运行时行为,风险交互仍需在平台里核对。
@@ -0,0 +1,23 @@
1
+ # 语义裁决协议(Workflow A 步骤 1 细则)
2
+
3
+ > 从 SKILL.md Workflow A 外迁(2026-09-21 优化方案 P0-3)。做语义裁决(步骤 1/2 之间)时读本文件;脚本可以给出候选信号,但**组件语义最终由 Agent 结合截图和层级树裁决**,禁止把业务词、画板名或某个项目的文案写进生成器。
4
+
5
+ 每个可见簇先归入:`static-text / display-value / input / action / navigation / list / media / decoration`。
6
+
7
+ 裁决必须同时记录三类证据:**视觉形态、标注几何、预期交互**。至少两个信号一致才可定稿;冲突时以截图中的可交互控件和内容承载方式优先,层级名称只作参考。
8
+
9
+ ## 特别约束
10
+
11
+ - **展示值 vs 输入值**:大号货币符号 + 数字 + 右侧编辑/箭头控件,应判为 `input`;用 `Input` 承载数值,货币符号独立为 Text 前缀。输入框高度读标注或按字号行高推导,不得写死整卡高度。
12
+ - **单一消费**:一个源文本只能被一个语义块消费。列表标题、按钮文案、主金额、辅助文案不得重复渲染;发现重影时先检查语义块重叠消费,再调样式。
13
+ - **分段壳只消费一次**:顶部小高度壳若几何包含两个及以上 Tab 文本,按 segmented control 处理,不再同时作为顶部装饰背景。控件高度用「文字上边距 × 2 + 文字高度」推导,行与子项显式垂直居中;选中/未选中底色优先读壳和标注,缺失时按标注缺陷降级。已导出且像素身份可信的 Tab 背景切片作视觉层,其内置文字不得再显示一遍;保留透明 Text 只作为无障碍与点击状态层。
14
+ - **文字底壳跟随标注**:文字外缘附近的小型纯色 shape 是该文本的背景壳;宽度、高度、圆角和颜色必须一起迁移到包裹容器,不能只还原文字。大面板、列表壳和顶部整卡壳不得因局部包围关系被误判为文字底壳。
15
+ - **几何顺序**:顶层语义块按标注 y 插入,不按脚本段落顺序输出。同行判定必须消费原始标注元素;主金额合成片段不得复用为组标签;列表/重复媒体带内的分隔线留在项模板里,不提升为顶层 divider。
16
+ - **重复控件归模板**:同一右缘、同尺寸、等距重复的小控件是列表/重复行模板的一部分;必须由 ListLayout/重复项模板承载,禁止同时作为孤立媒体块再次渲染。副金额、按钮、表单值等已消费文本也必须从列表标题候选中互斥排除。
17
+ - **间距只计一次**:相邻语义块的标注间距不能同时塞进上一块底部和下一块顶部。列表标题、列表项和查看更多必须注册语义边界;列表行内边距由「标注行高 - 内容高」推导,ListLayout 行间距显式归零。
18
+ - **缺失容器高度用子元素推导**:面板标注缺 height 时,取已消费子元素最大底沿加面板 padding 作为语义底沿;后续按钮、标题和列表间距按最近底沿计算,不用段落级「上一文本」跳过中间媒体。
19
+ - **高度由内容撑开**:Root/普通容器用 `auto`;Input、Image、Icon、Divider 和单行 Text 等叶子控件允许从标注读显式高度。Text 高度来自标注 rect,不改 `lineHeight`;纵向节奏仍由容器 padding 控制。
20
+ - **媒体缺陷降级有序**:紧凑媒体缺导出切图时先消费上游 `cropSlice`(从原画板局部裁切的证据),失败才用 `http://iph.href.lu/{w}x{h}?text=...` 占位图;不要用纯色块伪装成图片。弥散、光晕、噪点和不规则纹理属于位图证据,不能用纯色或规则 CSS 渐变近似。宽幅/大面积背景是特例:没有可靠导出切片时禁止画板裁切(会带上前景文字),用标注近似纯色兜底并标记 `background-asset-unavailable` 留给人工补图;训练只局部遮罩该背景矩形,文本、组件和其余像素继续考核。独立细线必须映射 `Divider` 并保持逻辑 `1px`;ListLayout 必须显式声明 `row-spacing=0` 与 `edge-insets={0,0,0,0}`,重复项模板继承标注字号/字重/颜色。
21
+ - **面板重叠有界修正**:只有轻微重叠可把面板流式高度截到下一重叠顶边,且不超过 `max(16px, 25% 面板高度)`;列表等完整业务容器不裁剪。所有 `Text` 保持不可压缩,对齐决定辅助 padding 只落在必要一侧。
22
+ - **垂直节奏用 padding 不用 lineHeight**:渲染器文字行高 = `fontSize × 1.5`(实测;标注给了 lineHeight 就直接设,见 ui_fidelity_rules.md §15),设计稿 `rect.height` 比率在 1.27-1.50 间波动;控制元素垂直位置时优先通过容器 `paddingTop/paddingBottom` 调整,**不设 `lineHeight`**——设 lineHeight 会触发 flex 重新分配空间,简单卡片内容会被拉伸到整个卡片高度。区块间距从 `semanticTopGap(y)` 推导(= 当前块 y - 上一个语义块 bottom),确保渲染内容带 y 位置与设计稿对齐。列表项行内 padding 由「标注行高 - 内容高」÷2 推导,不用固定 token 兜底。
23
+ - **裁决留痕**:语义结论写进训练报告或 lessons,不写入 schema 的非 DSL 字段。
@@ -46,11 +46,12 @@ schema 引用:/slices/8.icon_通用_金额隐藏@3x.png
46
46
  | `展开.png` / `icon (2).png` | `iconCollapse@3x.png` |
47
47
  | `成都农商银行logo-26` | `logoBank@3x.png` |
48
48
 
49
- - **禁用名**:中文/非 ASCII、空格、`编组 N` / `矩形 N` / `形状` / `位图`、导出目录前缀(`8.icon_…`、`99.切图_…`、`4.信息展示_…`)、副本后缀(`xxx (2)`、`xxx_alt2`、`xxx_副本`)、裸通用名(`icon.png`、`image1.png`、`bg.png`)、哈希/纯数字名。lint `r59-slice-semantic-name` 告警(含空格另按 R31 报 ERROR)。
49
+ - **禁用名**:中文/非 ASCII、空格、**URL 保留字符(`# ? % & +`)**、`编组 N` / `矩形 N` / `形状` / `位图`、导出目录前缀(`8.icon_…`、`99.切图_…`、`4.信息展示_…`)、副本后缀(`xxx (2)`、`xxx_alt2`、`xxx_副本`)、裸通用名(`icon.png`、`image1.png`、`bg.png`)、哈希/纯数字名。lint `r59-slice-semantic-name` 告警;**含空格按 R31、含保留字符按 R31b 报 ERROR**。
50
+ - **禁用名落盘前必须消毒**:`lib/slice_dedupe.mjs → sanitizeSliceFileName()` 是唯一入口(`crop_slices.mjs` 已内置),引用写**消毒后**的名字。**不要靠 URL 转义(`%23`)绕过**:字面 `%` 会二次编码成 `%2523`,且 `sliceNameFromRef()` 先 decode、`%23` 解码回 `#` 仍被 `r31b` 拦(详见 `generation_pitfalls.md §9-R31`)。
50
51
  - **同一张图全项目只有一个名字**:多卡共用时引用同一语义名,**不允许一卡一名**(`assetAllocRadar_arrow_right.png` 与 `chevron_right.png` 同图两名 = 违规,见 §3.1 存量证据)。
51
52
  - **改名要成套做**:改 `src/slices/` 文件名 + 用 `lib/slice_dedupe.mjs → rewriteSliceRefs` 改写**所有**卡片 schema/data 里的引用 + 同步 `src/slices/manifest.json`(已上传过的图改名后按内容 sha256 仍复用原线上地址,见 §3.2)。**禁止**新旧两份并存。
52
53
  - **上游保留原名作证据**:`UI/<稿>/<画板>/slices/` 与 `spec.slices` 保持导出原名不动(来源可追溯),语义化只发生在 ② 汇聚落点这一层;元素名 ↔ 导出名匹配仍走 `resolveSliceExport`(§5)。
53
- - **机械产线现状**:`sliceRefPath()` / v3 产线产出的仍是导出路径扁平名(过渡形态,lint 会告警)。**Agent 生成时优先在 IR 里直接写语义名**(`pi.value: "/slices/iconArrowRight@3x.png"`,并把该名字的切图放进要素包 `slices/`),或落地后按上一条成套改名;交付前 `r59` 告警应清零。
54
+ - **机械产线现状(2026-09-21 更新)**:`sliceRefPath()` 产出前已统一过 `sanitizeSliceFileName()`,即**消毒后的导出路径扁平名**(`8.icon/通用/警示-#999@3x.png` → `8.icon_通用_警示-999@3x.png`)——与落盘名严格同源,`r31b`(保留字符)/ `r31c`(磁盘无此文件)双 ERROR 由此可机械验证;**旧实现的 `%23` / `%3F` 编码已废弃**(生成器不得产出被自己门禁拦下的引用)。但它**仍是导出路径扁平名**(过渡形态):`r59` 会告警。**Agent 生成时优先在 IR 里直接写语义名**(`pi.value: "/slices/iconArrowRight@3x.png"`,并把该名字的切图放进要素包 `slices/`),或落地后按上一条成套改名;交付前 `r59` 告警应清零。
54
55
  - **存量清理**:历史落地的非语义名 / 同图多名用 `scripts/slices_normalize.mjs` 成套归一化(默认 dry-run 出报告,`--write` 落盘;改名 + 合并副本 + 改写全部卡片引用 + 同步 manifest 一次做完)。脚本会同时报告**悬空引用**(卡片引用了 `src/slices/` 里不存在的文件)——这类问题改名救不了,需回设计稿重新导出或换图。
55
56
 
56
57
  ## 2. schema 里的引用格式:`/slices/***.**`
@@ -144,7 +145,7 @@ sketch-spec-to-card 产出的 spec 中,**slice 元素的 `name`(图层路径
144
145
  | 拷贝被引用切图到 `src/slices` | `scripts/generate_cards.mjs`(`collectSlicesToSrcSlices`,仅 referenced) |
145
146
  | 本地 `/slices/*` 静态服务(自包含) | `scripts/render_card.mjs`(临时 HTTP 中间件,直接读 `slicesDir`) |
146
147
  | ③ 阶段:上传切图 + 写映射 + 改写引用 | `skills/finmall-card-sync/scripts/slice_upload.mjs`(`uploadSlices`);HTTP 端点 `server/core/sliceUpload.js`(`POST /__slice-upload`);编辑器入口 `card-editor/App.vue`(`uploadCardSlices`);MCP `card_slice_upload` |
147
- | 语义名判定 + 内容 sha256 去重 + 引用改写(共享实现) | `scripts/lib/slice_dedupe.mjs`(`checkSliceName` / `buildContentIndex` / `sha256File` / `duplicatePeers` / `rewriteSliceRefs` / `collectSliceRefStrings`) |
148
+ | 语义名判定 + 消毒 + 内容 sha256 去重 + 引用改写(共享实现) | `scripts/lib/slice_dedupe.mjs`(`checkSliceName` / **`sanitizeSliceFileName`** / **`sliceNameMatches`** / **`findUrlReservedChars`** / **`sliceNameFromRef`** / **`isRemoteSliceRef`** / **`buildSliceFileIndex`** / **`resolveSliceRefOnDisk`** / `buildContentIndex` / `sha256File` / `duplicatePeers` / `rewriteSliceRefs` / `collectSliceRefStrings`)—— lint / preflight / crop_slices / slicePath / expand_ir / generate_cards 全部共用,**改一处即全链路生效** |
148
149
  | 像素级内容判重(裁切图 ≡ 导出资产 / 已重命名共用切图) | `scripts/lib/slice_pixels.mjs`(`pixelsMatch` / `findPixelTwin` / `cropPng` / `pngPixelSha` / `readPng`) |
149
150
  | 缺口审计:指纹分组 + 像素复用 + manifest 别名(`renamedFrom`/`mergedFrom`) | `scripts/spec_audit.mjs`(`groupByFingerprint` / `regionTwin` / `buildAliasMap`;verdict `content-twin`、crops[].reuseOf/alsoRects) |
150
151
  | 裁切落盘前判重(批内 + reuse-dir + 工程 src/slices) | `scripts/crop_slices.mjs`(输出 `reused[]`,命中不写盘) |
@@ -607,3 +607,35 @@ Text 的 `value`(`itemValueFrom:"1"`)绑:
607
607
  - **转义**:写进 schema `itemValue` 时若含正则须双反斜杠(`\d`→`\\d`);本表达式无正则,直接可用。
608
608
  - **同步 data.json**:新增数组字段后跑 `derive_data_json.py --overwrite` + validate。
609
609
  - **分隔符保留**:join 参数必须用设计稿原文分隔符(含前后空格 `" · "`),否则视觉与设计稿不符。
610
+
611
+ ## 20. 语义决策层(Phase 2:启发式提名,AI 终审)
612
+
613
+ **定位**:v3 的 extractSemantics 用几何启发式从 spec 提取语义决策(倒计时识别、表单行占位、动态参数绑定、画板类型等)。当生成结果语义层出错(误识别/漏识别/绑定错)时,**优先走决策回路改裁决,不改脚本**——脚本只在出现全新布局模式时才动。
614
+
615
+ **机制**(`spec_to_schema_v3.mjs`,2026-09-21 起):
616
+
617
+ ```bash
618
+ # 1) 提名:导出语义决策视图(--dry 只审不改,不生成卡片)
619
+ node scripts/spec_to_schema_v3.mjs --spec <spec.json> --id <卡id> --sem-dump .cache/sem/<卡id>.sem.json --dry
620
+
621
+ # 2) 终审:AI 审阅 decisions 字段,纠错后写 decisions 文件(只含要改的字段)
622
+ node scripts/spec_to_schema_v3.mjs --spec <spec.json> --id <卡id> --decisions .cache/sem/<卡id>.decisions.json
623
+ ```
624
+
625
+ **decisions 白名单**(其余字段一律忽略,几何证据保持脚本所有):
626
+
627
+ | 字段 | 类型 | 用途 |
628
+ |---|---|---|
629
+ | `artboardType` | string | 画板类型改判(影响骨架选择) |
630
+ | `bindings` | object | 动态参数绑定改判(entities/literalBind/multiToken 整体替换;姓名/卡号等该绑定没绑的在此补) |
631
+ | `countdown` | object\|null | 倒计时改判:null 禁用;改 numberText/unitText/strokeWidth/配色。**consumed 由脚本按 numberText+几何重链**,AI 只给纯数据 |
632
+ | `cardFill` / `cardBackgroundGradient` | string\|object | 卡底色/渐变改判(注意:--design-image 采样会在其之上二次校正) |
633
+ | `disable` | string[] | 禁用提名字段(listTitle/viewMore/collapseBtn/countdown/topDecoration/midCluster → 置 null),如「误判出列表标题」→ `{"disable":["listTitle"]}` |
634
+
635
+ **铁律**:sem 内元素引用(sourceEl)参与同一性排除(`shell.texts.includes(aux.sourceEl)` 等),**decisions 文件里禁止出现任何 sourceEl / 元素对象**——只有上表白名单字段会被消费,其余静默忽略。`semDecisionsView()` 导出的视图已全部剥元素引用,可直接 JSON 回写。
636
+
637
+ **CLI 附带优化**:`--sem-dump`/`--decisions` 路径下语义提取全程只跑一次(CLI 提取 → 裁决 → semOverride 注入 generate),修复原先 CLI+generate 双跑 extractSemantics 的浪费(该函数 4000+ 行,双跑 ≈ 每卡多付一次全量几何分析)。
638
+
639
+ **文件结构(2026-09-21 拆分,行为不变)**:v3 已拆为三层——`scripts/lib/v3_shared.mjs`(共享 helper/常量,1116 行)、`scripts/lib/v3_extract.mjs`(语义识别 + 决策回路,4108 行)、`scripts/lib/v3_assemble.mjs`(schema 机械组装,5443 行);`scripts/spec_to_schema_v3.mjs` 只剩 CLI 编排壳(96 行)并 re-export `generate/extractSemantics/semDecisionsView/applySemDecisions`(外部消费方 import 路径不变)。组装器与识别器分别演进:**新布局模式只改 extract 层;组件映射/样式组装问题只改 assemble 层**。`check_magic_values.mjs` 的 TARGETS 已覆盖全部四文件。
640
+
641
+ **冻结启发式增长(2026-09-21 起,硬性)**:v3_extract.mjs 的语义启发式规则数冻结——新语义模式**不再**往识别层加规则。处置路径:① sem 决策回路(--sem-dump 审阅 → --decisions 终审,本节上文);② decisions 白名单覆盖不了的(新增语义块、新布局拓扑)→ AI 视觉主判走 Workflow A 整树 IR;③ 仅当同一规则缺陷影响 ≥3 张存量卡还原度时才可改 extract 层,改后必跑 `scripts/v3_regression.mjs`(Pass@1 + 基线漂移门禁;金标清单登记在仓库 `training/pass1/cases.json`,skill 脚本不持有业务 UI 路径;漂移属预期改进才 --update-baseline 并随改动提交)。历史教训档案 `training/lessons/lessons.jsonl`(116 条)已标注 `solidified` 字段:lint=18(固化进校验规则)/ script=24(固化进脚本行为)/ doc=70(固化进 references)/ pending=3 / superseded=1——该文件属 P2 维护档案,**生成上下文不加载**。
@@ -80,7 +80,7 @@ IoU(交并比)是位置评分核心(权重 40%),同时受位置和尺
80
80
 
81
81
  ## 9. 背景与色彩兜底
82
82
 
83
- - 卡片背景**永远不透明**:从 spec `cardFill` 取;缺失时从截图采样主色调;都不可得时用 `#FFFFFF`。
83
+ - 卡片背景**不可完全透明**(必须有可见底色;**半透明允许**,cardFill 含 alpha 时原样还原):从 spec `cardFill` 取;缺失时从截图采样主色调(实色);都不可得时用 `#FFFFFF`。
84
84
  - 缺少切图素材的背景区域:用**纯色近似**替代(取截图平均色),不计入还原度考核。
85
85
  - **禁止从画板裁切背景**(会带入前层叠加文本),除非该区域确认无叠加内容。
86
86
 
@@ -129,3 +129,15 @@ IoU(交并比)是位置评分核心(权重 40%),同时受位置和尺
129
129
  文本自身几何属性,两者不冲突。容器间距仍用 margin/padding,不用 lineHeight。
130
130
  - 混排基线陷阱:不同字号 Text 同行用 `alignItems:baseline` 时,小字号行盒会被基线对齐
131
131
  推出大字号行盒之外(Row 高度 +2px);标注同顶同高时改用 `alignItems:center`。
132
+
133
+ ## 16. 混排文本口径(MultiText,train-proven:seq3-l05)
134
+
135
+ **问题**:同一视觉文本被标注拆成多个元素(加粗前缀 + 中段 + 后缀、金额 + 单位),渲染端按混排/金额铁律合并或拆分后,**期望侧组件计数与渲染端口径不一致** → 误报 component-count-mismatch。
136
+
137
+ **口径纪律(两端必须同源)**:
138
+
139
+ 1. **一个视觉文本 = 一个语义块**(Workflow A 裁决):同段多 style 用 **MultiText itemList** 承载(每段 value/color/fontSize/fontWeight),不拆成多个顶层 Text 节点;金额 + 单位按数字语义提取铁律(§0-14)走「数字绑定 + 单位 Text/后缀」,同属一个语义块。
140
+ 2. **期望侧(trainer 要素断言 / 组件计数)与渲染端同口径**:要么两端都按「合并后一条」计,要么都按渲染节点计——切不可渲染端合并、期望侧逐元素计。
141
+ 3. position / duplication 维度沿用既有去重口径,不因混排重复计数。
142
+
143
+ **渲染范式(MultiText)**:itemList 每项独立 properties(value/color/fontSize/fontWeight/fontStyle/textDecoration/backgroundColor/tag);同一行多段不同字体;点击 event.__do__ 冒泡去重(component_specs.md 有完整 itemId 表)。
@@ -4,8 +4,9 @@ autofix_schema.py — 机械修复「契约级」高频错误,把「校验-修
4
4
 
5
5
  只修可由代码确定性决策的 ERROR 类问题(不臆造语义):
6
6
  1. itemValueFrom == "1" 但缺 itemDefaultValue → 按值类型补兜底(字符串 '' / 数字 0 / 布尔 False / 数组 [] / 对象 {})。
7
- 2. 重复 componentId → 重编号为全局连续唯一 comp_N。
8
- 3. 缺失 data.json 或 key 与 inputParams 漂移 → 由 inputParams.defaultValue 重新派生并写回。
7
+ 2. itemValueFrom == "2" 但值是 $ 表达式 → 翻回 "1"(R25:图片槽只放文件值,表达式在原样透传下渲染空白)。
8
+ 3. 重复 componentId → 重编号为全局连续唯一 comp_N。
9
+ 4. 缺失 data.json 或 key 与 inputParams 漂移 → 由 inputParams.defaultValue 重新派生并写回。
9
10
 
10
11
  不处理需要语义判断的 WARNING(未知样式子键、flex 枚举等)——那些留给人工/卡片作者。
11
12
 
@@ -115,6 +116,47 @@ def walk_fix_defaults(node, fixed):
115
116
  return changed
116
117
 
117
118
 
119
+ def walk_fix_expr_slot(node, fixed):
120
+ """把「图片/文件槽(itemValueFrom="2")里塞了 $ 表达式」的属性翻回变量槽("1")。
121
+
122
+ R25 第三态:Image 的 value itemTypeRange 是 "image,var" —— 静态路径走 "2"、
123
+ 变量绑定走 "1"。运行时 property.js onPreview 对 "2" 分支是 `out[itemKey] = itemValue`
124
+ **原样透传**,于是 `"$icon"` 会被直接当成图片地址写进
125
+ `background-image: url($icon)` —— 画布空白,且校验/运行期都不报错。
126
+
127
+ 表达式不可能是合法的文件值,所以这个翻转是确定性的(不臆造语义)。
128
+ """
129
+ changed = False
130
+
131
+ def _fix(items, where):
132
+ nonlocal changed
133
+ for p in items or []:
134
+ if not isinstance(p, dict):
135
+ continue
136
+ if str(p.get('itemValueFrom')) != '2':
137
+ continue
138
+ val = p.get('itemValue')
139
+ if not (isinstance(val, str) and val.startswith('$')):
140
+ continue
141
+ p['itemValueFrom'] = '1'
142
+ if 'itemDefaultValue' not in p:
143
+ p['itemDefaultValue'] = _fallback_for(val)
144
+ fixed.append(f'itemValueFrom "2"→"1"(图片槽放了表达式): {where}/{p.get("itemKey")}')
145
+ changed = True
146
+
147
+ label = node.get('className') or 'node'
148
+ _fix(node.get('properties'), label)
149
+ _fix(node.get('directives'), f'{label}.directive')
150
+ for grp in node.get('style', {}).get('properties', []) or []:
151
+ if isinstance(grp, dict):
152
+ _fix([grp], f'{label}.style')
153
+ _fix(grp.get('itemChildren', {}).get('properties'), f'{label}.style')
154
+ for child in _children(node):
155
+ if walk_fix_expr_slot(child, fixed):
156
+ changed = True
157
+ return changed
158
+
159
+
118
160
  def fix_duplicate_ids(node, fixed):
119
161
  seen = set()
120
162
  duplicated = False
@@ -196,6 +238,7 @@ def autofix(path, check=False, no_data=False):
196
238
  schema = json.load(f)
197
239
  fixed = []
198
240
  walk_fix_value_from(schema.get('view', {}), fixed)
241
+ walk_fix_expr_slot(schema.get('view', {}), fixed)
199
242
  walk_fix_defaults(schema.get('view', {}), fixed)
200
243
  fix_duplicate_ids(schema.get('view', {}), fixed)
201
244
  fix_data_json(schema, schema_dir, fixed, skip=no_data)
@@ -15,6 +15,12 @@
15
15
  *
16
16
  * 退出码:0 = 无偏差;非 0 = 存在未覆盖 / 超差项(供 CI 识别)。
17
17
  *
18
+ * 报告里的 `elements` 是**机器门禁字段**(iterate.mjs --gate 直接读):
19
+ * - elements.texts.missing = 设计稿里有、schema 里找不到的文案(漏建 / 被占位值顶掉)
20
+ * - elements.texts.invented = schema 里出现、设计稿里没有的**自造文案**(占位符 / 编的样例数据)
21
+ * - elements.slices = 切图覆盖(spec / audit / schema 引用三方计数,信息项)
22
+ * 判据只有一条:**设计稿的要素是固定真值,迭代只许改样式布局,不许丢要素、不许自造内容**。
23
+ *
18
24
  * 用法:
19
25
  * node scripts/check_fidelity.mjs --spec UI/<画板>/spec.json --schema src/cards/<id>/schema.json
20
26
  * node scripts/check_fidelity.mjs --spec <spec.json> --schema <schema.json> --card 1 --json
@@ -22,6 +28,7 @@
22
28
  */
23
29
  import fs from 'node:fs'
24
30
  import path from 'node:path'
31
+ import { pathToFileURL } from 'node:url'
25
32
 
26
33
  function parseArgs(argv) {
27
34
  const out = { spec: '', schema: '', card: 1, json: false, fontTol: 2, colorTol: 24, data: '' }
@@ -164,12 +171,17 @@ function colorClose(a, b, tol) {
164
171
  }
165
172
 
166
173
  /* ── 主流程 ─────────────────────────────────────────────────── */
167
- function main() {
168
- const args = parseArgs(process.argv.slice(2))
174
+ /**
175
+ * 机器可复用的入口(iterate.mjs --gate 直接 import 本函数,不再靠 shell 出去解析文本)。
176
+ * @param {{spec:string,schema:string,card?:number,data?:string,fontTol?:number,colorTol?:number}} input
177
+ * @returns {object} report(含 elements 门禁字段)
178
+ */
179
+ export function buildFidelityReport(input = {}) {
180
+ const args = { card: 1, fontTol: 2, colorTol: 24, data: '', ...input }
169
181
  const specPath = path.resolve(args.spec)
170
182
  const schemaPath = path.resolve(args.schema)
171
- if (!fs.existsSync(specPath)) { console.error(`spec.json 不存在: ${specPath}`); process.exit(1) }
172
- if (!fs.existsSync(schemaPath)) { console.error(`schema.json 不存在: ${schemaPath}`); process.exit(1) }
183
+ if (!fs.existsSync(specPath)) throw new Error(`spec.json 不存在: ${specPath}`)
184
+ if (!fs.existsSync(schemaPath)) throw new Error(`schema.json 不存在: ${schemaPath}`)
173
185
 
174
186
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
175
187
  const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
@@ -185,7 +197,7 @@ function main() {
185
197
  }
186
198
  const cards = spec.cards || []
187
199
  const card = cards[args.card - 1]
188
- if (!card) { console.error(`卡片序号 ${args.card} 不存在(共 ${cards.length} 张卡)`); process.exit(1) }
200
+ if (!card) throw new Error(`卡片序号 ${args.card} 不存在(共 ${cards.length} 张卡)`)
189
201
 
190
202
  const specTexts = collectSpecTexts(card)
191
203
  const schemaTexts = []
@@ -223,8 +235,19 @@ function main() {
223
235
  }
224
236
  }
225
237
 
238
+ // 去重:同一段文案在列表里出现 N 次(3 条产品行)只需报一次,否则清单被重复项淹没
239
+ const missingTexts = [...new Set(findings.filter((f) => f.type === 'missing-text').map((f) => f.spec))]
240
+ const elements = {
241
+ texts: {
242
+ total: specTexts.length,
243
+ matched,
244
+ missing: missingTexts,
245
+ invented: inventedTexts(specTexts, schemaTexts, values),
246
+ },
247
+ slices: sliceCoverage({ specPath, schemaPath, schema }),
248
+ }
226
249
  const violations = findings.length
227
- const report = {
250
+ return {
228
251
  spec: path.relative(process.cwd(), specPath),
229
252
  schema: path.relative(process.cwd(), schemaPath),
230
253
  card: args.card,
@@ -234,13 +257,95 @@ function main() {
234
257
  violations,
235
258
  fontTol: args.fontTol,
236
259
  colorTol: args.colorTol,
260
+ elements,
237
261
  findings,
238
262
  }
263
+ }
264
+
265
+ /**
266
+ * schema 里出现、设计稿里没有的文案 —— 「自造内容」清单。
267
+ *
268
+ * 为什么单列一条:实测(2026-09-21 demo 建卡回合)模型把设计稿的产品名 / 利率 / 期限
269
+ * 全换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素还能刷到 89%,
270
+ * 而 card 的内容真值全丢了 —— 只查 missing 不够,得把「凭空多出来的文案」也点名。
271
+ * 只统计**看起来像内容**的短文本(≥2 字、非纯符号/数字单位),避免把「%」「查看更多」这类
272
+ * 通用 UI 文案误报成自造。
273
+ */
274
+ function inventedTexts(specTexts, schemaTexts, values) {
275
+ const specN = specTexts.map((t) => norm(t.text))
276
+ const out = []
277
+ const seen = new Set()
278
+ for (const s of schemaTexts) {
279
+ for (const c of candidates(s, values)) {
280
+ const v = String(c || '').trim()
281
+ if (v.length < 2 || seen.has(v)) continue
282
+ if (/^[%%·・\-—+*/\d.,::()()\s]+$/.test(v)) continue // 纯符号 / 数字 / 单位:不是内容要素
283
+ seen.add(v)
284
+ if (!specN.some((sp) => sp === v || sp.includes(v) || v.includes(sp))) out.push(v)
285
+ }
286
+ }
287
+ return out.slice(0, 20)
288
+ }
289
+
290
+ /** 切图覆盖:spec 声明数 / audit 落盘数 / schema 引用数(信息项,不单独判红) */
291
+ function sliceCoverage({ specPath, schemaPath, schema }) {
292
+ const specDir = path.dirname(specPath)
293
+ const auditPath = path.join(path.dirname(schemaPath), '.audit.json')
294
+ let specCount = null
295
+ let auditCount = null
296
+ try {
297
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
298
+ const card = (spec.cards || [])[0]
299
+ specCount = (card?.elements || []).filter((e) => e && e.type === 'slice').length
300
+ } catch {
301
+ /* 拿不到就不报 */
302
+ }
303
+ try {
304
+ const audit = JSON.parse(fs.readFileSync(path.resolve(auditPath), 'utf8'))
305
+ auditCount = Array.isArray(audit.slices) ? audit.slices.length : null
306
+ } catch {
307
+ /* 无 audit 属正常(非设计稿链路) */
308
+ }
309
+ const refs = new Set()
310
+ const scan = (n) => {
311
+ if (!n || typeof n !== 'object') return
312
+ for (const p of n.properties || []) {
313
+ if (typeof p.itemValue === 'string' && p.itemValue.includes('/slices/')) refs.add(p.itemValue)
314
+ }
315
+ for (const g of n.style?.properties || []) {
316
+ for (const c of g.itemChildren?.properties || []) {
317
+ if (typeof c.itemValue === 'string' && c.itemValue.includes('/slices/')) refs.add(c.itemValue)
318
+ }
319
+ }
320
+ for (const c of n.components || []) scan(c)
321
+ for (const sl of n.slots || []) for (const c of sl.components || []) scan(c)
322
+ }
323
+ scan(schema.view || schema)
324
+ return { specCount, auditCount, schemaRefs: refs.size, refs: [...refs].slice(0, 40) }
325
+ }
239
326
 
327
+ function main() {
328
+ const args = parseArgs(process.argv.slice(2))
329
+ let report
330
+ try {
331
+ report = buildFidelityReport(args)
332
+ } catch (e) {
333
+ console.error(String(e?.message || e))
334
+ process.exit(1)
335
+ }
240
336
  if (args.json) {
241
337
  console.log(JSON.stringify(report, null, 2))
242
338
  } else {
243
- console.log(`spec 文本元素 ${specTexts.length} 个,schema Text 节点 ${schemaTexts.length} 个,匹配 ${matched} 个`)
339
+ const { specTextCount, schemaTextCount, matched, violations, findings, elements } = report
340
+ console.log(`spec 文本元素 ${specTextCount} 个,schema Text 节点 ${schemaTextCount} 个,匹配 ${matched} 个`)
341
+ if ([specTextCount, schemaTextCount, matched, violations, findings, elements].length && elements.texts.missing.length) {
342
+ console.log(`\n[要素缺失] ${elements.texts.missing.length} 条设计稿文案在 schema 里找不到(先补要素,再调样式):`)
343
+ for (const t of elements.texts.missing) console.log(` - "${t}"`)
344
+ }
345
+ if (elements.texts.invented.length) {
346
+ console.log(`\n[自造内容] ${elements.texts.invented.length} 条 schema 里出现、设计稿里没有的文案(占位值?):`)
347
+ for (const t of elements.texts.invented) console.log(` - "${t}"`)
348
+ }
244
349
  if (!violations) {
245
350
  console.log('[OK] 保真度对齐:文本覆盖完整,字号/颜色均在容差内。')
246
351
  } else {
@@ -250,8 +355,9 @@ function main() {
250
355
  }
251
356
  }
252
357
  }
253
-
254
- process.exit(violations ? 1 : 0)
358
+ process.exit(report.violations ? 1 : 0)
255
359
  }
256
360
 
257
- main()
361
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
362
+ if (invokedDirectly) main()
363
+
@@ -17,7 +17,12 @@ import path from 'node:path'
17
17
  import { fileURLToPath } from 'node:url'
18
18
 
19
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
20
- const TARGETS = ['spec_to_schema_v3.mjs']
20
+ const TARGETS = [
21
+ 'spec_to_schema_v3.mjs', // CLI 编排壳
22
+ 'lib/v3_shared.mjs', // 共享 helper / 常量
23
+ 'lib/v3_extract.mjs', // 语义识别层
24
+ 'lib/v3_assemble.mjs', // schema 组装层
25
+ ]
21
26
 
22
27
  /** 需要 Token 化的样式键 → 允许的 Token 访问器 */
23
28
  const KEY_ACCESSOR = {