基于 FigmaMCP 的 D2C 工具(六): verify(上)——静态义务和 ComponentUsageIR
在前五篇中,我们已经完成:
- 同一份设计稿为什么会生成出漂移的结果
- 为什么我把归一化放在工具侧,而不是让设计师再出一份干净稿
- 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(证明义务):
// 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 相关字段全部省略:
// 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[] }
| UnresolvedFact3.2 抽什么、怎么抽
入口是 extractComponentUsage(file, { projectRoot, catalog })。步骤写在注释里:
// 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
我们看一个具体例子:
<!-- 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 和文本节点省略:
{
"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。顺序固定:
// 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 每一条对应一条义务,直接上代码:
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 结构
// 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 我们执行一下
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 节点带布局约束。跑出来的报告:
{
"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写样式,或删掉这个 classbinding:prop:size:改成size="medium"就 pass。业务上确实要动态就保留 unknown,走 runtimebinding: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 时,我看的顺序固定:
boundary:fingerprint:*有 fail 就停。上游过期,重跑 ingest / decidestructure:selected-component没过,源码根本没用上 target,查 importcontract:required-*和structure:slot:*,是缺了还是动态,两种修法不一样binding:prop:*,是值对不上还是 reference。skeleton:*是runtime-required,准备第七篇的场景,或者先给节点补样式- 最后才看
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,以及为什么截图在这套体系里只做诊断、不参与证明。