基于 FigmaMCP 的 D2C 工具(三): catalog——机器出事实,人补判断
在前两篇中,我们已经完成:
- 把同一份设计稿的生成漂移拆成结构身份、数值来源、组件边界三类 把输入噪声拆成错位、缺失、冗余三类
- 定下输入默认带噪、归一化放在工具侧的原则 贴了 ingest 把 Figma 节点收成 DesignIntentIR 的输入输出
本篇在此基础上进入管线的第一块地基:catalog。ingest 认出 Frame 12 是一个 button 只解决了一半 项目里到底有哪些 Button、它们的 API 是什么、哪些能被自动选中 这些问题要 catalog 来回答。这篇写我为什么把 catalog 拆成 generated / overrides / links 三层 三层怎么合并成 CatalogSnapshot structureFamilies 是怎么派生的 以及踩过的坑。
组件名一律用 Dialog / Button 这类通用名 组件 id 用
dialog/button。真实实现里每个对象都带 evidence 数组 这篇全部省掉。
1. 搜到了 为什么不让用
catalog 第一次能搜到项目里的 Dialog 那天 大家挺兴奋。Agent 调 d2c_search_catalog 命中了:
{
"kind": "catalog-search",
"count": 1,
"hits": [
{
"id": "dialog",
"name": "Dialog",
"roles": [],
"importPath": "components/Dialog.vue",
"autoMatch": { "eligible": false, "reviewed": false },
"completeness": "complete"
}
]
}然后 d2c decide 给的却是 needs-review 不是 reuse。有同事觉得引擎矫情:都搜到了还不让用?
我让他看 hits 里那两个字段。roles 是空的 autoMatch.eligible 是 false reviewed 也是 false。generated 层把 props / slots 抽全了 所以 completeness 是 complete;但没人审过这个组件能不能被自动选中 也没人说它扮演什么角色。搜得到 只说明索引里有这张卡;能不能自动选中 是另一道门。
这道门是我故意留的。理由要从 v1 说起。
1.1 v1 的糊状索引
v1 时期我做过一个什么都往里扔的索引。组件名、大概角色、Figma 里见过的 key、谁偶发写过的注意事项、甚至某次生成成功的 prompt 摘要 全部平铺在一个 JSON 里。检索体验短期很好 搜 dialog 总能泛出一堆东西。出问题的时候完全没法问责 具体事故放在第 9 节。
复盘的时候我把索引里的信息按寿命和权限分了一下 发现是三类东西被揉在一起:
- 能从源码 / 编译器重复得到的事实:props、类型、必填、slots / events。人肉维护会过期 机器抽更合适。
- 必须人审的判断:这个组件扮演什么角色、哪些场景禁止自动选用、允许怎样扩展、能不能参与自动匹配。
- 设计与代码的身份映射:Figma 的 componentKey 对应哪个 componentId 属性怎么对到 prop。映射错了会稳定地错 必须评审。
三类信息的失败模式不一样。事实抽错 重新 build 就能修;判断写错 会扩散到所有自动裁决;链接写错 会让错误身份带着高置信冲进硬约束。塞进同一个可写索引 权限和审计都会糊掉。
所以 v2 我把 catalog 拆开了。
2. 三层加一个编译产物
目录长这样:
catalog/
generated/ # 机器写 每次 build 整目录替换 人不要碰
dialog.json
button.json
...
overrides/ # 人写 走 PR
dialog.yaml
button.yaml
links/ # 人写 走 PR
dialog.yaml
snapshot.json # build 产物 运行时唯一读的文件generated 里是 ComponentContractIR 组件 API 的事实;overrides 里是 roles、hazards、ExtensionRecipe、autoMatch、structureFamilies 这些人的判断;links 里是 Figma componentKey 到 componentId 的身份映射;snapshot.json 是三层合并后的 CatalogSnapshot。
flowchart LR SRC[组件库 d.ts<br/>项目 .vue] -->|抽取器| G[generated/*.json<br/>ComponentContractIR] O[overrides/*.yaml<br/>CatalogOverlay] --> B L[links/*.yaml<br/>FigmaComponentLink] --> B G --> B[d2c catalog build<br/>applyOverlay + validLinks + 指纹] B --> S[snapshot.json<br/>CatalogSnapshot] S --> I[ingest 挂 codeComponentId] S --> D[decide 召回 + 硬约束] S --> V[verify 查契约] S --> M[MCP search_catalog]
要点:
- 运行时只读 snapshot。decide、verify、MCP 工具都不会去读 overrides 或 links 的 yaml。旁路一开 本地没 build 的覆盖和 CI 上的 snapshot 就会分叉 同一份设计在两台机器上得出不同 verdict。
- generated 是输出不是输入。每次 build 都会把
generated/整个目录替换掉。人在里面改的任何东西下一次 build 就没了。 - 改完 overrides / links 必须跑
d2c catalog build;d2c catalog check校验 schema 和指纹 对不上直接抛错 CI 上 check 不过合并就被挡住。
3. generated:机器出事实
3.1 ComponentContractIR
先看类型 按真实 schema 精简:
interface ComponentContractIR {
schemaVersion: 2
kind: 'component-contract'
id: string // kebab id 例如 dialog / button
name: string
source: {
kind: 'library' | 'project' // 组件库还是项目组件
importPath: string
files?: string[] // 抽取时读过的文件 sources 指纹从这来
fingerprint: string
}
roles: string[] // 语义角色 generated 里通常是空的
structureFamilies: string[] // 结构签名族 抽取器派生 overlay 可覆盖
verifiedSupport: number // 人工批过的次数 只影响排序
props: Array<{ name: string, type: TypeExpression, required: boolean, default?: BindingValue }>
models: Array<{ name: string, prop: string, event: string, type: TypeExpression }> // v-model 对
events: Array<{ name: string, payload: TypeExpression[] }>
slots: Array<{ name: string, required: boolean, props: SlotProp[], dynamic: boolean }>
attrs: { inheritAttrs: boolean | UnresolvedFact, forwarding: AttrForwarding[] }
coverage: Record<CoverageKey, { status: 'proven' | 'unresolved' | 'not-applicable', unresolved?: UnresolvedFact }>
hazards: Hazard[] // generated 里为空
extensionRecipes: ExtensionRecipe[] // generated 里为空
autoMatch: { eligible: boolean, reviewed: boolean } // generated 里都是 false
completeness: { status: 'complete' | 'incomplete', unresolved: UnresolvedFact[] }
}抽取器分三个:两个组件库 d.ts 抽取器、一个项目 .vue 抽取器。三个共用 TypeScript 的类型检查器 抽出来的类型是结构化的 TypeExpression 不是 "boolean | undefined" 这样的字符串。后面 decide 判断绑定值合不合法 verify 判断源码里传的 prop 类型对不对 都要靠这个结构。
一份 dialog 的 generated 契约精简之后大概这样:
{
"id": "dialog",
"name": "Dialog",
"source": { "kind": "project", "importPath": "components/Dialog.vue", "fingerprint": "5d84dc9e…" },
"roles": [],
"structureFamilies": ["dialog"],
"props": [
{ "name": "modelValue", "type": { "kind": "primitive", "name": "boolean" }, "required": false },
{ "name": "title", "type": { "kind": "primitive", "name": "string" }, "required": false },
{ "name": "width", "type": { "kind": "primitive", "name": "number" }, "required": false, "default": { "kind": "literal", "value": 480 } }
],
"models": [{ "name": "modelValue", "prop": "modelValue", "event": "update:modelValue" }],
"events": [{ "name": "close", "payload": [] }],
"slots": [
{ "name": "default", "required": false, "props": [], "dynamic": false },
{ "name": "header", "required": false, "props": [{ "name": "close", "type": { "kind": "function" } }], "dynamic": false },
{ "name": "footer", "required": false, "props": [], "dynamic": false }
],
"hazards": [],
"extensionRecipes": [],
"autoMatch": { "eligible": false, "reviewed": false },
"completeness": { "status": "complete", "unresolved": [] }
}3.2 generated 故意没有的东西
roles 空 hazards 空 extensionRecipes 空 autoMatch 全 false。这不是抽不出来 是我不让抽取器写。
早期我在抽取阶段顺手用目录名推角色 路径里带 dialog 就标 dialog。短期召回率上升 长期一堆误标:工具类弹层、演示用的封装、测试夹具 全被标成可复用的业务弹窗。推角色是判断 不是事实。判断进了 generated 就会在每次 build 时被机器的启发式重新盖写 人的评审无处落脚。后来我把角色只允许出现在 overrides。
3.3 抽不出来怎么办
有的组件抽取会失败 比如类型写得太绕 类型检查器解不出来。这时候抽取器不会跳过这个组件 也不会猜 而是产出一份全 unresolved 的契约:
{
"id": "legacy-picker",
"props": [], "models": [], "events": [], "slots": [],
"coverage": { "props": { "status": "unresolved" }, "slots": { "status": "unresolved" } },
"autoMatch": { "eligible": false, "reviewed": false },
"completeness": {
"status": "incomplete",
"unresolved": [{ "kind": "unresolved", "reason": "incomplete-component-contract", "detail": "Cannot resolve props type for legacy-picker" }]
}
}它仍然在 snapshot 里 搜得到 但 completeness 是 incomplete 自动路径选不中它。这是 fail-closed 在 catalog 这一层的落点:抽不出来的组件不是不存在 是明确标成不完整。
3.4 可重复
同一提交下 build 两次 generated 应该一致。build 的时候契约会按 id 排序 JSON 的 key 也会排序之后再算哈希 这样 fingerprints.generated 才有意义:指纹变了 说明契约真的变了 旧的 DecisionRecord 整单作废 不是大概还能用。
4. overrides:人补判断
4.1 一份 dialog 的 overlay
overlay 是一个 yaml 文件 id 必须是 generated 里已有的组件。字段都是可选的 写了哪个就覆盖哪个。直接看一份完整的:
schemaVersion: 2
kind: component-overlay
id: dialog
roles:
- dialog
semantics:
whenToUse:
- 需要遮罩 + 居中卡片的模态场景
antiPatterns:
- 自己写 fixed 遮罩 不用 Dialog
- 表格单元格里直接嵌 Dialog
figmaHints:
- 图层名带 弹窗 / 对话框 / modal
hazards:
- id: no-dialog-in-table-cell
severity: block
scope:
- '**'
condition: dialog rendered inside a table cell
message: 表格单元格里禁止直接嵌 Dialog 会锁滚动
- id: mobile-needs-review
severity: review
scope:
- 'app/pages/m/**'
condition: dialog used on mobile routes
message: 移动端路由用 Dialog 要人看一眼 通常该用 Drawer
extensionRecipes:
- id: footer-actions
operations:
- kind: fill-slot
targets:
- footer
- kind: set-prop
targets:
- width
- showClose
constraints:
width:
- kind: literal
value: 480
- kind: literal
value: 640
scopes:
- '**'
autoMatch:
eligible: true
reviewed: true4.2 每个字段管什么
roles。声明这个组件在语义上能扮演什么。decide 召回时 设计节点的 role 和这里对表。没有评审过的 roles 角色约束走 unknown 不是"目录名像就当过"。
hazards。风险项 带 scope(glob)和严重度。block 命中候选直接不满足;review 命中把 verdict 推向 needs-review;warn 只记录。不靠加权分翻盘 命中就是命中。上面那条 no-dialog-in-table-cell 是真实挡过事的:有一次生成把带全局滚动锁的 Dialog 嵌进了表格单元格 页面一打开表格就滚不动了。
ExtensionRecipe。合法扩展的菜单。reuse 要求不引入任何扩展操作;一旦需要往 footer 塞按钮、改 width 就必须能在适用的 recipe 里找到对应的 operation。上面这份说的是:footer 可以填;width 可以设但只能是 480 或 640;showClose 可以设。其他 prop 想动 菜单里没有 decide 就不给 extend 只给 unknown / needs-review。六种 operation:set-prop、bind-event、fill-slot、compose-wrapper、outer-layout、token-override。decide 篇细写 这里先记住扩展是声明出来的 不是生成出来的。
autoMatch。reviewed 表示有人审过这个组件可以参与自动匹配;eligible 表示它进入候选集。分开是因为有的组件审过了但不想让它自动进业务页 比如演示组件 想留在人工搜索里给研发参考。
semantics。给人和 Agent 读的 不进约束求解器。我踩过的坑是有人把注意事项写成自由文本 指望 Agent 去读。能挡事的必须是结构化字段 hazard、recipe、autoMatch。注释可以留 但别假装注释是门。
4.3 scaffold
overrides 不用从零手写 d2c overrides scaffold 会生成一份最小合法模板:
d2c overrides scaffold --component-id dialog# TODO: fill in curated semantics for dialog.
# Minimal valid overlay; every field below is optional per CatalogOverlaySchema.
schemaVersion: 2
kind: component-overlay
id: dialog
roles: []
autoMatch:
eligible: false
reviewed: falsescaffold 会先检查 dialog 在 snapshot 里存不存在 不存在直接报错 不会给你生成一个指向幽灵组件的 overlay。写完之后它会自动跑一次 build 让 snapshot 立刻带上这份 overlay。
5. links:设计身份接进来
5.1 FigmaComponentLink
一条 link 说清三件事:Figma 侧的 componentKey(变体集还有 componentSetKey)对应 catalog 里哪个 componentId;Figma 属性怎么落到代码侧 target.kind 只能是 prop / model / slot / event / token;设计侧取值怎么换成代码侧取值。
schemaVersion: 2
kind: figma-component-link
componentKey: 16a7aa909691a6e2dc232c8165927714e684f03a
componentSetKey: 3f0c2e…
componentId: button
propertyMappings:
- figmaProperty: Type
target:
kind: prop
name: type
values:
Primary:
kind: literal
value: primary
Default:
kind: literal
value: default
- figmaProperty: Label
target:
kind: slot
name: default有了这条 link 第二篇里 ingest 遇到 componentKey 是这个值的 instance 节点 就会直接给它挂上 codeComponentId: button。decide 召回的时候这个候选走的是 identity-link 排在所有其他来源前面:不是长得像 是就是它。
5.2 校验
link 写错的破坏力最大 错映射会让错误身份带着最高置信冲进去。所以 build 的时候会做两层校验:
componentId必须在 generated 里存在。指向不存在组件的 link 直接报错 build 失败。propertyMappings里每一条 target 必须在目标契约里存在:prop 名在props里 slot 名在slots里 event 名在events里。写了一个契约里没有的 prop build 同样失败。
d2c links validate{
"kind": "links-validate-report",
"status": "fail",
"errors": [
{ "componentId": "button", "message": "Figma property Size targets missing prop size." }
]
}这个例子里 Button 的契约没有 size 这个 prop link 里却映射了 校验直接失败。以前 v1 这种错会一直活到运行时 Agent 按映射写了 size="large" 页面报 warning 没人管。
links 不管风险。有人想在 link 里写备注 “这个 Figma 组件不要用于移动端” 那是 hazard 的活 写在 link 里引擎看不见。
6. 合并规则
6.1 applyOverlay
build 的合并逻辑不复杂 我把关键部分按真实实现精简出来:
function applyOverlay(contract: ComponentContractIR, overlay: CatalogOverlay): ComponentContractIR {
// overlay 声明了 coverage 就按人工确认的重算 unresolved 否则沿用 generated 的
const unresolved = overlay.coverage
? recomputeUnresolved(contract, overlay.coverage)
: contract.completeness.unresolved
return {
...contract,
aliases: unique([...(contract.aliases ?? []), ...(overlay.aliases ?? [])]), // 只有 aliases 是合并
roles: overlay.roles ?? contract.roles, // 其他都是整体替换
structureFamilies: overlay.structureFamilies ?? contract.structureFamilies,
hazards: overlay.hazards ?? contract.hazards,
extensionRecipes: overlay.extensionRecipes ?? contract.extensionRecipes,
autoMatch: overlay.autoMatch
? {
...overlay.autoMatch,
// 人说 eligible 也不算 还要 reviewed 且没有任何 unresolved
eligible: overlay.autoMatch.eligible && overlay.autoMatch.reviewed && unresolved.length === 0,
}
: contract.autoMatch,
completeness: { status: unresolved.length ? 'incomplete' : 'complete', unresolved },
}
}要点:
- 替换 不是合并。overlay 写了
rolesgenerated 的 roles 就整个没了;写了hazards就整个替换。只有aliases是取并集。我一开始做的是数组合并 后来发现合并让人没法删东西:抽取器派生了一个错的 structureFamily 人想去掉它 合并语义下做不到。 eligible是算出来的 不是人填的。人填eligible: true但契约里还有 unresolved 合并之后 eligible 还是 false。schema 层面还有一道校验:autoMatch.eligible为 true 时必须reviewed为 true 且completeness.status为 complete 否则 snapshot 直接 parse 失败。- overlay 指向的 id 必须在 generated 里。没有对应契约的 overlay 会报
No generated component contract exists for overlay …build 失败。人不能凭空编造一个组件。
6.2 指纹
snapshot 里带六个指纹 加一个总指纹:
{
"kind": "catalog-snapshot",
"fingerprint": "a3f1…",
"fingerprints": {
"extractor": "…", // 抽取器代码本身的哈希 抽取逻辑改了也算变
"inventory": "…", // 发现了哪些组件文件
"generated": "…", // 合并后契约的哈希
"overrides": "…", // overrides 目录所有 yaml 的哈希
"links": "…", // links 目录所有 yaml 的哈希
"sources": "…" // 契约 source.files 里所有源码文件的哈希
},
"contracts": [ "…" ],
"links": [ "…" ],
"generatedAt": "2026-…"
}d2c catalog check 做的事就是在当前工作区重新算这六个指纹 和 snapshot 里记的比。任何一个对不上就抛 CatalogDriftError:
D2C catalog drift detected:
- curated overlay fingerprints are stale
- component source fingerprints are stale第一条是改了 overrides 忘了 build;第二条是有人改了组件源码 但 snapshot 还是旧的。decide 和 verify 加载 snapshot 时就会抛这个错 不会拿过期的 catalog 往下跑。
每一份 DecisionRecord 都带 FingerprintSet 里面的 catalog 就是这个总指纹。catalog 变了 旧裁决拿去 verify 指纹对不上 整单作废。这是粗粒度的 会多跑一些任务;细粒度的"只作废受影响的裁决"我暂时没做 漏作废比多跑贵得多。
6.3 build 的输出
d2c catalog build{
"kind": "catalog-build-report",
"status": "pass",
"summary": {
"fingerprint": "a3f1…",
"contracts": 212,
"completeContracts": 187,
"autoMatchContracts": 41,
"links": 6,
"generatedAt": "2026-…"
},
"errors": [],
"written": 213
}三个数字值得看一眼:212 个契约 187 个完整 41 个能自动匹配。完整和能自动匹配之间差的那 146 个 就是搜得到但选不中的。这个差值不是问题 是待办 每补一份 overlay 它就少一个。
errors 非空的时候 status 是 fail 什么都不会写 build 不会产出一份半对的 snapshot。写入先到临时目录再整目录 rename 中途失败会把旧的 generated 恢复回来。
7. structureFamilies 怎么派生
命名和目录不可靠。项目里可能有一个通用浮层封装 名字完全不提 dialog;也可能有一个叫 DialogSomething 的东西 其实只是文档演示。角色靠 roles 身份靠 links 中间那截 我用结构签名把形状对得上的候选拉进名单。
派生逻辑在抽取器里跑 输入是已经抽出来的 props / models / slots / events 名字 输出是族名。族名复用的是 DesignRole 的词表 这样 decide 可以直接和设计节点的 role 对表。按真实实现精简:
function deriveStructureFamilies(input: {
identity: string // kebab id 名称规则在这里匹配
props: Set<string>
models: Set<string> // v-model 的 prop 名
slots: Set<string>
events: Set<string>
}): string[] {
const id = input.identity.toLowerCase()
const families: string[] = []
const hasProp = (...names: string[]) => names.some(n => input.props.has(n))
const hasEvent = (...names: string[]) => names.some(n => input.events.has(n))
const hasSlot = (...names: string[]) => names.some(n => input.slots.has(n))
// 名称规则优先 目录名和导出名在组件库里足够可靠 形状规则补充命名不含族词的组件
if (/modal|dialog/.test(id)
|| (input.models.size > 0 && hasProp('title', 'closeOnClickModal', 'showClose') && hasEvent('close', 'closed')))
families.push('dialog')
// 有 click 事件、没有 v-model、也没有别的族的特征 prop → button
if (hasEvent('click') && input.models.size === 0
&& !hasProp('placement', 'options', 'data', 'columns', 'href') && !hasSlot('reference'))
families.push('button')
if (input.models.size > 0 && hasProp('placeholder') && !hasProp('options'))
families.push('input')
if (input.props.has('data') && hasProp('columns', 'rowKey'))
families.push('table')
// ... drawer / tabs / select / checkbox / switch / tooltip / pagination / card / media 同理
return families.sort()
}要点:
- 多族同时命中是允许的。一个组件既像 dialog 又像 card 两个族都进。族只决定入场 不决定结果。
- 族是抽取器派生的 但 overlay 可以整体覆盖。派生错了 人在 overrides 里写一行
structureFamilies: [dialog]就替换掉。 - 入场之后硬约束照旧走。身份冲突、completeness、autoMatch、roles 兼容、hazard、绑定能不能落、布局能力、扩展是否在菜单里 一条都不少。structureFamilies 不等于语义已对齐 更不等于可以 reuse。
召回的顺序在 decide 里是固定的:identity-link 最前 然后 role-index 然后 structure-index。未评审的结构签名候选排在已评审的 role 命中之后 不会反过来压制人审过的候选。verifiedSupport 只在 structure-index 内部调顺序 不跨来源。族应该窄、可解释 往上堆所有模糊组件 入场名单会膨胀 needs-review 只会更多。
8. 可搜 不等于可自动选
回到第 1 节那个问题。d2c_search_catalog 会返回未完整、未评审的条目 这是刻意的。人需要看见半成品 才能去补覆盖。自动路径则看 completeness 和 autoMatch 两道门。混用这两条路径 是"搜到了为什么不 reuse"争议的根源。
对新同学我通常演示一遍完整流程:
# 1. 搜到了
d2c_search_catalog { "query": "dialog" }
# → autoMatch: { eligible: false, reviewed: false } roles: []
# 2. 看一眼全局状态
d2c contracts status
# → { total: 212, reviewed: 41, eligible: 41, incomplete: 25 }
# 3. 跑 decide 拿到 needs-review
d2c decide --input .d2c/design/new-account-dialog.json --scope app/pages/account
# → verdict: needs-review 候选 dialog 的 auto-match 约束 unknown
# 4. 补 overlay
d2c overrides scaffold --component-id dialog
# 编辑 overrides/dialog.yaml 填 roles / autoMatch / hazards / recipe
# 5. build 再 decide
d2c catalog build
d2c decide --input .d2c/design/new-account-dialog.json --scope app/pages/account
# → verdict: reuse整段过程没有改 prompt 也没有让设计师再出干净稿 只是把人的判断写进了 overrides。
9. 踩坑
9.1 人手写的契约漂移
v1 的组件 API 是我手写在索引里的:props 名字、类型都是人抄的。抄的那一刻是对的 组件库升一个版本就不对了。最典型的一次是组件库把某个 slot 从 title 改成 header 索引没更新 Agent 按旧名写 页面标题静默消失。没有任何报错。
v2 里契约是编译器抽的 组件源码一变 fingerprints.sources 就变 check 直接报 drift。人不再抄 API 人只写 API 之上的判断。
9.2 覆盖层被机器覆盖
这个坑是 v2 早期踩的。那时候 build 的逻辑是把 overlay 直接 merge 进 generated/*.json 再写回去。我在 generated/dialog.json 里手改了一个 roles 想快速试一下 下一次 build 抽取器重新生成 我改的东西没了。更糟的是有段时间 overlay 也是写进 generated 目录的 build 一跑 overlay 跟着一起被覆盖。
后来我把规则定死:generated 是输出 每次 build 整目录替换 人不碰;人写的东西只放 overrides 和 links 两个目录。合并只发生在内存里 结果写进 snapshot。三个目录各自有指纹 谁变了一眼看出来。
9.3 半成品被自动选中
事故发生在 overrides 还很薄的阶段。generated 已经能抽出一个表单弹窗封装的 API 检索排得很靠前。那时候我还没把 autoMatch.reviewed 当成硬门 排序分又被错误地掺进了"能不能用"的判断里。Agent 选中了它 写成 reuse 又按页面需要加了两个契约里没有的 prop。页面演示通过了。
后面的连锁反应:有人为了兼容那次生成 把临时 prop 真的加进了组件公共 API 契约被污染;其他页面开始复用被污染的 API;某次库升级删掉了临时 prop 一批页面同时报错。
复盘时有人提要不要禁止 Agent 改组件库。我觉得洞在更前面:半成品不该被自动选中 扩展不该绕过 ExtensionRecipe 搜到不该等于 reuse。改完之后 autoMatch 和 completeness 分成两组字段 不完整或未评审的契约自动路径不可选中 检索仍可见方便人补。报表上的自动 reuse 率掉了一截 掉的那截里 相当一部分是以前不该算成功的成功。
9.4 忘了 build
本地开发时最常见的自伤。改完 overlay 没 build decide 仍说扩展不合法 因为 snapshot 里还没有那份 recipe。表现出来像引擎不认账 实际是 snapshot 过期。
现在 decide 一启动就加载 snapshot 加载时就跑指纹校验 overrides 目录的哈希和 snapshot 里记的不一样 直接抛 curated overlay fingerprints are stale。宁可让人多跑一次 build 也不让引擎在过期的 catalog 上给结论。
还有人用"先让它 eligible 反正后面有 verify"当理由放行半成品。verify 回答的是实现是否兑现了裁决 不是裁决是否选对了组件。选错组件却实现一致 verify 可能仍然全绿。门必须在 catalog 和 decide 不能外包给下游。
小结
本篇完成了:
- 写清了 v1 糊状索引的问题 以及我把 catalog 拆成 generated / overrides / links 三层的理由:事实、判断、身份映射的寿命和权限不一样
- 贴了 ComponentContractIR 的精简类型 以及 generated 契约、overlay、link 的 JSON / YAML 示例
- 贴了 applyOverlay 的合并规则:替换不是合并 eligible 是算出来的 六个指纹加一个总指纹
- 贴了 structureFamilies 的派生逻辑 以及它只决定入场不决定结果的位置
- 记了四个坑:手写契约漂移、覆盖层被机器覆盖、半成品被自动选中、忘了 build
catalog 到这里有了:机器出事实 人补判断 运行时只读 snapshot 门可解释。下一篇回到 ingest 把第二篇只贴了输入输出的归一化过程展开:keep / flatten / drop 每条规则怎么定的 匿名壳上的布局属性丢了怎么办 角色推断的优先级为什么这么排 以及 DesignIntentIR 里 unresolved 和 layout 图的完整形状。