基于 FigmaMCP 的 D2C 工具(五): decide——reuse / extend / new 的判定

18 分钟阅读

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

  • 说清了同一份设计稿为什么会生成结果漂移
  • 在工具侧做归一化,而不是让设计师再出一份干净稿
  • 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 的入口只有一个函数,三个参数,一个返回值:

ts
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 精简版

ts
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:候选完整,且每个候选都被硬约束判成 unsatisfiable
  • needs-review:其余全部情况。歧义、有 unknown、宇宙不完整、显式身份却不满足

要点:

  • verdict 是 z.enum,只有四个值。写 reuse-with-warning 这种直接 parse 报错。
  • outcome 和 verdict 不是一回事。outcome 是单个候选相对硬约束的结果,verdict 是整次裁决的出口。
  • 我没有留第五个值。想模糊的,只能走 needs-review 让人看。

2. 候选检索只负责排序

2.1 四种合法来源

候选从哪来,写在 retrieveCandidates 里。直接上伪代码,思路在注释里:

ts
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 的分数我写的是 InfinityJSON.stringify 会把它序列化成 null,落盘的 DecisionRecord 再 parse 时 rankingScore 类型不对,schema 直接抛错。后来我改成 Number.MAX_SAFE_INTEGERRetrievedCandidateSchema 里也补了 z.number().finite(),从入口就挡掉。

2.3 model / embedding 是 runtime-prohibited

RetrievedCandidate.source 的枚举里还留着 modelembedding,但引擎内部没有任何生产者路径会吐这两种来源:没有检索服务,没有向量索引。我留着它们是为了离线分析时能标注来源。

调用方就算硬塞进来,也只是在 sourceRank 里排最后。它们参与排序,不参与任何硬约束,不能翻 verdict。

3. 硬约束:solver 的评估顺序

3.1 一条约束长什么样

每条硬约束都是一个 ProofObligation(证明义务),结果只有三种:

ts
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,顺序固定:

ts
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-reviewautoMatch.eligible && reviewed 才 pass,否则 unknown。这是评审门闩,没批过的契约不能自动匹配。
  • contract:completenesscompleteness.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 的确定性清单:

ts
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-propbind-eventfill-slotcompose-wrapperouter-layouttoken-overridebind-model 只能由 links 的 propertyMappings 产生,不能作为扩展请求。

4.2 buildBindingPlan 的来源顺序

prop 值可能来自四个地方,先后顺序和覆盖关系如下:

ts
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 过,仍然不够:

ts
// 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、值有什么限制:

ts
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 大概是这样:

json
{
  "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

ts
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。区别只有一个:

ts
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]

图里两个分支最容易写错,贴一下对应的代码:

ts
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 选项不是一回事:

ts
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 四个指纹

ts
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:

ts
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=PrimarySize=Small,子节点 12:35 是文字 “提交”,带一个 click 交互。links 里把 Type 映到 prop typePrimary → "primary"

bash
d2c decide \
  --input .d2c/design-intent.json \
  --scope "src/views/order/**" \
  --node-id 12:34 \
  --task-id order-submit \
  --out .d2c/decision.json

输出(所有 evidence 字段已删):

json
{
  "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 是 linksize 的 source 是 component-property。前者走 links 的 propertyMappings 拿到 "primary";后者是 Size 归一化后和契约 prop size 同名,直接取了 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:loading unknown → 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 被忽略。

后来我把召回和判定彻底拆开,改成现在这样:

  1. 分数只进 sort 比较器,任何约束函数都读不到它。
  2. 硬约束列表固定执行,pass / fail / unknown 决定 outcome,outcome 再进状态机。
  3. 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 的输入输出,DecisionRecordCandidateEvaluation 的精简类型
  • 候选检索的四种来源和排序比较器,以及为什么只排序不判定
  • evaluateCandidate 的固定评估顺序,每条硬约束的 pass / fail / unknown 边界
  • BindingPlan 的七个 kind、四种来源优先级、绑定阶段的失败形态、providedBindings 的配方门闩
  • ExtensionRecipe 怎么划 extend 的边界,reuse 与 extend 的判别只看 extensionRecipeId
  • verdict 状态机、isCompleteUniverseFingerprintSet、schema 出口与 compact 候选
  • 一个 Button 的完整 DecisionRecord 走读,以及排序不等于决策的踩坑

decide 只保证这个节点选谁、绑什么。它不读原始 Figma,不解析 Vue,产出的 DecisionRecord 是给实现侧的合同草稿,不是验收证明。下一篇将开始介绍我们的verify:从 Vue / TS 源码抽出 ComponentUsageIR,按 BindingPlan 逐条回检,指纹对账,以及静态阶段的 ProofObligation 哪些能证明、哪些只能挂 runtime-required。

相关文章
前后篇
OLDER → 基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR
评论