基于 FigmaMCP 的 D2C 工具(六): verify(上)——静态义务和 ComponentUsageIR

15 分钟阅读

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

  • 同一份设计稿为什么会生成出漂移的结果
  • 为什么我把归一化放在工具侧,而不是让设计师再出一份干净稿
  • catalog 三层:generated / overrides / links
  • ingest 产出 DesignIntentIR
  • decide 产出 DecisionRecord,里面有 verdict 和 BindingPlan

本篇在此基础上做 verify 的静态部分:从实现好的 Vue 文件反向抽出 ComponentUsageIR,拿它和 BindingPlan 逐条对,每条对照结果记成一条 ProofObligation,最后聚合成 VerifyReport。浏览器里的运行时量测、skeleton 等价比较、截图,留给下一篇。

我的目的是把静态校验的输入、输出和判定规则讲清楚。抽取器内部对 Vue 编译器 AST 的遍历、UnoCSS 的生成过程这里不展开,只贴到能看懂 IR 从哪来的程度。

1. 为什么有了 DecisionRecord 还要 verify

decide 回答的是:在当前 catalog 和这份设计意图下,该不该复用、绑什么。它不回答实现文件到底写成了什么样。

我早期把 decide 的结果直接当验收结论。verdict 是 reuse,Agent 说按计划实现了就算过关。后来翻实现文件,几类问题反复出现:

  • 标签名对了,但 import 的是另一个封装,不是 catalog 里那个契约
  • 必填 prop 散在 v-bind="obj" 里,看不出到底有没有传
  • 槽名写成 #[slotName],模板里不知道最终填的是哪个槽
  • 组件外面多包了一层 div 调间距,计划里没有这个操作

这些问题只看生成结果的文本看不出来。所以我把流程改成 decide 之后必须过 verify static,需要几何和运行态证据的再走 verify runtime:

flowchart LR
  A[DecisionRecord<br/>verdict + BindingPlan] --> B[人 / Agent 写实现]
  B --> C[d2c verify static]
  C -->|抽| D[ComponentUsageIR]
  D --> E[逐条对 BindingPlan]
  E --> F[VerifyReport]
  F -->|全 pass| G[accepted]
  F -->|有 fail| H[rejected]
  F -->|有 unknown| I[needs-review<br/>转 verify runtime 或人审]

要点:

  • verify 不改 DecisionRecord。verdict 落盘后不可变,verify 只产出另一份 VerifyReport
  • CLI 和 MCP 走同一个服务层的 verifyStatic,判定逻辑不在适配器里
  • static 快、确定性高、失败信息结构化;runtime 贵、依赖场景文件。先 static 再 runtime 是我定的顺序

2. ProofObligation:只有 pass / fail / unknown

静态校验的最小单位是一条 ProofObligation(证明义务):

ts
// schemas/proof.ts(精简)
interface ProofObligation {
  id: string                     // 形如 binding:prop:title、boundary:fingerprint:catalog
  category: 'contract' | 'semantic' | 'behavior' | 'structure' | 'layout' | 'boundary'
  phase?: 'static' | 'runtime'   // 静态侧一律 static
  outcome: 'pass' | 'fail' | 'unknown'
  expected?: unknown             // fail 时带期望值
  actual?: unknown               // fail 时带实际值
  differences?: Array<{ path: string, expected: unknown, actual: unknown, message: string }>
  unresolved?: UnresolvedFact    // unknown 时必须带,说明为什么证不了
}

// schemas/evidence.ts(精简)
interface UnresolvedFact {
  kind: 'unresolved'
  reason: 'dynamic-expression'   // 值是表达式 / 引用,静态看不到最终值
    | 'dynamic-spread'           // v-bind="obj",可能给了也可能没给
    | 'dynamic-style'            // :class / :style / css 里的 v-bind()
    | 'unresolved-symbol'        // class 没有样式来源、token 找不到
    | 'ambiguous-component-resolution' // 标签解析不到契约
    | 'stale-fingerprint'        // decision 里缺这枚指纹
    | 'runtime-required'         // 只能靠浏览器证
    | /* …decide 阶段用的几个省略 */ 'unknown'
  detail?: string
}

schema 上有两条 superRefine,我特意加的:outcome 是 unknown 却没带 unresolved,parse 报错;outcome 不是 unknown 却带了,同样报错。作用是逼每个 unknown 都写清楚原因码,没有原因码的 unknown 排障时等于没有信息。

要点:

  • 三态,没有分数,没有 0.8 算过
  • unknown 不算过。聚合时 unknown 保持 unknown,不折成 pass
  • fail 一定带 expected / actual,方便 explain 摊开看

静态义务按 id 前缀分几组:boundary:fingerprint:* 指纹;semantic:design-unresolved:N / boundary:source-unresolved:N 两份 IR 自带的未解析事实;structure:design-node / contract:selected-component / structure:selected-component 三个定位;contract:required-* 必填项;binding:* / behavior:event:* / structure:slot:* / layout:token:* 对应 BindingPlan 的操作;skeleton:* 布局骨架。后面伪代码里能看到每组怎么产生。

3. 从源码抽 ComponentUsageIR

3.1 类型精简版

字段名按真实 schema 保留,evidence 相关字段全部省略:

ts
// schemas/component-usage.ts(精简)
interface ComponentUsageIR {
  schemaVersion: 2
  kind: 'component-usage'
  id: string                      // usage:<相对路径>
  source: { file: string, fingerprint: string }   // 指纹 = 源文件 + 参与的样式文件
  roots: ComponentUsageNode[]     // template 顶层节点
  styles: UsageStyleFact[]        // 编译后的 CSS,一条声明一条事实
  layout: LayoutConstraintGraph   // 从 styles 投影出的布局事实,和 DesignIntentIR.layout 同一个类型
  unresolved: UnresolvedFact[]    // 抽取器自己解不开的账
}

interface ComponentUsageNode {
  id: string                      // <文件>:<行>:<列>:<序号>
  nodeKind: 'component' | 'element' | 'template' | 'text'
  tag: string
  resolution:                     // 标签解析到哪个契约,verify 定位靶点只认这个字段
    | { kind: 'resolved', contractId: string, importPath?: string }
    | { kind: 'native', tag: string }
    | { kind: 'unresolved', fact: UnresolvedFact }
  props: Array<{ name: string, value: BindingValue, syntax: 'static' | 'bind' | 'shorthand' }>
  models: Array<{ name: string, argument?: string, value: BindingValue }>   // v-model
  events: Array<{ name: string, handler: BindingValue }>                   // @xxx
  slots: Array<{ name: BindingValue, dynamic: boolean }>                   // <template #xxx>
  spreads: Array<{ target: 'props' | 'attrs' | 'listeners', value: BindingValue }>  // v-bind="obj"
  directives: UsageDirective[]; branches: UsageBranch[]; loops: UsageLoop[]
  classes?: BindingValue[]; styles?: BindingValue[]; text?: BindingValue
  children: ComponentUsageNode[]
}

// schemas/binding.ts(精简)。BindingPlan 里的期望值也是这个类型,两边形状一致才能比
type BindingValue =
  | { kind: 'literal', value: string | number | boolean | null }
  | { kind: 'reference', name: string, path?: string[] }   // :size="size"、:v="a.b.c"
  | { kind: 'expression', source: string, possibleValues?: BindingValue[] }
  | { kind: 'object', entries: Array<{ key: string, value: BindingValue }> }
  | { kind: 'array', items: BindingValue[] }
  | UnresolvedFact

3.2 抽什么、怎么抽

入口是 extractComponentUsage(file, { projectRoot, catalog })。步骤写在注释里:

ts
// src/usage/source-usage.ts(伪代码,按真实流程精简)
async function extractComponentUsage(file, { projectRoot, catalog }) {
  const parsed = parseSfc(readFileSync(file))     // @vue/compiler-sfc;解析报错直接抛,不产出半份 IR

  // 1. <script> 里的 import 收成 局部名 -> 模块路径(typescript 编译器 API)
  const imports = parseImports(script)
  // 2. catalog 契约建索引:id / name / aliases / source.file 归一化成 kebab-case 当 key
  //    Nuxt 自动注册的组件从 .nuxt/components.d.ts 补进索引
  const components = componentIndex(catalog); addNuxtComponents(components, projectRoot)

  // 3. template 用 @vue/compiler-dom 解析,递归生成节点
  //    - 组件标签先查索引,再拿 import 路径对契约的 importPath;都对不上 -> resolution unresolved
  //    - 属性归类:普通 attribute -> props(static);:xxx -> props(bind);v-bind="obj" -> spreads
  //      v-model -> models;@xxx -> events;<template #xxx> 由父组件收成 slots,槽名 isStatic 才是 literal
  //    - 表达式走 parseBindingExpression:字符串/数字/布尔 -> literal;标识符 -> reference;
  //      a.b.c -> reference + path;对象/数组字面量递归;三元 -> expression + possibleValues
  const roots = parseTemplate(template).children.flatMap(n => usageNode(n, undefined, ctx))

  // 4. 样式:<style> 块 compileStyleAsync 编译(scoped 带 data-v-xxx),postcss 逐条声明记一条事实
  //    nuxt.config.ts 的 css: [] 和 UnoCSS 生成结果同样逐条记
  //    模板里的静态 class 没被 UnoCSS 匹配、也没被任何 selector 覆盖 -> unresolved-symbol
  const styles = await extractStyles(...)

  // 5. styles 投影进 layout 图:selector 能匹配到节点才投
  //    display:flex -> axis:row;flex-direction:column -> axis:column;gap / padding / sizing / overflow 各有 relation
  addLayoutFromStyles(roots, styles, ctx.layout)

  return ComponentUsageIRSchema.parse({ ..., source: { file, fingerprint: fingerprintFiles([file, ...styleFiles]) } })
}

要点:

  • 抽取是确定性的。同一份源码加同一份 catalog,抽出来的 IR 一模一样。这是后面能拿指纹对账的前提
  • 标签解析认契约索引和 import 路径,不认标签字符串长得像
  • 不做数据流分析。:size="size" 哪怕 size 是个常量,也只记 reference

3.3 一段 SFC 和抽出来的 IR

我们看一个具体例子:

vue
<!-- pages/confirm.vue -->
<script setup lang="ts">
import Dialog from '~/components/Dialog.vue'
const visible = ref(false)
const size = 'medium'
const footerSlot = 'footer'
function onConfirm() {}
</script>

<template>
  <div class="page">
    <Dialog v-model="visible" title="确认删除" :size="size" :closable="true" @confirm="onConfirm">
      <p class="tip">删除后不可恢复</p>
      <template #[footerSlot]>
        <Button type="primary">确定</Button>
      </template>
    </Dialog>
  </div>
</template>

<style scoped>
.page { display: flex; flex-direction: column; gap: 16px; padding: 24px; }
</style>

抽出来的 IR,只保留 Dialog 节点和相关部分,evidence 和文本节点省略:

json
{
  "kind": "component-usage",
  "id": "usage:pages/confirm.vue",
  "source": { "file": "pages/confirm.vue", "fingerprint": "sha256:9f2c…" },
  "roots": [{
    "id": "pages/confirm.vue:11:3:1", "nodeKind": "element", "tag": "div",
    "resolution": { "kind": "native", "tag": "div" },
    "classes": [{ "kind": "literal", "value": "page" }],
    "children": [{
      "id": "pages/confirm.vue:12:5:2", "nodeKind": "component", "tag": "Dialog",
      "resolution": { "kind": "resolved", "contractId": "dialog", "importPath": "~/components/Dialog.vue" },
      "props": [
        { "name": "title",    "value": { "kind": "literal",   "value": "确认删除" }, "syntax": "static" },
        { "name": "size",     "value": { "kind": "reference", "name": "size" },     "syntax": "bind" },
        { "name": "closable", "value": { "kind": "literal",   "value": true },       "syntax": "bind" }
      ],
      "models": [{ "name": "modelValue", "value": { "kind": "reference", "name": "visible" } }],
      "events": [{ "name": "confirm", "handler": { "kind": "reference", "name": "onConfirm" } }],
      "slots":  [{ "name": { "kind": "expression", "source": "footerSlot" }, "dynamic": true }],
      "spreads": [],
      "children": [
        { "id": "pages/confirm.vue:13:7:3", "nodeKind": "element",  "tag": "p", "classes": [{ "kind": "literal", "value": "tip" }] },
        { "id": "pages/confirm.vue:14:7:5", "nodeKind": "template", "tag": "template",
          "directives": [{ "name": "slot", "argument": { "kind": "expression", "source": "footerSlot" } }],
          "children": [{ "nodeKind": "component", "tag": "Button", "resolution": { "kind": "resolved", "contractId": "button" } }] }
      ]
    }]
  }],
  "styles": [
    { "source": "sfc-scoped", "selector": ".page[data-v-9f2c1a3b]", "property": "display", "value": { "kind": "literal", "value": "flex" } },
    "…flex-direction / gap / padding 三条同形…"
  ],
  "layout": {
    "relations": [
      { "kind": "containment", "parentId": "pages/confirm.vue:11:3:1", "childId": "pages/confirm.vue:12:5:2" },
      { "kind": "axis", "nodeId": "pages/confirm.vue:11:3:1", "value": "column" },
      { "kind": "gap",  "nodeId": "pages/confirm.vue:11:3:1", "value": 16 },
      "…padding 一条…"
    ]
  },
  "unresolved": [
    { "kind": "unresolved", "reason": "unresolved-symbol",
      "detail": "Class 'tip' was matched neither by UnoCSS nor by component or global styles." }
  ]
}

对着看几个地方:

  • Dialog 的 resolution.contractId 是 dialog,是 import 路径和契约 source.importPath 对上的结果
  • :size="size" 是 reference;#[footerSlot] 进了 slots,dynamic: true
  • <p class="tip"> 是 Dialog 的直接子节点、不是 template,所以算 default 槽的内容
  • .page 的四条声明投成了 axis / gap / padding,挂在 div 上。Dialog 节点自己一条布局事实都没有
  • tip 没有任何样式来源,记了一条 unresolved-symbol。这条后面会变成一条 unknown 义务

3.4 抽不出来怎么记

遇到看不清的东西,抽取器只做一件事:记账,不猜。账记在三个地方:

  • 节点级:resolution.kind = 'unresolved',标签解析不到契约时
  • IR 级:usage.unresolved。没有 template、样式块编译失败、class 没有样式来源、:class / :style 是动态的
  • 布局图级:usage.layout.unresolved。@media 下的非可见性声明、动态 class

约束:

  • 样式块编译失败不会让整份抽取失败。那个块变成一条 value 为 UnresolvedFact 的 UsageStyleFact,usage.unresolved 多一条 dynamic-style
  • CSS 里的 v-bind() 值直接记 dynamic-style,不试图求值
  • 没有 template 的组件产出一个空的 template 根节点,加一条 unsupported-vue-construct。这份 IR 进 verify,structure:selected-component 一定 fail

抽取器没有单独的 CLI 子命令,跟在 d2c verify static 里跑,--file 指哪个 Vue 文件就抽哪个。

4. 静态校验:拿 BindingPlan 逐条对 UsageIR

4.1 入口顺序

verifyStatic 吃四样东西:DecisionRecord、DesignIntentIR、ComponentUsageIR、CatalogSnapshot。顺序固定:

ts
// src/verify-v2/static-verifier.ts(伪代码,按真实流程精简)
function verifyStatic({ decision, design, usage, catalog }): VerifyReport {
  const obligations: ProofObligation[] = []

  // 1. 四枚指纹先对。decision 里缺这枚 -> unknown(stale-fingerprint);对不上 -> fail,带 expected/actual
  //    任何一枚 fail,后面的义务照样生成,但整单一定是 fail
  for (const [name, expected, actual] of [
    ['extractor', decision.fingerprints.extractor, catalog.fingerprints.extractor],
    ['design',    decision.fingerprints.design,    design.source.fingerprint],
    ['catalog',   decision.fingerprints.catalog,   catalog.fingerprint],
    ['source',    decision.fingerprints.source,    usage.source.fingerprint],
  ]) obligations.push(fingerprintProof(name, expected, actual))

  // 2. 两份 IR 自带的 unresolved 原样投影成 unknown,不吞掉
  design.unresolved.forEach((fact, i) => obligations.push(unknown(`semantic:design-unresolved:${i}`, fact)))
  usage.unresolved.forEach((fact, i)  => obligations.push(unknown(`boundary:source-unresolved:${i}`, fact)))

  // 3. 三个定位。usageNode 认的是 resolution.contractId,不是 tag 字符串
  const designNode = flatten(design.roots).find(n => n.id === decision.designNodeId)
  const contract   = catalog.contracts.find(c => c.id === decision.target)
  const usageNode  = flatten(usage.roots).find(n => n.resolution.kind === 'resolved' && n.resolution.contractId === decision.target)
  obligations.push(designNode ? pass('structure:design-node')        : fail('structure:design-node', 'Design node … was not found.'))
  obligations.push(contract   ? pass('contract:selected-component')  : fail('contract:selected-component', 'Catalog contract … was not found.'))
  obligations.push(usageNode  ? pass('structure:selected-component') : fail('structure:selected-component', 'Source does not use ….'))

  // 4. 契约和 usage 节点都在,才查必填项、才逐条对 BindingPlan
  if (contract && usageNode) {
    obligations.push(...verifyRequiredContract(contract, usageNode))
    obligations.push(...verifyOperations(decision.bindingPlan?.operations ?? [], usageNode, usage.styles))
  }
  // 5. 设计节点和 usage 节点都在,才碰布局骨架
  if (designNode && usageNode) obligations.push(...skeletonLayoutObligations(designNode, usageNode, design, usage))

  // 6. 聚合:有 fail -> fail;否则有 unknown -> unknown;否则 pass
  const status = reportStatus(obligations)
  return VerifyReportSchema.parse({
    kind: 'verify-report', id: `verify:${decision.id}:static`, decisionId: decision.id,
    fingerprints: { /* 以当前实际值为准重新写一遍 */ },
    target: { kind: 'static', sourceFile: usage.source.file },
    obligations, status,
    disposition: status === 'pass' ? 'accepted' : status === 'fail' ? 'rejected' : 'needs-review',
    artifacts: [],   // 静态阶段没有产物,截图之类是 runtime 的事
  })
}

约束:

  • 顺序不能调。节点都找不到就去比 BindingPlan,只会造出一堆无意义的 fail
  • 定位不做模糊匹配。有同事提过在 structure:selected-component 上加别名兼容,我否掉了。一旦标签长得像就算命中,用错组件会被涂成 pass,这条义务就没意义了
  • 指纹 fail 的正确动作是回去重跑 decide,不是改源码把 verify 糊过去。source 指纹在 decide 时可能还没有(源码还没写),那 verify 记 unknown,这是正常流程

4.2 必填 prop 和必填 slot

契约里标 required 的项,不管 BindingPlan 提没提,都查:

  • 必填 prop:出现在 props,或 v-model 对应的 prop,或 v-bind="{ title: 'x' }" 这种能静态看见 key 的对象 spread,都算 pass。节点上有 spread 但看不见 key,unknown(dynamic-spread)。都没有,fail
  • 必填 slot:有字面量同名槽 pass。只有动态槽名 unknown(dynamic-expression)。都没有,fail

4.3 BindingPlan 六种操作逐条对

BindingPlan.operations 每一条对应一条义务,直接上代码:

ts
function verifyOperations(operations, node, styles): ProofObligation[] {
  const tokens = tokenChannel(styles)   // 收 styles 里 --xxx 的字面量定义和 var(--xxx) 引用
  return operations.map((op, index) => {
    switch (op.kind) {
      case 'set-prop': {
        const actual = node.props.find(p => p.name === op.prop)?.value
        if (!actual && node.spreads.length) return unknown(`binding:prop:${op.prop}`, 'dynamic-spread')  // 可能在 spread 里
        return compareBinding(`binding:prop:${op.prop}`, op.value, actual)
      }
      case 'bind-model': {
        const actual = node.models.find(m => (m.argument ?? 'modelValue') === op.prop)
        // 缺 v-model -> fail;有 -> 比值。值几乎总是 reference,所以几乎总是 unknown
        return actual ? compareBinding(`binding:model:${op.model}`, op.value, actual.value)
                      : fail(`binding:model:${op.model}`, `v-model ${op.model} is absent.`)
      }
      case 'bind-event':
        // 只查有没有绑,不比 handler。handler 是什么函数静态证不了
        return node.events.some(e => e.name === op.event)
          ? pass(`behavior:event:${op.event}`) : fail(`behavior:event:${op.event}`, `Event ${op.event} is not bound.`)
      case 'fill-slot': {
        const literalHit = node.slots.some(s => s.name.kind === 'literal' && s.name.value === op.slot)
        const defaultHit = op.slot === 'default' && node.children.some(c => c.nodeKind !== 'template')  // default 槽:有非 template 子节点就算填了
        if (literalHit || defaultHit) return pass(`structure:slot:${op.slot}`)
        if (node.slots.some(s => s.name.kind !== 'literal')) return unknown(`structure:slot:${op.slot}`, 'runtime-required')  // 动态槽名可能填的就是它
        return fail(`structure:slot:${op.slot}`, `Slot ${op.slot} is not filled.`)
      }
      case 'compose-wrapper': return unknown(`boundary:wrapper:${index}`, 'runtime-required')  // 要祖先感知,静态不证
      case 'outer-layout':    return unknown(`layout:outer:${index}`, 'runtime-required')      // 要几何,静态不证
      case 'token-override':  return verifyTokenOverride(op, tokens)
      default:                return unknown(`contract:operation:${index}`, 'unsupported-vue-construct')
    }
  })
}

// 三态比较,整个静态校验里最常被调用的函数
function compareBinding(id, expected, actual): ProofObligation {
  if (!actual) return fail(id, 'Expected binding is absent.', expected, undefined)
  // reference / expression / unresolved 静态拿不到最终值 -> unknown(dynamic-expression)
  if (actual.kind === 'unresolved' || actual.kind === 'expression' || actual.kind === 'reference')
    return unknown(id, actual.kind === 'unresolved' ? actual : unresolvedFact('dynamic-expression', ...))
  // 剩下 literal / object / array,整体序列化后比
  return JSON.stringify(expected) === JSON.stringify(actual) ? pass(id) : fail(id, 'Binding value differs.', expected, actual)
}

token-override 是三级判定:styles 里能找到 --token: 字面量 的定义,就归一化后比值,pass 或 fail;定义走 var() / color-mix(),unknown(runtime-required),只有 computed-style 通道能升级;根本找不到,unknown(unresolved-symbol)。

要点:

  • set-prop 的 pass 条件是字面量完全相等。:size="size" 只能 unknown,要静态过就写 size="medium"
  • bind-model 静态只能证有没有,证不了值。这是我接受的限制
  • fill-slot 对 default 槽宽松,对具名槽必须字面量同名
  • compose-wrapper / outer-layout 静态不证,直接挂 runtime-required,第七篇的事

4.4 设计有布局、usage 没布局事实

设计侧对这个节点如果有 axis / alignment / gap / padding / sizing / grid-tracks / wrap / overflow 这几类骨架事实,静态侧会尝试做 skeleton 等价。但前面有一道检查:

  • 设计侧对这个节点没有布局约束,没东西要证,返回空
  • 设计侧有,usage 侧对应节点一条布局事实都没有,标记出一条 skeleton:<designNodeId> 的 unknown,原因码 runtime-required,不许当等价
  • 两边都有,才进 proveSkeletonEquivalence,第七篇讲

回到 3.3 的例子:.page 的布局事实挂在 div 上,Dialog 节点自己没有。设计侧的 Dialog 节点若带了 axis 和 gap,这里就出一条 unknown。usage 侧为 0 有两种可能:页面真没写布局,或者样式写在抽取器还没覆盖的地方。静态分不清,所以我选 unknown,交给 runtime 或人来判断。

5. VerifyReport

5.1 结构

ts
// schemas/verify.ts(精简)
interface VerifyReport {
  kind: 'verify-report'
  id: string                                  // verify:<decisionId>:static
  decisionId: string
  fingerprints: { extractor: string, catalog: string, design: string, source: string }
  target: { kind: 'static', sourceFile: string }
        | { kind: 'route' | 'component', scenarioId: string, viewport: {…}, … }   // runtime 用
  obligations: ProofObligation[]
  status: 'pass' | 'fail' | 'unknown'
  disposition?: 'accepted' | 'rejected' | 'needs-review'
  artifacts: Array<{ kind: 'dom' | 'computed-style' | 'geometry' | 'screenshot' | 'visual-diff' | …, diagnosticOnly: boolean, … }>
  createdAt: string
}

schema 上同样有 superRefine:status 必须和 obligations 算出来的一致,手改过不了 parse;disposition 是 accepted 时 status 必须是 pass。

status 是证明结果,disposition 是流程上怎么处置这张单。evolve 只读 accepted / rejected 的报告,needs-review 不进演进输入,第八篇讲。

5.2 我们执行一下

bash
d2c verify static \
  --decision .d2c/decisions/confirm-dialog.json \
  --design   .d2c/design/confirm.json \
  --file     pages/confirm.vue \
  --out      .d2c/verify/confirm-dialog.static.json

假设第五篇 decide 给 3.3 那个页面的 BindingPlan 是:set-prop title / size / closable、bind-model modelValue、bind-event confirm、fill-slot default / footer。Dialog 契约必填 prop 是 title,设计侧 Dialog 节点带布局约束。跑出来的报告:

json
{
  "kind": "verify-report",
  "id": "verify:decision:confirm-dialog:static",
  "target": { "kind": "static", "sourceFile": "pages/confirm.vue" },
  "obligations": [
    { "id": "boundary:fingerprint:extractor", "category": "boundary", "outcome": "pass" },
    "…design / catalog / source 三枚指纹 pass…",
    { "id": "boundary:source-unresolved:0", "category": "boundary", "outcome": "unknown",
      "unresolved": { "reason": "unresolved-symbol", "detail": "Class 'tip' was matched neither by UnoCSS nor by component or global styles." } },
    { "id": "structure:design-node",        "category": "structure", "outcome": "pass" },
    { "id": "contract:selected-component",  "category": "contract",  "outcome": "pass" },
    { "id": "structure:selected-component", "category": "structure", "outcome": "pass" },
    { "id": "contract:required-prop:title", "category": "contract",  "outcome": "pass" },
    { "id": "binding:prop:title",           "category": "contract",  "outcome": "pass" },
    { "id": "binding:prop:size",            "category": "contract",  "outcome": "unknown",
      "unresolved": { "reason": "dynamic-expression", "detail": "Binding size requires runtime proof." } },
    { "id": "binding:prop:closable",        "category": "contract",  "outcome": "pass" },
    { "id": "binding:model:modelValue",     "category": "contract",  "outcome": "unknown",
      "unresolved": { "reason": "dynamic-expression", "detail": "Binding visible requires runtime proof." } },
    { "id": "behavior:event:confirm",       "category": "behavior",  "outcome": "pass" },
    { "id": "structure:slot:default",       "category": "structure", "outcome": "pass" },
    { "id": "structure:slot:footer",        "category": "structure", "outcome": "unknown",
      "unresolved": { "reason": "runtime-required", "detail": "A dynamic slot name may fill slot footer; runtime proof is required." } },
    { "id": "skeleton:design:dialog-1",     "category": "layout",    "outcome": "unknown",
      "unresolved": { "reason": "runtime-required", "detail": "Skeleton layout proof requires source-side layout facts; the usage layout graph has none for node pages/confirm.vue:12:5:2." } }
  ],
  "status": "unknown",
  "disposition": "needs-review",
  "artifacts": []
}

12 条 pass,5 条 unknown,0 条 fail。整单 unknown,needs-review。这 5 条分别怎么处理:

  • boundary:source-unresolved:0:给 tip 写样式,或删掉这个 class
  • binding:prop:size:改成 size="medium" 就 pass。业务上确实要动态就保留 unknown,走 runtime
  • binding:model:modelValue:v-model 静态证不了值,这条会一直 unknown,靠 runtime 场景
  • structure:slot:footer:#[footerSlot] 改成 #footer 就 pass。这个页面里动态槽名没有必要
  • skeleton:design:dialog-1:Dialog 节点没有布局事实。给它写样式让抽取器投影出来,或者等 runtime 量

改完前四条再跑,剩 binding:model:modelValue 和 skeleton 两条,仍是 needs-review。这是正常的,static 证不了的就该留给下一篇。

d2c explain --input <report.json> 会输出一份 verify-explanation,把 failed 和 unknown 两组单独摊出来。它不重跑校验,不改 status,不把 unknown 升成 pass,只是省得翻几十条 pass。

6. 踩坑

6.1 只看生成结果的文本

早期没有 UsageIR 这一层。验证的做法是对生成的 Vue 文件跑几条正则:有没有 <Dialog、有没有 title=、有没有 #footer。三条都命中就算 reuse 兑现了。

出问题的场景:

  • <Dialog 命中了,但 import 的是业务目录下另一个同名封装,catalog 里那个契约根本没被用到
  • title= 命中了,值是 :title="props.title",正则看不出这和计划里的字面量是不是一回事
  • #footer 没命中,因为写成了 v-slot:footer。反过来 #[footerSlot] 会被宽松一点的正则误命中

后来我把这一层换成先抽 ComponentUsageIR,再拿 BindingPlan 逐条对。差别在于:组件身份看 resolution.contractId,不看标签文本;值有 literal / reference / expression 之分,比对时能区分对得上、对不上、看不清;槽名走 Vue 编译器解析,#footer / v-slot:footer / #[x] 三种写法归到同一个字段,动态与否有明确标记。

这一步改动量不小,抽取器本身就写了不少代码。但它把静态校验从字符串匹配换成了结构比对,后面所有义务都建立在这上面。

6.2 unknown 涂绿

那段时间为了让 CI 能绿,汇总时把 unknown 折成了 pass。结果 decide 很严,verify 很松,问题在验证这一步溜出去了。

后来我把聚合规则写死在 schema 的 superRefine 里:有 fail 就是 fail;没 fail 有 unknown 就是 unknown;status 和 obligations 不一致,parse 报错。CI 门禁可以自己定策略,比如允许 unknown 不允许 fail,但引擎内部不许把 unknown 变成 pass。

6.3 动态 slot 当填上

旧逻辑里,槽名是表达式时算可能填了,然后记 pass。抽查源码发现必填槽是空的,表达式运行时求出来是另一个名字。现在动态槽名只能 unknown,原因码 runtime-required。要 pass 要么改字面量,要么走 runtime 场景。

7. 排障顺序

报告是 needs-review 或 rejected 时,我看的顺序固定:

  1. boundary:fingerprint:* 有 fail 就停。上游过期,重跑 ingest / decide
  2. structure:selected-component 没过,源码根本没用上 target,查 import
  3. contract:required-* 和 structure:slot:*,是缺了还是动态,两种修法不一样
  4. binding:prop:*,是值对不上还是 reference。
  5. skeleton:* 是 runtime-required,准备第七篇的场景,或者先给节点补样式
  6. 最后才看 explain,确认没有漏读的原因码

大多数 unknown 停在第 2 到 4 步。早期我一上来就怀疑 skeleton,浪费了不少时间。

对不上时改哪一侧:源码错了改源码;源码故意扩展、计划没配方,回 overrides 补 ExtensionRecipe 再重跑 decide;计划过期重 ingest / decide。改 verify 放在行里不放在选项里,第九篇讲为什么。

小结

本篇完成了:

  • ProofObligation 三态和 UnresolvedFact 原因码,schema 层强制 unknown 必须带原因
  • ComponentUsageIR 的类型、抽取流程、属性归类规则,以及抽不出来时记在哪
  • 一段 SFC 和它抽出来的 IR 对照
  • verifyStatic 的固定顺序:指纹、unresolved 投影、三个定位、必填项、BindingPlan 六种操作、布局前置检查
  • VerifyReport 的结构和 status / disposition 聚合规则
  • 三个踩坑:只看文本、unknown 涂绿、动态 slot 当填上

下一篇是 verify的下半篇:SkeletonNode 怎么从两侧布局图投影出来、skeleton 等价比较的规则、d2c verify runtime 怎么用浏览器场景量 geometry 和 computed-style,把本篇留下的 runtime-required 升成 pass 或 fail,以及为什么截图在这套体系里只做诊断、不参与证明。

系列 · 基于 FigmaMCP 的 D2C 工具 · 第 6 篇 / 共 9 篇
01基于 FigmaMCP 的 D2C 工具(一): 为什么同一份设计稿的生成结果会漂移02基于 FigmaMCP 的 D2C 工具(二): 为什么我不靠设计师再出一份干净稿03基于 FigmaMCP 的 D2C 工具(三): catalog——机器出事实,人补判断04 基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR05基于 FigmaMCP 的 D2C 工具(五): decide——reuse / extend / new 的判定
06 基于 FigmaMCP 的 D2C 工具(六): verify(上)——静态义务和 ComponentUsageIR本篇
07 基于 FigmaMCP 的 D2C 工具(七): verify——skeleton 与运行时量测08基于 FigmaMCP 的 D2C 工具(八): 账本与 evolve——只提案,不自动改库09基于 FigmaMCP 的 D2C 工具(九): 我收紧过的规则,以及 Agent/MCP 怎么接进来
评论