# Prompt — INFRA-F26: extract-figma timestamp idempotence

> **角色**：executor
> **范围**：让 `figma-sync/extract.mjs` 和 `figma-sync/normalize-component-tokens.mjs` 在内容未变时**不重写文件**，消除 ~9000 文件 timestamp churn。仅改这两个脚本 + 可选少量 helper 整理。
>
> ⚠️ **本任务需要 commit**——脚本改动是 source of truth。
> ⚠️ **不扩范围**：只改/创建 §0 文件清单内的文件；不动 figma-sync 其他脚本（含 `normalize.mjs` 的 `normalizedAt` 同类问题——那是后续 task）、不动 figma-data/（让验证步骤跑脚本去更新）、不动 backlog.md / retrospection。
> ⚠️ §0 所有设计决策已由 plan owner 写定，**不要自行重设计**（不要换 hash 算法 / 不要改 JSON 格式 / 不要改文件路径）。
> ⚠️ 完成后按 §4 格式回报。

---

## §0 — Plan owner 已定裁定

### 背景

每次 `pnpm sync:extract` + `pnpm generate:component-tokens` 都会把 raw + normalized JSON 的 timestamp 字段刷新成"现在"，即使 Figma API 返回的 component bytes 一字未变。结果是 `git status` 永久挂 ~9000 个 dirty 文件，commit history 严重噪音。

修复后预期效果：第一次跑可能有真实 diff（首次 idempotence baseline），**第二次连续跑 → 0 dirty**。

### §0.1 — 修改文件清单（共 2 个脚本）

| 文件 | 操作 |
|---|---|
| `figma-sync/extract.mjs` | 修改：`saveJson` 改 idempotent + 顶部加 `--force` 解析 |
| `figma-sync/normalize-component-tokens.mjs` | 修改：`writeJson` 改 idempotent + 顶部加 `--force` 解析 |

### §0.2 — Idempotence 实现策略

两个脚本结构对称，都有一个 single write helper（`saveJson` / `writeJson`）。修改这个 helper 加 idempotence。

**核心比对逻辑**（伪代码）：

```javascript
function writeIfChanged(path, newData, timestampFields, { force = false } = {}) {
  // newData 是即将写入的对象（含 timestamp）
  const newJson = `${JSON.stringify(newData, null, 2)}\n`

  if (force) {
    writeFileSync(path, newJson, 'utf8')
    return { wrote: true, reason: 'force' }
  }

  // 文件不存在 → 必写
  if (!existsSync(path)) {
    writeFileSync(path, newJson, 'utf8')
    return { wrote: true, reason: 'new' }
  }

  // 文件存在 → 剥离 timestamp 字段后比对
  const existing = JSON.parse(readFileSync(path, 'utf8'))
  const stripFn = (obj) => stripPaths(obj, timestampFields)
  const a = JSON.stringify(stripFn(existing), null, 2)
  const b = JSON.stringify(stripFn(newData), null, 2)

  if (a === b) {
    return { wrote: false, reason: 'unchanged' }
  }

  writeFileSync(path, newJson, 'utf8')
  return { wrote: true, reason: 'changed' }
}

// stripPaths(obj, ['extractedAt'])
// stripPaths(obj, ['tokenizedAt', 'source.rawExtractedAt'])
// 支持 dot path（'source.rawExtractedAt'）：递归剥指定字段
function stripPaths(obj, paths) {
  const clone = structuredClone(obj)
  for (const p of paths) {
    const segments = p.split('.')
    let cursor = clone
    for (let i = 0; i < segments.length - 1; i++) {
      if (cursor && typeof cursor === 'object') cursor = cursor[segments[i]]
    }
    if (cursor && typeof cursor === 'object') delete cursor[segments.at(-1)]
  }
  return clone
}
```

**说明**：
- `structuredClone` 是 Node 17+ 内置，prompt owner 已确认 node 版本 >= 20（CI uses Node 22）
- 只剥**时间戳字段**，不剥 `rawSha256`（它是 deterministic from content；如果 content 变了 sha 自然变，比对会触发写入）
- 也不剥 `count` / `components[]` 等业务字段（这些变化就是真实变化）

### §0.3 — extract.mjs 改动点

文件顶部加 `--force` flag 解析：

```javascript
const FORCE = process.argv.includes('--force')
```

新增 helper `stripPaths` 和重写 `saveJson` —— 推荐结构：

```javascript
function stripPaths(obj, paths) { /* 见 §0.2 */ }

function saveJson(relPath, data, timestampFields = []) {
  const fullPath = resolve(DATA_DIR, relPath)
  ensureDir(resolve(fullPath, '..'))

  const newJson = `${JSON.stringify(data, null, 2)}\n`

  if (!FORCE && existsSync(fullPath)) {
    const existing = JSON.parse(readFileSync(fullPath, 'utf8'))
    if (
      JSON.stringify(stripPaths(existing, timestampFields), null, 2) ===
      JSON.stringify(stripPaths(data, timestampFields), null, 2)
    ) {
      console.log(`unchanged → figma-data/${relPath}`)
      return
    }
  }

  writeFileSync(fullPath, newJson, 'utf8')
  console.log(`saved → figma-data/${relPath}`)
}
```

并 import 补 `existsSync, readFileSync` from `'fs'`。

**调用点更新**（3 处 saveJson 调用都要传 `['extractedAt']`）：
- L252（`raw/components/${filename}`）：`saveJson(..., component, ['extractedAt'])`
- L255（`raw/components.index.json`）：`saveJson(..., {...}, ['extractedAt'])`
- L234（`raw/variables.json`）：`saveJson(..., {...}, ['extractedAt'])`

### §0.4 — normalize-component-tokens.mjs 改动点

同样模式。顶部加：

```javascript
const FORCE = process.argv.includes('--force')
```

`existsSync` 已经隐式存在（`readdirSync`），补 import 即可。

重写 `writeJson`：

```javascript
function writeJson(path, value, timestampFields = []) {
  const newJson = `${JSON.stringify(value, null, 2)}\n`

  if (!FORCE && existsSync(path)) {
    const existing = JSON.parse(readFileSync(path, 'utf8'))
    if (
      JSON.stringify(stripPaths(existing, timestampFields), null, 2) ===
      JSON.stringify(stripPaths(value, timestampFields), null, 2)
    ) {
      return
    }
  }

  writeFileSync(path, newJson, 'utf8')
}
```

**调用点更新**（2 处 writeJson 调用）：
- L240（`OUTPUT_DIR/${file}`）：`writeJson(outputPath, normalized, ['tokenizedAt', 'source.rawExtractedAt'])`
- L252（`INDEX_OUTPUT_PATH`）：`writeJson(INDEX_OUTPUT_PATH, {...}, ['normalizedAt'])`

注意：normalized component file 的 `rawExtractedAt` 在 `source.rawExtractedAt`（嵌套字段），所以 path = `'source.rawExtractedAt'`。`rawSha256` **不**列入 strip——它是 content hash，本身就是稳定 invariant。

### §0.5 — `--force` flag 行为

- 默认（无 `--force`）：跑完后内容未变的文件不写盘
- `--force`：无条件写盘（刷新时间戳）——audit 工具 / debug 需要时用

不需要其他 CLI option。不需要 verbose log。

---

## §1 — 必读输入

1. [`figma-sync/extract.mjs`](../../../figma-sync/extract.mjs) — 当前 `saveJson` + 3 处调用点（L234 / L252 / L255）
2. [`figma-sync/normalize-component-tokens.mjs`](../../../figma-sync/normalize-component-tokens.mjs) — 当前 `writeJson` + 2 处调用点（L240 / L252）
3. [`figma-sync/sync-figma-library.mjs`](../../../figma-sync/sync-figma-library.mjs) — pipeline 入口（不改，仅理解）

---

## §2 — 任务清单（按顺序）

### 任务 2.1 — 修改 `figma-sync/extract.mjs`

按 §0.3：
- 顶部 import 加 `existsSync, readFileSync`
- 顶部加 `FORCE` flag 解析
- 加 `stripPaths` helper
- 重写 `saveJson` 加 idempotence + 接收 `timestampFields` 参数
- 3 处调用传入 `['extractedAt']`

### 任务 2.2 — 修改 `figma-sync/normalize-component-tokens.mjs`

按 §0.4：
- 顶部 import 加 `existsSync`（`readFileSync` 已有）
- 顶部加 `FORCE` flag 解析
- 加 `stripPaths` helper（与 extract.mjs 同实现——可以接受小量重复，不要为此抽 shared module）
- 重写 `writeJson` 加 idempotence + 接收 `timestampFields` 参数
- 2 处调用分别传入 `['tokenizedAt', 'source.rawExtractedAt']` 和 `['normalizedAt']`

### 任务 2.3 — 验证 normalize-component-tokens.mjs idempotence

extract.mjs 需要 FIGMA_TOKEN（live API）不能本地验，仅代码 review。但 normalize 可以本地跑：

```bash
# 先确认起点干净（不动现有 dirty 文件，但记录 baseline）
git status --short figma-data/normalized/components-tokenized > /tmp/baseline.txt
git status --short figma-data/normalized/components-tokenized.index.json >> /tmp/baseline.txt

# 第一次跑（应该会把现有挂着 dirty 的 tokenizedAt 重写——结果可能仍 dirty 但稳定）
pnpm generate:component-tokens

# 记录第一次结果
git status --short figma-data/normalized/components-tokenized > /tmp/run1.txt
git status --short figma-data/normalized/components-tokenized.index.json >> /tmp/run1.txt

# 第二次跑（这是关键：内容未变，应该 0 写入）
pnpm generate:component-tokens

# 记录第二次结果
git status --short figma-data/normalized/components-tokenized > /tmp/run2.txt
git status --short figma-data/normalized/components-tokenized.index.json >> /tmp/run2.txt

# 比对 run1 vs run2 — 应该完全一致（同一组 dirty 文件，无新增）
diff /tmp/run1.txt /tmp/run2.txt
```

**验证标准**：
- `diff /tmp/run1.txt /tmp/run2.txt` 输出空 → idempotent ✅
- 如果有 diff → 失败，§4 记录

### 任务 2.4 — 验证 `--force` flag 行为

```bash
# 加 --force
node figma-sync/normalize-component-tokens.mjs --force

# 应该所有相关文件都被重写（time stamp 刷新）
git status --short figma-data/normalized/components-tokenized | wc -l
# 比未加 --force 多很多（基本所有 tokenized 文件 dirty）
```

**验证标准**：`--force` 比无 flag 多刷新（不必精确数量，但必须显著多）。

### 任务 2.5 — vitest 不破

```bash
pnpm test
```

预期：`105 passed | 1 skipped`（基线由 commit 5b39e886 建立）。

### 任务 2.6 — build 不破

```bash
pnpm build
```

预期：✅ 无错。

### 任务 2.7 — extract.mjs 静态代码自检

不跑 live extract（没 FIGMA_TOKEN）。手动 grep 确认改动正确：

```bash
grep -n "saveJson\|FORCE\|stripPaths\|existsSync\|readFileSync" figma-sync/extract.mjs | head -20
```

确认：
- `import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs'` ✅
- 顶部 `const FORCE = process.argv.includes('--force')` ✅
- `function stripPaths(...)` 定义 ✅
- `function saveJson(relPath, data, timestampFields = [])` 签名 ✅
- 3 处调用 `saveJson(..., ..., ['extractedAt'])` ✅

### 任务 2.8 — commit

```bash
git add figma-sync/extract.mjs figma-sync/normalize-component-tokens.mjs
git commit -m "fix(INFRA-F26): timestamp-aware idempotent writes in extract.mjs + normalize-component-tokens.mjs"
```

**不要** commit `figma-data/normalized/` 下的任何文件——那是验证步骤的副产物，user 后续会单独决定怎么处理 baseline。

---

## §3 — 验收清单

- [ ] `figma-sync/extract.mjs` `saveJson` 接收 `timestampFields` 参数，按 §0.3 实现 idempotence + `FORCE` flag
- [ ] `figma-sync/normalize-component-tokens.mjs` `writeJson` 接收 `timestampFields` 参数，按 §0.4 实现
- [ ] 两个脚本都加 `--force` flag 顶部解析
- [ ] `stripPaths` helper 支持 dot path（验证 `'source.rawExtractedAt'` 可剥嵌套字段）
- [ ] `pnpm generate:component-tokens` 连跑两次，第二次 0 新增 dirty（`diff run1 run2` 空）
- [ ] `pnpm generate:component-tokens --force` 仍能正常刷新所有文件
- [ ] `pnpm test` 仍 105 passed
- [ ] `pnpm build` 仍 ✅
- [ ] commit 仅含两个 .mjs 文件改动，**不**含 figma-data/ 改动
- [ ] **不动**：normalize.mjs（同类问题但 out-of-scope）/ 其他 figma-sync 脚本 / docs / backlog / retrospection

---

## §4 — 完成报告

```
## INFRA-F26 extract-figma timestamp idempotence 完成报告

### extract.mjs 改动
- saveJson 签名：(relPath, data, timestampFields = []) ✅
- FORCE flag：✅
- stripPaths helper：✅
- 3 处调用都传 ['extractedAt']：✅

### normalize-component-tokens.mjs 改动
- writeJson 签名：(path, value, timestampFields = []) ✅
- FORCE flag：✅
- stripPaths helper：✅
- 调用 1（OUTPUT_DIR/${file}）：['tokenizedAt', 'source.rawExtractedAt'] ✅
- 调用 2（INDEX_OUTPUT_PATH）：['normalizedAt'] ✅

### Idempotence 验证（normalize）
- run1 dirty 数：N
- run2 dirty 数：N
- diff run1 vs run2：[空 / 有差异]
- 结论：[idempotent ✅ / 失败原因]

### --force flag 验证
- 不加 --force：N 文件 dirty
- 加 --force：M 文件 dirty
- M > N：✅

### 回归
- pnpm test：105 passed / 失败 N
- pnpm build：✅ / 失败

### extract.mjs 静态自检
- imports / FORCE / stripPaths / saveJson 签名 / 3 处调用：✅

### commit hash
[hash]

### 未解决项 / blocker
- [无 / 描述]

DONE — extract + normalize-component-tokens 都已 idempotent，user 后续可 pnpm sync:figma-library --with-extract 验证全 pipeline 0 dirty。
```
