基于 FigmaMCP 的 D2C 工具(五): decide——reuse / extend / new 的判定
在前四篇中,我们已经完成:
- 说清了同一份设计稿为什么会生成结果漂移
- 在工具侧做归一化,而不是让设计师再出一份干净稿
- catalog 三层:generated / overrides / links
- ingest 把 Figma 噪声收成 DesignIntentIR
本篇在此基础上实现 decide:拿着 DesignIntentIR 和只读的 CatalogSnapshot,对一个设计节点判出 reuse / extend / new / needs-review 四选一,并产出一份 DecisionRecord(裁决记录)给下一步 verify 用。
我的目的是把裁决过程讲清楚 而不是把整个 solver 贴出来 所以下面的类型和伪代码都是按真实实现精简过的 字段名保持一致 每个对象上的 evidence 数组基本都删了 大家可以自行对照 schema
1. decide 的输入和输出
1.1 solveReuse 的签名
solver 的入口只有一个函数,三个参数,一个返回值:
export function solveReuse(
design: DesignIntentIR, // 上一篇 ingest 的产物
catalog: CatalogSnapshot, // 第三篇三层合并后的快照 这里只读
options: SolveOptions,
): DecisionRecord
export interface SolveOptions {
taskId: string
scope: string // 目标代码路径 hazard / recipe 都按它匹配
designNodeId?: string // 不传时按约定找默认节点 排障时我建议显式传
retrievedCandidates?: RetrievedCandidate[] // 调用方额外塞的候选 只参与排序
providedBindings?: Record<string, BindingValue> // 调用方直接给的 prop 值
extensionRequests?: ExtensionRequest[] // 请求按配方做扩展
candidateUniverseComplete?: boolean // 调用方断言候选宇宙完整 能不能判 new 看它
sourceFingerprint?: string // 实现侧源码指纹 verify 时对账
createdAt?: string
}1.2 DecisionRecord 精简版
export interface DecisionRecord {
schemaVersion: 2
kind: 'decision'
id: string // decision:<taskId>:<designNodeId>
taskId: string
designIntentId: string
designNodeId: string
fingerprints: FingerprintSet // 四个输入哈希 见第 7 节
candidateUniverse: {
scope: string
status: 'complete' | 'incomplete'
fingerprint: string // 就是 catalog 指纹
}
candidates: Array<CandidateEvaluation | CompactCandidate>
verdict: 'reuse' | 'extend' | 'new' | 'needs-review'
target?: string // 选中的组件 id
bindingPlan?: BindingPlan // 见第 4 节
pendingObligations?: ProofObligation[] // 选中候选上仍为 unknown 的约束 留给 verify
unresolved: UnresolvedFact[] // 本轮解不开的事实
review?: { status: 'unreviewed' | 'approved' | 'rejected', reviewer?: string, reviewedAt?: string }
createdAt: string
}
// 每个候选各自一份评估
export interface CandidateEvaluation {
componentId: string
retrieval: {
rank: number // 排序后的位置 从 0 开始
sources: Array<'identity-link' | 'role-index' | 'structure-index' | 'history' | 'embedding' | 'model'>
rankingScore?: number
}
outcome: 'satisfiable' | 'unsatisfiable' | 'unknown'
constraints: ProofObligation[] // 全部硬约束的结果
unsatisfiedCore: string[] // fail 掉的约束 id
bindingPlan?: BindingPlan
}四个 verdict 的含义:
reuse:有唯一(或显式身份)的 satisfiable 候选,且 BindingPlan 里没有扩展操作extend:同样 satisfiable,但 BindingPlan 里带了允许的扩展操作new:候选完整,且每个候选都被硬约束判成 unsatisfiableneeds-review:其余全部情况。歧义、有 unknown、宇宙不完整、显式身份却不满足
要点:
- verdict 是
z.enum,只有四个值。写reuse-with-warning这种直接 parse 报错。 - outcome 和 verdict 不是一回事。outcome 是单个候选相对硬约束的结果,verdict 是整次裁决的出口。
- 我没有留第五个值。想模糊的,只能走 needs-review 让人看。
2. 候选检索只负责排序
2.1 四种合法来源
候选从哪来,写在 retrieveCandidates 里。直接上伪代码,思路在注释里:
function retrieveCandidates(design, catalog, external, includeCompleteUniverse) {
const merged = new Map<string, RetrievedCandidate>()
// 1. identity-link:设计节点自带 codeComponentId,或 componentKey 经 links 映到组件
// rankingScore 用 Number.MAX_SAFE_INTEGER 不用 Infinity 原因见 2.2
const explicit = design.component?.codeComponentId
?? catalog.links.find(l => l.componentKey === design.component?.componentKey)?.componentId
if (explicit && catalog.contracts.some(c => c.id === explicit))
merged.set(explicit, { componentId: explicit, source: 'identity-link', rankingScore: MAX_SAFE_INTEGER })
if (design.role) {
// 2. role-index:契约 roles 包含设计 role
for (const c of catalog.contracts.filter(c => c.roles.includes(design.role)))
if (!merged.has(c.id)) merged.set(c.id, { componentId: c.id, source: 'role-index' })
// 3. structure-index:契约 structureFamilies 命中设计 role
// verifiedSupport(overrides 回灌的批过次数)折进 rankingScore 只在同 source 内靠前
for (const c of catalog.contracts.filter(c => c.structureFamilies.includes(design.role)))
if (!merged.has(c.id)) merged.set(c.id, structureIndexEntry(c))
}
// 4. 外部候选(history 等):不在 catalog 里的直接丢
for (const cand of external)
if (catalog.contracts.some(c => c.id === cand.componentId) && !merged.has(cand.componentId))
merged.set(cand.componentId, cand)
// 调用方断言宇宙完整时 把带结构签名或人工盖章 eligible 的契约全部拉进来
// 门槛不变:无签名且没盖章的契约照样不放行
if (includeCompleteUniverse)
for (const c of catalog.contracts)
if ((c.structureFamilies.length || c.autoMatch.eligible) && !merged.has(c.id))
merged.set(c.id, structureIndexEntry(c))
// 排序:source 优先级 → rankingScore 降序 → componentId 字典序
return [...merged.values()].sort((a, b) =>
sourceRank(a.source) - sourceRank(b.source)
|| (b.rankingScore ?? 0) - (a.rankingScore ?? 0)
|| a.componentId.localeCompare(b.componentId))
}
function sourceRank(source) {
return ['identity-link', 'role-index', 'structure-index', 'history', 'embedding', 'model'].indexOf(source)
}要点:
- 这一步的产物只是一个有序数组。它不满足任何硬约束,也不改写 verdict。这是我给 decide 定的第一条规矩,后面第 9 节会讲为什么。
- structure-index 排在 role-index 之后,是我故意压的。结构签名多数还没评审,不能盖过已评审的角色命中。
- 第三个比较项用 componentId 字典序,是为了同分时结果确定。同一份输入跑两次,rank 必须一样,否则第 6 节的排名否决就不稳定。
2.2 为什么是 MAX_SAFE_INTEGER
早期 identity-link 的分数我写的是 Infinity。JSON.stringify 会把它序列化成 null,落盘的 DecisionRecord 再 parse 时 rankingScore 类型不对,schema 直接抛错。后来我改成 Number.MAX_SAFE_INTEGER,RetrievedCandidateSchema 里也补了 z.number().finite(),从入口就挡掉。
2.3 model / embedding 是 runtime-prohibited
RetrievedCandidate.source 的枚举里还留着 model 和 embedding,但引擎内部没有任何生产者路径会吐这两种来源:没有检索服务,没有向量索引。我留着它们是为了离线分析时能标注来源。
调用方就算硬塞进来,也只是在 sourceRank 里排最后。它们参与排序,不参与任何硬约束,不能翻 verdict。
3. 硬约束:solver 的评估顺序
3.1 一条约束长什么样
每条硬约束都是一个 ProofObligation(证明义务),结果只有三种:
export interface ProofObligation {
id: string // 如 contract:prop:type、semantic:role
category: 'contract' | 'semantic' | 'behavior' | 'structure' | 'layout' | 'boundary'
phase?: 'static' | 'runtime' // decide 里产出的全是 static
outcome: 'pass' | 'fail' | 'unknown'
expected?: unknown
actual?: unknown
differences?: Array<{ path: string, expected: unknown, actual: unknown, message: string }>
unresolved?: UnresolvedFact // outcome 为 unknown 时必填 其余情况禁止出现
evidence?: EvidenceRef[]
}schema 上有一条 superRefine:unknown 必须带结构化的 unresolved,pass / fail 不允许带。这条我坚持加上,是因为早期的 unknown 经常只有一句自由文本,后面 verify 和 evolve 没法按 reason 归类。
3.2 evaluateCandidate 的执行顺序
每个候选跑同一套 evaluateCandidate,顺序固定:
function evaluateCandidate(ctx, retrieved, rank): CandidateEvaluation {
const constraints: ProofObligation[] = []
// 第一组:不看绑定 只看身份与契约状态
constraints.push(identityConstraint(ctx)) // semantic:identity
constraints.push(reviewConstraint(ctx)) // contract:auto-match-review
constraints.push(completenessConstraint(ctx)) // contract:completeness
constraints.push(semanticConstraint(ctx)) // semantic:role
constraints.push(hazardConstraint(ctx)) // boundary:hazards
constraints.push(...designFactConstraints(ctx))// semantic:design-fact:* 把 IR 上的 unresolved 原样带进来
// 第二组:先构建 BindingPlan 再基于 plan 判剩下三类
const binding = buildBindingPlan(ctx)
constraints.push(...binding.constraints) // contract:prop:* / behavior:event:* / structure:slot:*
constraints.push(...requiredSlotConstraints(ctx, binding.plan)) // contract:required-slot:*
constraints.push(...layoutCapabilityConstraints(ctx, binding.plan)) // layout:capability:*
constraints.push(...extensionConstraints(ctx, binding.plan)) // boundary:extension:*
// 汇总:有 fail 就是 unsatisfiable 没 fail 有 unknown 就是 unknown 全 pass 才 satisfiable
const failures = constraints.filter(c => c.outcome === 'fail')
const unknown = constraints.filter(c => c.outcome === 'unknown')
const outcome = failures.length ? 'unsatisfiable' : unknown.length ? 'unknown' : 'satisfiable'
return {
componentId: ctx.contract.id,
retrieval: { rank, sources: [retrieved.source], rankingScore: retrieved.rankingScore },
outcome,
constraints,
unsatisfiedCore: failures.map(c => c.id),
bindingPlan: binding.plan,
}
}顺序分两组是我定的。第一组不依赖 BindingPlan,先跑能让报表里最靠前的约束就是身份和契约状态,人一眼能看出这个候选是不是根本没资格。第二组必须等 plan 构建完,因为必填槽有没有 fill-slot、布局关系有没有被 default slot 装下,都要看 plan 里的 operations。
注意这里没有短路。第一组 fail 了,第二组照样跑完。原因是报表要完整:一个候选被判 unsatisfiable,我希望人能看到它所有的问题,而不是只看到第一个。
3.3 每条约束判什么
semantic:identity:设计带显式代码身份时,候选必须就是它,否则 fail。没有显式身份直接 pass。contract:auto-match-review:autoMatch.eligible && reviewed才 pass,否则 unknown。这是评审门闩,没批过的契约不能自动匹配。contract:completeness:completeness.status === 'complete'才 pass,否则 unknown。semantic:role:设计有 role 且契约 roles 包含它 → pass;设计没 role 或契约没评审 roles → unknown;角色冲突 → fail。boundary:hazards:契约上有 severity 为 block 且 scope 命中的 hazard → fail。semantic:design-fact:*:DesignIntentIR 和布局图上的 unresolved,以及可见性仍是 unresolved 对象的情况,一律 unknown 带入。- 绑定类:见第 4 节。
contract:required-slot:*:契约 required slot 必须在 plan 里有 fill-slot,否则 fail。layout:capability:*:设计节点碰到的每条布局关系,契约要么声明了对应 layoutCapability,要么 containment 已被 default slot 的 fill-slot 装下;否则 unknown。boundary:extension:*:每个 extensionRequest 必须找到适用的 ExtensionRecipe 且能物化进 plan,否则 fail。
约束:
- fail 和 unknown 的选择不是随意的。缺能力声明用 unknown,是因为组件多半能布局,只是 overrides 没补;fail 会把它推到 new。角色冲突用 fail,是因为 Button 契约配 dialog 角色不可能靠补声明修好。
- 我选择让 unknown 留在 needs-review,逼人补契约,而不是新建一套 DOM。这和第三篇说的分工是一回事:机器出事实,人补判断。
4. BindingPlan:六类操作
4.1 类型
BindingPlan 是选中目标后,告诉实现侧怎么把设计接到组件 API 的确定性清单:
export interface BindingPlan {
schemaVersion: 2
kind: 'binding-plan'
componentId: string
operations: BindingOperation[]
unresolved: UnresolvedFact[]
}
// 每个操作都带 designEvidence / contractEvidence
// source 记来源 extensionRecipeId 只有走配方的操作才有
type BindingOperation =
| { kind: 'set-prop', prop: string, value: BindingValue, ...Evidence }
| { kind: 'bind-model', model: string, prop: string, event: string, value: BindingValue, ...Evidence }
| { kind: 'bind-event', event: string, handler: BindingValue, ...Evidence }
| { kind: 'fill-slot', slot: string, contentNodeIds: string[], ...Evidence }
| { kind: 'token-override', token: string, value: BindingValue, ...Evidence }
| { kind: 'compose-wrapper', wrapperComponentId: string, ...Evidence }
| { kind: 'outer-layout', layout: LayoutConstraintGraph, ...Evidence }
interface Evidence {
designEvidence: EvidenceRef[]
contractEvidence: EvidenceRef[]
extensionRecipeId?: string
source?: 'provided' | 'component-property' | 'link' | 'rule' | 'default'
}BindingValue 的 kind 有 literal / reference / expression / object / array,外加 UnresolvedFact。expression 可以带 possibleValues,4.3 节会用到。
schema 上是 7 个 kind。标题里说的六类,是 ExtensionRecipe 允许的扩展 kind:set-prop、bind-event、fill-slot、compose-wrapper、outer-layout、token-override。bind-model 只能由 links 的 propertyMappings 产生,不能作为扩展请求。
4.2 buildBindingPlan 的来源顺序
prop 值可能来自四个地方,先后顺序和覆盖关系如下:
function buildBindingPlan(ctx) {
const mapped = new Map<string, BindingValue>()
const provenance = new Map<string, 'provided' | 'component-property' | 'link' | 'rule'>()
// 1. provided:调用方直接给的 先写进去 但 4.4 节会再卡一道
for (const [name, value] of Object.entries(ctx.providedBindings))
mapped.set(name, value), provenance.set(name, 'provided')
// 2. component-property:Figma componentProperties / variantProperties
// 名字归一化后(去掉非字母数字 转小写)和契约 prop 同名的直接映 值保留 Figma 原样
for (const [figmaName, raw] of Object.entries(designProperties)) {
const prop = ctx.contract.props.find(p => normalizeName(p.name) === normalizeName(figmaName))
if (prop) mapped.set(prop.name, literal(raw)), provenance.set(prop.name, 'component-property')
}
// 3. link:links 上的 propertyMappings 目标可以是 prop / model / event / slot / token
// prop 会覆盖上一步同名结果 其余四种直接产出 bind-model / bind-event / fill-slot / token-override
const link = ctx.links.find(l => l.componentKey === ctx.design.component?.componentKey)
for (const m of link?.propertyMappings ?? []) { /* 略 */ }
// 4. rule:按组件 id 索引的硬编码变体规则 只补 link 没映到的洞 link 赢过 rule
for (const token of variantTokens(ctx.design)) {
const matched = VARIANT_BINDING_RULES[ctx.contract.id]?.[token]
if (matched && !mapped.has(matched.target))
mapped.set(matched.target, matched.value), provenance.set(matched.target, 'rule')
}
// 后面几步按顺序:
// - 设计上还有状态没被任何一步吃掉 → unknown(unresolved-symbol) 不忽略 挂账
// - mapped 里每个 prop 逐一过:契约有没有这个 prop → 值过不过类型 / validator → provided 有没有配方声明
// - 必填 prop 没来源 → unknown(runtime-required)
// - 有文字节点且契约有 default slot → fill-slot default
// - interactions 里带 click → 契约有 click 事件就 bind-event 没有就 fail
// - extensionRequests 逐个找 recipe 找到且 target 存在就物化成操作
}要点:
- 顺序决定覆盖。link 在 component-property 之后写入,所以 links 里显式映射的值会盖掉同名猜测。rule 只补洞,不覆盖。
- 设计状态映射不上,记
unresolved-symbol进 unknown。我不让 solver 瞎猜。 - rule 是我早期为了让第一批组件跑起来写的硬编码表,按组件 id 隔离。后来 links 起来了,rule 就退成兜底。它还在,是因为删掉会让没配 links 的老组件全部掉进 unknown。
4.3 绑定阶段的几种失败
这几种在报表里很容易被误读成引擎不懂设计,单独列一下:
- 契约没有这个 prop:
contract:prop:<name>fail。多见于 Agent 按英文常识造属性名,或 Figma 属性名没在 links 里映射。 - 值超出类型或 enum:
contract:prop-value:<name>fail。literal 直接比;expression 若带possibleValues且全过就 pass,有一个不过就 fail,没有 possibleValues 就 unknown 留给 runtime。 - 类型本身解不开:契约 prop 类型是 reference / generic / intersection,或 validator 是 predicate → unknown。
- 必填 prop 没来源:
contract:required-prop:<name>unknown,reason 是runtime-required。decide 允许选中但要标记,verify 再验证。 - 交互触发:trigger 含 click 而契约没有 click 事件 → fail;trigger 看不懂 → unknown。
把这些写成语义理解失败,会推人去换更大的模型。实际该做的是补 links 映射、补 ExtensionRecipe,或者承认要 new。
4.4 providedBindings 必须配方声明
调用方通过 providedBindings 塞值,类型对、validator 过,仍然不够:
// provenance 为 provided 且值过了类型校验 还要看 scope 内有没有 recipe 声明了这个 prop
if (provenance.get(name) === 'provided' && proof.outcome === 'pass' && !providedBindingDeclared(ctx, name, value)) {
const fact = unresolvedFact('undeclared-provided-binding',
`Provided binding ${name} is not declared by an extension recipe for ${ctx.contract.id}; human review is required.`)
constraints.push(unknown(`contract:provided-binding:${name}`, 'contract', fact))
continue // 不产出 set-prop
}
function providedBindingDeclared(ctx, name, value) {
return findRecipe(ctx.contract.extensionRecipes, { kind: 'set-prop', target: name, value }, ctx.scope) !== undefined
}这一条是后来我收紧的。早期 Agent 靠 providedBindings 绕过未声明 prop,报表上是 reuse,源码里却多了契约菜单外的属性。收紧之后 needs-review 变多,这是预期内的。
5. ExtensionRecipe 决定 extend 的边界
5.1 recipe 类型和一份 Button 的 overrides
ExtensionRecipe 写在契约或 overrides 里,声明在哪些 scope 允许哪种 kind、打在哪个 target、值有什么限制:
export interface ExtensionRecipe {
id: string
scopes: string[] // glob 支持 * 和 **
operations: Array<{
kind: 'set-prop' | 'bind-event' | 'fill-slot' | 'compose-wrapper' | 'outer-layout' | 'token-override'
targets?: string[] // 不写表示该 kind 下任意 target
constraints?: Record<string, BindingValue | BindingValue[]> // 按 target 限定允许的值
}>
}一份 Button 在 overrides 里的 recipe 大概是这样:
{
"id": "button-order-page",
"scopes": ["src/views/order/**"],
"operations": [
{ "kind": "outer-layout" },
{
"kind": "set-prop",
"targets": ["loading"],
"constraints": { "loading": { "kind": "literal", "value": true } }
},
{ "kind": "compose-wrapper", "targets": ["Card"] }
]
}含义:在订单页范围内,允许给 Button 外面套一层布局,允许把 loading 设成 true(别的值不行),允许用 Card 把它包起来。菜单外的动作没有配方,进不了 plan。
5.2 extensionConstraints
function extensionConstraints(ctx, plan): ProofObligation[] {
return ctx.extensionRequests.map((request, index) => {
const recipe = findRecipe(ctx.contract.extensionRecipes, request, ctx.scope)
// 没配方 → fail
if (!recipe)
return fail(`boundary:extension:${index}`, 'boundary', `No extension recipe permits ${request.kind}.`)
// 配方有 但 target 不在契约里 → fail
if (!extensionTargetExists(ctx.contract, request))
return fail(`boundary:extension:${index}`, 'boundary', `Extension target ${request.target} is not declared.`)
// 配方有 target 有 还要看 plan 里真的物化出了带 extensionRecipeId 的操作
const applied = plan.operations.some(op => op.extensionRecipeId === recipe.id && op.kind === request.kind)
return applied ? pass(`boundary:extension:${index}`, 'boundary') : fail(`boundary:extension:${index}`, 'boundary', 'could not be materialized')
})
}
// target 必须在契约里真实存在 每种 kind 查的表不一样
function extensionTargetExists(contract, request) {
if (request.kind === 'outer-layout') return request.target == null // outer-layout 不需要 target
if (!request.target) return false
if (request.kind === 'set-prop') return contract.props.some(p => p.name === request.target)
if (request.kind === 'bind-event') return contract.events.some(e => e.name === request.target)
if (request.kind === 'fill-slot') return contract.slots.some(s => s.name === request.target || (s.dynamic && s.name === '*'))
if (request.kind === 'compose-wrapper') return contract.dependencies.some(d => d.componentId === request.target)
return request.kind === 'token-override'
}约束:
- compose-wrapper 的 target 必须在契约 dependencies 里。随便包一层 div 不算扩展。
- fill-slot 要么命中具名 slot,要么契约声明了 dynamic 的
*slot。 - 扩展请求 fail 会让候选 unsatisfiable。这是有意的:你请求了菜单外的东西,这个候选就不该 satisfiable。
5.3 reuse 和 extend 的边界
状态机里 extend 不是 reuse 失败后的降级。两者的前提一样:候选 satisfiable。区别只有一个:
function hasExtension(plan?: BindingPlan): boolean {
return Boolean(plan?.operations.some(op => op.extensionRecipeId))
}
verdict = hasExtension(selected.bindingPlan) ? 'extend' : 'reuse'schema 层再卡一次:verdict 为 reuse 时 operations 里不能有带 extensionRecipeId 的操作;verdict 为 extend 时至少要有一个。两边都错不了。
实现侧如果发现不包一层排不好,正确路径是提 extensionRequest → 命中 recipe → verdict 变 extend → 再实现 → verify。不是先按 reuse 交付,再手工包一层。static verify 对 compose-wrapper / outer-layout 会挂 runtime-required,没有配方的 wrapper 连 decide 都进不去。
6. verdict 状态机
所有候选评估完,进状态机。先看图:
flowchart TD
Start[全部候选评估完] --> Exact{设计有显式身份候选?}
Exact -->|有且 satisfiable| Ext1{BindingPlan 含扩展操作?}
Ext1 -->|否| Reuse[verdict = reuse]
Ext1 -->|是| Extend[verdict = extend]
Exact -->|有但不 satisfiable| NR1[needs-review<br/>带上身份违反或 unknown 事实]
Exact -->|无显式身份| Sat{satisfiable 数量}
Sat -->|恰好 1| Rank{有排名更靠前的 unknown?}
Rank -->|是| NR2[needs-review<br/>排名否决]
Rank -->|否| Ext2{BindingPlan 含扩展操作?}
Ext2 -->|否| Reuse
Ext2 -->|是| Extend
Sat -->|大于 1| NR3[needs-review<br/>ambiguous-component-resolution]
Sat -->|0| AllUnsat{全部 unsatisfiable<br/>且宇宙完整?}
AllUnsat -->|是| New[verdict = new]
AllUnsat -->|否| NR4[needs-review<br/>incomplete-candidate-universe 或仍有 unknown]
图里两个分支最容易写错,贴一下对应的代码:
const satisfiable = evaluations.filter(c => c.outcome === 'satisfiable')
const exact = exactId ? evaluations.find(c => c.componentId === exactId) : undefined
if (exact) {
// 显式身份优先于一切排序 link 指着 A A 不满足 不会换成 B
if (exact.outcome === 'satisfiable') {
selected = exact
verdict = hasExtension(exact.bindingPlan) ? 'extend' : 'reuse'
} else {
verdict = 'needs-review'
unresolved.push(...unknownFacts(exact))
if (exact.outcome === 'unsatisfiable')
unresolved.push(unresolvedFact('incomplete-component-contract', `Explicit Figma identity ${exact.componentId} violates hard constraints.`))
}
}
else if (satisfiable.length === 1) {
// 排名否决:前面还有 unknown 的候选 不能让后面碰巧 satisfiable 的直接过
const unknownHigherPriority = evaluations.some(c => c.outcome === 'unknown' && c.retrieval.rank < satisfiable[0].retrieval.rank)
if (unknownHigherPriority) {
verdict = 'needs-review'
unresolved.push(...evaluations.flatMap(unknownFacts))
} else {
selected = satisfiable[0]
verdict = hasExtension(selected.bindingPlan) ? 'extend' : 'reuse'
}
}
// 其余分支:>1 个 satisfiable → ambiguous-component-resolution
// 全部 unsatisfiable 且 isCompleteUniverse → new
// 否则 needs-review 并带上 incomplete-candidate-universe
// 选中候选上的 unknown 约束带走 留给 verify 继续证
const pendingObligations = selected?.constraints.filter(c => c.outcome === 'unknown')要点:
- 显式身份优先于排序。link 指着 A,A 不满足,不会因为 B 分更高就 reuse B。
- 歧义不靠分数打破。两个都 satisfiable,必须人选。
- 排名否决只在无显式身份的分支里生效。有显式身份时排序不重要,身份就是答案。
- new 只在宇宙完整时出现。宇宙不完整时缺的可能是其实能复用但没召回的组件。
- needs-review 是 fail-closed 的落点。任何一条路走不通,都掉到这里,不会掉到 reuse。
isCompleteUniverse 的判定也贴一下,因为它和 candidateUniverseComplete 选项不是一回事:
function isCompleteUniverse(catalog, evaluated, asserted?: boolean): boolean {
if (!asserted) return false // 调用方没断言 直接 false
// 宇宙 = 入场门槛放行的契约:带结构签名 或人工盖章 eligible
const universe = catalog.contracts.filter(c => c.structureFamilies.length > 0 || c.autoMatch.eligible)
const evaluatedIds = new Set(evaluated.map(c => c.componentId))
// 每个都评估过 且每个契约都 complete 才算完整
return universe.every(c => evaluatedIds.has(c.id) && c.completeness.status === 'complete')
}调用方断言只是必要条件。宇宙里有一个契约还 incomplete,照样算不完整,new 就判不出来。
7. FingerprintSet 与 schema 出口
7.1 四个指纹
export interface FingerprintSet {
extractor: string // 契约抽取器版本指纹 来自 catalog
catalog: string // CatalogSnapshot 指纹
design: string // DesignIntentIR 的 source.fingerprint
source?: string // 可选 实现侧源码指纹 verify 时再和 usage 对
}指纹的算法很直接:fingerprintFiles 把文件按相对路径排序,路径和内容交替喂进 sha256,中间用 \0 隔开。排序是必须的,否则同一批文件换个遍历顺序就是另一个哈希。
任一输入变了,旧裁决在 verify 阶段指纹对账失败,整份记录作废。decide 当时判断对了,不代表 catalog rebuild 之后还能用。我要的是可审计的绑定,不是这张记录永远有效。
7.2 DecisionRecordSchema 的出口检查
solveReuse 的最后一行不是 return record,而是 DecisionRecordSchema.parse(record)。除了字段和枚举,schema 上还有几条 superRefine:
- verdict 为 new:candidateUniverse.status 必须 complete,candidates 非空且全部 unsatisfiable,不能有 target 和 bindingPlan。
- verdict 为 reuse / extend:target 对应的候选必须 satisfiable,bindingPlan 必须存在且 componentId 等于 target。
- reuse 的 bindingPlan 不能有带 extensionRecipeId 的操作;extend 至少要有一个。
- verdict 为 needs-review:unresolved 非空,或者至少有一个候选 outcome 为 unknown。空手的 needs-review 不允许。
- 候选层面:satisfiable 必须有 bindingPlan 且约束全 pass;unsatisfiable 的 unsatisfiedCore 必须非空且都指向 fail 掉的约束;unknown 至少要有一条 unknown 约束。
CLI 和 MCP 走同一层 service,适配器改不了 verdict,也绕不过 parse。我见过的被 parse 打回的记录有:verdict 写成 reuse-with-warning、candidates 里塞自由文本 reason、BindingPlan 收成一个字符串、fingerprint 缺字段。严格 schema 的麻烦是调用方要跟着版本升级序列化,换来的是 ledger、evolve、verify 永远读同一种形状。
7.3 compact 候选
非 satisfiable 的候选落盘时会裁成 compact:
export interface CompactCandidate {
componentId: string
retrieval: CandidateEvaluation['retrieval']
outcome: 'unsatisfiable' | 'unknown'
unsatisfiedCore: string[]
unresolvedReasons?: string[] // unknown 约束的 reason 去重
compact: true
}完整约束可以用同指纹输入确定性重放出来,记录不用加几十 MB 的义务清单。satisfiable 的保留全量,因为 BindingPlan 和后面的 verify 真要读。排障需要全量时,d2c decide 加 --full-evaluations <path> 会把裁剪前的评估另外落一份。
8. 跑一遍:一个 Button 的 DecisionRecord
我们拿一个订单页上的主按钮走一遍。设计节点 12:34 在 Figma 里挂了组件链接,componentProperties 里有 Type=Primary、Size=Small,子节点 12:35 是文字 “提交”,带一个 click 交互。links 里把 Type 映到 prop type,Primary → "primary"。
d2c decide \
--input .d2c/design-intent.json \
--scope "src/views/order/**" \
--node-id 12:34 \
--task-id order-submit \
--out .d2c/decision.json输出(所有 evidence 字段已删):
{
"schemaVersion": 2,
"kind": "decision",
"id": "decision:order-submit:12:34",
"taskId": "order-submit",
"designIntentId": "design-intent:figma-rest:file-abc",
"designNodeId": "12:34",
"fingerprints": {
"extractor": "sha256:9c2e…",
"catalog": "sha256:41d7…",
"design": "sha256:b08f…"
},
"candidateUniverse": { "scope": "src/views/order/**", "status": "incomplete", "fingerprint": "sha256:41d7…" },
"candidates": [
{
"componentId": "Button",
"retrieval": { "rank": 0, "sources": ["identity-link"], "rankingScore": 9007199254740991 },
"outcome": "satisfiable",
"constraints": [
{ "id": "semantic:identity", "category": "semantic", "outcome": "pass" },
{ "id": "contract:auto-match-review", "category": "contract", "outcome": "pass" },
{ "id": "contract:completeness", "category": "contract", "outcome": "pass" },
{ "id": "semantic:role", "category": "semantic", "outcome": "pass" },
{ "id": "boundary:hazards", "category": "boundary", "outcome": "pass" },
{ "id": "contract:prop:type", "category": "contract", "outcome": "pass" },
{ "id": "contract:prop:size", "category": "contract", "outcome": "pass" },
{ "id": "semantic:default-slot-content", "category": "semantic", "outcome": "pass" },
{ "id": "behavior:event:click", "category": "behavior", "outcome": "pass" }
],
"unsatisfiedCore": [],
"bindingPlan": {
"schemaVersion": 2,
"kind": "binding-plan",
"componentId": "Button",
"operations": [
{ "kind": "set-prop", "prop": "type", "value": { "kind": "literal", "value": "primary" }, "source": "link" },
{ "kind": "set-prop", "prop": "size", "value": { "kind": "literal", "value": "Small" }, "source": "component-property" },
{ "kind": "fill-slot", "slot": "default", "contentNodeIds": ["12:35"] },
{ "kind": "bind-event", "event": "click", "handler": { "kind": "reference", "name": "handleClick" } }
],
"unresolved": []
}
},
{
"componentId": "LegacyButton",
"retrieval": { "rank": 1, "sources": ["role-index"] },
"outcome": "unknown",
"unsatisfiedCore": [],
"unresolvedReasons": ["incomplete-component-contract"],
"compact": true
}
],
"verdict": "reuse",
"target": "Button",
"bindingPlan": { "…": "同 candidates[0].bindingPlan" },
"pendingObligations": [],
"unresolved": [],
"createdAt": "2026-03-02T09:12:41.000Z"
}几处值得看:
type的 source 是link,size的 source 是component-property。前者走 links 的 propertyMappings 拿到"primary";后者是Size归一化后和契约 propsize同名,直接取了 Figma 原值"Small"。如果契约size的 validator 是 enum 且只认小写,这条会 fail 成contract:prop-value:size,正确修法是在 links 里补Size的映射,不是改 solver。LegacyButton也是 button 角色,但 autoMatch 没评审,outcome 是 unknown,落盘成 compact。它排在 rank 1,不影响结果,因为 Button 是显式身份,走的是状态机第一个分支。candidateUniverse.status是 incomplete,因为命令行没传--complete-universe。这一单要是所有候选都 unsatisfiable,也只能 needs-review,判不出 new。pendingObligations为空,说明选中候选上没有 unknown。如果文案是绑定表达式而不是 literal,这里会挂一条dynamic-expression,留给 verify。
再看两个变体,命令不变,只改输入:
- 同样有 link,但通过
--bindings '{"loading":{"kind":"literal","value":true}}'塞了 prop。契约有loading,类型对,但 scope 内没有 recipe 声明它 →contract:provided-binding:loadingunknown → Button 的 outcome 变 unknown → 显式身份但不 satisfiable → needs-review。不会自动去掉这个 prop 继续 reuse。 - 加
--extensions '[{"kind":"outer-layout"}]',且 overrides 里有 5.1 节那份 recipe → plan 里多一条带extensionRecipeId的 outer-layout → verdict 变 extend。pendingObligations里可能还挂着外层布局要几何证明的 unknown,那是第七篇 runtime verify 的事。
9. 踩坑:排序不等于决策
9.1 早期用相似度分数拍板
最早的版本里,rankingScore 不只排序,还参与判定:分数高过一个阈值就 reuse。结构索引里一个高分候选,契约没评审、必填槽没绑上,照样被抬进 reuse。人工抽查才发现 BindingPlan 是空的,hazard 也被跳过了。
那段时间我还试过把模型的相似度也加权进去,想让"很有把握"的候选过线。结果是分数好看的候选带着未评审契约混进 reuse,页面里出现未声明 prop、错误槽名、hazard 被忽略。
后来我把召回和判定彻底拆开,改成现在这样:
- 分数只进 sort 比较器,任何约束函数都读不到它。
- 硬约束列表固定执行,pass / fail / unknown 决定 outcome,outcome 再进状态机。
- model / embedding 来源运行时禁止当证据;providedBindings 必须配方声明。
改完之后错复用立刻下降,needs-review 上升。报表变难看的那段时间,我盯的是人工评审通过率,不是自动 reuse 率。
9.2 用第二名救场
连带的一个坑:显式身份失败时,有同事提议自动 fallback 到排序第一的 satisfiable 候选。理由是演示时不会卡在 needs-review。
我反对的理由是:设计师在 Figma 里把这个节点链到了 A,系统交付了 B,还宣称 reuse。这在审计里过不去。状态机现在没有这条路,显式身份不满足只有 needs-review 一个出口。
9.3 candidateUniverseComplete 为什么是调用方断言
--complete-universe 这个旗标我没有让 solver 自己推。它表示:这次评估的候选集合,在业务上可以视为 catalog 在该 scope 的完整宇宙。solver 不会根据分数或数量去猜宇宙是否完整。
断言为假时,缺召回可能只是检索不全,new 会变成假的新组件提案,后面实现侧真的会去新建一套 DOM。这个责任放在调用方(CLI 旗标或上层 Agent 策略),比放在 solver 里猜安全。
history 来源同样只能帮排序。历史命中不能证明今天契约仍 complete、仍 reviewed,硬约束每次都重跑。
小结
本篇完成了:
solveReuse的输入输出,DecisionRecord与CandidateEvaluation的精简类型- 候选检索的四种来源和排序比较器,以及为什么只排序不判定
evaluateCandidate的固定评估顺序,每条硬约束的 pass / fail / unknown 边界BindingPlan的七个 kind、四种来源优先级、绑定阶段的失败形态、providedBindings 的配方门闩ExtensionRecipe怎么划 extend 的边界,reuse 与 extend 的判别只看extensionRecipeId- verdict 状态机、
isCompleteUniverse、FingerprintSet、schema 出口与 compact 候选 - 一个 Button 的完整 DecisionRecord 走读,以及排序不等于决策的踩坑
decide 只保证这个节点选谁、绑什么。它不读原始 Figma,不解析 Vue,产出的 DecisionRecord 是给实现侧的合同草稿,不是验收证明。下一篇将开始介绍我们的verify:从 Vue / TS 源码抽出 ComponentUsageIR,按 BindingPlan 逐条回检,指纹对账,以及静态阶段的 ProofObligation 哪些能证明、哪些只能挂 runtime-required。