# INFRA-F98 — Figma 凭据解析收成单一真源 + 防副本闸（设计）

> 状态：**设计已定，实施未开工**。
> 真源关系：本文件是判据真源；缺陷实测数字与历史在 [backlog [[INFRA-F98]]](../../internal/backlog.md)。
> 落地后本文件的入站指针放进 `scripts/lib/figma-env.mjs` 与 `scripts/audit-figma-env-single-source.mjs` 头注释（范式 = `audit-sort-tokens.mjs`）。

---

## 1. 缺陷（2026-08-06 全部活源实测）

10 个脚本读 Figma REST 凭据，**没有一个读 `.env`**，且要的变量名与本机 `.env` 里的不同：

| 面 | 用的名字 |
|---|---|
| `.env.example`（committed）+ 全部 consumer 面（[`CONSUMER_AUDIT_SETUP.md`](../../CONSUMER_AUDIT_SETUP.md) · [`templates/audit-workflow.yml`](../../../templates/audit-workflow.yml) · [`docs/templates/consumer-audit-ci.yml`](../../templates/consumer-audit-ci.yml) · [`mockup-conventions.md`](../../internal/mockup-conventions.md)）+ 这 10 个脚本 | `FIGMA_PERSONAL_ACCESS_TOKEN` |
| [`figma-sync/api.mjs`](../../../figma-sync/api.mjs) + [`figma-sync/README.md`](../../../figma-sync/README.md) + 本机 `.env` | `FIGMA_TOKEN` |
| CI secret | `FIGMA_TOKEN`，在 workflow 里映射成前者 |

⇒ 照 sync 管线配好 `.env` 的人跑那 10 个，得到的是 `FIGMA_PERSONAL_ACCESS_TOKEN env var not set` ——**读起来像「这台机器没凭据」，实际凭据一直在**。

**已付代价**（backlog 登记）：机器总闸跑不了 → 多轮改用手写替代检查 → 手写产生过假阴性（同族 §I2 一处 54px 既有重叠连续多轮被写成 `overlap 0`）。

### 1.1 两处订正 backlog entry 自己的描述

- entry ⛔「别在单个脚本里加 fallback…9 个各写一遍就是 9 处副本」——**那个形态已经发生了 3 次**：[`audit-mockup-connector.mjs:19`](../../../scripts/audit-mockup-connector.mjs) 与 [`audit-mockup-library-origin.mjs:39`](../../../scripts/audit-mockup-library-origin.mjs) 各写了 `PERSONAL || FIGMA_TOKEN`，[`audit-mockup-conformance.mjs:94-95`](../../../scripts/audit-mockup-conformance.mjs) 另写了一份 env 映射。⇒ 本条不是「防止开始分叉」，是「已分叉 3 处，一并收敛」。
- entry「复用 `figma-sync/api.mjs` 已存在的 `.env` 解析器」——`loadEnvFile()` **未 export**，且 `api.mjs:33-34` 在模块顶层就 `requireEnv('FIGMA_FILE_KEY')` + `requireEnv('FIGMA_TOKEN')`（import 期副作用）⇒ 那 10 个**不能**靠 import `api.mjs` 复用。必须先抽成独立模块。

### 1.2 entry 预言的「第 10 份副本」已在 16 小时内发生

`7d53d368`（2026-08-06 09:56，并行 session）新建的 [`audit-mockup-geometry-consistency.mjs:332`](../../../scripts/audit-mockup-geometry-consistency.mjs) 直读 `process.env.FIGMA_PERSONAL_ACCESS_TOKEN`、不读 `.env`、无别名。实跑复现：

```
$ node scripts/audit-mockup-geometry-consistency.mjs --file YbsPRUVmNdsbN40NNwh1Gn
Error: FIGMA_PERSONAL_ACCESS_TOKEN env var is not set.
```

它带单测、带 npm script，是正经落地——**说明根因是「下一个写脚本的人没有可照做的东西」，不是有人偷懒**。这条实测速率（1 份/天）是本设计包含防副本闸的依据。

### 1.3 反向缺口（同根因的镜像面）

`.env.example` 只列 `FIGMA_PERSONAL_ACCESS_TOKEN` + `FIGMA_FILE_KEY`，**没有 `FIGMA_TOKEN`** ⇒ 照它逐字配好 `.env` 的新克隆者跑 `pnpm sync:figma-library` 会撞 `FIGMA_TOKEN is not configured`。

---

## 2. 受益面（先测用途，再核数字）

| 消费面 | 是否跑这 10 个脚本 | 本设计对它 |
|---|---|---|
| **DS repo 本机** | ✅ | 修好 |
| **consumer 产品仓库**（npm 包；`files[]` 含 `scripts`，实测 **14 个 `audit-mockup-*` 进 tarball**，`figma-sync/` **0 个**） | ✅ `node node_modules/@ux-team/tvu-design-system/scripts/audit-mockup-conformance.mjs --file <key>` | 修好，且 consumer 从此不必手 `export`（今天他们踩同一个坑） |
| **claude.ai/Design**（`ds-bundle/`，整个 bundle 里只有 `reference/upstream-gate.schema.json` 一个文件来自 `scripts/`） | ❌ **没有 node，永远跑不了** | **零收益** |

⇒ owner 2026-08-06 确认两个场景都要覆盖，但**它们在 env 层不相交**。真正相交的是 conformance report（F62-a 防伪 gate 读的产物）：实测 [`audit-mockup-conformance.mjs:163`](../../../scripts/audit-mockup-conformance.mjs) 的 exit 2 = `ERROR (could not run)` 已是与 1 = `FINDINGS` 并列的一等状态，`buildReport` 逐条记 `subAudits[].exitCode`，gate 要求全 0 ⇒ 无 token 环境产出的 report 恒 `ok:false`。

**claude.ai/Design 那半边 = 让 report 多一个合法生产者，是防伪策略决定，另立独立 brainstorm**（见 §8）。本设计**不碰 report schema 与防伪语义**，因此不给那半边关任何门。

---

## 3. 决定与被排除的路线

### 3.1 共享模块落位 = `scripts/lib/figma-env.mjs`

⛔ **不放 `figma-sync/lib/`**。虽然 `scripts/ → figma-sync/lib/` 有先例（[`extract-figma-icon-worklist.mjs:6`](../../../scripts/extract-figma-icon-worklist.mjs) import `affordance-sot.mjs`），但 `npm pack --dry-run --ignore-scripts` 实测：`scripts/lib/` 5 个文件全进包、**`figma-sync/` 0 个进包** ⇒ 放那里会让 shipped 的 consumer 脚本 import 一个 tarball 里不存在的文件。

`api.mjs` 反向 import `scripts/lib/` 无问题：它自己不进包，只在仓库内跑。

### 3.2 消费形态 = 具名函数调用（非 import 副作用）

| 路线 | 判定 |
|---|---|
| **① 具名函数 `requireFigmaToken()`**（采纳） | 直读 `process.env.FIGMA_*` 在那 10 个里归零 ⇒ §5 的闸判据落在「字面量归零」这种无逃逸口的形态上 |
| ② `loadDotEnv()` + `resolveFigmaToken()` 两步 | 每脚本两处调用；漏掉 `loadDotEnv()` 的脚本**静默**退回今天的坏行为 —— 正是本条要消灭的失败类 |
| ③ `import './lib/figma-env.mjs'` 副作用式归一化 | diff 最小，但保留全部直读 ⇒ **与闸互斥**（闸只能退化成「检查有没有那行 import」）；且 import 副作用正是本轮要从 `api.mjs` 拆掉的形态 |

### 3.3 规范名 = `FIGMA_PERSONAL_ACCESS_TOKEN`，`FIGMA_TOKEN` 是别名

依据是 committed 的 `.env.example` + 全部 consumer 面用前者；`FIGMA_TOKEN` 只活在 `figma-sync` 内部与 CI secret 名。

⛔ **两个名字都不改，CI secret 名也不动**。本设计只把「谁是规范名」从散在 3 处的 fallback 收进一处定义。

> 注：backlog entry 的 ⛔ 原文是「别改 `.env` 的 key 名 —— `figma-sync` 那条链在用」。按 `.env.example` 看，**偏离仓库自身文档契约的是 `figma-sync`**，不是那 10 个。结论（不改名）相同，但理由不是 entry 给的那个。

---

## 4. 共享模块 `scripts/lib/figma-env.mjs`

`.env` 解析器全仓只此一份。导出三个：

| 导出 | 语义 |
|---|---|
| `loadDotEnv()` | 幂等；读 `process.cwd()/.env`；**已存在的 `process.env` 不覆盖**（沿用 `api.mjs` 现有语义）；文件不存在 = 静默无操作 |
| `getFigmaToken()` | 先 `loadDotEnv()`，再按 `FIGMA_PERSONAL_ACCESS_TOKEN` → `FIGMA_TOKEN` 顺序取；**占位值视同没有**；取不到返回 `undefined`，**不退出** |
| `requireFigmaToken()` | 同上；取不到则打统一文案 + `process.exit(2)` |

三个定值：

1. **占位值视同没有。** `.env.example` 发的是 `FIGMA_PERSONAL_ACCESS_TOKEN=your_figma_personal_access_token_here`；照抄不改的人今天会拿这串去请求 Figma 拿 **403**，读起来像「token 无效 / 权限不够」而不是「你还没配」。`api.mjs` 已有此判定（`includes('your_figma_')`），搬进共享模块让那 10 个也享有。
2. **两个函数而非一个**，因为 [`audit-mockup-integrity.mjs:73`](../../../scripts/audit-mockup-integrity.mjs) 是 `IS_CLI && !TOKEN` 才 die（它被单测 import）。`require` / `get` 正好对上两种用法，不必为它开特例。
3. **exit 2 是行为保持不是新语义。** 那 10 个当前**全部**已是 exit 2 + 近似文案（逐个核过），集中化是删 10 份副本；`audit-mockup-conformance.mjs` 只判 `code === 0`，不区分 1 与 2，无破坏面。

---

## 5. 改动清单

### 5.1 那 10 个脚本

| 类 | 文件:行 | 现在 | 改成 |
|---|---|---|---|
| **7 个只认规范名、无兜底** | colors:53 · typography-icon:58 · integrity:72 · overlap:119 · bilingual-spacing:185 · binding-fidelity:221 · geometry-consistency:332 | `process.env.FIGMA_PERSONAL_ACCESS_TOKEN` + 各自一段报错 | `requireFigmaToken()`；integrity 例外用 `IS_CLI ? requireFigmaToken() : getFigmaToken()` |
| **2 个各写了一遍别名** | connector:19 · library-origin:39 | `PERSONAL \|\| FIGMA_TOKEN` | 同上；`\|\|` 删掉，别名逻辑只活在模块里 |
| **1 个总闸** | conformance:72 + :94-95 | :72 读 env；:94-95 把 `FIGMA_TOKEN` 写回 `PERSONAL` 好让 `spawnSync` 子进程继承 | :72 换 `getFigmaToken()`（`fetchFigmaLastModified` 是 fail-open，不该 exit）；**:94-95 整段删** —— 子进程各自读 `.env`，映射不再必要 |

净效果：`process.env.FIGMA_*` 直读点 **12 处 → 1 处**（共享模块自己）。

> **删 :94-95 不留缺口**（实施时别因为「看起来在兜底」就保留它）：唯一靠它的场景是「shell 里只 export 了 `FIGMA_TOKEN`、没有 `.env`」。`spawnSync` 的子进程继承 `process.env` ⇒ 子进程里 `FIGMA_TOKEN` 仍在，而每个子进程各自调 `requireFigmaToken()`，别名顺序会解析到它。⇒ 该场景由模块覆盖，映射行是纯冗余。此断言由 §7 验证 5「只有 FIGMA_TOKEN」那一向覆盖，**要在总闸路径上跑一次**、不只跑单个子审计。

### 5.2 `figma-sync/api.mjs`

删私有 `loadEnvFile()`，改 import 共享 `loadDotEnv()`。**`requireEnv` 与顶层求值语义原样不动。**

> 有意偏离 brainstorm 中途口述的「顺手让副作用只发生在调用点」：`api.mjs:33` 是 `export const FILE_KEY = requireEnv(...)` 顶层**导出绑定**，3 个消费者（`export-icons` / `sync-mockup-data` / `extract`）拿它当默认参数，改惰性要连带改导出形态与全部默认参数。而那 10 个脚本**不 import `api.mjs`**，该副作用已不挡任何事 ⇒ 少改一处、少一份回归风险。

### 5.3 文档 / 契约面

- **`.env.example`** 补 `FIGMA_TOKEN` 并注明「两个都认，规范名是 `FIGMA_PERSONAL_ACCESS_TOKEN`」——修 §1.3 的反向缺口。
- **`docs/CONSUMER_AUDIT_SETUP.md`** 加一句「放 `.env` 即可，不必手动 `export`」。
- **`figma-sync/README.md`** 指回规范名。

---

## 6. 防副本闸 `scripts/audit-figma-env-single-source.mjs`

四条判据：

- **S1 分母 fail closed** —— `scripts/audit-mockup-*.mjs` 匹配到 0 份即 FAIL（防 glob 写错 / 目录改名后闸变空过）
- **S2 正条** —— 这些文件里出现**直读 Figma 凭据的形态**即 FAIL；白名单只有 `scripts/lib/figma-env.mjs` 自己。
  受检形态 = 点访问 `process.env.FIGMA_*` · 括号访问 `process.env['FIGMA_*']` · 单行解构 `const { FIGMA_* } = process.env`。
  ⚠️ **已知漏检面（须写进脚本头并每次自印）**：跨语句别名（`const e = process.env; e.FIGMA_TOKEN`）· 多行解构 · 动态属性名。
  ⛔ 判据永远只匹配**读取形态**，绝不匹配裸变量名 —— 注释与错误文案里本就该出现那两个名字，匹配裸名会让闸不可用。

  > **2026-08-06 owner 裁定的判据扩展**：本条原写「出现 `process.env.FIGMA_` 字面量即 FAIL」（= 只认点访问）。Task 6 review 指出括号访问与解构会静默逃逸，而**解构恰是 Node 里最常见的写法**，且这闸的服务对象正是「下一个认真写脚本的人」。owner 拍板扩判据。扩展**不产生存量误报**（已有 13 份实测仍 exit 0）。
- **S3 反向** —— 共享模块必须存在且真导出 `loadDotEnv` / `getFigmaToken` / `requireFigmaToken`（防「闸绿但模块被删或改名」这种绿得没有信息量的情形）
- **S4 豁免 shrink-only** —— 具名 + 带日期 + 带修法方向；不再命中即 FAIL 并要求删行。**表空着是终态不是待办**

**挂载三处**（对齐 gate 平权 [[INFRA-F61]]，范式取自 `audit:demo-css-page-scope`）：

1. **L4** `.husky/pre-commit` 条件触发 —— staged 命中 `scripts/audit-mockup-*.mjs` / `scripts/lib/figma-env.mjs` / 闸自身
2. **L5** `package.json` `prepublishOnly`
3. **L5** `.gitea/workflows/pr-checks.yml`

⛔ **覆盖面如实写进脚本头且每次运行自印**：它只扫 `scripts/audit-mockup-*.mjs`，**扫不到** `figma-sync/` 下或别处新写的 Figma REST 脚本。别因为它绿就宣称「全仓不会再分叉」。

---

## 7. 验证矩阵

**决定性那组 = 真故障对照**（其余为廉价注入）：

| # | 做什么 | 必须看到 |
|---|---|---|
| 1 | 本机 `.env` 原样（只有 `FIGMA_TOKEN`），改前跑 geometry-consistency | 复现 `FIGMA_PERSONAL_ACCESS_TOKEN env var is not set` |
| 2 | 改后同一环境同一命令 | **真发出请求**（拿到 Figma 真实响应） |
| 3 | **阴性对照**：`.env` 里 token 改坏一位 | Figma **403**，而不是「没 token」——排除「只是把报错文案改掉了」 |
| 4 | `.env` 填成 `your_..._here` | 报「未配置」，**不是** 403 |
| 5 | 别名三向：只有 PERSONAL / 只有 FIGMA_TOKEN / 两者都有 | 三种都取到；两者都有时取 PERSONAL |
| 6 | 闸注入：往任一 `audit-mockup-*` 塞回一行直读 | 红，且**只**红 S2 |
| 7 | 闸注入：共享模块改名 | 红，且只红 S3 |
| 8 | 闸注入：glob 改成匹配不到 | 红，且只红 S1 |
| 9 | 回归：`api.mjs` 3 个消费者各真跑一次 | 与换 loader 前一致 |
| 10 | `vitest` 全量 + 相关 audit 全 exit 0；新模块与新闸各配单测 | 绿 |

纪律：注入后复原须 `sha256`/`cmp` 逐字节核；**造故障前先确认造故障的工具自己是好的**（看被测系统产物有没有被写过，别只看退出码）。

全程零 `.vue` / `.css` ⇒ 不触发 `VISUAL_COMMIT_APPROVED`。

---

## 8. 非目标（本轮不做，各自独立登记）

1. **claude.ai/Design 半边** —— 让 conformance report 多一个合法生产者（`use_figma` in-file 机检落盘同形 report）。owner 2026-08-06 确认 claude.ai/Design 消费是目标 ⇒ **F62 候选②（缺 token 即拒绝进 mockup 轮）已被排除**。剩余候选：① 降级通道（entry ⛔ 勿默认选，放宽防伪强度）· ③ **延迟验证**（in-file report 标 claim，防伪字段照填，等任何一次有 token 的运行对同一 fileKey + `lastModified` 复跑时升级为 verified —— 不降防伪强度，把「信任」换成「记账 + 后验」）。**F98 落地后立即作为独立 brainstorm 交 owner 拍。**
2. **4 个进包脚本 import 不进包的 `figma-sync/`** —— `extract-figma-icon-worklist` / `audit-composition-exports` / `audit-token-contract` / `audit-token-exports`，在 consumer 环境里是断链。是否为活缺陷取决于 consumer 侧是否被要求跑它们，独立判定，**不塞进 F98**。
3. **闸的扫描面扩到 `figma-sync/` 与其它目录** —— 判据面一变就要重新回答「哪些脚本算 Figma REST 消费者」，是独立的判据设计问题。

---

## 9. ⛔ 清单（实施时逐条守）

- 别在单个脚本里加变量名 fallback 就算修完（9→10 份会分叉的副本）
- 别改 `.env` 的 key 名，也别动 CI secret 名
- 别把共享模块放 `figma-sync/`（tarball 不含它）
- 别用 import 副作用式归一化（与闸互斥）
- 别碰 conformance report 的 schema 与防伪语义（那是半边 B 的地盘）
- 别让闸声称覆盖了它扫不到的目录
- 新闸落地第一天就带豁免表 = 判据没想清楚；先收窄谓词
