# INFRA-F48：sort:tokens — variables.css 分类顺序守护 lint（设计）

- **日期**：2026-07-07
- **backlog**：INFRA-F48
- **规则真源**：`docs/meta-rules.md` §1「分类型真源 / 派生产物按分类分组展示」子规则
- **enforcement**：本 lint = 该规则的 L4（pre-commit hook）机械化落地

## 1. 背景与问题

`src/tokens/variables.css` 按语义分类分组展示（Neutrals / Brand & status / Text / Icon / …），组内有序。2026-07-06 曾发生 **Icon 色值被劈成两块**（同一分类被拆散），靠人工重排修复（commit `5298fba1`）。目前该分类组织**只有人工纪律守着**，无机械守护——重排后易再次悄悄漂移。

本 lint 把「按分类分组展示」这条规则从 L1（人工）升到 L4（pre-commit 强制）。

## 2. 范围：单文件 lint 即覆盖全链路

规则名义上覆盖四条链路（`pnpm generate` / 更新 Figma 库 / `design-sync` / `export:claude-design-bundle`）。核实后确认**守住 `variables.css` 一处即全覆盖**：

- **`pnpm generate`（`figma-sync/generate-tokens.mjs`）**：`renderTokens` 只**就地改写 primitive token 的值**，行顺序 / 注释 / 其余一切逐字保留（幂等）——**永不重排**。故无需改生成器，也无需生成器侧的排序逻辑。
- **`export:claude-design-bundle`（`scripts/export-claude-design-bundle.mjs:64`）**：**逐字拷贝** `variables.css` 整份进 bundle（不拆分 / 不改名）。design-sync、Claude.ai/design 都消费这份拷贝 → 自动继承顺序。

∴ 下游都是「就地保值」或「逐字拷贝」，只要 `variables.css` 本身有序，下游必然有序。**本设计只做一个作用于 `variables.css` 的 lint，不给每条链路各写闸。**

## 3. 校验什么：两条零-config 结构不变式

脚本 `scripts/audit-sort-tokens.mjs` 解析 `variables.css` 全部 `:root` / `[data-theme="light"]` 块，校验两条：

- **I1 — 同名分组标题不重复**：任一块内，同一条分组注释文字不得出现两次。
  - 直接命中「Icon 被劈成两块（两个相同 `/* ===== Icon ===== */` 标题）」。
  - 「故意的按关注点分组」（如 Domain 块里 `--notification-bg-*` 归 variant 色区、`--notification-width-*` 归结构尺寸区，两处**标题文字不同**）**不会误报**——正是想要的行为。
- **I2 — 无游离 token**：每条 `--x: …;` 声明前必须有分组标签，不许在块顶「裸奔」。
  - 「分组标签」= 任一 `/* … */` 注释行，**不区分风格**（`===== X =====` / `── X ──` / `=== X ===` / `/* Foo */` 都算）。
  - 紧贴 `:root {` **之前**一行的 `── X ──` 块级注释，算作该块的标签（Spacing/Radius 等块的标签写在 `:root {` 外，其块内 token 直接跟随合法，不误报）。

**判定语义**：任一块违反任一条 → 脚本 `exit 1` + 打印文件行号 + 违规说明。零违规 → `exit 0`。

**为什么这两条够**：`pnpm generate` 不重排 + 人工作者维护顺序 → 只要「分类不被拆散、token 不游离」，分类分组展示即成立。加 / 移 token 永不误报（除非真重复了标题或把 token 丢到无标签处）→ 维护成本 ≈ 0。

## 4. 明确不做（YAGNI / 保持简单）

- **不做 dark/light 严格镜像校验**：该检查需按标题匹配 + 排除 Derived aliases 的有意子集（light 仅覆盖随 theme 变的 token），边界复杂；且它防的「dark/light 漂移」**不是已发生的 bug**。dark/light 一致性继续由 meta-rule L1 人工纪律守，真碰到漂移 bug 再加（可见报错、非静默，加得起）。
- **不做外部 config 定顺序 / token→分类映射**：会引入第二真源（加 token 要同步改 config），违「真源单一」。故也**不强制** token 的绝对块归属（「某 token 必须在某块」靠人工 review）。
- **不做 `--fix` 自动重排**：重排是语义判断，v1 只报错、人工修。
- **不校验 Spacing/Radius 等块的组内绝对顺序**：I1/I2 已保证「有标签、无拆散、无游离」，绝对排序留人工。

## 5. 接线（照现有 ~15 个 gate 惯例）

- **npm script**：`package.json` 加 `"audit:sort-tokens": "node scripts/audit-sort-tokens.mjs"`。
- **pre-commit**（`.husky/pre-commit`）：新增一段，**仅当 `src/tokens/variables.css` 或脚本自身 staged 时才跑**（照现有 gate 的条件触发 idiom：`git diff --cached --name-only --diff-filter=AM | grep -qE '(src/tokens/variables\.css|scripts/audit-sort-tokens\.mjs)'`），不通过则阻断 commit。放在现有 `token-contract` gate 附近（同属 variables.css 关注点，两闸不重叠：token-contract 管值 vs Figma，sort-tokens 管分组结构）。
- **不与其它 gate 冲突**：sort-tokens 只读 `variables.css` 分组结构，与 visual gate / demo-framework-parity / token-contract / eslint 等关注点均不重叠。

## 6. 测试

`tests/audit-sort-tokens.test.ts`（vitest），脚本核心逻辑抽成**可导出纯函数**（如 `checkSortTokens(cssText) → { ok, violations }`），测：

- ✅ 当前真实 `variables.css` → PASS（0 violation）。
- ❌ I1 夹具：同块内重复 `/* ===== Icon ===== */` → 报重复标题。
- ❌ I2 夹具：块顶标签前有裸 token → 报游离。
- ✅ 边界：Domain 块「同组件前缀分两处、标题文字不同」→ 不误报；Spacing 块「标签在 `:root {` 外」→ 不误报。

## 7. 已知局限

- 不强制 token 的绝对块归属：把 spacing token 误塞进 radius 块（且落在某标签下）不会被 I1/I2 catch，靠人工 review。取舍：换零-config + 零误报的简单性。
- 不校验 dark/light 一致性（见 §4）。

## 8. 交付物

- `scripts/audit-sort-tokens.mjs`（含可导出纯函数）
- `tests/audit-sort-tokens.test.ts`
- `package.json` +1 script
- `.husky/pre-commit` +1 条件段
- backlog INFRA-F48 收尾（实现后按维护规则移除或标 resolved）
