# INFRA-F145 修法 (a)：cleanup 判「已发布」换成 Figma 侧真源 — 实施计划

> **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:** 让 `cleanup-unpublished` 判「这个图标还发布着吗」的输入面，从**本管线自己上一轮的产物**换成 **Figma 侧真源**（REST `/v1/files/:key/components`），使 Figma 库新增的图标不再在被任何人看见之前就被删掉。

**Architecture:** 网络那一半单独成脚本，把 REST 已发布组件集落盘成**带日期的快照** `figma-data/published/components-rest.json`；`cleanup-unpublished` 读快照而不是自己联网 —— 保住它「纯同步、无凭据也能 dry-run」的性质，也让它能被**整脚本 fixture harness** 覆盖（本闸今天零回归面）。快照带 `fetchedAt` + `figmaFileKey`，让 cleanup 能对陈旧 / 错文件的输入 **fail-closed**，⛔ 而不是回落到旧 manifest。

**Tech Stack:** Node ESM · 现有 `figma-sync/api.mjs` 凭据面（`X-Figma-Token`）· vitest

**Spec:** `docs/internal/backlog.md` §Active [[INFRA-F145]] entry（判据链三步 + 建议路径候选 (a)/(b)）+ owner 2026-09-03 拍定走 **(a)**。

---

## Global Constraints

- ⛔ **绝不回落**到 `figma-data/published/icons/manifest.json` 当判据 —— 那是病灶本身。拿不到新输入面就 fail-closed。
- ⛔ **不加白名单放行新图标** —— entry 逐字：「那是给循环论证打补丁」。owner 同轮重申。
- ⛔ **不跑不带 `--dry-cleanup` 的 `pnpm sync:figma-library --with-extract`** —— 它会按 entry 描述的方式真删 16 个活着的已发布图标。要看行为面只跑 `--dry-cleanup`。
- 一次性脚本纪律：⛔ 不写 `node -e` / `python3 -c`，写成 `.mjs` 文件；每个取证脚本带**已知向量自测 + 阴性对照**；任何文本注入前后各带 assert。
- **不写 changeset**：`figma-sync/**` 与 `figma-data/**` 均不在 `package.json` `files[]` 白名单里（2026-09-03 现取核过，白名单里的 `scripts/*` 只有 11 项具名 + `scripts/audit-mockup-*.mjs`）。
- 报告措辞必须与判据**实际知道的东西**一致：⛔ 不再写「已被取消发布或废弃」这种断言 —— 它其实只知道「不在我这份输入面里」。
- ⛔ 本计划**不替真源编设计理由**：affordance 分片里的 `visual_signature` / `synonyms_*` / `when_to_use` 是语义字段，机械生成的部分与需人审的部分必须分开标注。

## 现取基线（2026-09-03 实测，⛔ 别抄，各步自己重跑）

| 量 | 值 | 取法 |
|---|---|---|
| REST `/files/:key/components` | **200** · `meta.components` **4728** 条 | 探针（正对照 200 / 无凭据 **401** / 不存在端点 **404** 三态齐） |
| 其中 `containing_frame.pageName === '— — Icons'` | **660** | 同上 |
| `figma-data/published/icons/manifest.json` `records` | **644**（`generatedAt` 2026-06-09） | 计数脚本（⚠️ 顶层是 wrapper，条目在 `records`，⛔ 别数顶层键 = 4） |
| `raw/components.index.json` 里 Icons 页条目 | **644**（总 685 · `extractedAt` 2026-07-29） | 同上 |
| REST 有 / manifest 无 | **16** | 差集 |
| **manifest 有 / REST 无** | **0** | 差集 —— ⇒ **全量 644 条证明没有一个图标是真被取消发布的**（entry 当时只抽验了 14 个） |

那 16 个的 `created_at` 横跨 **2026-06-30 → 2026-09-02**，含 `icon/Setting/Reboot`（`6430:126`）—— 正是阻塞 power-icons 收口的那一个。

## File Structure

| 文件 | 责任 | 动作 |
|---|---|---|
| `figma-sync/api.mjs` | Figma REST 凭据面与端点封装 | **改**：加一个导出 `getPublishedComponents()` |
| `figma-sync/fetch-published-components.mjs` | 把 REST 已发布组件集落盘成带日期快照。**只写这一个文件，不做任何删除** | **新建** |
| `figma-data/published/components-rest.json` | 判据的新输入面（Figma 侧真源快照） | **新建**（tracked，同 `published/icons/manifest.json`） |
| `figma-sync/cleanup-unpublished.mjs` | 剔除未发布内容。判据输入面 + fail-closed + 报告措辞 | **改** |
| `tests/cleanup-unpublished-cli.test.ts` | 整脚本 fixture-root harness（本闸今天**零回归面**） | **新建** |
| `figma-sync/sync-figma-library.mjs` | 编排 | **改**：Step 0.6 接线 |

---

### Task 1: REST 取数 + 带日期快照落盘

**Files:**
- Modify: `figma-sync/api.mjs`（在 `getFileStyles` 之后加一个导出）
- Create: `figma-sync/fetch-published-components.mjs`
- Modify: `package.json`（加 script `sync:published-components`）

**Interfaces:**
- Produces: `getPublishedComponents(fileKey?) → Promise<{meta:{components:Array<{node_id,name,containing_frame:{pageName}}>}}>`
- Produces: 快照文件 schema —— `{ fetchedAt: ISO string, figmaFileKey: string, source: 'rest:/v1/files/:key/components', count: number, byPage: Record<string, number>, records: Array<{nodeId: string, figmaName: string, pageName: string}> }`

- [ ] **Step 1: 加 api.mjs 导出**

在 `figma-sync/api.mjs` 的 `getFileStyles` 之后追加：

```js
/**
 * Team-library 已发布组件集（INFRA-F145 判据的输入面）。
 * ⚠️ 与 `getFile()` 返回的 `components` 字段**不是**一回事：那个是「本文件里被实例引用到的
 * 组件定义」，本端点是「已 publish 到团队库的组件」——后者才是「还发布着吗」的真源。
 * 实测 2026-09-03：Professional plan 下 200 可达（variables/local 的 403 是那条 scope 独有的，
 * ⛔ 别外推成「REST 全堵」）。
 */
export async function getPublishedComponents(fileKey = FILE_KEY) {
  return get(`/files/${fileKey}/components`)
}
```

- [ ] **Step 2: 写 fetch-published-components.mjs**

```js
#!/usr/bin/env node
/**
 * fetch-published-components — 把 Figma 侧「已发布组件集」落盘成带日期的快照。
 *
 * 为什么存在（INFRA-F145）：cleanup-unpublished 原先判「已发布」读的是
 * `figma-data/published/icons/manifest.json` —— 那是本管线 Step 13b 自己的**上一轮产物**。
 * ⇒ 新加的图标在 Step 1 就被删掉，于是永远进不了那份 manifest，下一轮依然不认识它 = 闭环。
 * 本脚本把判据输入面换成 Figma REST `/v1/files/:key/components`（team library 已发布集）。
 *
 * ⛔ 为什么是「落盘快照」而不是让 cleanup 自己联网：
 *   cleanup 是纯同步、无凭据也能 dry-run 的脚本，且必须能被整脚本 fixture harness 覆盖
 *   （它今天零回归面）。⇒ 网络那一半单独成脚本，cleanup 只读快照。
 *   快照带 fetchedAt / figmaFileKey，让 cleanup 能对陈旧或错文件的输入 fail-closed。
 *
 * ⛔ 本脚本不删除任何东西，只写 figma-data/published/components-rest.json。
 *
 * 退出码：0 = 写好 · 2 = 取数失败 / 响应形态不对（fail-closed，⛔ 不写半份快照）
 */
import { writeFileSync, mkdirSync } from 'fs'
import { dirname, resolve } from 'path'
import { getPublishedComponents, FILE_KEY } from './api.mjs'

const ROOT = process.cwd()
const OUT_PATH = resolve(ROOT, 'figma-data/published/components-rest.json')

const raw = await getPublishedComponents().catch((e) => {
  console.error(`❌ fetch-published-components: REST 取数失败 — ${e.message}`)
  console.error('   ⛔ 不写快照。cleanup 会因此 fail-closed 拒绝删除，这是设计意图。')
  process.exit(2)
})

const list = raw?.meta?.components
if (!Array.isArray(list) || list.length === 0) {
  console.error(`❌ fetch-published-components: 响应形态不对或为空（meta.components = ${JSON.stringify(list)?.slice(0, 80)}）`)
  console.error('   ⛔ 空集合绝不落盘 —— 那会让 cleanup 把整个库判成「全部未发布」。')
  process.exit(2)
}

const records = list.map((c) => ({
  nodeId: c.node_id,
  figmaName: c.name,
  pageName: c.containing_frame?.pageName ?? null,
}))
const missingPage = records.filter((r) => !r.pageName).length
const byPage = {}
for (const r of records) byPage[r.pageName ?? '(无 pageName)'] = (byPage[r.pageName ?? '(无 pageName)'] ?? 0) + 1

const snapshot = {
  fetchedAt: new Date().toISOString(),
  figmaFileKey: FILE_KEY,
  source: 'rest:/v1/files/:key/components',
  count: records.length,
  byPage,
  records,
}

mkdirSync(dirname(OUT_PATH), { recursive: true })
writeFileSync(OUT_PATH, JSON.stringify(snapshot, null, 2) + '\n')
console.log(`✓ 已发布组件快照: ${records.length} 条 → ${OUT_PATH}`)
for (const [p, n] of Object.entries(byPage).sort((a, b) => b[1] - a[1])) console.log(`    ${String(n).padStart(5)}  ${p}`)
if (missingPage) console.log(`  ⚠️ 其中 ${missingPage} 条无 pageName（按页判的规则对它们不生效，如实登记）`)
```

- [ ] **Step 3: package.json 加 script**

在 `scripts` 里加：`"sync:published-components": "node figma-sync/fetch-published-components.mjs"`

- [ ] **Step 4: 真跑一次，核快照内容**

Run: `pnpm sync:published-components`
Expected: `✓ 已发布组件快照: 4728 条`，分页里 `660  — — Icons`。

- [ ] **Step 5: 阴性对照 —— 证明它对坏输入会 fail-closed**

Run（临时把 token 改坏，⛔ 用 env 覆盖，别改 `.env` 文件）：
```bash
env FIGMA_PERSONAL_ACCESS_TOKEN=figd_bogus FIGMA_TOKEN=figd_bogus node figma-sync/fetch-published-components.mjs > /tmp/f145-neg.out 2>&1; echo "EXIT=$?"
```
Expected: `EXIT=2` + stdout 含「REST 取数失败」+ **快照文件的 mtime 未变**（用 `stat -f %m` 前后对比）。

- [ ] **Step 6: Commit**

```bash
git -c core.hooksPath=.husky/_ commit -F <msg> -- figma-sync/api.mjs figma-sync/fetch-published-components.mjs figma-data/published/components-rest.json package.json
git reset -- figma-sync/api.mjs figma-sync/fetch-published-components.mjs figma-data/published/components-rest.json package.json
```
（新文件先 `git add`，`commit -- <path>` 不带 untracked。）

---

### Task 2: 先给活闸补回归面 —— 并证明它**今天**会犯这个错

**为什么这一步在改判据之前**：本闸 `grep -rln 'cleanup-unpublished' tests/` 只命中 `tests/IconRegistry.test.ts`（那是引它当叙述），**判据本身零回归面**。按 memory `gate-regression-face-whole-script-fixture`：⛔ 别先抽纯函数单测（那覆盖不到接线），走**整脚本 fixture-root harness**，零改动活闸把逻辑 + 接线一起覆盖。并按 memory `regression-pass-needs-fault-proof`：**先证故障态成立**，再谈修。

**Files:**
- Create: `tests/cleanup-unpublished-cli.test.ts`

**Interfaces:**
- Consumes: 无（本 Task 不改活闸）
- Produces: 一个 `runCleanupIn(fixtureRoot: string, args: string[]) → {code: number, stdout: string, report: string}` helper，Task 3 继续用

- [ ] **Step 1: 写 fixture harness + 第一条「证明故障存在」的测试**

```ts
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'

const SCRIPT = resolve(__dirname, '../figma-sync/cleanup-unpublished.mjs')

/** 造一棵最小 figma-data 树。icons = Icons 页上的 nodeId 列表；published = 判据输入面认识的那些。 */
function makeFixture({ icons, publishedIcons, extractedAt = '2026-07-29T09:10:16.245Z' }: {
  icons: string[]; publishedIcons: string[]; extractedAt?: string
}) {
  const root = mkdtempSync(join(tmpdir(), 'f145-'))
  mkdirSync(join(root, 'figma-data/raw/components'), { recursive: true })
  mkdirSync(join(root, 'figma-data/published/icons'), { recursive: true })
  const components = icons.map((id) => ({
    figmaName: `icon/test/${id}`, nodeId: id, type: 'COMPONENT',
    pageName: '— — Icons', filename: `icon-${id.replace(':', '_')}.json`, variants: 0,
  }))
  for (const c of components) writeFileSync(join(root, 'figma-data/raw/components', c.filename), '{}')
  writeFileSync(join(root, 'figma-data/raw/components.index.json'),
    JSON.stringify({ extractedAt, figmaFileKey: 'TESTKEY', count: components.length, components }, null, 2))
  writeFileSync(join(root, 'figma-data/published/icons/manifest.json'),
    JSON.stringify({ generatedAt: '2026-06-09T00:00:00.000Z', count: publishedIcons.length,
      records: publishedIcons.map((id) => ({ nodeId: id })) }, null, 2))
  return root
}

function runCleanupIn(root: string, args: string[] = []) {
  let code = 0
  let stdout = ''
  try {
    stdout = execFileSync('node', [SCRIPT, ...args], { cwd: root, encoding: 'utf8' })
  } catch (e: any) {
    code = e.status ?? 1
    stdout = (e.stdout ?? '') + (e.stderr ?? '')
  }
  const reportPath = join(root, 'docs/internal/cleanup-unpublished-report.md')
  return { code, stdout, report: existsSync(reportPath) ? readFileSync(reportPath, 'utf8') : '' }
}

describe('cleanup-unpublished 判据输入面（INFRA-F145）', () => {
  it('【故障存在性】旧判据把「已发布但不在本管线上一轮产物里」的图标判成待删', () => {
    // A/B 已在上一轮产物里；C 是 Figma 库新加的、已发布 —— 它不该被删
    const root = makeFixture({ icons: ['1:1', '1:2', '9:9'], publishedIcons: ['1:1', '1:2'] })
    const { code, report } = runCleanupIn(root)
    expect(code).toBe(0)                       // dry-run 不报错，这正是它危险的地方
    expect(report).toContain('| 删除 | 1 |')   // ← 故障态：9:9 被判待删
    expect(report).toContain('9:9')
  })
})
```

- [ ] **Step 2: 跑，确认故障态真的成立**

Run: `npx vitest run tests/cleanup-unpublished-cli.test.ts`
Expected: **PASS** —— 它断言的是「今天的错误行为存在」。⛔ 如果它 FAIL，说明 fixture 没造对，先修 fixture 再往下；⛔ 别直接去改活闸。

- [ ] **Step 3: 加阴性对照 —— 证明这个 harness 不是恒绿**

在同一个 `describe` 里追加：

```ts
  it('【阴性对照】上一轮产物认识全部图标时，一个都不删（证明 harness 不是恒报「删」）', () => {
    const root = makeFixture({ icons: ['1:1', '1:2'], publishedIcons: ['1:1', '1:2'] })
    const { code, report } = runCleanupIn(root)
    expect(code).toBe(0)
    expect(report).toContain('| 删除 | 0 |')
  })

  it('【阴性对照】非生产页的条目照旧被删（证明 harness 能看见「删」这一侧）', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'] })
    const idx = join(root, 'figma-data/raw/components.index.json')
    const j = JSON.parse(readFileSync(idx, 'utf8'))
    j.components.push({ figmaName: 'draft/x', nodeId: '7:7', type: 'COMPONENT',
      pageName: 'Research', filename: 'draft-x.json', variants: 0 })
    j.count = j.components.length
    writeFileSync(idx, JSON.stringify(j, null, 2))
    const { report } = runCleanupIn(root)
    expect(report).toContain('| 删除 | 1 |')
    expect(report).toContain('非生产页')
  })
```

- [ ] **Step 4: 跑全部三条**

Run: `npx vitest run tests/cleanup-unpublished-cli.test.ts`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add tests/cleanup-unpublished-cli.test.ts
git -c core.hooksPath=.husky/_ commit -F <msg> -- tests/cleanup-unpublished-cli.test.ts
git reset -- tests/cleanup-unpublished-cli.test.ts
```

---

### Task 3: 换判据输入面 + 三重 fail-closed + 报告措辞

**Files:**
- Modify: `figma-sync/cleanup-unpublished.mjs:36`（常量）、`:61-76`（`classify`）、`:83,87`（读取）、`:72`（措辞）、`:171-181`（报告小节标题）
- Modify: `tests/cleanup-unpublished-cli.test.ts`（把 Task 2 那条「故障存在性」翻转成「已修」+ 补 fail-closed 三条）

**Interfaces:**
- Consumes: Task 1 的快照 schema（`fetchedAt` / `figmaFileKey` / `records[].{nodeId,pageName}`）
- Consumes: Task 2 的 `makeFixture` / `runCleanupIn`（`makeFixture` 需扩一个 `snapshot` 参数）

- [ ] **Step 1: 先把测试改成期望「修好后」的行为（会红）**

把 Task 2 Step 1 那条改名并翻转断言，并给 `makeFixture` 加快照参数：

```ts
/** snapshot: undefined = 与 publishedIcons 同集合；null = 不写快照文件（测 fail-closed）；否则显式列表 */
function makeFixture({ icons, publishedIcons, snapshot = undefined, fetchedAt = '2026-09-03T00:00:00.000Z',
  extractedAt = '2026-07-29T09:10:16.245Z', fileKey = 'TESTKEY' }: {
  icons: string[]; publishedIcons: string[]; snapshot?: string[] | null
  fetchedAt?: string; extractedAt?: string; fileKey?: string
}) {
  const root = mkdtempSync(join(tmpdir(), 'f145-'))
  mkdirSync(join(root, 'figma-data/raw/components'), { recursive: true })
  mkdirSync(join(root, 'figma-data/published/icons'), { recursive: true })
  const components = icons.map((id) => ({
    figmaName: `icon/test/${id}`, nodeId: id, type: 'COMPONENT',
    pageName: '— — Icons', filename: `icon-${id.replace(':', '_')}.json`, variants: 0,
  }))
  for (const c of components) writeFileSync(join(root, 'figma-data/raw/components', c.filename), '{}')
  writeFileSync(join(root, 'figma-data/raw/components.index.json'),
    JSON.stringify({ extractedAt, figmaFileKey: fileKey, count: components.length, components }, null, 2))
  // 旧输入面照旧写出来 —— 它必须**留在原地却不再被读**，这样「有人偷偷改回去」会被测试抓到
  writeFileSync(join(root, 'figma-data/published/icons/manifest.json'),
    JSON.stringify({ generatedAt: '2026-06-09T00:00:00.000Z', count: publishedIcons.length,
      records: publishedIcons.map((id) => ({ nodeId: id })) }, null, 2))
  if (snapshot !== null) {
    const ids = snapshot ?? publishedIcons
    writeFileSync(join(root, 'figma-data/published/components-rest.json'),
      JSON.stringify({ fetchedAt, figmaFileKey: fileKey, source: 'rest:/v1/files/:key/components',
        count: ids.length, byPage: { '— — Icons': ids.length },
        records: ids.map((id) => ({ nodeId: id, figmaName: `icon/test/${id}`, pageName: '— — Icons' })) }, null, 2))
  }
  return root
}
```

```ts
  it('【已修】Figma 侧真源说它还发布着 ⇒ 不删（即便本管线上一轮产物不认识它）', () => {
    const root = makeFixture({ icons: ['1:1', '1:2', '9:9'], publishedIcons: ['1:1', '1:2'],
      snapshot: ['1:1', '1:2', '9:9'] })
    const { code, report } = runCleanupIn(root)
    expect(code).toBe(0)
    expect(report).toContain('| 删除 | 0 |')
  })

  it('【真废弃仍被删】Figma 侧真源里没有 ⇒ 照删，且措辞不再断言「已被取消发布或废弃」', () => {
    const root = makeFixture({ icons: ['1:1', '8:8'], publishedIcons: ['1:1', '8:8'], snapshot: ['1:1'] })
    const { report } = runCleanupIn(root)
    expect(report).toContain('| 删除 | 1 |')
    expect(report).toContain('8:8')
    expect(report).not.toContain('已被取消发布或废弃')
    expect(report).toContain('不在 Figma 已发布组件集')
  })

  it('【fail-closed ①】快照缺失 + --apply ⇒ 非 0 退出且一个文件都不删', () => {
    const root = makeFixture({ icons: ['1:1', '9:9'], publishedIcons: ['1:1'], snapshot: null })
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('判据输入面不可用')
    expect(existsSync(join(root, 'figma-data/raw/components/icon-9_9.json'))).toBe(true)
  })

  it('【fail-closed ②】快照的 fileKey 与 raw 不一致 + --apply ⇒ 非 0 退出', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'], snapshot: ['1:1'], fileKey: 'TESTKEY' })
    const p = join(root, 'figma-data/published/components-rest.json')
    const j = JSON.parse(readFileSync(p, 'utf8')); j.figmaFileKey = 'OTHERKEY'
    writeFileSync(p, JSON.stringify(j, null, 2))
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('fileKey')
  })

  it('【fail-closed ③】快照比 raw 旧 + --apply ⇒ 非 0 退出（旧快照会漏掉新组件）', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'], snapshot: ['1:1'],
      fetchedAt: '2026-07-01T00:00:00.000Z', extractedAt: '2026-07-29T09:10:16.245Z' })
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('快照比 raw 旧')
  })

  it('【dry-run 不 fail-closed，但必须显式警告】快照缺失时只报不删', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'], snapshot: null })
    const { code, stdout } = runCleanupIn(root)
    expect(code).toBe(0)
    expect(stdout).toContain('判据输入面不可用')
  })
```

- [ ] **Step 2: 跑，确认新测试红**

Run: `npx vitest run tests/cleanup-unpublished-cli.test.ts`
Expected: 【已修】/【真废弃】/三条 fail-closed / dry-run 警告 **全部 FAIL**（活闸还没改）；两条阴性对照仍 PASS。

- [ ] **Step 3: 改活闸**

`figma-sync/cleanup-unpublished.mjs`：

① 顶部文件注释里，把「但其中"— — Icons"页只保留在 published manifest 里的图标」改为：

```
 *   - 但其中"— — Icons"页只保留 **Figma 侧真源说还发布着** 的图标
 *     （判据输入面 = figma-data/published/components-rest.json，由 sync:published-components
 *      从 REST /v1/files/:key/components 落盘。⛔ 别改回 published/icons/manifest.json ——
 *      那是本管线 Step 13b 自己的上一轮产物，会形成闭环让新图标永远进不来，INFRA-F145）
```

② 常量（`:36` 那行）替换为：

```js
// ⛔ INFRA-F145：判「已发布」的输入面必须是 Figma 侧真源，不是本管线自己的产物。
// 旧值 = figma-data/published/icons/manifest.json（Step 13b 的输出）⇒ 新图标在 Step 1 就被删，
// 于是永远进不了那份 manifest，下一轮依然不认识它 = 闭环。实证：2026-06-30→09-02 新增的
// 16 个已发布图标一个都没进来，含 icon/Setting/Reboot (6430:126)。
const PUBLISHED_SNAPSHOT_PATH = resolve(ROOT, 'figma-data/published/components-rest.json')
```

③ `classify()` 的图标分支措辞（`:70-74`）改为：

```js
  if (component.pageName === ICONS_PAGE) {
    if (!publishedIconNodeIds.has(component.nodeId)) {
      return { keep: false, reason: '不在 Figma 已发布组件集（REST /files/:key/components 快照）' }
    }
  }
```

④ `main()` 里读取与三重校验（替换 `:83` 与 `:87-88`）：

```js
  const rawIndex = readJSON(RAW_INDEX_PATH)

  // ── 判据输入面的三重 fail-closed（INFRA-F145）─────────────────────────────
  // ⛔ 任一不满足时**绝不回落**到 published/icons/manifest.json —— 那正是本条要修掉的病灶。
  //    APPLY 模式：直接非 0 退出，一个文件都不删。
  //    DRY-RUN：不删文件所以无害，但必须把话说清楚，⛔ 别静默出一份判据不成立的报告。
  const problems = []
  let snapshot = null
  if (!existsSync(PUBLISHED_SNAPSHOT_PATH)) {
    problems.push(`快照不存在：${PUBLISHED_SNAPSHOT_PATH}（跑 \`pnpm sync:published-components\`）`)
  } else {
    snapshot = readJSON(PUBLISHED_SNAPSHOT_PATH)
    if (!Array.isArray(snapshot.records) || snapshot.records.length === 0) {
      problems.push('快照 records 为空 —— 空集合会把整个库判成「全部未发布」')
    }
    if (snapshot.figmaFileKey && rawIndex.figmaFileKey && snapshot.figmaFileKey !== rawIndex.figmaFileKey) {
      problems.push(`fileKey 不一致：快照 ${snapshot.figmaFileKey} ≠ raw ${rawIndex.figmaFileKey}`)
    }
    if (snapshot.fetchedAt && rawIndex.extractedAt && Date.parse(snapshot.fetchedAt) < Date.parse(rawIndex.extractedAt)) {
      problems.push(`快照比 raw 旧（fetchedAt ${snapshot.fetchedAt} < extractedAt ${rawIndex.extractedAt}）—— 旧快照看不见 raw 里的新组件，照它删会误删`)
    }
  }
  if (problems.length) {
    console.log('⛔ 判据输入面不可用：')
    for (const p of problems) console.log(`   · ${p}`)
    if (APPLY) {
      console.log('   ⇒ APPLY 模式下拒绝删除任何文件（fail-closed）。⛔ 不回落旧 manifest —— 那是 INFRA-F145 的病灶本身。')
      process.exit(2)
    }
    console.log('   ⇒ DRY-RUN：继续出报告，但下面「删除」那一列的图标部分**判据不成立**，⛔ 别据它动手。')
  }

  const normIndex = existsSync(NORM_INDEX_PATH) ? readJSON(NORM_INDEX_PATH) : null
  const normManifest = existsSync(NORM_MANIFEST_PATH) ? readJSON(NORM_MANIFEST_PATH) : null

  const publishedIconNodeIds = new Set(
    (snapshot?.records ?? [])
      .filter((r) => r.pageName === ICONS_PAGE)
      .map((r) => r.nodeId)
      .filter(Boolean),
  )
  console.log(`Published icons（Figma 侧真源，快照 @ ${snapshot?.fetchedAt ?? 'n/a'}）: ${publishedIconNodeIds.size}`)
```

⑤ 报告里那个小节标题（`:173`）改为：

```js
    L.push('### 待删除的图标（在 raw 但 Figma 侧真源说它已不发布）')
```

并在报告「概览」之后插入一行判据出处（放在 `L.push('')` 之后、`### 删除分布（按原因）` 之前）：

```js
  L.push(`> 判据输入面：\`figma-data/published/components-rest.json\`（${snapshot?.source ?? 'n/a'}，fetchedAt ${snapshot?.fetchedAt ?? 'n/a'}，Icons 页 ${publishedIconNodeIds.size} 条）`)
  if (problems.length) L.push(`> ⛔ **本轮判据输入面不可用**：${problems.join(' · ')} —— 图标那部分的「删除」结论不成立。`)
  L.push('')
```

- [ ] **Step 4: 跑测试，全绿**

Run: `npx vitest run tests/cleanup-unpublished-cli.test.ts`
Expected: 8 passed

- [ ] **Step 5: 故障注入 —— 证明每条判据各自守着自己那一条**

逐条停用再跑，确认**只有对应那条**转红（⛔ 改的是 scratchpad 副本，不动真文件；改完用 `git diff --exit-code figma-sync/cleanup-unpublished.mjs` 确认真文件零残留）：

| 注入 | 期望只红这条 |
|---|---|
| 删掉 fileKey 那条 push | 【fail-closed ②】 |
| 删掉 fetchedAt 那条 push | 【fail-closed ③】 |
| 把 `process.exit(2)` 改成不退出 | 【fail-closed ①②③】三条 |
| 把 `filter((r) => r.pageName === ICONS_PAGE)` 去掉 | 无（阴性对照：fixture 里全是 Icons 页 ⇒ 这条注入**不该**红。如实登记该 harness 覆盖不到跨页混淆） |

- [ ] **Step 6: Commit**

---

### Task 4: 编排接线（Step 0.6）

**Files:**
- Modify: `figma-sync/sync-figma-library.mjs`（文件头步骤表 + `runOrAbort` 序列，Step 0 之后 Step 1 之前）

**Interfaces:**
- Consumes: Task 1 的 `figma-sync/fetch-published-components.mjs`

- [ ] **Step 1: 文件头步骤表加一行**

在 `*   ─── 跨层 cleanup ───` 上方插入：

```
 *   0.6. sync:published-components          （拉 Figma 已发布组件集 → components-rest.json；
 *                                             随 --with-extract 跑，是 Step 1 判据的输入面，INFRA-F145）
```

- [ ] **Step 2: 接线**

在 `runOrAbort('Step 0: sync:extract…')` 之后、`const cleanupArgs = …` 之前插入：

```js
  // INFRA-F145：Step 1 的判据输入面。只在 --with-extract 时刷新（同一凭据面、同一轮数据）；
  // 没有 extract 时沿用磁盘上已有快照，由 cleanup 自己的 fetchedAt 校验兜底。
  if (WITH_EXTRACT) {
    runOrAbort('Step 0.6: sync:published-components（Figma 已发布组件集快照）', [
      'figma-sync/fetch-published-components.mjs',
    ])
  }
```

- [ ] **Step 3: 干跑核编排（⛔ 不带 --with-extract，不触网、不删文件）**

Run: `node figma-sync/sync-figma-library.mjs --dry-cleanup --skip-diff-report --skip-worklist --skip-icons --skip-sot-audit > /tmp/f145-orch.out 2>&1; echo "EXIT=$?"`
Expected: 输出里 Step 1 打印 `Published icons（Figma 侧真源，快照 @ …）: 660`，且 `figma-data/raw/components.index.json` 的 mtime 未变。

- [ ] **Step 4: Commit**

---

### Task 5（⏸ 需 owner 另行开门，⛔ 不在本轮范围内）: 真跑一次 sync 让那 16 个进来 + affordance 登记

**为什么单独隔出来、且默认不做**：它不是「修判据」，是一次**数据更新**——

1. 要重跑 `extract`（拉整份 Figma 文件）+ `sync:icons`（660 个 SVG 的网络导出），会重写 `figma-data/raw/**`、`normalized/**`、`published/icons/**` 数千个 tracked 文件；
2. 之后 Step 14 `audit:figma-vs-sot` 会按设计报 `A=16` 要求登记 affordance，而分片里的 `visual_signature` / `synonyms_en` / `synonyms_zh` / `when_to_use` 是**语义字段**，机械生成的部分能自动填，另一部分撞 AGENTS「⛔ 不替真源编设计理由」，需要人审（现存条目的 `notes` 逐字写着 `pending Phase C user review`）；
3. 那 16 个的落点分片全部已存在，⛔ 不需要新建 category：`Picture.json`(4) · `output.json`(6) · `mark.json`(2) · `Setting.json`(1) · `file.json`(1) · `input.json`(1) · `logo.json`(1)。

⇒ **Task 1-4 交付后，「新图标进不来」这个机制缺陷就已经修好了**；Task 5 是把积压的 16 个真正接进来。两者可以分轮，且 Step 14 是闸、不会让它被静默漏掉。

---

### Task 6: 落库

**Files:**
- Modify: `docs/internal/backlog.md`（[[INFRA-F145]] entry）
- Modify: `docs/STATUS.md`（§三 14 那一行 + 顶条）
- Modify: `docs/internal/STATUS-CHANGELOG.md`

- [ ] **Step 1: backlog entry**：把「建议路径（⛔ 未实施，先要 owner 拍方向）」改成「owner 2026-09-03 拍定 (a)，已实施」，**连同当时实测的影响面一起留档**（660/644/16/反向差集 0 + 那 16 个的名字与 created_at 跨度），并把「现取判据命令 ①」的期望值翻转（`ICON_MANIFEST_PATH` 已不存在 ⇒ 判据要换成核 `PUBLISHED_SNAPSHOT_PATH`）。⏸ Task 5 未做 ⇒ **entry 不删档**，改成只剩「把积压的 16 个接进来」这一件。
- [ ] **Step 2: STATUS** §三 14 表格里 F145 那行改成「已修，剩 Task 5 等 owner 开门」。
- [ ] **Step 3: Commit + push + `git ls-remote` 双 remote 验**
