# INFRA-F98 Figma 凭据单一真源 + 防副本闸 — 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:** 让那 10 个 `scripts/audit-mockup-*.mjs` 在配好 `.env` 的机器上直接能跑，且全仓只有一个地方定义「Figma 凭据从哪来」，并上闸防止再长出第 11 份副本。

**Architecture:** 新建 `scripts/lib/figma-env.mjs` 作全仓唯一 `.env` 解析器 + 唯一凭据解析点（规范名 `FIGMA_PERSONAL_ACCESS_TOKEN`，别名 `FIGMA_TOKEN`，占位值视同没有）。10 个 audit 脚本与 `figma-sync/api.mjs` 全部改用它；新增 `audit:figma-env-single-source` 闸挂 L4 pre-commit + L5 prepublishOnly + Gitea pr-checks 三处。

**Tech Stack:** 原生 Node ESM（`node:fs` / `node:path` / `node:url`，无新依赖）· vitest 单测 · husky pre-commit · Gitea Actions

**判据真源：** [`docs/superpowers/specs/2026-08-06-infra-f98-figma-env-single-source-design.md`](../specs/2026-08-06-infra-f98-figma-env-single-source-design.md)（owner 2026-08-06 已 review 通过，commit `15aaafaa`）。**本计划与 spec 冲突时以 spec 为准，改本计划不改 spec 的判据。**

## Global Constraints

- ⛔ 别在单个脚本里加变量名 fallback 就算修完（那是 10 份会分叉的副本）
- ⛔ 别改 `.env` 的 key 名，也别动 CI secret 名（`FIGMA_TOKEN`）
- ⛔ 别把共享模块放 `figma-sync/`——实测 `npm pack` 不含该目录，shipped 的 consumer 脚本会 import 到不存在的文件
- ⛔ 别用 import 副作用式归一化（与闸互斥）
- ⛔ 别碰 conformance report 的 schema 与防伪语义（那是独立项 F62 半边 B 的地盘）
- ⛔ 别让闸声称覆盖了它扫不到的目录（它只扫 `scripts/audit-mockup-*.mjs`）
- ⛔ 新闸落地第一天就带豁免表 = 判据没想清楚；先收窄谓词，`EXEMPTIONS` 必须是空数组
- **全程零 `.vue` / `.css`** —— 触到就停下问 owner（`VISUAL_COMMIT_APPROVED` 由 AI 自设是违规）
- **executor 不准自 commit / push**：每个 Task 跑完只报 diff + 命令原始 stdout，由 plan owner 复审后统一提交
- 提交纪律（plan owner 执行）：`git commit -F <msg 文件> -- <路径逐条内联>`，紧跟 `git reset -- <路径>`；push 落地只信 `git ls-remote`
- 本仓库多 session 并行：动手前 `git fetch`，dirty / untracked 文件先假设别 session 在用，不消化
- 变量名常量只在共享模块里出现一次；其它文件不得再写 `'FIGMA_PERSONAL_ACCESS_TOKEN'` 或 `'FIGMA_TOKEN'` 字面量（**文档与注释除外**）

## 基线（动手前实测，别引用别处的数字）

| 项 | 值 | 取法 |
|---|---|---|
| 起点 commit | `15aaafaa`（spec 落地那条） | `git log --oneline -1` |
| `pnpm vitest run` | **1292 passed / 11 skipped**（113 files passed / 11 skipped，164.75s）—— plan owner 2026-08-06 11:17 于 `15aaafaa` 实测 | `pnpm vitest run \| tail -8` |
| 本轮新增 case | Task 1 的 **12** 条 + Task 6 的 8 条 = **20** ⇒ 收尾应为 **1312 passed** | 计划自带（原写 14+8=22 是 plan owner 算错，2026-08-06 Task 1 实跑 12 passed 后订正） |
| `process.env.FIGMA_` 全仓命中 | **12 处**（10 个 audit 脚本 + api.mjs 内部）→ 目标 **1 个文件**（`scripts/lib/figma-env.mjs`） | `grep -rn 'process\.env\.FIGMA_' scripts/ figma-sync/` |
| `.env` 现有 key | `FIGMA_FILE_KEY` / `FIGMA_TOKEN` 两个 | `awk -F= '/^[A-Z_]+=/{print $1}' .env` |

⚠️ 本仓库多 session 并行，起点 commit 可能已前移 —— **动手前先 `git fetch` 复核**，别照抄这一行。

---

### Task 1: 共享模块 `scripts/lib/figma-env.mjs` + 单测（含改前基线取证）

**Files:**
- Create: `scripts/lib/figma-env.mjs`
- Test: `tests/figma-env.test.ts`

**Interfaces:**
- Consumes: 无（本 plan 第一个 Task）
- Produces: 三个具名导出，后续所有 Task 依赖
  - `loadDotEnv({ cwd?: string, env?: object }) => void`
  - `getFigmaToken({ cwd?: string, env?: object }) => string | undefined`
  - `requireFigmaToken({ cwd?: string, env?: object }) => string`（取不到则 `process.exit(2)`）

- [ ] **Step 1: 先取改前基线（证明故障存在，不是空过）**

Run:
```bash
node scripts/audit-mockup-geometry-consistency.mjs --file YbsPRUVmNdsbN40NNwh1Gn ; echo "exit=$?"
```
Expected：stderr 出现 `Error: FIGMA_PERSONAL_ACCESS_TOKEN env var is not set.` 且 `exit=2`。
**把这段原始输出逐字贴进 Task 报告**——Task 8 的验证 1↔2 要拿它做前后对照。
同时确认 `.env` 里确实只有两个 key（`awk -F= '/^[A-Z_]+=/{print $1}' .env` → `FIGMA_FILE_KEY` / `FIGMA_TOKEN`）。

- [ ] **Step 2: 写失败的单测**

Create `tests/figma-env.test.ts`：

```ts
// tests/figma-env.test.ts
import { describe, it, expect, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
import { loadDotEnv, getFigmaToken, requireFigmaToken } from '../scripts/lib/figma-env.mjs'

// `package.json` 是 type:module —— 显式派生，不依赖 vitest 注入 __dirname
const HERE = dirname(fileURLToPath(import.meta.url))

const dirs: string[] = []
function tmpWithEnv(contents: string | null): string {
  const d = mkdtempSync(join(tmpdir(), 'f98-'))
  dirs.push(d)
  if (contents !== null) writeFileSync(join(d, '.env'), contents)
  return d
}
afterEach(() => { while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }) })

describe('loadDotEnv', () => {
  it('reads keys from .env into the given env object', () => {
    const cwd = tmpWithEnv('FIGMA_TOKEN=figd_from_file\n')
    const env: Record<string, string> = {}
    loadDotEnv({ cwd, env })
    expect(env.FIGMA_TOKEN).toBe('figd_from_file')
  })

  it('never overwrites a key that already exists', () => {
    const cwd = tmpWithEnv('FIGMA_TOKEN=figd_from_file\n')
    const env: Record<string, string> = { FIGMA_TOKEN: 'figd_from_shell' }
    loadDotEnv({ cwd, env })
    expect(env.FIGMA_TOKEN).toBe('figd_from_shell')
  })

  it('is a silent no-op when .env does not exist', () => {
    const cwd = tmpWithEnv(null)
    const env: Record<string, string> = {}
    expect(() => loadDotEnv({ cwd, env })).not.toThrow()
    expect(Object.keys(env)).toHaveLength(0)
  })

  it('skips blank lines and comments, and keeps "=" inside values', () => {
    const cwd = tmpWithEnv('\n# a comment\nFIGMA_TOKEN=a=b=c\n')
    const env: Record<string, string> = {}
    loadDotEnv({ cwd, env })
    expect(env.FIGMA_TOKEN).toBe('a=b=c')
  })
})

describe('getFigmaToken', () => {
  it('takes the canonical name', () => {
    const cwd = tmpWithEnv(null)
    expect(getFigmaToken({ cwd, env: { FIGMA_PERSONAL_ACCESS_TOKEN: 'figd_canon' } })).toBe('figd_canon')
  })

  it('falls back to the alias when only the alias is set', () => {
    const cwd = tmpWithEnv(null)
    expect(getFigmaToken({ cwd, env: { FIGMA_TOKEN: 'figd_alias' } })).toBe('figd_alias')
  })

  it('prefers the canonical name when both are set', () => {
    const cwd = tmpWithEnv(null)
    const env = { FIGMA_PERSONAL_ACCESS_TOKEN: 'figd_canon', FIGMA_TOKEN: 'figd_alias' }
    expect(getFigmaToken({ cwd, env })).toBe('figd_canon')
  })

  it('resolves from .env when the process env has neither', () => {
    const cwd = tmpWithEnv('FIGMA_TOKEN=figd_from_file\n')
    expect(getFigmaToken({ cwd, env: {} })).toBe('figd_from_file')
  })

  it('treats the .env.example placeholder as absent (both names)', () => {
    const cwd = tmpWithEnv(null)
    expect(getFigmaToken({ cwd, env: { FIGMA_PERSONAL_ACCESS_TOKEN: 'your_figma_personal_access_token_here' } })).toBeUndefined()
    expect(getFigmaToken({ cwd, env: { FIGMA_TOKEN: 'your_figma_token_here' } })).toBeUndefined()
  })

  it('returns undefined instead of exiting when nothing is configured', () => {
    const cwd = tmpWithEnv(null)
    expect(getFigmaToken({ cwd, env: {} })).toBeUndefined()
  })
})

describe('requireFigmaToken', () => {
  it('returns the token without exiting when configured', () => {
    const cwd = tmpWithEnv(null)
    expect(requireFigmaToken({ cwd, env: { FIGMA_TOKEN: 'figd_alias' } })).toBe('figd_alias')
  })

  it('exits 2 and names the canonical variable when nothing is configured', () => {
    const cwd = tmpWithEnv(null)
    const mod = resolve(HERE, '../scripts/lib/figma-env.mjs')
    const probe = join(cwd, 'probe.mjs')
    writeFileSync(probe, `import { requireFigmaToken } from ${JSON.stringify(mod)}\nrequireFigmaToken()\n`)
    let code = 0
    let stderr = ''
    try {
      execFileSync(process.execPath, [probe], { cwd, env: { PATH: process.env.PATH ?? '' }, encoding: 'utf8' })
    } catch (e: any) {
      code = e.status
      stderr = String(e.stderr)
    }
    expect(code).toBe(2)
    expect(stderr).toContain('FIGMA_PERSONAL_ACCESS_TOKEN')
    expect(stderr).toContain('.env')
  })
})
```

- [ ] **Step 3: 跑测试确认它失败**

Run: `pnpm vitest run tests/figma-env.test.ts`
Expected: FAIL —— 报找不到 `../scripts/lib/figma-env.mjs`。

- [ ] **Step 4: 写模块**

Create `scripts/lib/figma-env.mjs`：

```js
// figma-env.mjs — 全仓唯一的 `.env` 解析器 + 唯一的 Figma 凭据解析点。
//
// 判据真源 = docs/superpowers/specs/2026-08-06-infra-f98-figma-env-single-source-design.md
// 缘起 = INFRA-F98：10 个 scripts/audit-mockup-*.mjs 都要 FIGMA_PERSONAL_ACCESS_TOKEN 却
// 一个都不读 `.env`，而本机 / sync 管线的 `.env` 里叫 FIGMA_TOKEN ⇒ 配好凭据的人跑那 10 个
// 一律得到「没 token」，读起来像「这台机器没凭据」，实际凭据一直在。曾迫使多轮改用手写替代
// 检查，而手写产生过假阴性（同族 §I2 一处 54px 既有重叠被连续多轮写成 overlap 0）。
//
// ⛔ 别在调用方各写一遍名字 fallback —— 那正是本模块要消灭的形态（落地前已发生 3 次：
//    connector / library-origin 各写 `PERSONAL || FIGMA_TOKEN`，conformance 另写一份 env 映射）。
// ⛔ 别改 `.env` 的 key 名，也别动 CI secret 名（FIGMA_TOKEN）—— 改名只是把缺口挪个位置。
// ⛔ 别放到 figma-sync/ 下：`npm pack` 实测不含该目录，而 14 个 audit-mockup-* 进包，
//    放那儿会让 shipped 的 consumer 脚本 import 到 tarball 里不存在的文件。
//
// 谁在守「调用方不许绕过本模块」= scripts/audit-figma-env-single-source.mjs
// （L4 pre-commit 条件触发 + L5 prepublishOnly + Gitea pr-checks）。
// ⚠️ 那条闸只扫 `scripts/audit-mockup-*.mjs`，扫不到 figma-sync/ 下或别处新写的 REST 脚本。

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'

// 规范名 = `.env.example` 与全部 consumer 面（CONSUMER_AUDIT_SETUP.md / templates/audit-workflow.yml
// / docs/templates/consumer-audit-ci.yml / mockup-conventions.md）用的那个。
// 别名 = figma-sync/ 内部与 CI secret 用的那个。两个都认，两个都不改名。
const CANONICAL = 'FIGMA_PERSONAL_ACCESS_TOKEN'
const ALIAS = 'FIGMA_TOKEN'

// `.env.example` 发的是 `FIGMA_PERSONAL_ACCESS_TOKEN=your_figma_personal_access_token_here`。
// 照抄不改的人若不拦，会拿这串去请求 Figma 拿 403 —— 读起来像「token 无效 / 权限不够」，
// 而不是「你还没配」。此判定原本只有 figma-sync/api.mjs 有，现在那 10 个也享有。
const PLACEHOLDER_RE = /your_figma_/i

/**
 * 把仓库根的 `.env` 读进 env 对象。幂等；**已存在的键不覆盖**（沿用 api.mjs 原语义：
 * shell 里 export 的、CI secret 注入的，都优先于 `.env`）。文件不存在 = 静默无操作。
 */
export function loadDotEnv({ cwd = process.cwd(), env = process.env } = {}) {
  let raw
  try {
    raw = readFileSync(resolve(cwd, '.env'), 'utf8')
  } catch {
    return // `.env` 是可选的：CI 与已 export 的环境都不需要它
  }
  for (const rawLine of raw.split('\n')) {
    const line = rawLine.trim()
    if (!line || line.startsWith('#')) continue
    const index = line.indexOf('=')
    if (index === -1) continue
    const key = line.slice(0, index).trim()
    const value = line.slice(index + 1).trim()
    if (key && !(key in env)) env[key] = value
  }
}

/** 解析 Figma 凭据；取不到返回 undefined，**不退出**（给 fail-open 调用方与被单测 import 的模块用）。 */
export function getFigmaToken({ cwd, env = process.env } = {}) {
  loadDotEnv({ cwd, env })
  for (const name of [CANONICAL, ALIAS]) {
    const value = env[name]?.trim()
    if (value && !PLACEHOLDER_RE.test(value)) return value
  }
  return undefined
}

/** 同 getFigmaToken，但取不到就打统一文案并 exit 2（= 那 10 个脚本原本各自的行为）。 */
export function requireFigmaToken(options = {}) {
  const token = getFigmaToken(options)
  if (token) return token
  console.error(`Error: ${CANONICAL} is not configured.`)
  console.error(`  Put it in .env at the repo root — no need to export it:`)
  console.error(`      ${CANONICAL}=figd_xxx`)
  console.error(`  (\`${ALIAS}\` is also accepted — that is the name figma-sync/ and the CI secret use.)`)
  console.error('  Create a token at https://www.figma.com/developers/api#access-tokens')
  process.exit(2)
}
```

- [ ] **Step 5: 跑测试确认全过**

Run: `pnpm vitest run tests/figma-env.test.ts`
Expected: PASS，12 个 case 全绿（4 loadDotEnv + 6 getFigmaToken + 2 requireFigmaToken）。

- [ ] **Step 6: 报告（不 commit）**

报 `git diff --stat` + 上面两次 vitest 的原始 stdout + Step 1 的基线输出逐字。

---

### Task 2: 迁移 6 个 CLI-only 脚本（Pattern A）

**Files:**
- Modify: `scripts/audit-mockup-colors.mjs:53-59`
- Modify: `scripts/audit-mockup-typography-icon.mjs:58-64`
- Modify: `scripts/audit-mockup-overlap.mjs:119-123`
- Modify: `scripts/audit-mockup-bilingual-spacing.mjs:185-191`
- Modify: `scripts/audit-mockup-binding-fidelity.mjs:221-225`
- Modify: `scripts/audit-mockup-geometry-consistency.mjs:332-336`

**Interfaces:**
- Consumes: `requireFigmaToken` from `scripts/lib/figma-env.mjs`（Task 1）
- Produces: 无新接口；这 6 个文件里 `process.env.FIGMA_` 命中数归 0

> **为什么这 6 个可以直接用 `requireFigmaToken()`**：它们全是 CLI-only（`tests/` 里没有任何文件 import 它们），今天的行为就是「没 token 就 exit 2」，换成 `requireFigmaToken()` 是行为保持 + 删掉 6 份重复文案。

- [ ] **Step 1: 加 import**

6 个文件各在既有 import 段末尾加一行（`colors` / `typography-icon` 若无 import 段，则加在文件顶部 shebang/头注释之后）：

```js
import { requireFigmaToken } from './lib/figma-env.mjs'
```

- [ ] **Step 2: 替换 colors + typography-icon 的 token 段**

这两个文件里各有一段**逐字相同**的代码，替换它：

```js
const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN
if (!TOKEN) {
  console.error('Error: FIGMA_PERSONAL_ACCESS_TOKEN env var is not set.')
  console.error('  Create a personal access token at https://www.figma.com/developers/api#access-tokens')
  console.error('  Then: export FIGMA_PERSONAL_ACCESS_TOKEN=figd_xxx')
  process.exit(2)
}
```

改成：

```js
const TOKEN = requireFigmaToken()
```

- [ ] **Step 3: 替换 overlap + binding-fidelity + geometry-consistency 的 token 段**

这三个各有（缩进 2 空格，在 main 函数内）：

```js
  const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN
  if (!TOKEN) {
    console.error('Error: FIGMA_PERSONAL_ACCESS_TOKEN env var is not set.')
    process.exit(2)
  }
```

改成：

```js
  const TOKEN = requireFigmaToken()
```

- [ ] **Step 4: 替换 bilingual-spacing 的 token 段**

```js
  const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN
  if (!TOKEN) {
    console.error('Error: FIGMA_PERSONAL_ACCESS_TOKEN env var is not set.')
    console.error('  Create a personal access token at https://www.figma.com/developers/api#access-tokens')
    console.error('  Then: export FIGMA_PERSONAL_ACCESS_TOKEN=figd_xxx')
    process.exit(2)
  }
```

改成：

```js
  const TOKEN = requireFigmaToken()
```

- [ ] **Step 5: 核实这 6 个已归零，且真能起来了**

Run:
```bash
grep -n 'process\.env\.FIGMA_' scripts/audit-mockup-colors.mjs scripts/audit-mockup-typography-icon.mjs scripts/audit-mockup-overlap.mjs scripts/audit-mockup-bilingual-spacing.mjs scripts/audit-mockup-binding-fidelity.mjs scripts/audit-mockup-geometry-consistency.mjs ; echo "grep exit=$?"
node scripts/audit-mockup-geometry-consistency.mjs --file YbsPRUVmNdsbN40NNwh1Gn ; echo "exit=$?"
```
Expected：`grep exit=1`（零命中）；geometry-consistency **不再**报「没 token」而是真发出请求（Task 1 Step 1 那条错误信息必须消失）。

- [ ] **Step 6: 回归**

Run: `pnpm vitest run tests/mockup-bilingual-spacing.test.ts tests/mockup-binding-fidelity.test.ts tests/mockup-overlap.test.ts tests/audit-mockup-geometry-consistency.test.ts`
Expected: **0 failed**。把 `N passed` 的实际数字贴进报告 —— 与全局基线的比对由 plan owner 跨 Task 做（见 §基线）。

- [ ] **Step 7: 报告（不 commit）** —— 贴 `git diff` 全文 + 上面命令原始 stdout。

---

### Task 3: 迁移 3 个被单测 import 的脚本（Pattern B / C）

**Files:**
- Modify: `scripts/audit-mockup-integrity.mjs:72-78`
- Modify: `scripts/audit-mockup-connector.mjs:19`
- Modify: `scripts/audit-mockup-library-origin.mjs:39`

**Interfaces:**
- Consumes: `requireFigmaToken` / `getFigmaToken` from `scripts/lib/figma-env.mjs`（Task 1）
- Produces: 无新接口；这 3 个文件里 `process.env.FIGMA_` 命中数归 0

> **为什么这 3 个不能照抄 Task 2**：它们在**模块顶层**求值 TOKEN，而 `tests/AuditMockupIntegrityProbeI2.test.ts` 与 `tests/mockup-connector.test.ts` 会 import 它们 —— 顶层调 `requireFigmaToken()` 会让**单测进程直接 exit 2**。integrity 已有 `IS_CLI` 闸门，connector / library-origin 的真正 guard 在 main 里（`:157` / `:78`），顶层那行只是取值。

- [ ] **Step 1: integrity —— 用 IS_CLI 三元**

加 import：
```js
import { getFigmaToken, requireFigmaToken } from './lib/figma-env.mjs'
```

把：
```js
const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN
if (IS_CLI && !TOKEN) {
  die(
    'FIGMA_PERSONAL_ACCESS_TOKEN env var not set.\n' +
      'Create a personal access token at https://www.figma.com/developers/api#access-tokens',
  )
}
```

改成：
```js
const TOKEN = IS_CLI ? requireFigmaToken() : getFigmaToken()
```

- [ ] **Step 2: connector —— 顶层取值改非退出版，main 里的 guard 原样保留**

加 import：
```js
import { getFigmaToken } from './lib/figma-env.mjs'
```

把 `:19`：
```js
const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN || process.env.FIGMA_TOKEN
```
改成：
```js
const TOKEN = getFigmaToken()
```

⛔ **不要动 `:157` 那行 `if (!TOKEN) { console.error('Missing FIGMA_PERSONAL_ACCESS_TOKEN (or FIGMA_TOKEN)'); process.exit(2) }`** —— 它是 main 内的 guard，行为保持。（它是注释/文案里的名字，不是 `process.env.` 读取，闸不会命中。）

- [ ] **Step 3: library-origin —— 同 connector**

加 import：
```js
import { getFigmaToken } from './lib/figma-env.mjs'
```

把 `:39`：
```js
const TOKEN = process.env.FIGMA_PERSONAL_ACCESS_TOKEN || process.env.FIGMA_TOKEN
```
改成：
```js
const TOKEN = getFigmaToken()
```

⛔ 同样不动 `:78` 的 main guard。

- [ ] **Step 4: 核实归零 + 单测没被顶层退出打死**

Run:
```bash
grep -n 'process\.env\.FIGMA_' scripts/audit-mockup-integrity.mjs scripts/audit-mockup-connector.mjs scripts/audit-mockup-library-origin.mjs ; echo "grep exit=$?"
pnpm vitest run tests/AuditMockupIntegrityProbeI2.test.ts tests/mockup-connector.test.ts
```
Expected：`grep exit=1`；两份单测 PASS，条数与改前一致。

- [ ] **Step 5: 核实 CLI 路径仍能起来**

Run:
```bash
node scripts/audit-mockup-library-origin.mjs --file YbsPRUVmNdsbN40NNwh1Gn ; echo "exit=$?"
```
Expected：不再是 `Missing env FIGMA_PERSONAL_ACCESS_TOKEN`；真发出请求。

- [ ] **Step 6: 报告（不 commit）**

---

### Task 4: 迁移 conformance 总闸（Pattern D）

**Files:**
- Modify: `scripts/audit-mockup-conformance.mjs:72`（`fetchFigmaLastModified` 里的读）
- Modify: `scripts/audit-mockup-conformance.mjs:91-96`（env 映射整段删）

**Interfaces:**
- Consumes: `getFigmaToken` from `scripts/lib/figma-env.mjs`（Task 1）
- Produces: 无新接口。`fetchFigmaLastModified(fileKey, { timeoutMs })` 签名与 fail-open 语义**不变**（`tests/mockup-conformance-report.test.ts` 与 `scripts/audit-mockup-handoff-evidence.mjs:15` 都依赖它）

- [ ] **Step 1: 加 import**

在既有 import 段末尾：
```js
import { getFigmaToken } from './lib/figma-env.mjs'
```

- [ ] **Step 2: 换 `fetchFigmaLastModified` 里的读**

把：
```js
// 复用子审计同一 token（FIGMA_PERSONAL_ACCESS_TOKEN，上面已把 FIGMA_TOKEN 映射过去）。
export async function fetchFigmaLastModified(fileKey, { timeoutMs = 8000 } = {}) {
  const token = process.env.FIGMA_PERSONAL_ACCESS_TOKEN
```

改成：
```js
// 凭据经 scripts/lib/figma-env.mjs 解析（两个变量名 + `.env`，见 INFRA-F98）。
// 这里刻意用非退出版：本函数是 fail-open 的，无 token 返回 null 让调用方降级，不能 exit。
export async function fetchFigmaLastModified(fileKey, { timeoutMs = 8000 } = {}) {
  const token = getFigmaToken()
```

- [ ] **Step 3: 删掉 env 映射整段**

把这 6 行整段删除（含 3 行注释）：
```js
// env 兼容：子审计读 FIGMA_PERSONAL_ACCESS_TOKEN，但本地 .env / CI secret 常叫 FIGMA_TOKEN。
// 若 PERSONAL 未设而 FIGMA_TOKEN 有值，则映射过去，spawnSync 的子进程才能继承到 token。
// （不打印 token 值。）
if (!process.env.FIGMA_PERSONAL_ACCESS_TOKEN && process.env.FIGMA_TOKEN) {
  process.env.FIGMA_PERSONAL_ACCESS_TOKEN = process.env.FIGMA_TOKEN
}
```

> **删它不留缺口**（别因为「看起来在兜底」就保留）：唯一靠它的场景是「shell 里只 export 了 `FIGMA_TOKEN`、没有 `.env`」。`spawnSync` 子进程继承 `process.env` ⇒ 子进程里 `FIGMA_TOKEN` 仍在，而每个子进程各自调 `requireFigmaToken()`，别名顺序会解析到它。Step 5 就是在总闸路径上验这一条。

- [ ] **Step 4: 核实归零 + 单测**

Run:
```bash
grep -n 'process\.env\.FIGMA_' scripts/audit-mockup-conformance.mjs ; echo "grep exit=$?"
pnpm vitest run tests/mockup-conformance-report.test.ts tests/mockup-handoff-evidence.test.ts
```
Expected：`grep exit=1`；两份单测 PASS。

- [ ] **Step 5: 总闸路径上验「只有 FIGMA_TOKEN」那一向（这是删映射行的承重验证）**

Run（临时把 `.env` 移开，只从 shell 给别名，确保走的是子进程继承那条路）：
```bash
mv .env .env.f98bak
FIGMA_TOKEN="$(awk -F= '/^FIGMA_TOKEN=/{print $2}' .env.f98bak)" \
  node scripts/audit-mockup-conformance.mjs --file YbsPRUVmNdsbN40NNwh1Gn --non-blocking 2>&1 | tail -20
mv .env.f98bak .env
```
Expected：summary 里 **不再有** `⚠️ ERROR (could not run)` 那一串因缺 token 导致的条目（可以有 FINDINGS，那是内容问题不是凭据问题）。
⚠️ **跑完必须确认 `.env` 已还原**：`ls -la .env && awk -F= '/^[A-Z_]+=/{print $1}' .env`。

- [ ] **Step 6: 报告（不 commit）**

---

### Task 5: `figma-sync/api.mjs` 换用共享 loader + 3 消费者回归

**Files:**
- Modify: `figma-sync/api.mjs:1-31`

**Interfaces:**
- Consumes: `loadDotEnv` from `scripts/lib/figma-env.mjs`（Task 1）
- Produces: 无变化 —— `FILE_KEY` 仍是顶层 `export const`，`getFile` / `getFileMeta` / `getNodes` / `getVariables` / `getFileStyles` / `getImages` 签名全不变

> **有意只做半步**：`api.mjs:33` 是 `export const FILE_KEY = requireEnv(...)` 顶层**导出绑定**，3 个消费者（`export-icons` / `sync-mockup-data` / `extract`）拿它当默认参数用；改成惰性要连带改导出形态与全部默认参数。而那 10 个脚本**不 import `api.mjs`**，该 import 期副作用已经不挡任何事。⇒ 本 Task **只**把重复的 `.env` 解析器换掉，`requireEnv` 与顶层求值语义原样不动。

- [ ] **Step 1: 删私有 loader，改用共享的**

把 `figma-sync/api.mjs` 顶部这段：
```js
import { readFileSync } from 'fs'
import { resolve } from 'path'

const BASE = 'https://api.figma.com/v1'

function loadEnvFile() {
  try {
    const env = readFileSync(resolve(process.cwd(), '.env'), 'utf8')
    for (const rawLine of env.split('\n')) {
      const line = rawLine.trim()
      if (!line || line.startsWith('#')) continue
      const index = line.indexOf('=')
      if (index === -1) continue
      const key = line.slice(0, index).trim()
      const value = line.slice(index + 1).trim()
      if (key && !(key in process.env)) process.env[key] = value
    }
  } catch {
    // .env is optional when environment variables are already provided.
  }
}
```

改成：
```js
// `.env` 解析器全仓只有一份：scripts/lib/figma-env.mjs（INFRA-F98）。
// 此处刻意只换加载器，不动下面 requireEnv 与顶层求值语义 —— FILE_KEY 是顶层 export
// 绑定，3 个消费者拿它当默认参数，改惰性要连带改导出形态，收益不抵风险。
import { loadDotEnv } from '../scripts/lib/figma-env.mjs'

const BASE = 'https://api.figma.com/v1'
```

再把 `loadEnvFile()` 那一行调用改成：
```js
loadDotEnv()
```

⛔ **`requireEnv` 函数、`export const FILE_KEY = requireEnv('FIGMA_FILE_KEY')`、`const TOKEN = requireEnv('FIGMA_TOKEN')` 三处原样不动。**

- [ ] **Step 2: 核实 `readFileSync` / `resolve` 是否还有别的用处**

Run: `grep -n 'readFileSync\|resolve(' figma-sync/api.mjs`
若已无其它使用 → 删掉那两行 import；若还有 → 保留。**不要凭印象删。**

- [ ] **Step 3: 3 个消费者真跑一次（回归）**

Run:
```bash
node -e "import('./figma-sync/api.mjs').then(m => console.log('FILE_KEY=', m.FILE_KEY))"
node --check figma-sync/export-icons.mjs && node --check figma-sync/extract.mjs && node --check figma-sync/sync-mockup-data.mjs && echo "syntax ok"
```
Expected：`FILE_KEY=YbsPRUVmNdsbN40NNwh1Gn`；`syntax ok`。

⛔ **别用 `pnpm sync:mockup YbsPRUVmNdsbN40NNwh1Gn` 作回归命令**（本行原来就是这么写的，2026-08-06 Task 5 实测证伪后订正）：`sync-mockup-data.mjs` 是给**产品 mockup 文件**用的，其 usage 文本自己就写着「not the design-system library」；指向 DS 库 key 会在 `JSON.stringify` 撞 V8 字符串长度上限 `RangeError`。那个崩溃发生在 `loadDotEnv()` 成功、`requireEnv()` 通过、`getFile()` 真拿到响应**之后**，与本 Task 无关，但会让这条回归命令**永远失败**、从而无法证明任何事。

- [ ] **Step 4: 阴性对照 —— 证明 `.env` 真的还在被读**

Run:
```bash
mv .env .env.f98bak
node -e "import('./figma-sync/api.mjs').then(()=>console.log('UNEXPECTED OK')).catch(e=>console.log('expected throw:', e.message))"
mv .env.f98bak .env
awk -F= '/^[A-Z_]+=/{print $1}' .env
```
Expected：打印 `expected throw: FIGMA_FILE_KEY is not configured...`（证明 `.env` 是唯一来源、换 loader 后仍生效），且 `.env` 已还原、两个 key 都在。

- [ ] **Step 5: 报告（不 commit）**

---

### Task 6: 防副本闸 `audit:figma-env-single-source` + 单测 + 挂三处

**Files:**
- Create: `scripts/audit-figma-env-single-source.mjs`
- Test: `tests/audit-figma-env-single-source.test.ts`
- Modify: `package.json`（加 script + 追加进 `prepublishOnly`）
- Modify: `.husky/pre-commit`（末尾加条件块）
- Modify: `.gitea/workflows/pr-checks.yml`（加一步）

**Interfaces:**
- Consumes: `scripts/lib/figma-env.mjs` 的存在与三个导出名（Task 1）
- Produces: `auditFigmaEnvSingleSource({ root }) => { ok: boolean, scanned: string[], failures: string[] }`（供单测调用）；CLI 退出码 0 = PASS / 1 = FAIL

- [ ] **Step 1: 写失败的单测**

Create `tests/audit-figma-env-single-source.test.ts`：

```ts
// tests/audit-figma-env-single-source.test.ts
import { describe, it, expect, afterEach } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { auditFigmaEnvSingleSource } from '../scripts/audit-figma-env-single-source.mjs'

// `package.json` 是 type:module —— 显式派生，不依赖 vitest 注入 __dirname
const HERE = dirname(fileURLToPath(import.meta.url))

const dirs: string[] = []
const SHARED_OK = [
  'export function loadDotEnv() {}',
  'export function getFigmaToken() {}',
  'export function requireFigmaToken() {}',
].join('\n')

function fixture({ audits = {}, shared = SHARED_OK as string | null } = {}): string {
  const root = mkdtempSync(join(tmpdir(), 'f98gate-'))
  dirs.push(root)
  mkdirSync(join(root, 'scripts', 'lib'), { recursive: true })
  if (shared !== null) writeFileSync(join(root, 'scripts', 'lib', 'figma-env.mjs'), shared)
  for (const [name, body] of Object.entries(audits)) {
    writeFileSync(join(root, 'scripts', name), body as string)
  }
  return root
}
afterEach(() => { while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }) })

describe('S1 分母 fail closed', () => {
  it('fails when no audit-mockup-*.mjs is found at all', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {} }) })
    expect(r.ok).toBe(false)
    expect(r.failures.join('\n')).toMatch(/S1/)
  })
})

describe('S2 不得直读 process.env.FIGMA_', () => {
  it('passes when every audit script goes through the shared module', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {
      'audit-mockup-a.mjs': "import { requireFigmaToken } from './lib/figma-env.mjs'\nconst T = requireFigmaToken()\n",
    } }) })
    expect(r.ok).toBe(true)
    expect(r.scanned).toHaveLength(1)
  })

  it('fails and names the file when one reads process.env.FIGMA_ directly', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {
      'audit-mockup-a.mjs': 'const T = process.env.FIGMA_PERSONAL_ACCESS_TOKEN\n',
    } }) })
    expect(r.ok).toBe(false)
    expect(r.failures.join('\n')).toMatch(/S2/)
    expect(r.failures.join('\n')).toContain('audit-mockup-a.mjs')
  })

  it('also catches the alias name', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {
      'audit-mockup-a.mjs': 'const T = process.env.FIGMA_TOKEN\n',
    } }) })
    expect(r.ok).toBe(false)
    expect(r.failures.join('\n')).toMatch(/S2/)
  })

  it('does not flag the variable name appearing in a comment or message', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {
      'audit-mockup-a.mjs': "// Requires: FIGMA_PERSONAL_ACCESS_TOKEN\nconsole.error('Missing FIGMA_PERSONAL_ACCESS_TOKEN (or FIGMA_TOKEN)')\n",
    } }) })
    expect(r.ok).toBe(true)
  })
})

describe('S3 共享模块必须在且导出齐全', () => {
  it('fails when the shared module is missing', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {
      'audit-mockup-a.mjs': 'const T = 1\n',
    }, shared: null }) })
    expect(r.ok).toBe(false)
    expect(r.failures.join('\n')).toMatch(/S3/)
  })

  it('fails when an export was renamed away', () => {
    const r = auditFigmaEnvSingleSource({ root: fixture({ audits: {
      'audit-mockup-a.mjs': 'const T = 1\n',
    }, shared: 'export function loadDotEnv() {}\nexport function getFigmaToken() {}\n' }) })
    expect(r.ok).toBe(false)
    expect(r.failures.join('\n')).toContain('requireFigmaToken')
  })
})

describe('真仓库非空过钉', () => {
  it('passes on the real repository and scans all ten audit-mockup scripts that need a token', () => {
    const r = auditFigmaEnvSingleSource({ root: resolve(HERE, '..') })
    expect(r.failures).toEqual([])
    expect(r.ok).toBe(true)
    expect(r.scanned.length).toBeGreaterThanOrEqual(10)
  })
})
```

- [ ] **Step 2: 跑测试确认它失败**

Run: `pnpm vitest run tests/audit-figma-env-single-source.test.ts`
Expected: FAIL —— 找不到 `../scripts/audit-figma-env-single-source.mjs`。

- [ ] **Step 3: 写闸**

> ⚠️ **2026-08-06 判据已扩，下面这份代码是扩展前的快照**。Task 6 review 指出 S2 的 `/process\.env\.FIGMA_/` 只认点访问，而括号访问与解构（**解构是 Node 最常见写法**）会静默逃逸；owner 当日拍板扩判据。**判据真源在 [spec §6](../specs/2026-08-06-infra-f98-figma-env-single-source-design.md)，不是这里** —— ⛔ 别照下面的代码块把正则改回只认点访问。同批还放宽了 S3 的导出识别（原只认 `export function name(`，纯风格重构成 `export const name = () =>` 会让闸假红；**假红的闸会被它挡住的人关掉**，比漏检更糟）。

Create `scripts/audit-figma-env-single-source.mjs`：

```js
#!/usr/bin/env node
// audit-figma-env-single-source.mjs — 让「Figma 凭据只有一个解析点」从约定变成闸。
//
// 判据真源 = docs/superpowers/specs/2026-08-06-infra-f98-figma-env-single-source-design.md
// 缘起 = INFRA-F98。那条 entry 的 §触发查看条件 预言「下次新增读 Figma REST 的脚本时会变成
// 第 10 份副本」——**16 小时内就发生了**：7d53d368 新建的 audit-mockup-geometry-consistency.mjs
// 直读 process.env.FIGMA_PERSONAL_ACCESS_TOKEN、不读 .env，落地即在本机上跑不起来。它带单测、
// 带 npm script，是正经落地 ⇒ 根因是「下一个写脚本的人没有可照做的东西」，不是有人偷懒。
// 这条 1 份/天的实测速率就是本闸存在的依据。
//
// 挂载（L4 + L5 ×2，对齐 INFRA-F61 gate 平权 —— 拦 PR 的也拦 push:master）：
//   · L4  .husky/pre-commit          条件触发（staged 命中 audit-mockup-* / lib/figma-env.mjs / 本闸）
//   · L5  package.json prepublishOnly
//   · L5  .gitea/workflows/pr-checks.yml
//
// ⚠️ 覆盖面（每次运行自印，别读成比这更多）：只扫 `scripts/audit-mockup-*.mjs`。
//    **扫不到** figma-sync/ 下、scripts/ 下其它命名、或别处新写的 Figma REST 脚本。
//    要扩扫描面得先回答「谁算 Figma REST 消费者」——那是独立的判据设计问题，别顺手扩。
// ⛔ 别把变量名字面量当判据（注释与错误文案里本来就该出现名字）；判据是 `process.env.FIGMA_` 这个**读取形态**。

import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'

const SHARED_REL = join('scripts', 'lib', 'figma-env.mjs')
const REQUIRED_EXPORTS = ['loadDotEnv', 'getFigmaToken', 'requireFigmaToken']
const DIRECT_READ_RE = /process\.env\.FIGMA_/

// S4 豁免表 —— 具名 + 带日期 + 带修法方向；shrink-only（不再命中就 FAIL 要求删行）。
// **空着是终态，不是待办。** 新闸落地第一天就带豁免 = 判据没想清楚。
export const EXEMPTIONS = []

export function auditFigmaEnvSingleSource({ root } = {}) {
  const repoRoot = root ?? resolve(dirname(fileURLToPath(import.meta.url)), '..')
  const scriptsDir = resolve(repoRoot, 'scripts')
  const failures = []

  // ---------- 分母 ----------
  let names = []
  try {
    names = readdirSync(scriptsDir)
      .filter(n => n.startsWith('audit-mockup-') && n.endsWith('.mjs'))
      .sort()
  } catch {
    names = []
  }

  // S1 — 分母 fail closed：glob 写错 / 目录改名 / 脚本被搬走都必须红，不能静静地变成空过。
  if (names.length === 0) {
    failures.push(`S1 分母为 0：${scriptsDir} 下找不到任何 audit-mockup-*.mjs。闸没有输入 = 没有闸。`)
  }

  // S2 — 这些文件里不得出现 `process.env.FIGMA_` 读取形态。
  const violations = []
  for (const name of names) {
    const text = readFileSync(join(scriptsDir, name), 'utf8')
    text.split('\n').forEach((line, i) => {
      if (DIRECT_READ_RE.test(line)) violations.push({ name, line: i + 1, text: line.trim() })
    })
  }
  const exemptNames = new Set(EXEMPTIONS.map(e => e.file))
  for (const v of violations) {
    if (exemptNames.has(v.name)) continue
    failures.push(
      `S2 ${v.name}:${v.line} 直读 Figma 凭据 —— 改用 scripts/lib/figma-env.mjs 的 ` +
        `requireFigmaToken() / getFigmaToken()。命中行：${v.text}`,
    )
  }

  // S4 — 豁免 shrink-only：不再命中的豁免必须删行，否则表会变成会漂的第二副本。
  for (const e of EXEMPTIONS) {
    if (!violations.some(v => v.name === e.file)) {
      failures.push(`S4 豁免已 stale：${e.file} 不再命中 S2，请删掉这行豁免。`)
    }
  }

  // S3 — 反向：共享模块必须存在且真导出那三个名字。
  // 没有它，S2 全绿也可能只是「大家都不读凭据了」——那种绿没有信息量。
  const sharedPath = resolve(repoRoot, SHARED_REL)
  if (!existsSync(sharedPath)) {
    failures.push(`S3 共享模块不存在：${SHARED_REL}。S2 的「归零」在没有唯一入口时没有意义。`)
  } else {
    const shared = readFileSync(sharedPath, 'utf8')
    for (const name of REQUIRED_EXPORTS) {
      if (!new RegExp(`export\\s+function\\s+${name}\\b`).test(shared)) {
        failures.push(`S3 ${SHARED_REL} 未导出 ${name}（被改名或删了？调用方会静默失效）。`)
      }
    }
  }

  return { ok: failures.length === 0, scanned: names, failures }
}

// ---------- CLI ----------
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
  const result = auditFigmaEnvSingleSource()
  console.log('═══ figma-env single-source gate ═══')
  console.log(`  覆盖面：scripts/audit-mockup-*.mjs，本次扫了 ${result.scanned.length} 份`)
  console.log('  ⚠️ 扫不到 figma-sync/ 下或别处新写的 Figma REST 脚本 —— 绿不代表全仓无副本')
  console.log(`  豁免：${EXEMPTIONS.length} 条（空 = 终态）`)
  if (result.ok) {
    console.log('✅ PASS — 凭据解析只有一个入口')
    process.exit(0)
  }
  for (const f of result.failures) console.error(`  ❌ ${f}`)
  console.error(`❌ FAIL — ${result.failures.length} 条`)
  process.exit(1)
}
```

- [ ] **Step 4: 跑测试确认全过**

Run: `pnpm vitest run tests/audit-figma-env-single-source.test.ts`
Expected: PASS（含「真仓库非空过钉」那条）。

- [ ] **Step 5: CLI 跑一次真仓库**

Run: `node scripts/audit-figma-env-single-source.mjs ; echo "exit=$?"`
Expected：`exit=0`，且自印那三行覆盖面/豁免声明；扫描份数 ≥ 10。

- [ ] **Step 6: 挂 L5 —— package.json**

在 `scripts` 段加（紧邻其它 `audit:` 条目）：
```json
"audit:figma-env-single-source": "node scripts/audit-figma-env-single-source.mjs",
```

并在 `prepublishOnly` 那条**长串的末尾**追加：
```
 && pnpm run audit:figma-env-single-source
```

- [ ] **Step 7: 挂 L4 —— `.husky/pre-commit`**

在文件末尾（`✅ pre-commit gates passed` 那行**之前**）加：

```bash
# ── Figma 凭据单一解析点 gate (INFRA-F98) ──────────────────────────────────────
# 缘起：10 个 audit-mockup-* 各自直读 env 且都不读 .env，配好凭据的人一律得到「没
# token」；entry 预言的「第 10 份副本」在 16 小时内真的发生（7d53d368）。
# 触发面 = 任一 audit-mockup 脚本、共享模块、闸自身。diff-filter 带 D：删掉一份脚本会
# 改变分母，那正是该重跑的时刻（S1 fail closed / S4 豁免 shrink-only）。
if git diff --cached --name-only --diff-filter=AMD | grep -qE '(scripts/audit-mockup-[^/]+\.mjs|scripts/lib/figma-env\.mjs|scripts/audit-figma-env-single-source\.mjs)'; then
  pnpm run audit:figma-env-single-source
else
  echo "   ✓ no figma-env-relevant files staged, skipped"
fi
```

- [ ] **Step 8: 挂 L5 —— `.gitea/workflows/pr-checks.yml`**

在 `audit:demo-css-page-scope` 那一步之后加：

```yaml
      - name: Figma 凭据单一解析点 gate (INFRA-F98)
        # 10 个 audit-mockup-* 曾各自直读 env 且都不读 .env → 配好凭据的人一律「没 token」，
        # 迫使改用手写替代检查，而手写产生过假阴性。entry 预言的第 10 份副本 16 小时内发生。
        # 纯 node 离线判据（读目录 + 正则），Gitea runner 无 root 亦可跑。
        # 与 pre-commit 同一条闸 —— INFRA-F61 gate 平权：拦 PR 的也拦 push:master。
        run: pnpm audit:figma-env-single-source
```

- [ ] **Step 9: 三处挂载各自验一次「闸真的挂上了」**

Run:
```bash
# L5 npm script
pnpm run audit:figma-env-single-source ; echo "exit=$?"
# prepublishOnly 串里真的有它
node -e "const p=require('./package.json'); console.log(p.scripts.prepublishOnly.includes('audit:figma-env-single-source'))"
# L4 hook：真 stage 一个命中文件，直接跑 hook 本体（不造空 commit）
# ⛔ 别 stage 一个与 HEAD 内容相同的已跟踪文件（本行原写 scripts/lib/figma-env.mjs，2026-08-06 Task 6
#    实测证伪）：`git add` 一个未修改的文件不产生任何 staged 变更，`--diff-filter=AMD` 看不到它 →
#    hook 走 skipped 分支，看起来像「闸没挂上」，实为**检查方法自己的假阴性**。
#    要用一个真有变更的文件（新建未跟踪的、或本轮已改的）。
git add scripts/audit-figma-env-single-source.mjs && sh .husky/pre-commit 2>&1 | grep -A2 'INFRA-F98\|figma-env' ; git reset -- scripts/audit-figma-env-single-source.mjs
# pr-checks 里有这一步
grep -n 'audit:figma-env-single-source' .gitea/workflows/pr-checks.yml
```
Expected：npm script `exit=0`；`true`；hook 输出里出现**真实闸输出**（不是 `skipped`）；yml grep 有命中。

- [ ] **Step 10: 三组故障注入，各只红对应那一条**

对每组：注入 → 跑闸记 stdout → 复原 → `git diff --exit-code <file>` 确认逐字节还原。

| 组 | 注入 | Expected |
|---|---|---|
| A | 往 `scripts/audit-mockup-colors.mjs` 塞回一行 `const X = process.env.FIGMA_TOKEN` | exit 1，**只**有 S2 且点名 colors |
| A2 | 同上但用括号访问 `process.env['FIGMA_TOKEN']` | exit 1，只有 S2（判据扩展后新增） |
| A3 | 同上但用单行解构 `const { FIGMA_TOKEN } = process.env` | exit 1，只有 S2（判据扩展后新增） |
| B | `git mv scripts/lib/figma-env.mjs scripts/lib/figma-env-x.mjs` | exit 1，出现 S3（S2 可能同时红，因为调用方 import 断了也没关系——**记录实际输出，不要预设**） |
| C | 临时把闸里 `n.startsWith('audit-mockup-')` 改成 `n.startsWith('audit-mockup-zzz-')` | exit 1，**只**有 S1 |

⚠️ **造故障前先确认造故障的工具自己是好的**：每组注入后先 `grep` 确认那行/那个改动**真的在文件里**，再看退出码。「退出码变了」不等于「故障造出来了」。

- [ ] **Step 11: 报告（不 commit）** —— 贴三组注入的原始 stdout + 复原核验。

---

### Task 7: 文档 / 契约面（修反向缺口）

**Files:**
- Modify: `.env.example`
- Modify: `docs/CONSUMER_AUDIT_SETUP.md`
- Modify: `figma-sync/README.md`

**Interfaces:**
- Consumes: Task 1 定下的规范名/别名语义
- Produces: 无代码接口

> **为什么必须做**：`.env.example` 今天只列 `FIGMA_PERSONAL_ACCESS_TOKEN` + `FIGMA_FILE_KEY`，**没有 `FIGMA_TOKEN`** ⇒ 照它逐字配好 `.env` 的新克隆者跑 `pnpm sync:figma-library` 会撞 `FIGMA_TOKEN is not configured`。不补这一行，「配好 `.env`」这句话就没有可照做的定义。

- [ ] **Step 1: 重写 `.env.example`**

整份替换为：

```
# Figma personal access token (read scope).
# 两个名字都认，解析器 = scripts/lib/figma-env.mjs（INFRA-F98）：
#   · FIGMA_PERSONAL_ACCESS_TOKEN — 规范名，audit 脚本与 consumer 面用它
#   · FIGMA_TOKEN                 — 别名，figma-sync/ 与 CI secret 用它
# 优先级：PERSONAL 优先；只配其中一个就够。**放在 .env 即可，不必 export。**
# CI 里 secret 名为 FIGMA_TOKEN，workflow 可直接注入同名变量（见 docs/CONSUMER_AUDIT_SETUP.md）。
FIGMA_PERSONAL_ACCESS_TOKEN=your_figma_personal_access_token_here
FIGMA_TOKEN=your_figma_token_here
FIGMA_FILE_KEY=YbsPRUVmNdsbN40NNwh1Gn
```

⚠️ 占位值必须保留 `your_figma_` 前缀 —— Task 1 的 `PLACEHOLDER_RE` 靠它把「照抄没改」判成未配置而不是 403。

- [ ] **Step 2: `docs/CONSUMER_AUDIT_SETUP.md` 加一句**

在讲 token 配置的段落里加：

```markdown
> **放 `.env` 即可，不必手动 `export`。** 审计脚本经 `scripts/lib/figma-env.mjs` 自动读取消费仓库根目录的 `.env`，
> 并同时接受 `FIGMA_PERSONAL_ACCESS_TOKEN`（规范名）与 `FIGMA_TOKEN`（别名，= CI secret 名）。
```

- [ ] **Step 3: `figma-sync/README.md` 指回规范名**

把提到 `FIGMA_TOKEN` 的那处补一句：

```markdown
（`FIGMA_TOKEN` 是别名；规范名是 `FIGMA_PERSONAL_ACCESS_TOKEN`，两个都认，解析器见 `scripts/lib/figma-env.mjs`。）
```

- [ ] **Step 4: 验证占位值这条链真的通**

Run:
```bash
mv .env .env.f98bak
cp .env.example .env
node scripts/audit-mockup-colors.mjs --file YbsPRUVmNdsbN40NNwh1Gn ; echo "exit=$?"
mv .env.f98bak .env
awk -F= '/^[A-Z_]+=/{print $1}' .env
```
Expected：报 `is not configured`（**不是** Figma 403），`exit=2`；`.env` 已还原。

- [ ] **Step 5: 文档闸不红**

Run: `pnpm run audit:stale-anchors && pnpm run audit:doc-sync && pnpm run audit:rule-inventory`
Expected: 三条全 exit 0。

- [ ] **Step 6: 报告（不 commit）**

---

### Task 8: 全量验证（spec §7 十条）+ 交付报告

**Files:** 无改动（纯验证）

**Interfaces:**
- Consumes: Task 1-7 的全部产出
- Produces: 一份可复核的验证报告，供 plan owner 复审后统一 commit

- [ ] **Step 1: 真故障对照（承重那一组）**

Run:
```bash
awk -F= '/^[A-Z_]+=/{print $1}' .env   # 确认仍是 FIGMA_FILE_KEY / FIGMA_TOKEN 两个
node scripts/audit-mockup-geometry-consistency.mjs --file YbsPRUVmNdsbN40NNwh1Gn 2>&1 | head -5 ; echo "exit=$?"
```
Expected：与 Task 1 Step 1 的基线**形成对照** —— 那句 `FIGMA_PERSONAL_ACCESS_TOKEN env var is not set.` 必须消失，脚本真发出请求。两段输出并排贴进报告。

- [ ] **Step 2: 阴性对照（排除「只是改了报错文案」）**

Run：把 `.env` 里 token 值改坏一位（保留前缀 `figd_`），跑同一条命令，然后**立刻还原**并核验。
Expected：得到 Figma 的 **403 / 401**，**不是**「没 token」。这一步证明它真读到了凭据并真发了请求。
⚠️ 还原后 `awk -F= '/^FIGMA_TOKEN=/{print $2}' .env` 必须与改前逐字相同（改前先存一份哈希）。

- [ ] **Step 3: 占位值那一向（本 Task 自己再跑一遍，别引用别的 Task 的输出）**

Run:
```bash
mv .env .env.f98bak
cp .env.example .env
node scripts/audit-mockup-colors.mjs --file YbsPRUVmNdsbN40NNwh1Gn 2>&1 | head -3 ; echo "exit=$?"
mv .env.f98bak .env
awk -F= '/^[A-Z_]+=/{print $1}' .env
```
Expected：输出含 `is not configured`（**不是** Figma 403），`exit=2`；`.env` 已还原为 `FIGMA_FILE_KEY` / `FIGMA_TOKEN`。

- [ ] **Step 4: 别名三向**

Run（每次都临时移开 `.env`，跑完还原）：
```bash
mv .env .env.f98bak
T="$(awk -F= '/^FIGMA_TOKEN=/{print $2}' .env.f98bak)"
FIGMA_PERSONAL_ACCESS_TOKEN="$T" node scripts/audit-mockup-colors.mjs --file YbsPRUVmNdsbN40NNwh1Gn >/dev/null 2>&1 ; echo "canonical only exit=$?"
FIGMA_TOKEN="$T" node scripts/audit-mockup-colors.mjs --file YbsPRUVmNdsbN40NNwh1Gn >/dev/null 2>&1 ; echo "alias only exit=$?"
FIGMA_PERSONAL_ACCESS_TOKEN="$T" FIGMA_TOKEN=figd_wrong node scripts/audit-mockup-colors.mjs --file YbsPRUVmNdsbN40NNwh1Gn >/dev/null 2>&1 ; echo "both (canonical wins) exit=$?"
mv .env.f98bak .env ; awk -F= '/^[A-Z_]+=/{print $1}' .env
```
Expected：三次都**不是** 2（0 或 1 都算通过——1 是内容 findings，不是凭据问题）；第三次证明 canonical 优先（若别名优先会拿 `figd_wrong` 撞 403）。

- [ ] **Step 5: 闸的终态自检（注入证据由 Task 6 产出，本步只验终态）**

Run:
```bash
node scripts/audit-figma-env-single-source.mjs ; echo "exit=$?"
node -e "import('./scripts/audit-figma-env-single-source.mjs').then(m=>console.log('exemptions=',m.EXEMPTIONS.length))"
grep -rn 'process\.env\.FIGMA_' scripts/ figma-sync/ ; echo "grep exit=$?"
```
Expected：`exit=0`；`exemptions= 0`（**空表是终态**，落地即带豁免 = 判据没想清楚）；全仓 `process.env.FIGMA_` 只剩 `scripts/lib/figma-env.mjs` 内部那几处（逐条列出确认没有别的文件）。

- [ ] **Step 6: api.mjs 回归（本 Task 自己再跑一遍）**

Run:
```bash
node -e "import('./figma-sync/api.mjs').then(m => console.log('FILE_KEY=', m.FILE_KEY))"
node --check figma-sync/export-icons.mjs && node --check figma-sync/extract.mjs && node --check figma-sync/sync-mockup-data.mjs && echo "syntax ok"
```
Expected：`FILE_KEY=YbsPRUVmNdsbN40NNwh1Gn`；`syntax ok`。

- [ ] **Step 7: 全量测试 + 相关 audit**

Run:
```bash
pnpm vue-tsc --noEmit ; echo "vue-tsc exit=$?"
pnpm vitest run 2>&1 | tail -5
pnpm run audit:scripts-stdlib && pnpm run audit:figma-env-single-source && pnpm run audit:status-consistency && pnpm run audit:doc-shape && pnpm run audit:plan-lifecycle && pnpm run audit:stale-anchors
echo "audits exit=$?"
```
Expected：`vue-tsc exit=0`；vitest **0 failed**，且应为 **1312 passed / 11 skipped**（= §基线 1292 + 本轮新增 20）。对不上就逐份查，别用「大概」放过。六条 audit 全 exit 0。

- [ ] **Step 8: 全程零 `.vue` / `.css` 自证**

Run: `git status --short | grep -E '\.(vue|css)$' ; echo "visual-files exit=$?"`
Expected：`exit=1`（零命中）⇒ 视觉门未触发，`VISUAL_COMMIT_APPROVED` 无需也不得设置。

- [ ] **Step 9: 交付报告（不 commit）**

汇总：改前/改后对照两段原始输出 · 阴性对照 · 别名三向 · 三组注入 + 复原核验 · vitest 前后条数 · 六条 audit 退出码 · `git diff --stat` 全量。**由 plan owner 复审后统一提交。**

---

## 收尾（plan owner 做，不属 executor 范围）

- [ ] 逐 Task 复审 executor 报告：**声明必须有可复核证据**（原始 stdout / diff），文字总结不采信
- [ ] 分批 commit：`git commit -F <msg> -- <路径逐条内联>`，每次紧跟 `git reset -- <路径>`
- [ ] push 后 `git ls-remote` 双 remote 亲验
- [ ] backlog：[[INFRA-F98]] 收口删档；把仍生效的约束**搬家**后再删（候选：⛔ 三条 + 闸的覆盖面边界 → 已在闸头注释；`.env.example` 双名语义 → 已在文件里）
- [ ] STATUS.md：`Last updated` 改今天 + 当日摘要；旧摘要 prepend 进 STATUS-CHANGELOG
- [ ] tracker：§轨道 C 追加一行（内容摘要 / 日期 / 耗时 / commit）
- [ ] **立即启动独立 brainstorm**：F62 半边 B —— conformance report 的第二个合法生产者，三候选（①降级通道 / ~~②硬前置~~ 已被 owner 目标排除 / ③延迟验证）交 owner 拍
- [ ] 独立登记（不在本轮修）：4 个进包脚本 import 不进包的 `figma-sync/`（`extract-figma-icon-worklist` / `audit-composition-exports` / `audit-token-contract` / `audit-token-exports`）
