基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR

15 分钟阅读

在前三篇中,我们已经完成:

  • 同一份设计稿反复生成,结果为什么会漂移
  • 为什么我不靠设计师再出一份干净稿,而是在工具侧做归一化
  • catalog 三层 generated / overrides / links,运行时只读 CatalogSnapshot

本篇在此基础上做消费端的第一步:ingest。输入是 Figma 原始节点,输出是 DesignIntentIR。后面的 decide 和 verify 只认这份 IR,不再回头读图层。所以这一步如果把一个真按钮压没了,后面整条链路都会跟着错。

我的目的是把归一化的流程讲清楚 不是把 schema 里每个字段都列全 所以下面贴的类型和 JSON 都是精简版 只保留文中用到的字段 完整 schema 大家可以自行对照 zod 定义

1. Figma 原始节点有多脏

先看一个最常见的例子。设计师画了一个主按钮:圆角矩形加一行文字。图层面板里是这样:

text
Group 12
├── Decoration line          RECTANGLE 1px 高
└── Frame 1000011563         FRAME 圆角 4 实心填充
    └── 确认                  TEXT

对应的 MCP dump 精简之后如下,第 9 节的前后对照会用这份数据:

json
{
  "node": {
    "id": "1:2", "name": "Group 12", "type": "GROUP",
    "absoluteBoundingBox": { "x": 16, "y": 600, "width": 343, "height": 84 },
    "children": [
      { "id": "1:3", "name": "Decoration line", "type": "RECTANGLE", "fills": [{ "type": "SOLID" }],
        "absoluteBoundingBox": { "x": 16, "y": 600, "width": 343, "height": 1 }, "children": [] },
      { "id": "1:4", "name": "Frame 1000011563", "type": "FRAME", "cornerRadius": 4, "fills": [{ "type": "SOLID" }],
        "absoluteBoundingBox": { "x": 16, "y": 640, "width": 343, "height": 44 },
        "children": [
          { "id": "1:5", "name": "确认", "type": "TEXT", "characters": "确认", "fills": [{ "type": "SOLID" }],
            "boundVariables": { "fills": { "id": "VariableID:12:34" } },
            "absoluteBoundingBox": { "x": 165, "y": 652, "width": 45, "height": 20 }, "children": [] }
        ] }
    ]
  }
}

这份数据的问题:

  • 没有自动布局。Frame 1000011563 没有 layoutMode,子节点全靠绝对坐标摆位
  • 组嵌套没有语义。Group 12 只是设计师框选之后按了一下 Cmd+G
  • 命名随意。真正的按钮叫 Frame 1000011563,反而是装饰线有个能看懂的名字
  • adapter 丢字段。TEXT 节点少了 fontSize。这是 MCP 序列化时丢的,Figma 里这个 key 一定存在

人看一眼就知道这是个按钮。机器拿到的是四个散装节点。ingest 要做的就是把它们收成一棵能用的树,并且把每一步判断记下来。

2. DesignIntentIR 长什么样

2.1 类型

我们先来看输出的类型。按 zod schema 精简:

ts
interface DesignIntentIR {
  schemaVersion: 2
  kind: 'design-intent'
  id: string                          // design:{documentId}:{rootIds}
  source: {
    adapter: 'figma-rest' | 'figma-mcp' | 'design-intent-v2'
    documentId: string
    fingerprint: string               // 整份输入 JSON 的 sha256
    evidence: EvidenceRef[]
  }
  roots: DesignIntentNode[]           // 至少一个 空树在 schema 层就过不去
  normalization: NormalizationTrace[] // 每个节点的 keep / flatten / drop 记录
  layout: LayoutConstraintGraph       // 布局约束图 单独成图 不挂在节点上
  unresolved: UnresolvedFact[]        // 解不开的事实 不是警告
  evidence: EvidenceRef[]
}

// EvidenceRef = { source, locator, extractorVersion, fingerprint, confidence }
// locator 格式 figma://{documentId}/{nodeId} 后面 ProofObligation 引用设计侧证据就靠它

interface DesignIntentNode {
  id: string
  name: string
  nodeType: 'frame' | 'group' | 'instance' | 'text' | 'shape' | 'vector' | 'unknown' | /* ... */
  role?: DesignRole                   // button / input / table / dialog / display ... 可以为空
  visible?: boolean | UnresolvedFact
  component?: { componentKey?: string, codeComponentId?: string, variantProperties?: Record<string, string>, evidence: EvidenceRef[] }
  text?: { characters: string, evidence: EvidenceRef[] }
  bounds?: { x: number, y: number, width: number, height: number }
  tokens?: DesignToken[]              // 见第 7 节
  interactions?: Array<{ trigger: string, action: string, destinationNodeId?: string, evidence: EvidenceRef[] }>
  children: DesignIntentNode[]
  evidence: EvidenceRef[]
}

要点:

  • normalization 记每个节点的动作。后面排查某个按钮为什么不见了,全靠它
  • layout 单独成图,verify 阶段的 skeleton 比较只读这张图
  • role 可选。名字和形状都对不上的节点照样 keep,role 空着,到 decide 里落成 unknown,不会随手贴一个 button

2.2 三种 adapter

入口是 ingestDesign(input, options),adapter 三种:

  • figma-rest:从 document(GET /files)或 nodes.*.document(GET /nodes)取根节点,然后清洗
  • figma-mcp:形状不固定,依次兼容 nodes 数组、nodes 对象、nodedocument、单个带 id 的节点,然后清洗
  • design-intent-v2:已经是 v2 IR,只过 DesignIntentIRSchema.parse做一下解析。schemaVersion 不是 2 直接报错 “D2C v1 design IR is unsupported. Re-ingest the original Figma payload.”

CLI 用法:

bash
d2c ingest --input button.json --adapter figma-mcp --document-id demo-file --out .d2c/design-intent.json

约束:

  • 找不到根节点在洗之前就报错 “figma-mcp payload contains no design nodes.”。这是输入形状问题,不是清洗过猛
  • 整棵树被扔光也报错 “All design nodes were dropped by deterministic normalization.”,空 roots 不许往下传
  • fingerprint 是整份输入 JSON 的内容哈希。在 Figma 里动一个无关图层再 dump 一次,指纹就变。这是故意的:DecisionRecord 绑定的是这次喂进引擎的字节,不是我觉得还是同一份稿

2.3 ingest 流程

flowchart TD
  A[Figma payload] --> B{adapter}
  B -->|design-intent-v2| C[schemaVersion 必须是 2<br/>直接过 schema]
  B -->|figma-rest / figma-mcp| D[算 fingerprint<br/>rootsForAdapter 取根]
  D --> E[normalizeNode 深度递归]
  E --> F{normalizationAction}
  F -->|drop| G[写 trace 返回空]
  F -->|flatten| H[写 trace 带 mappedNodeIds<br/>子节点上浮到父级]
  F -->|keep| I[写 trace<br/>进 layout.nodes 写 containment]
  I --> J[addLayoutRelations]
  J --> K[flagMissingAdapterFields<br/>缺 key 落 unresolved]
  K --> L[inferRole 角色提示<br/>命中再补一条 trace]
  L --> M[extractTokens<br/>组装 DesignIntentNode]
  G --> N{roots 为空?}
  H --> N
  M --> N
  N -->|是| O[报错]
  N -->|否| P[DesignIntentIRSchema.parse]

3. 归一化:每个节点三选一

3.1 keep / flatten / drop

对每个 Figma 节点,normalizationAction 只给一个动作:

  • keep:进意图树,继续挂子节点,进布局图
  • flatten:自己消失,子节点上浮到父级。trace 里记 mappedNodeIds,标明这层壳对应哪些子节点
  • drop:自己和子树都不要。只有明确的装饰叶子才会走到这里

drop 的条件最苛刻,keep 是默认值。这个顺序是我改过一次之后定下来的,3.3 节会讲。

3.2 决策伪代码

直接上代码,具体思路在注释里:

ts
function normalizationAction(node): { action: 'keep' | 'flatten' | 'drop', rule: string, confidence: number } {
  const name = node.name?.trim() ?? ''
  const children = node.children ?? []
  const hasIdentity = Boolean(node.componentKey || node.mainComponent?.key || node.componentSetKey)
  const hasContent = typeof node.characters === 'string' && node.characters.length > 0
  const hasInteraction = Boolean(node.reactions?.length || node.prototypeInteractions?.length)

  // 1. 装饰叶子才 drop 五个条件同时满足 少一个都不行
  const decorativeName = /^(?:background|divider|decoration|shadow|line)(?:\s|$)/i.test(name)
  if (!hasIdentity && !hasContent && !hasInteraction && !children.length && decorativeName)
    return { action: 'drop', rule: 'explicit-decorative-leaf', confidence: 0.98 }

  // 2. 匿名包裹 Group 12 / Frame 1000011563 这类名字 自己没有身份没有文字
  const isAnonymousWrapper = !hasIdentity && !hasContent && !hasInteraction && children.length
    && /^(?:group|frame)\s*\d*$/i.test(name)
  if (isAnonymousWrapper) {
    // 压扁之前先看形状 数字名 Frame 里经常住着真按钮 见第 4 节
    const structural = inferStructuralRole(node)
    if (structural && ['button', 'input', 'media', 'dialog'].includes(structural.role))
      return { action: 'keep', rule: `${structural.rule}+pre-flatten`, confidence: structural.confidence }
    return { action: 'flatten', rule: 'anonymous-structural-wrapper', confidence: 0.95 }
  }

  // 3. 表格预检 必须在子节点被 flatten 之前用原始树判断 见 4.2
  if (isTableAreaShape(node))
    return { action: 'keep', rule: 'role-inference:structure:table+header+pre-flatten', confidence: 0.65 }

  // 4. 其余全部 keep 有组件身份的置信度 1 没有的 0.7 一样留下
  return hasIdentity
    ? { action: 'keep', rule: 'semantic-component-identity', confidence: 1 }
    : { action: 'keep', rule: 'preserve-uncertain-node', confidence: 0.7 }
}

trace 的结构很简单:

ts
interface NormalizationTrace {
  nodeId: string
  action: 'keep' | 'flatten' | 'drop'
  rule: string                        // 上面那些规则名 排查时直接搜
  confidence: number
  mappedNodeIds?: string[]            // 只有 flatten 有 记上浮的子节点 id
  evidence: EvidenceRef[]
}

要点:

  • 判断顺序就是优先级:装饰叶子、匿名包裹、表格预检、默认 keep
  • 设计师改过名的 Frame 不匹配匿名正则,走默认 keep
  • 置信度只写进 trace,不参与动作本身。keep 0.7 和 keep 1 在树里没有区别,区别在 decide 排候选的时候
  • normalizeNode 先递归子节点再写自己的 trace,所以子节点的记录排在父节点前面。flatten 时子节点的 parentId 直接接到上一级,containment 不会断
  • 角色推断(第 5 节)命中时会再补一条 keep,所以一个节点可能有两条 trace,规则名不同

排查时最常见的问题是:这个按钮为什么在 IR 里没了?打开 normalization 按 nodeId 搜,就能看到它是被 anonymous-structural-wrapper 压掉了,还是被 explicit-decorative-leaf 误删了。没有 trace 的时候,我只能拿原始 JSON 和洗后的树手工 diff,几轮下来人就废了。

3.3 踩坑:不认识的节点,早期我是直接丢的

早期版本的默认分支是 drop:没有组件身份、名字对不上别名表、形状也认不出来的节点,直接扔。当时的想法是树越干净,后面 decide 越好做。

结果是业务节点成批失踪。一个改过名的 Frame 叫 “操作区”,别名表里没有,形状也不像任何控件,就被丢了。decide 拿到的树很瘦,大量 needs-review,人工一看设计稿明明是标准布局。更麻烦的是丢了就没有痕迹,模型侧拿原始 MCP 树在脑补,两边对不上。

我后来把默认分支改成 keep,规则 preserve-uncertain-node,置信度 0.7。同时把另一类丢掉的东西也收回来:adapter 该给却没给的字段,不补默认值,落进 unresolved(第 8 节)。现在唯一会主动 drop 的只有装饰叶子。

多留噪声的成本是 decide 召回时候选多一些,可以靠 role 和硬约束过滤。误删的成本是下游对着空洞补东西,补出来的没有证据,查不了。两个我选前者。

4. 匿名 Frame 里可能住着按钮

4.1 先看形状再 flatten

匿名包裹这条规则还有一个坑:早期是先 flatten 所有 Frame 12 / Group 3,再对清爽的树做角色推断。图层树立刻干净,按钮也立刻消失。第 1 节的例子里,Frame 1000011563 一压扁,圆角、实心填充这些形状信号就跟着没了,剩下一个 TEXT 孤零零挂在上层。

所以现在匿名包裹在 flatten 之前先跑 inferStructuralRole。形状判断都是布尔加几何阈值,没有 embedding:

  • 按钮:有 TEXT 子节点,cornerRadius > 0,有可见 SOLID fill。三个都要。直角块加文字不算,直角块太常见,会误伤页面分区
  • 输入框:宽不小于高的 3 倍且高不超过 64,子 TEXT 文案匹配 请输入|输入|搜索|placeholder。只靠扁宽不够,横向导航条也是扁宽的
  • 媒体:有可见的 IMAGE fill
  • 弹层:三个信号同时出现。遮罩(非 FRAME / INSTANCE 的实心矩形,宽高都不小于父节点的 98%)、内容卡(完全落在父节点内部且比父节点小)、关闭图标(不超过 64x64 的小 FRAME / INSTANCE,在某张内容卡上沿之上)。缺一条就不认,只靠全屏背景块会误伤普通页面根节点

约束:

  • 形状检测的置信度全部压在 0.6 ~ 0.7。它只影响 decide 排候选的顺序,不跳过任何硬约束
  • 阈值全是线上误伤案例逼出来的,比如输入框的 height <= 64。调阈值靠加回归夹具
  • 只有 button / input / media / dialog 四种形状能阻止 flatten。table 不在列表里,因为同形的行容器本身就该 flatten

4.2 表格要读 flatten 前的原始树

表格是另一类 flatten 陷阱。常见结构不是一个叫 Table 的父节点下面整齐挂行,而是表头文字和行容器平级:

text
Frame 88                            表区域根
├── 币种 / 年化 / 期限 / 操作         4 个 TEXT
├── Frame 91 / Frame 92 / Frame 93   同形行容器 343 x 48 纵向排列

行容器 Frame 91 这些自己就符合匿名包裹条件,会被 flatten。如果等子节点洗完再推断,表头 TEXT 还在,行已经变成散装单元格,“表头 + 同形行组” 这个信号永远触发不了。所以 normalizationAction 在默认 keep 之前,用原始的 node.children 做一次表格预检 isTableAreaShape

  • 子节点至少 6 个(3 个表头 + 3 行)
  • 同一层至少 3 个有内容的 TEXT
  • 至少 3 个 FRAME / GROUP 名字去掉数字后相同,几何上宽扁(高不超过 64,宽不小于 2 倍高),并且 y 至少三个不同值。不看 y 的话横向导航条也会被认成重复行

命中之后表区域根 keep,置信度 0.65。行容器仍然按匿名规则处理,该 flatten 就 flatten。表区域根留下来,role 才能挂上 table。

5. 角色推断只是提示

keep 之后每个节点会过一遍 inferRole。这一步只给一个粗标签,供 decide 召回候选时排序,不代表已经认准了某个组件。推断分层,置信度从高到低,命中一层就返回:

  1. catalog links 已经链到代码组件,0.95,规则 role-inference:link
  2. 英文名按空白和 / _ - 拆词,精确撞别名表(button / btn / modal / dialog / nav …),0.9,role-inference:name
  3. 中文名按子串匹配,0.85,role-inference:name-zh
  4. 纯 TEXT 先当 display,0.8,role-inference:type
  5. 结构形状(第 4 节),0.55 ~ 0.7
  6. 只剩 layoutMode 的容器,container,0.5

中文别名为什么要单独处理:中文图层名没有空白可以拆词,只能子串匹配。别名表里既有 “搜索” 也有 “搜索框”,短的先匹配的话,所有搜索框都会先被打成更泛的 “搜索”。所以别名表按长度降序扫,“搜索框” 先命中:

ts
const chineseAliases = Object.entries(CHINESE_ROLE_ALIASES).sort((a, b) => b[0].length - a[0].length)
for (const [alias, role] of chineseAliases) {
  if (name.includes(alias)) return { role, confidence: 0.85, rule: 'role-inference:name-zh' }
}

要点:

  • 名字信号优先于形状信号。一个叫 “Submit button” 的圆角实心 Frame,trace 里只有 role-inference:name,不会再跑形状检测
  • 全部对不上就没有 role。不猜
  • modal / dialog / 弹窗 / 对话框 / 模态 都映到 dialog,抽屉单独 drawer

6. LayoutConstraintGraph 怎么从节点推出来

图由 nodesrelationsunresolved 三部分组成。关系是 discriminated union,这里只列 ingest 会写的:

ts
type LayoutRelation =
  | { kind: 'containment', parentId: string, childId: string }
  | { kind: 'order', parentId: string, childIds: string[] }
  | { kind: 'axis', nodeId: string, value: 'row' | 'column' | 'grid' | UnresolvedFact }
  | { kind: 'alignment', nodeId: string, axis: 'main' | 'cross', value: 'start' | 'center' | 'end' | 'space-between' | /* ... */ }
  | { kind: 'gap', nodeId: string, value: number | UnresolvedFact }
  | { kind: 'padding', nodeId: string, value: { top: number, right: number, bottom: number, left: number } }
  | { kind: 'sizing', nodeId: string, axis: 'horizontal' | 'vertical', mode: 'fixed' | 'hug' | 'fill' }
  | { kind: 'grid-tracks' | 'wrap' | 'overflow' | 'positioning', /* ... */ }
// 每条 relation 还带 evidence 上面省略了

只有 keep 的节点进图。containmentnormalizeNode 里写,其余在 addLayoutRelations 里按 Figma 字段逐条补:

ts
function addLayoutRelations(node, id, children, evidence, relations) {
  // 有子节点就写 order 顺序就是 children 数组顺序
  if (children.length)
    relations.push({ kind: 'order', parentId: id, childIds: children.map(c => c.id), evidence })
  // auto-layout 主轴 HORIZONTAL → row / VERTICAL → column / GRID → grid
  const axis = { HORIZONTAL: 'row', VERTICAL: 'column', GRID: 'grid' }[node.layoutMode]
  if (axis) relations.push({ kind: 'axis', nodeId: id, value: axis, evidence })
  // 对齐 MIN → start / CENTER → center / MAX → end / SPACE_BETWEEN → space-between 主轴交叉轴各一条
  if (typeof node.itemSpacing === 'number')
    relations.push({ kind: 'gap', nodeId: id, value: node.itemSpacing, evidence })
  // padding 四边齐了才写一条 缺一边整条不写 不写半吊子数字
  if ([node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft].every(v => typeof v === 'number'))
    relations.push({ kind: 'padding', nodeId: id, value: { top: node.paddingTop, right: node.paddingRight, bottom: node.paddingBottom, left: node.paddingLeft }, evidence })
  // sizing:layoutSizingHorizontal / Vertical 的 FIXED / HUG / FILL 两个轴各一条
  // grid 只知道行列数 track 统一写 1fr / auto
  // layoutWrap → wrap  clipsContent → overflow  positioning === 'ABSOLUTE' → positioning
}

约束:

  • 没开 auto-layout 的节点只有 containmentorder。第 1 节那个按钮就是这样,没有 axis 也没有 gap。ingest 不会从子节点坐标算出一个 gap 塞进去
  • padding 四边齐全才写。缺的那一边会在第 8 节落进 unresolved
  • 图里的 unresolved 和顶层的 unresolved 是两个数组。图的那份留给 verify:设计侧有布局意图、源码侧抽不出布局事实时,verify 会标 runtime-required,第六、七篇再展开

绝对定位的子节点怎么变成 flex 或 grid 这里不展开 ingest 只负责把 Figma 给的字段如实写进图 几何反推布局是 verify 阶段 skeleton 投影的事

7. token 归一化

Figma 里绑了变量的属性会出现在 boundVariables 上。ingest 把它们拍平成 DesignToken,只记 variableId 和属性名,不在这一步解析变量值:

ts
interface DesignToken {
  kind: 'color' | 'typography' | 'spacing' | 'radius' | 'shadow' | 'opacity' | 'other'
  property: string                    // Figma 里绑定变量的属性名 比如 fills
  variableId?: string                 // VariableID:xxx
  evidence: EvidenceRef[]
}
// kind 按属性名猜:color|fill|stroke → color  font|text → typography
// padding|gap|spacing → spacing  radius → radius  shadow|effect → shadow  猜不出落 other

第 1 节的 TEXT 节点出来就是一条 { kind: 'color', property: 'fills', variableId: 'VariableID:12:34' }。变量对应哪个 CSS 变量、值是多少,是 decide 拿 catalog 去对的事。ingest 只记录设计师绑了什么。

8. unresolved 为什么保留

UnresolvedFact{ kind: 'unresolved', reason, detail?, evidence }。ingest 阶段只产出 adapter-field-missing 这一种 reason,其余是 decide / verify 写的。规则是缺 key 才算缺:

ts
const SHAPE_NODE_TYPES = new Set(['FRAME', 'INSTANCE', 'RECTANGLE', 'VECTOR', 'LINE', 'ELLIPSE', 'POLYGON', 'STAR'])

// 只报 key 缺失 不报值为 0 或空数组
// Figma 数据源里这些 key 必然存在 缺 key 只可能是 adapter 序列化时丢了
function flagMissingAdapterFields(node, id, evidence, context) {
  const missing: string[] = []
  // 开了 auto-layout 的 FRAME / INSTANCE 必须有 itemSpacing 和四边 padding
  if ((node.type === 'FRAME' || node.type === 'INSTANCE') && node.layoutMode && node.layoutMode !== 'NONE') {
    if (typeof node.itemSpacing !== 'number') missing.push('itemSpacing')
    for (const key of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'])
      if (typeof node[key] !== 'number') missing.push(key)
  }
  if (node.type === 'TEXT' && typeof node.fontSize !== 'number') missing.push('fontSize')
  // 可见的形状节点必须有 fills 数组 空数组也算有
  if (SHAPE_NODE_TYPES.has(node.type) && node.visible !== false && !Array.isArray(node.fills)) missing.push('fills')
  if (missing.length)
    context.unresolved.push(unresolvedFact('adapter-field-missing',
      `Node ${id} (${node.type}) is missing expected adapter fields: ${missing.join(', ')}. Re-dump the design with a single complete serialization pass.`, evidence))
}

要点:

  • 值为 0 不算缺。paddingTop: 0 是设计师真的设了 0,paddingTop 这个 key 不存在才是 adapter 丢了
  • 隐藏的形状不查 fills,没开 auto-layout 的 Frame 不查 spacing
  • detail 里直接写修法:重新用一次完整的序列化导出

为什么留而不是补一个默认值。我试过给缺 itemSpacing 的节点补 0,结果 verify 拿这个 0 去和源码里的 gap: 8px 比,判定不等价,而且 report 里看不出这个 0 是设计师设的还是我补的。落进 unresolved 之后,verify 看到这条会把对应的 ProofObligation 标成 unknown,不是 pass 也不是 fail。缺证据就标记缺证据,从 ingest 开始就要遵守这个约定。

9. 前后对照:原始节点到 DesignIntentIR

我们执行一下 2.2 节的命令,把第 1 节那份 payload 喂进去。输出去掉重复的 evidence 之后是这样:

jsonc
{
  "schemaVersion": 2,
  "kind": "design-intent",
  "id": "design:demo-file:1:4",          // Group 12 被 flatten 根直接变成 Frame 1000011563
  "source": {
    "adapter": "figma-mcp", "documentId": "demo-file",
    "fingerprint": "3f9c…e21a",          // 整份输入 JSON 的 sha256
    "evidence": [{ "source": "figma-mcp", "locator": "figma://demo-file/document", "extractorVersion": "2.0.0", "fingerprint": "3f9c…e21a", "confidence": 1 }]
  },
  "roots": [{
    "id": "1:4", "name": "Frame 1000011563", "nodeType": "frame",
    "role": "button",                    // 靠形状推出来的 置信度 0.6 在 trace 里
    "visible": true,
    "bounds": { "x": 16, "y": 640, "width": 343, "height": 44 },
    "tokens": [], "interactions": [],
    "children": [{
      "id": "1:5", "name": "确认", "nodeType": "text", "role": "display", "visible": true,
      "text": { "characters": "确认", "evidence": [/* figma://demo-file/1:5 */] },
      "bounds": { "x": 165, "y": 652, "width": 45, "height": 20 },
      "tokens": [{ "kind": "color", "property": "fills", "variableId": "VariableID:12:34", "evidence": [/* 同上 */] }],
      "interactions": [], "children": [],
      "evidence": [/* figma://demo-file/1:5 */]
    }],
    "evidence": [/* figma://demo-file/1:4 */]
  }],
  "normalization": [
    // 子节点先写 父节点后写
    { "nodeId": "1:3", "action": "drop",    "rule": "explicit-decorative-leaf",                    "confidence": 0.98 },
    { "nodeId": "1:5", "action": "keep",    "rule": "preserve-uncertain-node",                     "confidence": 0.7 },
    { "nodeId": "1:5", "action": "keep",    "rule": "role-inference:type",                         "confidence": 0.8 },
    { "nodeId": "1:4", "action": "keep",    "rule": "role-inference:structure:button+pre-flatten", "confidence": 0.6 },
    { "nodeId": "1:4", "action": "keep",    "rule": "role-inference:structure:button",             "confidence": 0.6 },
    { "nodeId": "1:2", "action": "flatten", "rule": "anonymous-structural-wrapper", "mappedNodeIds": ["1:4"], "confidence": 0.95 }
  ],
  "layout": {
    "nodes": [{ "id": "1:5", "label": "确认" }, { "id": "1:4", "label": "Frame 1000011563" }],
    "relations": [
      { "kind": "containment", "parentId": "1:4", "childId": "1:5" },
      { "kind": "order",       "parentId": "1:4", "childIds": ["1:5"] }
      // 没有 axis / gap / padding 因为原始节点没开 auto-layout ingest 不从坐标反推
    ],
    "unresolved": []
  },
  "unresolved": [{
    "kind": "unresolved", "reason": "adapter-field-missing",
    "detail": "Node 1:5 (TEXT) is missing expected adapter fields: fontSize. Re-dump the design with a single complete serialization pass.",
    "evidence": [/* figma://demo-file/1:5 */]
  }],
  "evidence": [/* 同 source.evidence */]
}

对照原始输入看几处:

  • Group 12 没了,但 trace 里有它的 flatten 记录,mappedNodeIds 指向 1:4。想知道按钮原来在哪个组里,查这条就行
  • Decoration line 没了,trace 里是 drop。哪天设计师把一条业务分隔线也叫 Decoration,查 trace 能发现是被这条规则删的
  • Frame 1000011563 留下了,role 是 button。两条 trace 一条是 pre-flatten 检测给的,一条是 inferRole 再跑形状检测给的,能区分是哪一步认出来的
  • 布局图里只有 containment 和 order。设计师没开 auto-layout,ingest 就不写 axis 和 gap
  • TEXT 缺 fontSize 落进了 unresolved,节点本身照常 keep,文案和 token 都在

如果把 Frame 1000011563 改成真正的装饰:改名 divider,删掉子节点,去掉圆角。再跑一次 ingest,它应该变成 drop。这个我做成了回归夹具,比口头说装饰规则生效了有用。

同一份输入、同一个 extractorVersion,两次 ingest 的输出必须逐字节一致,包括 trace 顺序。这是 FingerprintSet 里 design 那一项能拿来比对的前提。

小结

本篇完成了:

  • Figma 原始节点的四类噪声:无自动布局、组嵌套、命名随意、adapter 丢字段
  • DesignIntentIR 的精简类型和三种 adapter,d2c ingest 用法
  • normalizationAction 的 keep / flatten / drop 判定顺序,以及 NormalizationTrace
  • 匿名 Frame 的 pre-flatten 形状检测,表格用原始树预检
  • 分层的角色推断和中文别名长度排序
  • LayoutConstraintGraph 从 auto-layout 字段逐条推出来,padding 四边齐才写
  • token 拍平,缺字段落 unresolved 而不是补默认值
  • 一份完整的前后对照

两个踩坑也写了:早期默认 drop 不认识的节点,我改成默认 keep 0.7;早期先 flatten 再推断,我改成 flatten 前先看形状。

下一篇写 decide:拿着这份 IR 和 CatalogSnapshot,候选怎么召回、硬约束怎么卡、BindingPlanExtensionRecipe 长什么样,以及 verdict 为什么只有 reuse / extend / new / needs-review 四种。

相关文章
前后篇
OLDER →基于 FigmaMCP 的 D2C 工具(三): catalog——机器出事实,人补判断