# 激活层硬化 Phase 2（Conformance Verifier 升级）Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把"流程线合规 / token 绑定率 / 手搓组件"走查从"AI 自觉"做成可运行的机器 gate，接进现有 `audit-mockup-conformance.mjs` 总闸，并加 repo 守门拦"AI 忘跑/目测"。

**Architecture:** 全部扩展现有 live-Figma 审计基建——新增 1 个独立子审计（connector）、扩展 2 个既有子审计（library-binding 名集、binding-fidelity 加 B-COVERAGE 档）、新增 1 个 repo 守门脚本 + pre-commit 挂接，最后把新子审计注册进总闸的 `AUDITS` 数组。无新框架、无新依赖。

**Tech Stack:** Native Node 18+（`node:fs` / `fetch`），Figma REST API（`FIGMA_PERSONAL_ACCESS_TOKEN`），vitest（fixture JSON 离线测），pnpm，husky pre-commit。

## Global Constraints

- **真源契约不回改**：流程线起点 = 白填充 `#FFFFFF` + 2px 蓝边 `#33A4FD`（§M23.6/C4）；M23.6 = 两端锚定（fromCanvas/tipCanvas vs 触发/目标 bbox ±8px）；正交折线 = 任一段 `Δx>4 && Δy>4` 即斜线违例（§M23.6 C）；brand green `#33ab4f` 出现在流程线任意部位 = 违例（§C4）。
- **token 绑定率**：spacing/radius scale 复用 `audit-mockup-binding-fidelity.mjs` 导出的 `loadScaleSets()`（data-driven，fallback spacing `[4,8,12,16,24,32,40,56]` / radius `[2,4,8,12,16,20]`）。
- **场景感知**：greenfield（场景3）blocking / 存量增量（场景2）`--non-blocking` warn——沿用 `audit-mockup-conformance.mjs` 现有 `--non-blocking` 模式。
- **退出码统一**：0 pass / 1 findings / 2 bad-usage 或 fetch error（与现有兄弟脚本一致）；无 token → exit 2。
- **逃生口**：binding-coverage 与 handdrawn 检测支持 ignore 标记（节点名含 `[[raw-ok]]` / `[[mock]]` 或 page sharedPluginData allowlist）→ 跳过。
- **Native Node only**，无新 npm 依赖；脚本风格对齐现有 `scripts/audit-mockup-*.mjs`（`parseArgs` / `figma(path)` fetch helper / `--file --node --json` flag）。
- **并行集成注意**：另有 session 正在给总闸加 overlap / bilingual-spacing 子审计并改 `audit-mockup-conformance.mjs`。**Task 5（接线）执行时先 `git status` + 读当前 `AUDITS` 数组实际内容**，把本 plan 的新条目 append 进去、不覆盖它的条目；若冲突，按"两边都保留"合并。
- Commit 默认含 push；commit message 英文 + 结尾 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`；commit 只 stage 自己改的文件（`git add <path>`，禁 `-A`，防与并行 session 互扫）。

---

### Task 1: `audit-mockup-connector.mjs` — 流程线确定性 gate

**Files:**
- Create: `scripts/audit-mockup-connector.mjs`
- Create: `tests/mockup-connector.test.ts`

**Interfaces:**
- Produces: `export function classifyConnectorViolations(reconnectMap, nodeIndex, scaleSets?) → { orthogonal: [...], anchoring: [...], color: [...], missing: bool }`，其中 `nodeIndex` 是 `{ [nodeId]: figmaNode }` 扁平索引，`reconnectMap` 是 `{ arrows: [{ id, fromCanvas, tipCanvas, from:{triggerElemId}, to:{targetElemId} }] }`。
- Produces: CLI `node scripts/audit-mockup-connector.mjs --file <key> [--node <id>] [--json]`，exit 0/1/2。

- [ ] **Step 1: 写失败测试（纯函数，离线 fixture）**

```ts
// tests/mockup-connector.test.ts
import { describe, it, expect } from 'vitest'
import { classifyConnectorViolations, segmentsOf } from '../scripts/audit-mockup-connector.mjs'

const BLUE = { type: 'SOLID', color: { r: 0.2, g: 0.64, b: 0.99 } }   // #33A4FD
const WHITE = { type: 'SOLID', color: { r: 1, g: 1, b: 1 } }
const GREEN = { type: 'SOLID', color: { r: 0.2, g: 0.67, b: 0.31 } }  // #33ab4f

function vec(data) { return { id: 'v1', type: 'VECTOR', strokes: [BLUE], strokeWeight: 2, vectorPaths: [{ data }] } }
function dot(fills, strokes) { return { id: 'd1', type: 'ELLIPSE', width: 6, height: 6, fills, strokes, strokeWeight: 2 } }

describe('segmentsOf', () => {
  it('flags a diagonal segment (Δx>4 && Δy>4)', () => {
    const segs = segmentsOf('M 0 0 L 100 80')
    expect(segs.some(s => s.diagonal)).toBe(true)
  })
  it('passes pure horizontal/vertical segments', () => {
    const segs = segmentsOf('M 0 8 L 100 8 M 100 8 L 100 60')
    expect(segs.every(s => !s.diagonal)).toBe(false === false && true)  // none diagonal
    expect(segs.some(s => s.diagonal)).toBe(false)
  })
})

describe('classifyConnectorViolations', () => {
  const trigger = { id: 'btn', absoluteBoundingBox: { x: 100, y: 100, width: 40, height: 20 } }
  const target = { id: 'tgt', absoluteBoundingBox: { x: 400, y: 100, width: 60, height: 20 } }
  const okArrow = {
    id: 'g1', fromCanvas: { x: 140, y: 110 }, tipCanvas: { x: 402, y: 110 },
    from: { triggerElemId: 'btn' }, to: { targetElemId: 'tgt' },
    groupChildren: [dot([WHITE], [BLUE]), vec('M 0 8 L 260 8 M 252 0 L 260 8 L 252 16')],
  }
  const index = { btn: trigger, tgt: target }

  it('passes a compliant arrow', () => {
    const r = classifyConnectorViolations({ arrows: [okArrow] }, index)
    expect(r.missing).toBe(false)
    expect(r.orthogonal).toHaveLength(0)
    expect(r.anchoring).toHaveLength(0)
    expect(r.color).toHaveLength(0)
  })
  it('flags start dot not anchored on trigger bbox (card-edge LINE)', () => {
    const bad = { ...okArrow, fromCanvas: { x: 300, y: 110 } }   // 远离 btn bbox
    const r = classifyConnectorViolations({ arrows: [bad] }, index)
    expect(r.anchoring.map(a => a.end)).toContain('start')
  })
  it('flags green stroke on the connector (C4)', () => {
    const bad = { ...okArrow, groupChildren: [dot([WHITE], [GREEN]), vec('M 0 8 L 260 8')] }
    const r = classifyConnectorViolations({ arrows: [bad] }, index)
    expect(r.color.length).toBeGreaterThan(0)
  })
  it('flags hollow start dot (fills empty)', () => {
    const bad = { ...okArrow, groupChildren: [dot([], [BLUE]), vec('M 0 8 L 260 8')] }
    const r = classifyConnectorViolations({ arrows: [bad] }, index)
    expect(r.color.length).toBeGreaterThan(0)
  })
  it('reports missing when reconnectMap absent', () => {
    const r = classifyConnectorViolations(null, index)
    expect(r.missing).toBe(true)
  })
})
```

- [ ] **Step 2: 跑测试看它失败**

Run: `pnpm vitest run tests/mockup-connector.test.ts`
Expected: FAIL（`Cannot find module ... audit-mockup-connector.mjs` 或函数未定义）

- [ ] **Step 3: 写实现**

```js
#!/usr/bin/env node
// audit-mockup-connector.mjs — §M23.6 / §C4 流程线确定性 gate。
// 读 reconnect_map（page sharedPluginData['ux_annotation']），逐 arrow 查：
//   正交折线(M23.6 C) / 两端锚定(M23.6 A) / 颜色+圆点形态(C4)。
// reconnect_map 缺/不全 → missing=true → exit 1（连线不可验证；M23.6 强制产出）。
import { fileURLToPath } from 'node:url'

const API = 'https://api.figma.com/v1'
const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN
const NS = 'ux_annotation'
const BLUE = { r: 0.2, g: 0.64, b: 0.99 }    // #33A4FD
const GREEN = { r: 0.2, g: 0.67, b: 0.31 }   // #33ab4f
const TOL = 8, COLOR_EPS = 0.04

const near = (a, b, eps = COLOR_EPS) => Math.abs(a - b) <= eps
const isColor = (c, ref) => c && near(c.r, ref.r) && near(c.g, ref.g) && near(c.b, ref.b)
const within = (pt, bbox) => !!bbox && !!pt &&
  pt.x >= bbox.x - TOL && pt.x <= bbox.x + bbox.width + TOL &&
  pt.y >= bbox.y - TOL && pt.y <= bbox.y + bbox.height + TOL

// parse "M x y L x y ..." → segments with diagonal flag
export function segmentsOf(data) {
  const toks = String(data || '').trim().split(/\s+/)
  const pts = []
  for (let i = 0; i < toks.length; i++) {
    if (toks[i] === 'M' || toks[i] === 'L') { pts.push({ cmd: toks[i], x: +toks[i + 1], y: +toks[i + 2] }); i += 2 }
  }
  const segs = []
  for (let i = 1; i < pts.length; i++) {
    if (pts[i].cmd !== 'L') continue
    const dx = Math.abs(pts[i].x - pts[i - 1].x), dy = Math.abs(pts[i].y - pts[i - 1].y)
    segs.push({ dx, dy, diagonal: dx > 4 && dy > 4 })
  }
  return segs
}

const solidFills = n => (n.fills || []).filter(f => f.visible !== false && f.type === 'SOLID')
const solidStrokes = n => (n.strokes || []).filter(s => s.visible !== false && s.type === 'SOLID')

export function classifyConnectorViolations(reconnectMap, nodeIndex, _scaleSets) {
  const out = { orthogonal: [], anchoring: [], color: [], missing: false }
  if (!reconnectMap || !Array.isArray(reconnectMap.arrows) || reconnectMap.arrows.length === 0) {
    out.missing = true
    return out
  }
  for (const a of reconnectMap.arrows) {
    const label = a.label || a.id
    const kids = a.groupChildren || []
    const ell = kids.find(k => k.type === 'ELLIPSE')
    const v = kids.find(k => k.type === 'VECTOR')
    // 锚定（M23.6 A）：起点锚触发元素 / 终点落 target
    const trig = a.from?.triggerElemId ? nodeIndex[a.from.triggerElemId] : null
    const tgt = a.to?.targetElemId ? nodeIndex[a.to.targetElemId] : null
    if (!within(a.fromCanvas, trig?.absoluteBoundingBox)) out.anchoring.push({ label, end: 'start' })
    if (!within(a.tipCanvas, tgt?.absoluteBoundingBox)) out.anchoring.push({ label, end: 'tip' })
    // 正交（M23.6 C）
    if (v) for (const s of segmentsOf(v.vectorPaths?.[0]?.data)) if (s.diagonal) { out.orthogonal.push({ label }); break }
    // 颜色 + 圆点形态（C4）：圆点白填充 + 蓝边；线/箭头蓝；禁绿
    if (ell) {
      const f = solidFills(ell)[0]?.color, st = solidStrokes(ell)[0]?.color
      if (!f || !isColor(f, { r: 1, g: 1, b: 1 })) out.color.push({ label, why: 'dot fill not white (hollow/wrong)' })
      if (!st || !isColor(st, BLUE)) out.color.push({ label, why: 'dot border not blue' })
    }
    for (const k of kids) {
      for (const p of [...solidFills(k), ...solidStrokes(k)]) {
        if (isColor(p.color, GREEN)) out.color.push({ label, why: 'brand green on connector (C4)' })
      }
    }
  }
  return out
}

// ---- CLI (live Figma) ----
async function figma(path) {
  const res = await fetch(`${API}${path}`, { headers: { 'X-Figma-Token': TOKEN } })
  if (!res.ok) throw new Error(`GET ${path} → ${res.status}`)
  return res.json()
}
function flatten(node, idx, groups) {
  if (!node || typeof node !== 'object') return
  idx[node.id] = node
  if (node.type === 'GROUP') groups[node.id] = node
  for (const c of node.children || []) flatten(c, idx, groups)
}
function parseArgs(argv) {
  const a = { _: [] }
  for (let i = 0; i < argv.length; i++) {
    const t = argv[i]
    if (t.startsWith('--')) { const n = argv[i + 1]; if (n === undefined || n.startsWith('--')) a[t.slice(2)] = true; else { a[t.slice(2)] = n; i++ } }
    else a._.push(t)
  }
  return a
}
async function main() {
  const args = parseArgs(process.argv.slice(2))
  if (!TOKEN) { console.error('Missing FIGMA_PERSONAL_ACCESS_TOKEN'); process.exit(2) }
  if (!args.file || args.file === true) { console.error('Usage: --file <key> [--node <id>] [--json]'); process.exit(2) }
  let doc, sharedByPage = {}
  try {
    const r = await figma(`/files/${args.file}?plugin_data=${NS}`)
    doc = r.document
  } catch (e) { console.error('Fetch failed:', e.message); process.exit(2) }
  const idx = {}, groups = {}
  flatten(doc, idx, groups)
  // reconnect_map 存在 page 节点 sharedPluginData[NS]['<feature>_reconnect_map']
  let reconnectMap = null
  for (const id of Object.keys(idx)) {
    const spd = idx[id].sharedPluginData?.[NS]
    if (!spd) continue
    const key = Object.keys(spd).find(k => k.endsWith('_reconnect_map'))
    if (key) { try { reconnectMap = JSON.parse(spd[key]) } catch {} }
  }
  // 把 group children 附到 arrow 上供 classify
  if (reconnectMap?.arrows) for (const a of reconnectMap.arrows) a.groupChildren = (groups[a.id]?.children) || []
  const r = classifyConnectorViolations(reconnectMap, idx)
  const findings = r.orthogonal.length + r.anchoring.length + r.color.length
  if (args.json) { console.log(JSON.stringify(r, null, 2)); process.exit(r.missing || findings ? 1 : 0) }
  if (r.missing) { console.log('❌ connector: reconnect_map 缺/空 → 连线不可验证（M23.6 强制产出）'); process.exit(1) }
  if (!findings) { console.log('✅ connector: 全部 arrow 合规（正交/两端锚定/颜色）'); process.exit(0) }
  console.log(`❌ connector: ${findings} finding(s)`)
  for (const o of r.orthogonal) console.log(`   · 斜线段 ${o.label}`)
  for (const a of r.anchoring) console.log(`   · ${a.end} 未锚定 ${a.label}`)
  for (const c of r.color) console.log(`   · 颜色/圆点 ${c.label}: ${c.why}`)
  process.exit(1)
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main().catch(e => { console.error(e); process.exit(2) })
```

- [ ] **Step 4: 跑测试看通过**

Run: `pnpm vitest run tests/mockup-connector.test.ts`
Expected: PASS（全部 case 绿）

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-mockup-connector.mjs tests/mockup-connector.test.ts
git commit -m "feat(audit): connector conformance gate — M23.6 两端锚定/正交/C4 颜色 (WS4-1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: 扩展 `audit-mockup-library-binding.mjs` — 手搓组件（非 INSTANCE 命名像组件）

**Files:**
- Modify: `scripts/audit-mockup-library-binding.mjs`（在既有 `checkM30NonInstanceIcon` 旁加 `checkNonInstanceComponent`）
- Create: `tests/mockup-handdrawn-component.test.ts`

**Interfaces:**
- Consumes: 既有脚本的 node-walk + `checkM30NonInstanceIcon` 范式。
- Produces: `export function checkNonInstanceComponent(node) → finding | null`（命中 canonical 组件名且 type≠INSTANCE 且无 ignore 标记 → finding）。

- [ ] **Step 1: 写失败测试**

```ts
// tests/mockup-handdrawn-component.test.ts
import { describe, it, expect } from 'vitest'
import { checkNonInstanceComponent } from '../scripts/audit-mockup-library-binding.mjs'

describe('checkNonInstanceComponent (handdrawn warn)', () => {
  it('flags a FRAME named "Button" that is not an INSTANCE', () => {
    expect(checkNonInstanceComponent({ id: '1', type: 'FRAME', name: 'Button' })).toBeTruthy()
  })
  it('flags "primary checkbox" frame', () => {
    expect(checkNonInstanceComponent({ id: '2', type: 'FRAME', name: 'primary checkbox' })).toBeTruthy()
  })
  it('does NOT flag an INSTANCE named Button', () => {
    expect(checkNonInstanceComponent({ id: '3', type: 'INSTANCE', name: 'Button' })).toBeNull()
  })
  it('does NOT flag unrelated frame name', () => {
    expect(checkNonInstanceComponent({ id: '4', type: 'FRAME', name: 'Header container' })).toBeNull()
  })
  it('respects [[mock]] ignore marker', () => {
    expect(checkNonInstanceComponent({ id: '5', type: 'FRAME', name: 'Button [[mock]]' })).toBeNull()
  })
})
```

- [ ] **Step 2: 跑测试看失败**

Run: `pnpm vitest run tests/mockup-handdrawn-component.test.ts`
Expected: FAIL（`checkNonInstanceComponent` 未导出）

- [ ] **Step 3: 写实现（加到 audit-mockup-library-binding.mjs）**

先 Read `scripts/audit-mockup-library-binding.mjs` 找到 `checkM30NonInstanceIcon` 定义与 walk 调用处。在其旁加：

```js
// canonical 组件名集（小写、去标点后匹配整词）——手搓 frame 伪装组件 warn（补 H 盲区）
const CANONICAL_COMPONENT_NAMES = new Set([
  'button', 'checkbox', 'radio', 'input', 'textfield', 'select', 'dropdown',
  'badge', 'tab', 'toggle', 'switch', 'modal', 'dialog', 'notification',
  'tooltip', 'pill', 'tag', 'chip',
])
const IGNORE_RE = /\[\[(mock|raw-ok|not-component)\]\]/i

export function checkNonInstanceComponent(node) {
  if (!node || node.type === 'INSTANCE' || node.type === 'COMPONENT') return null
  if (!['FRAME', 'GROUP', 'RECTANGLE'].includes(node.type)) return null
  const name = String(node.name || '')
  if (IGNORE_RE.test(name)) return null
  const words = name.toLowerCase().replace(/[^a-z0-9一-鿿]+/g, ' ').trim().split(/\s+/)
  const hit = words.find(w => CANONICAL_COMPONENT_NAMES.has(w))
  if (!hit) return null
  return { nodeId: node.id, name, matched: hit, type: node.type }
}
```

然后在既有 walk 里（与 `checkM30NonInstanceIcon` 同处）调用 `checkNonInstanceComponent(node)`，把命中收进一个 `handdrawnWarnings` 列表；输出时**作为 ⚠️ WARN 打印、不计入 fail 的 exit code**（启发式，non-blocking）。在脚本结尾打印：`⚠️ ${handdrawnWarnings.length} 处疑似手搓组件（非 INSTANCE 命名像组件）` + 每条 `· <name> (matched <word>)`，但 `process.exit` 仍只由既有 M0/M30 错误决定。

- [ ] **Step 4: 跑测试看通过**

Run: `pnpm vitest run tests/mockup-handdrawn-component.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-mockup-library-binding.mjs tests/mockup-handdrawn-component.test.ts
git commit -m "feat(audit): warn on non-instance frames named like canonical components (WS4-2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: `audit-mockup-binding-fidelity.mjs` 加 B-COVERAGE 档 — 未绑即报

**Files:**
- Modify: `scripts/audit-mockup-binding-fidelity.mjs`（加 `classifyCoverageViolations` + `--coverage` flag）
- Create: `tests/mockup-binding-coverage.test.ts`

**Interfaces:**
- Consumes: 既有 `loadScaleSets()`（导出）、既有 node-walk、既有 `boundVariables`（`bv`）检测。
- Produces: `export function classifyCoverageViolations(rootDoc, components, varName) → [{ nodeId, prop, value, kind }]`——itemSpacing/padding/fill/stroke 未绑任何变量/style **即报**（不管 on/off-scale），带 ignore 逃生口。

- [ ] **Step 1: 写失败测试**

```ts
// tests/mockup-binding-coverage.test.ts
import { describe, it, expect } from 'vitest'
import { classifyCoverageViolations } from '../scripts/audit-mockup-binding-fidelity.mjs'

const VAR = 'boundVariables'
function frame(extra) {
  return { id: 'f', type: 'FRAME', name: 'row', layoutMode: 'HORIZONTAL', itemSpacing: 16, paddingLeft: 8, ...extra }
}
describe('classifyCoverageViolations (未绑即报)', () => {
  it('flags on-scale itemSpacing/padding that are bound to NO variable', () => {
    const r = classifyCoverageViolations(frame({}), {}, VAR)
    const props = r.map(x => x.prop)
    expect(props).toContain('itemSpacing')   // 16 是 on-scale，但未绑 → 仍报
    expect(props).toContain('paddingLeft')
  })
  it('does NOT flag a prop bound to a variable', () => {
    const bound = frame({ [VAR]: { itemSpacing: { id: 'VariableID:1' } } })
    const r = classifyCoverageViolations(bound, {}, VAR)
    expect(r.map(x => x.prop)).not.toContain('itemSpacing')
  })
  it('respects [[raw-ok]] ignore marker on the node', () => {
    const r = classifyCoverageViolations(frame({ name: 'row [[raw-ok]]' }), {}, VAR)
    expect(r).toHaveLength(0)
  })
})
```

- [ ] **Step 2: 跑测试看失败**

Run: `pnpm vitest run tests/mockup-binding-coverage.test.ts`
Expected: FAIL（`classifyCoverageViolations` 未导出）

- [ ] **Step 3: 写实现（加到 audit-mockup-binding-fidelity.mjs）**

先 Read 该文件，复用既有 `boundVariables` 字段名常量与 walk。加：

```js
const COVERAGE_IGNORE_RE = /\[\[(raw-ok|mock|not-component)\]\]/i

// 未绑即报：autolayout itemSpacing/padding + 容器/文字 fill·stroke 没绑任何变量/style 就报，
// 不管裸值是否恰好 = token（区别 B-SCALE 只报 off-scale）。带 ignore 逃生口。
export function classifyCoverageViolations(rootDoc, components, varName) {
  const out = []
  const walk = (node) => {
    if (!node || typeof node !== 'object') return
    const name = String(node.name || '')
    if (COVERAGE_IGNORE_RE.test(name)) return   // 逃生口：整子树跳过
    const bv = node[varName] || {}
    const isAuto = node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL'
    if (isAuto) {
      for (const prop of ['itemSpacing', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom']) {
        if (typeof node[prop] === 'number' && node[prop] !== 0 && !bv[prop]) {
          out.push({ nodeId: node.id, name, prop, value: node[prop], kind: 'spacing-unbound' })
        }
      }
    }
    const hasStyle = node.styles && (node.styles.fill || node.styles.fills)
    const solidFill = (node.fills || []).some(f => f.visible !== false && f.type === 'SOLID')
    if (solidFill && !bv.fills && !hasStyle && !['ELLIPSE'].includes(node.type)) {
      out.push({ nodeId: node.id, name, prop: 'fills', value: null, kind: 'fill-unbound' })
    }
    const solidStroke = (node.strokes || []).some(s => s.visible !== false && s.type === 'SOLID')
    if (solidStroke && !bv.strokes) {
      out.push({ nodeId: node.id, name, prop: 'strokes', value: null, kind: 'stroke-unbound' })
    }
    for (const c of node.children || []) walk(c)
  }
  walk(rootDoc)
  return out
}
```

在 CLI main 里加 `--coverage` flag：传入时额外跑 `classifyCoverageViolations` 并把结果计入 findings（greenfield blocking）。**不传 `--coverage` 时维持原行为**（向后兼容；总闸接线时按场景决定传不传）。

- [ ] **Step 4: 跑测试看通过**

Run: `pnpm vitest run tests/mockup-binding-coverage.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-mockup-binding-fidelity.mjs tests/mockup-binding-coverage.test.ts
git commit -m "feat(audit): B-COVERAGE — flag unbound spacing/fill/stroke regardless of on-scale value (WS4-3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: `audit-mockup-handoff-evidence.mjs` + pre-commit 守门

**Files:**
- Create: `scripts/audit-mockup-handoff-evidence.mjs`
- Create: `tests/mockup-handoff-evidence.test.ts`
- Modify: `.husky/pre-commit`（加一道 gate）

**Interfaces:**
- Produces: `export function checkHandoffEvidence(markdown) → { isHandoff: bool, hasConformance: bool, hasIntegrity: bool, ok: bool }`。
- Produces: CLI `node scripts/audit-mockup-handoff-evidence.mjs <file...>`，exit 0 pass / 1 缺证据。

- [ ] **Step 1: 写失败测试**

```ts
// tests/mockup-handoff-evidence.test.ts
import { describe, it, expect } from 'vitest'
import { checkHandoffEvidence } from '../scripts/audit-mockup-handoff-evidence.mjs'

const good = `
## Handoff
Rule checklist: [M0 ✅ / M23 ✅]
\`\`\`
✅ mockup-conformance: all sub-audits passed
Integrity audit: I1=pass / I2=overlap-gap 12px / I3=overflow-count 0 / I4=orphan-count 0
\`\`\`
`
const noEvidence = `
## Handoff
Rule checklist: [M0 ✅ / M23 ✅]
（没贴脚本输出）
`
const notHandoff = `# 普通文档\n没有 rule checklist`

describe('checkHandoffEvidence', () => {
  it('passes a handoff with conformance + integrity evidence', () => {
    const r = checkHandoffEvidence(good)
    expect(r.isHandoff).toBe(true); expect(r.ok).toBe(true)
  })
  it('fails a handoff lacking the script evidence block', () => {
    const r = checkHandoffEvidence(noEvidence)
    expect(r.isHandoff).toBe(true); expect(r.ok).toBe(false)
  })
  it('ignores a non-handoff doc', () => {
    const r = checkHandoffEvidence(notHandoff)
    expect(r.isHandoff).toBe(false); expect(r.ok).toBe(true)
  })
})
```

- [ ] **Step 2: 跑测试看失败**

Run: `pnpm vitest run tests/mockup-handoff-evidence.test.ts`
Expected: FAIL

- [ ] **Step 3: 写实现**

```js
#!/usr/bin/env node
// audit-mockup-handoff-evidence.mjs — repo 守门：含 "Rule checklist:" 标记的 .md = mockup handoff，
// 必须同时含 conformance summary + Integrity audit 脚本证据块，否则阻断 commit（修 W5：目测不算）。
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

export function checkHandoffEvidence(md) {
  const text = String(md || '')
  const isHandoff = /Rule checklist:/i.test(text)
  if (!isHandoff) return { isHandoff: false, hasConformance: false, hasIntegrity: false, ok: true }
  const hasConformance = /mockup-conformance|conformance summary|audit:mockup-conformance/i.test(text)
  const hasIntegrity = /Integrity audit:\s*I1=/.test(text)
  const ok = hasConformance && hasIntegrity
  return { isHandoff, hasConformance, hasIntegrity, ok }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const files = process.argv.slice(2)
  let failed = 0
  for (const f of files) {
    let r
    try { r = checkHandoffEvidence(readFileSync(f, 'utf8')) } catch { continue }
    if (!r.isHandoff) continue
    if (!r.ok) {
      failed++
      console.error(`❌ ${f}: handoff 缺脚本证据（conformance=${r.hasConformance} integrity=${r.hasIntegrity}）—— 跑 pnpm audit:mockup-conformance 并贴输出`)
    }
  }
  process.exit(failed ? 1 : 0)
}
```

- [ ] **Step 4: 跑测试看通过**

Run: `pnpm vitest run tests/mockup-handoff-evidence.test.ts`
Expected: PASS

- [ ] **Step 5: 挂 pre-commit（仅扫 staged 的 .md）**

先 Read `.husky/pre-commit` 找现有 gate 的写法（如何取 staged 文件）。在合适位置加：

```bash
# mockup handoff evidence gate — staged 的 handoff .md 必须含脚本证据块
staged_md=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.md$' || true)
if [ -n "$staged_md" ]; then
  echo "▶ pre-commit: mockup handoff evidence"
  node scripts/audit-mockup-handoff-evidence.mjs $staged_md || exit 1
fi
```

- [ ] **Step 6: 验证 + Commit**

Run: `pnpm vitest run tests/mockup-handoff-evidence.test.ts` → PASS

```bash
git add scripts/audit-mockup-handoff-evidence.mjs tests/mockup-handoff-evidence.test.ts .husky/pre-commit
git commit -m "feat(gate): repo handoff-evidence guard — block commit when mockup handoff lacks script evidence (WS4-4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

> ⚠️ `.husky/pre-commit` 可能被并行 session 同时改（它动过 `.claude/hooks/`）。执行本 step 前 `git status` 确认 `.husky/pre-commit` 未被占用；若被占用，等其 clean 再做本 step，避免互扫。

---

### Task 5: 接进 `audit-mockup-conformance.mjs` 总闸 + blocking/warn 分级

**Files:**
- Modify: `scripts/audit-mockup-conformance.mjs`（`AUDITS` 数组 + summary 分级）

**Interfaces:**
- Consumes: Task 1 connector、Task 3 binding-fidelity `--coverage`、既有 integrity。
- Produces: 总闸跑 connector（blocking）+ binding-coverage（场景感知）+ 既有子审计；summary 区分 ❌FINDINGS（阻断）/ ⚠️WARN（非阻断）。

- [ ] **Step 1: 先核对当前 AUDITS 实际内容（防与并行 session 冲突）**

Run: `git status --short scripts/audit-mockup-conformance.mjs` + Read 该文件的 `AUDITS` 数组当前内容。
- 若文件被并行 session 占用（dirty）→ **暂停本 task，等其 clean**（避免互扫）。
- 记录现有条目（可能已含并行 session 加的 overlap / bilingual-spacing），本 step 在其基础上 **append**，不删不覆盖。

- [ ] **Step 2: 加 connector 条目 + blocking 字段**

在 `AUDITS` 数组追加（保留所有现有条目）：

```js
{ key: 'connector', script: 'audit-mockup-connector.mjs', blocking: true,
  args: f => ['--file', f, ...(NODE_ID ? ['--node', NODE_ID] : [])] },
```

给每个现有条目补 `blocking: true`（除 library-origin / 新 handdrawn warn 类 → `blocking: false`）。binding-fidelity 条目的 args 在 **非 `--non-blocking`（greenfield）** 时追加 `--coverage`：

```js
{ key: 'binding-fidelity', script: 'audit-mockup-binding-fidelity.mjs', blocking: true,
  args: f => ['--file', f, ...(NODE_ID ? ['--node', NODE_ID] : []), ...(NON_BLOCKING ? [] : ['--coverage'])] },
```

- [ ] **Step 3: summary 分级（❌FINDINGS vs ⚠️WARN）**

把现有 summary 循环改为按 `blocking` 分类：`blocking && code===1` → ❌FINDINGS（计入 fail）；`!blocking && code===1` → ⚠️WARN（打印但不计入 fail/不影响 exit）。最终 exit：有 blocking findings 且非 `--non-blocking` → 1，否则 0。

- [ ] **Step 4: 验证（无 token 也能验分级逻辑：跑一个会 exit 2 的场景看 summary 不崩）**

Run: `node scripts/audit-mockup-conformance.mjs --file dummy 2>&1 | tail -20`
Expected: 各子审计因无 token 报 ERROR（exit 2），summary 正常打印含 connector 行，进程不崩。

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-mockup-conformance.mjs
git commit -m "feat(gate): wire connector + binding-coverage into conformance master gate; blocking/warn tiers (WS4-5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Self-Review

**1. Spec coverage（对 spec §3 WS4）：**
- 4a connector verifier → Task 1 ✅
- 4b library-binding 扩名集（手搓组件）→ Task 2 ✅
- 4c binding-fidelity B-COVERAGE → Task 3 ✅
- 4d repo handoff 守门 → Task 4 ✅
- 总闸接线 + blocking/warn 分级 → Task 5 ✅

**2. Placeholder scan：** 每个实现 step 含完整代码；测试 step 含真实断言；命令含期望输出。无 TBD。

**3. Type 一致性：** `classifyConnectorViolations(reconnectMap, nodeIndex)` / `segmentsOf(data)` / `checkNonInstanceComponent(node)` / `classifyCoverageViolations(rootDoc, components, varName)` / `checkHandoffEvidence(md)` —— 测试与实现签名一致；`AUDITS` 条目 `blocking` 字段 Task 5 一致引用。

**4. 并行集成风险：** Task 4（.husky/pre-commit）、Task 5（conformance.mjs）都可能撞并行 session；两个 task 的 Step 1 都要求先 `git status` 确认未被占用、append-not-overwrite。

**注意**：Task 1-3 改的是各自独立的新/旧脚本（connector 新文件、library-binding、binding-fidelity），与并行 session 的 overlap/bilingual 文件不同，可正常并行推进；Task 4/5 是共享集成点，需序列化避让。
