# INFRA-F48 sort:tokens lint 实现计划

> **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:** 给 `src/tokens/variables.css` 加一个零-config lint，机械守护「分类分组展示」——同名分组标题不重复 + 无游离 token。

**Architecture:** 单个 `scripts/audit-sort-tokens.mjs`，核心是可导出纯函数 `checkSortTokens(cssText)`（逐行状态机解析 `:root` / `[data-theme]` 块），加 CLI runner。挂 pre-commit 条件触发（仅当 variables.css 或脚本自身 staged 才跑）、blocking。守 variables.css 一处即覆盖全链路（generate 就地保值不重排 / bundle 逐字拷贝）。

**Tech Stack:** Node ESM (`.mjs`)、vitest（`.test.ts` import `.mjs`，照 `tests/audit-demo-framework-parity.test.ts` 范式）、husky pre-commit。

**Spec:** [docs/superpowers/specs/2026-07-07-infra-f48-sort-tokens-lint-design.md](../specs/2026-07-07-infra-f48-sort-tokens-lint-design.md)

## Global Constraints

- **零外部 config**：真源=文件自身，不引入 token→分类映射或顺序声明文件。
- **零误报优先**：任何合法的现有 `variables.css` 结构必须 PASS（含 Domain 块「同前缀故意分两处、标题文字不同」、Spacing/Radius「标签写在 `:root {` 之外」）。
- **两条不变式**：I1 同名分组标题不重复（每块内）；I2 无游离 token（每条 `--x:` 前有分组标签）。
- **明确不做**：dark/light 严格镜像、绝对顺序校验、`--fix`、跨块归属校验。
- **接线照现有 gate 惯例**：npm script `audit:sort-tokens`；pre-commit 用 `git diff --cached --name-only --diff-filter=AM | grep -qE '(...)'` 条件触发。
- commit message 结尾加 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`。

---

## File Structure

- `scripts/audit-sort-tokens.mjs` — **新建**。纯函数 `checkSortTokens(cssText)` + CLI runner。唯一实现文件。
- `tests/audit-sort-tokens.test.ts` — **新建**。vitest：真实文件 PASS + I1/I2 违规夹具 + 两个零误报边界夹具。
- `package.json` — **改**。scripts 加 `"audit:sort-tokens"`。
- `.husky/pre-commit` — **改**。token-contract gate 之后加一段条件 gate。

---

## Task 1：`checkSortTokens` 纯函数 + 脚本（TDD）

**Files:**
- Create: `scripts/audit-sort-tokens.mjs`
- Test: `tests/audit-sort-tokens.test.ts`

**Interfaces:**
- Produces: `export function checkSortTokens(cssText: string): { ok: boolean, violations: Array<{ type: 'duplicate-label' | 'orphan-token', line: number, message: string }> }`。CLI：`node scripts/audit-sort-tokens.mjs [file...]`（无参默认 `src/tokens/variables.css`），有违规 `exit 1` 并打印 `L<行号> [type] message`，否则 `exit 0`。

- [ ] **Step 1: 写失败测试**

创建 `tests/audit-sort-tokens.test.ts`：

```ts
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { checkSortTokens } from '../scripts/audit-sort-tokens.mjs'

describe('checkSortTokens', () => {
  it('真实 variables.css 通过（0 violation）', () => {
    const css = readFileSync(resolve(__dirname, '../src/tokens/variables.css'), 'utf8')
    const r = checkSortTokens(css)
    expect(r.violations).toEqual([])
    expect(r.ok).toBe(true)
  })

  it('I1：同块内重复分组标题 → duplicate-label', () => {
    const css = [
      ':root {',
      '  /* ===== Icon ===== */',
      '  --icon-a: #111;',
      '  /* ===== Icon ===== */',
      '  --icon-b: #222;',
      '}',
    ].join('\n')
    const r = checkSortTokens(css)
    expect(r.ok).toBe(false)
    expect(r.violations.some(v => v.type === 'duplicate-label')).toBe(true)
  })

  it('I2：块顶标签前的裸 token → orphan-token', () => {
    const css = [
      ':root {',
      '  --orphan: #111;',
      '  /* ===== Icon ===== */',
      '  --icon-a: #222;',
      '}',
    ].join('\n')
    const r = checkSortTokens(css)
    expect(r.ok).toBe(false)
    expect(r.violations.some(v => v.type === 'orphan-token')).toBe(true)
  })

  it('零误报：Domain 式同前缀故意分两处（标题文字不同）通过', () => {
    const css = [
      ':root {',
      '  /* Notification variant bg */',
      '  --notification-bg-dark: #111;',
      '  /* Notification */',
      '  --notification-width: 480px;',
      '}',
    ].join('\n')
    const r = checkSortTokens(css)
    expect(r.ok).toBe(true)
  })

  it('零误报：标签写在 :root { 之外（Spacing 式）通过', () => {
    const css = [
      '/* ── Spacing ── */',
      ':root {',
      '  --sp-xxs: 4px;',
      '  --sp-xs: 8px;',
      '}',
    ].join('\n')
    const r = checkSortTokens(css)
    expect(r.ok).toBe(true)
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/audit-sort-tokens.test.ts`
Expected: FAIL — 无法解析 `../scripts/audit-sort-tokens.mjs`（模块不存在 / `checkSortTokens is not a function`）。

- [ ] **Step 3: 写实现**

创建 `scripts/audit-sort-tokens.mjs`：

```js
#!/usr/bin/env node
// audit-sort-tokens.mjs — INFRA-F48
// 守 src/tokens/variables.css 的分类分组展示：
//   I1 — 同一 :root / [data-theme] 块内，同名分组标题（注释）不重复
//        （命中「分类被拆成两个同名块」，如 Icon 出现两次）
//   I2 — 无游离 token：每条 `--x: …;` 前必须有分组标签
//        （块内注释，或紧贴 `:root {` 之前一行的 `── X ──` 注释）
// 零 config、自引用：文件即真源。不校验 dark/light 镜像 / 绝对顺序 / 块归属。
// 设计见 docs/superpowers/specs/2026-07-07-infra-f48-sort-tokens-lint-design.md
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

const OPENER = /^(:root|\[data-theme[^\]]*\])\s*\{/

// 去掉注释文字的首尾装饰（= ─ - 和空白）
function normalizeLabel(text) {
  return text
    .replace(/^[\s=─\-]+/, '')
    .replace(/[\s=─\-]+$/, '')
    .trim()
}

/**
 * @param {string} cssText
 * @returns {{ ok: boolean, violations: Array<{type: string, line: number, message: string}> }}
 */
export function checkSortTokens(cssText) {
  const lines = cssText.split('\n')
  const violations = []

  let inComment = false          // 处于多行 /* ... */ 内
  let inTrackedBlock = false     // 处于 :root / [data-theme] 块内
  let labelsInBlock = new Set()  // 当前块内出现过的 normalized 标题（I1）
  let hasLabelForBlock = false   // 当前块是否已见任何标题（含块前 pending）（I2）
  let pendingLabel = null        // 块外最近一条注释（即 `── X ──`）

  for (let idx = 0; idx < lines.length; idx++) {
    const lineNo = idx + 1
    const s = lines[idx].trim()

    if (inComment) {
      if (s.includes('*/')) inComment = false
      continue
    }

    // 本行有注释？
    const openIdx = s.indexOf('/*')
    if (openIdx !== -1) {
      const closeIdx = s.indexOf('*/', openIdx + 2)
      const commentText = closeIdx !== -1
        ? s.slice(openIdx + 2, closeIdx)
        : s.slice(openIdx + 2)
      const label = normalizeLabel(commentText)
      if (label) {
        if (inTrackedBlock) {
          if (labelsInBlock.has(label)) {
            violations.push({
              type: 'duplicate-label',
              line: lineNo,
              message: `分组标题重复（同一块内出现两次）：「${label}」— 分类被拆散？`,
            })
          } else {
            labelsInBlock.add(label)
          }
          hasLabelForBlock = true
        } else {
          pendingLabel = label
        }
      }
      if (closeIdx === -1) inComment = true
      continue // 注释行只当标签，不当声明（本文件无 token 行尾带注释的混排）
    }

    // 块起始？
    if (OPENER.test(s)) {
      inTrackedBlock = true
      labelsInBlock = new Set()
      hasLabelForBlock = false
      if (pendingLabel) {
        labelsInBlock.add(pendingLabel)
        hasLabelForBlock = true
      }
      pendingLabel = null
      continue
    }

    // 块结束？
    if (s === '}') {
      inTrackedBlock = false
      labelsInBlock = new Set()
      hasLabelForBlock = false
      pendingLabel = null
      continue
    }

    // 块内 token 声明？
    if (inTrackedBlock && /^--[A-Za-z0-9-]+\s*:/.test(s)) {
      if (!hasLabelForBlock) {
        const name = s.slice(0, s.indexOf(':')).trim()
        violations.push({
          type: 'orphan-token',
          line: lineNo,
          message: `游离 token（前面没有分组标题）：${name}`,
        })
      }
    }
  }

  return { ok: violations.length === 0, violations }
}

// CLI
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const files = process.argv.slice(2)
  if (files.length === 0) files.push('src/tokens/variables.css')
  let failed = 0
  for (const f of files) {
    const { ok, violations } = checkSortTokens(readFileSync(f, 'utf8'))
    if (!ok) {
      failed += violations.length
      console.error(`❌ ${f}: ${violations.length} 处分类顺序问题`)
      for (const v of violations) {
        console.error(`   L${v.line} [${v.type}] ${v.message}`)
      }
    } else {
      console.log(`✓ ${f}: 分类分组 OK（无拆散、无游离）`)
    }
  }
  process.exit(failed ? 1 : 0)
}
```

- [ ] **Step 4: 跑测试确认全过**

Run: `pnpm vitest run tests/audit-sort-tokens.test.ts`
Expected: PASS — 5/5（真实文件 PASS + I1/I2 违规命中 + 2 个零误报边界通过）。

- [ ] **Step 5: 跑 CLI 冒烟**

Run: `node scripts/audit-sort-tokens.mjs src/tokens/variables.css`
Expected: 打印 `✓ src/tokens/variables.css: 分类分组 OK（无拆散、无游离）`，`echo $?` = 0。

- [ ] **Step 6: Commit**

```bash
git add scripts/audit-sort-tokens.mjs tests/audit-sort-tokens.test.ts
git commit -m "feat(audit): INFRA-F48 sort-tokens lint — variables.css 分类分组守护

checkSortTokens 纯函数 + CLI：I1 同名分组标题不重复、I2 无游离 token。
零 config、零误报（Domain 同前缀分两处 / Spacing 块外标签均通过）。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 2：接线 npm script + pre-commit gate

**Files:**
- Modify: `package.json`（scripts 段加一行）
- Modify: `.husky/pre-commit`（token-contract gate 之后加一段）

**Interfaces:**
- Consumes: Task 1 的 `scripts/audit-sort-tokens.mjs`（CLI，`exit 1` on violation）。

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

在 `package.json` 的 `"scripts"` 里、`"audit:token-contract"` 那一行附近加：

```json
    "audit:sort-tokens": "node scripts/audit-sort-tokens.mjs src/tokens/variables.css",
```

（注意保持 JSON 逗号合法：加在某条已有 audit 脚本之后。）

- [ ] **Step 2: 验证 script 可跑**

Run: `pnpm run audit:sort-tokens`
Expected: `✓ src/tokens/variables.css: 分类分组 OK（无拆散、无游离）`，退出码 0。

- [ ] **Step 3: `.husky/pre-commit` 加条件 gate**

找到现有这段（token-contract gate）：

```sh
echo "▶ pre-commit: token-contract (variables.css primitive tier vs Figma — INFO-04)"
if git diff --cached --name-only --diff-filter=AM | grep -qE '(src/tokens/variables\.css|figma-data/normalized/variables\.json|scripts/audit-token-contract\.mjs)'; then
  pnpm run audit:token-contract
else
  echo "   ✓ no token-contract-relevant files staged, skipped"
fi
```

在它**之后**紧接着插入：

```sh
echo "▶ pre-commit: sort-tokens (variables.css 分类分组不拆散/无游离 — INFRA-F48)"
if git diff --cached --name-only --diff-filter=AM | grep -qE '(src/tokens/variables\.css|scripts/audit-sort-tokens\.mjs)'; then
  node scripts/audit-sort-tokens.mjs src/tokens/variables.css
else
  echo "   ✓ no sort-tokens-relevant files staged, skipped"
fi
```

- [ ] **Step 4: 验证 gate 生效（真触发）**

制造一个临时违规副本并直接跑脚本验证 `exit 1`（不污染真文件）：

Run:
```bash
cp src/tokens/variables.css /tmp/vt.css
printf '\n:root {\n  --dup: 1;\n  /* ===== X ===== */\n  --a: 1;\n  /* ===== X ===== */\n  --b: 2;\n}\n' >> /tmp/vt.css
node scripts/audit-sort-tokens.mjs /tmp/vt.css; echo "exit=$?"
```
Expected: 打印 `❌ /tmp/vt.css: ...` 含 `orphan-token`（`--dup`）+ `duplicate-label`（`X`），`exit=1`。

- [ ] **Step 5: 验证真文件仍放行**

Run: `node scripts/audit-sort-tokens.mjs src/tokens/variables.css; echo "exit=$?"`
Expected: `✓ ...`，`exit=0`。清理：`rm -f /tmp/vt.css`。

- [ ] **Step 6: Commit**

```bash
git add package.json .husky/pre-commit
git commit -m "chore(hooks): INFRA-F48 挂 sort-tokens pre-commit gate + npm script

仅当 variables.css 或脚本自身 staged 才跑，blocking；放 token-contract gate 之后。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## 收尾（两任务后，owner）

- 全套 pre-commit 跑一遍确认新 gate 不打断已有链路（用一次真实 commit 或空跑）。
- backlog `docs/internal/backlog.md` 的 INFRA-F48 按维护规则处理（实现完成 → 移除或标 resolved，改动留 git）。
- STATUS.md「Last updated」刷到今天。
- 三端 push（origin 双 URL：Gitea + GitHub）。

---

## Self-Review

**Spec coverage:**
- §2 单文件覆盖全链路 → Task 1 脚本只作用 variables.css；Global Constraints 记录 rationale。✓
- §3 I1/I2 两不变式 → Task 1 Step 3 实现 + Step 1 测试。✓
- §4 明确不做 → Global Constraints「明确不做」列全（无对应 task，正确）。✓
- §5 接线（npm script + pre-commit 条件触发 blocking，放 token-contract 附近）→ Task 2。✓
- §6 测试（纯函数 + 夹具 + 真文件 PASS）→ Task 1 Step 1（5 例含真文件 + I1 + I2 + 2 零误报边界）。✓
- §7 已知局限（不校验块归属 / dark-light）→ 不实现，Global Constraints 记录。✓

**Placeholder scan:** 无 TBD/TODO；每个 code step 给完整代码；每个 run step 给确切命令 + 预期输出。✓

**Type consistency:** `checkSortTokens(cssText) → { ok, violations:[{type,line,message}] }` 在 Interfaces、测试、实现、CLI 四处一致；`type` 值 `'duplicate-label'`/`'orphan-token'` 一致。✓
