# ESLint + jsx-a11y 扫尾（PR 3）实施计划

> **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：** 把前端剩余 229 条 ESLint warning 清零、所有规则 flip 到 error、移除 `--max-warnings` ratchet。

**Architecture：** 先搭工具（`voidPromise` / `useAutoFocus` / `activateOnEnterSpace`），再从类型源头（`api.ts`、SSE hooks）加类型让下游 `unsafe-*` 自愈，然后按规则类别批量修复，最后一次性翻规则级别 + 清空 migration 常量 + 移除 `--max-warnings`。

**Tech Stack：** TypeScript + React 19 + ESLint v9 flat config + typescript-eslint v8 + eslint-plugin-jsx-a11y + eslint-plugin-react-hooks v7 + vitest。

**Spec：** `docs/superpowers/specs/2026-04-14-eslint-a11y-pr3-design.md`

**前置条件：** PR 2 (#292) 已 merge 并 rebase 到最新。开始时 `cd frontend && pnpm lint 2>&1 | tail -1` 验证 warning 数量（预期 ~211），如偏差较大需要先和 spec 对齐实际清单。

**约定：** 本 PR 是批量 lint 清理，每个 Task 的 "测试" 是 `pnpm lint <scope>` 输出的 warning 数下降。非功能性 bug fix，TDD 的等价形式是：**每个 Task 完成时运行 `pnpm lint` 验证 warning 数减少**。

---

## 文件结构

**新增文件：**
- `frontend/src/utils/async.ts` — `voidPromise` / `voidCall` helpers
- `frontend/src/utils/async.test.ts` — helper 单元测试
- `frontend/src/utils/a11y.ts` — `activateOnEnterSpace` keyboard helper
- `frontend/src/hooks/useAutoFocus.ts` — 替换 `autoFocus` prop 的 hook

**关键修改文件：**
- `frontend/eslint.config.js` — 测试 override、删除 migration 常量、加严格化 override
- `frontend/package.json` — 移除 `--max-warnings=212`
- `frontend/src/api.ts` — API 响应类型定义（源头）
- `frontend/src/hooks/useTasksSSE.ts`、`useProjectEventsSSE.ts` — SSE payload 类型
- `frontend/src/pages/LoginPage.tsx` — autoFocus/async 的集中修复点
- `frontend/src/i18n/index.ts`、`frontend/src/test/setup.ts` — 顶层 floating promise
- 其余 ~30 个 components 文件 — 按规则散点修复
- `CONTRIBUTING.md` — 新增 disable 使用规范

---

## Task 1: 工具脚手架（helpers + hook）

**Files:**
- Create: `frontend/src/utils/async.ts`
- Create: `frontend/src/utils/async.test.ts`
- Create: `frontend/src/utils/a11y.ts`
- Create: `frontend/src/hooks/useAutoFocus.ts`

- [ ] **Step 1.1: 写 `async.ts` 的测试**

Create `frontend/src/utils/async.test.ts`：

```ts
import { describe, expect, it, vi } from "vitest";
import { voidCall, voidPromise } from "./async";

describe("voidPromise", () => {
  it("returns a void function that calls fn with args", () => {
    const fn = vi.fn(async (a: number, b: string) => `${a}-${b}`);
    const wrapped = voidPromise(fn);
    const result = wrapped(1, "x");
    expect(result).toBeUndefined();
    expect(fn).toHaveBeenCalledWith(1, "x");
  });

  it("routes rejection to console.error by default", async () => {
    const err = new Error("boom");
    const fn = vi.fn(async () => { throw err; });
    const spy = vi.spyOn(console, "error").mockImplementation(() => {});
    voidPromise(fn)();
    await new Promise((r) => setTimeout(r, 0));
    expect(spy).toHaveBeenCalledWith(err);
    spy.mockRestore();
  });

  it("routes rejection to custom onError when provided", async () => {
    const err = new Error("boom");
    const fn = vi.fn(async () => { throw err; });
    const onError = vi.fn();
    voidPromise(fn, { onError })();
    await new Promise((r) => setTimeout(r, 0));
    expect(onError).toHaveBeenCalledWith(err);
  });
});

describe("voidCall", () => {
  it("swallows rejection with console.error by default", async () => {
    const spy = vi.spyOn(console, "error").mockImplementation(() => {});
    voidCall(Promise.reject(new Error("x")));
    await new Promise((r) => setTimeout(r, 0));
    expect(spy).toHaveBeenCalled();
    spy.mockRestore();
  });

  it("routes rejection to custom onError", async () => {
    const onError = vi.fn();
    voidCall(Promise.reject(new Error("x")), onError);
    await new Promise((r) => setTimeout(r, 0));
    expect(onError).toHaveBeenCalled();
  });
});
```

- [ ] **Step 1.2: 运行测试确认失败**

```bash
cd frontend && pnpm vitest run src/utils/async.test.ts
```

Expected: FAIL（`async.ts` 不存在）

- [ ] **Step 1.3: 实现 `async.ts`**

Create `frontend/src/utils/async.ts`：

```ts
type VoidPromiseOptions = {
  onError?: (err: unknown) => void;
};

export function voidPromise<Args extends unknown[]>(
  fn: (...args: Args) => Promise<unknown>,
  opts?: VoidPromiseOptions,
): (...args: Args) => void {
  return (...args) => {
    fn(...args).catch((err: unknown) => {
      if (opts?.onError) opts.onError(err);
      else console.error(err);
    });
  };
}

export function voidCall<T>(
  promise: Promise<T>,
  onError: (err: unknown) => void = console.error,
): void {
  promise.catch(onError);
}
```

- [ ] **Step 1.4: 运行测试确认通过**

```bash
cd frontend && pnpm vitest run src/utils/async.test.ts
```

Expected: PASS（5 tests）

- [ ] **Step 1.5: 实现 `a11y.ts`**

Create `frontend/src/utils/a11y.ts`：

```ts
import type { KeyboardEvent } from "react";

export function activateOnEnterSpace(handler: () => void) {
  return (e: KeyboardEvent) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      handler();
    }
  };
}
```

- [ ] **Step 1.6: 实现 `useAutoFocus.ts`**

Create `frontend/src/hooks/useAutoFocus.ts`：

```ts
import { useEffect, useRef } from "react";

export function useAutoFocus<T extends HTMLElement>(enabled = true) {
  const ref = useRef<T>(null);
  useEffect(() => {
    if (enabled) ref.current?.focus();
  }, [enabled]);
  return ref;
}
```

- [ ] **Step 1.7: 验证新文件本身 lint 通过**

```bash
cd frontend && pnpm lint src/utils/async.ts src/utils/async.test.ts src/utils/a11y.ts src/hooks/useAutoFocus.ts
```

Expected: `0 errors, 0 warnings`

- [ ] **Step 1.8: Commit**

```bash
git add frontend/src/utils/async.ts frontend/src/utils/async.test.ts frontend/src/utils/a11y.ts frontend/src/hooks/useAutoFocus.ts
git commit -m "feat(frontend): add async/a11y helpers for PR 3 cleanup

- voidPromise / voidCall helpers for Promise-returning handlers and fire-and-forget
- activateOnEnterSpace keyboard helper for role=button ARIA pattern
- useAutoFocus hook to replace autoFocus prop

Refs #219"
```

---

## Task 2: 测试文件 ESLint override

**Files:**
- Modify: `frontend/eslint.config.js`

- [ ] **Step 2.1: 记录基线 warning 数**

```bash
cd frontend && pnpm lint 2>&1 | tail -1
```

记下输出，例如 `211 problems (0 errors, 211 warnings)`。

- [ ] **Step 2.2: 在 eslint.config.js 里加测试文件 override**

在 `frontend/eslint.config.js` 中，紧接着已有的 test 文件语言选项 override 之后（或在 MIGRATION 展开块之前），新增：

```js
  // 测试文件放宽 any 与 unsafe-* —— 测试环境允许 mock 便利
  {
    files: ["src/**/*.test.{ts,tsx}", "src/test/**/*.{ts,tsx}"],
    rules: {
      "@typescript-eslint/no-explicit-any": "off",
      "@typescript-eslint/no-unsafe-assignment": "off",
      "@typescript-eslint/no-unsafe-member-access": "off",
      "@typescript-eslint/no-unsafe-argument": "off",
      "@typescript-eslint/no-unsafe-call": "off",
      "@typescript-eslint/no-unsafe-return": "off",
    },
  },
```

- [ ] **Step 2.3: 运行 lint 确认测试文件 warning 消失**

```bash
cd frontend && pnpm lint 2>&1 | tail -1
```

Expected: warning 数下降（`stores.test.ts` 1 条 + `useTasksSSE.test.tsx` 4 条 + 可能的 `api.test.ts` / `OverviewCanvas.test.tsx` 等，估计降 ~10-20 条）

- [ ] **Step 2.4: 更新 `package.json` 的 `--max-warnings` 为新基线**

查看当前 warning 数（假设从 211 → 195），编辑 `frontend/package.json`：

```diff
- "lint": "eslint . --max-warnings=212",
+ "lint": "eslint . --max-warnings=<新数字>",
```

运行 `cd frontend && pnpm lint` 确认 exit 0（`--max-warnings` 不再阻塞）。

- [ ] **Step 2.5: Commit**

```bash
git add frontend/eslint.config.js frontend/package.json
git commit -m "chore(frontend): relax typed rules on test files

Tests legitimately use any/mock fixtures; scoping no-explicit-any
and no-unsafe-* off for src/**/*.test.* and src/test/** follows
industry convention.

Refs #219"
```

---

## Task 3: 类型源头 — `src/api.ts`

**Files:**
- Modify: `frontend/src/api.ts`（加响应类型）
- 可能需要查阅对应后端：`server/routers/auth.py`、`server/routers/tasks.py`

- [ ] **Step 3.1: 读取 api.ts 当前结构**

```bash
cd frontend && wc -l src/api.ts
```

打开 `frontend/src/api.ts` 浏览，定位所有返回 `any` 的函数与未类型化的 `fetch().json()` 调用。

- [ ] **Step 3.2: 定位后端响应 schema**

```bash
grep -n "class.*Response\|class.*BaseModel" server/routers/auth.py server/routers/tasks.py
```

将后端 Pydantic 模型字段镜像为前端 interface。

- [ ] **Step 3.3: 加登录响应类型**

在 `frontend/src/api.ts` 顶部（或 types 区域）新增：

```ts
export interface LoginResponse {
  access_token: string;
  token_type: string;
}

export interface ErrorResponse {
  detail: string;
}
```

把 `login(...)` 函数的返回值改为 `Promise<LoginResponse>`，内部 `const data = await res.json()` 用 `as LoginResponse` 断言（runtime 仍信任后端契约；断言会被后续的 `no-unnecessary-type-assertion` 重新评估）。

- [ ] **Step 3.4: 其他 API 函数类似处理**

逐一给 `api.ts` 中所有 `fetch()` 调用加返回类型。对 `tasks` 相关 API 定义 `TaskStats` / `TaskRecord` 等镜像 interface。

- [ ] **Step 3.5: 运行 typecheck 与 lint**

```bash
cd frontend && pnpm typecheck && pnpm lint src/api.ts src/pages/LoginPage.tsx 2>&1 | tail -20
```

Expected: typecheck 零 error；`api.ts` 与 `LoginPage.tsx` 的 unsafe-* 条目大幅下降。

- [ ] **Step 3.6: 更新 `--max-warnings` 数字**

```bash
cd frontend && pnpm lint 2>&1 | tail -1
```

把 `package.json` 的 `--max-warnings` 调到新值。

- [ ] **Step 3.7: Commit**

```bash
git add frontend/src/api.ts frontend/package.json
git commit -m "refactor(frontend): type API responses to eliminate any propagation

LoginResponse / TaskStats / etc. mirror the backend Pydantic models.
Downstream unsafe-* warnings in LoginPage / useTasksSSE drop from
~20 to near zero after this change.

Refs #219"
```

---

## Task 4: 类型源头 — SSE hooks

**Files:**
- Modify: `frontend/src/hooks/useTasksSSE.ts`
- Modify: `frontend/src/hooks/useProjectEventsSSE.ts`

- [ ] **Step 4.1: 给 `useTasksSSE.ts` 加 payload 类型**

在文件顶部（或相邻 types 区）新增：

```ts
interface TaskStatsEvent {
  type: "stats";
  stats: TaskStats;
}

type TaskEvent = TaskStatsEvent;

function isTaskStatsEvent(v: unknown): v is TaskStatsEvent {
  return typeof v === "object" && v !== null && (v as { type?: unknown }).type === "stats";
}
```

把 `EventSource` 回调中 `JSON.parse(e.data) as any` 改为：

```ts
const raw: unknown = JSON.parse(e.data);
if (isTaskStatsEvent(raw)) {
  setTaskStats(raw.stats);
}
```

- [ ] **Step 4.2: 给 `useProjectEventsSSE.ts` 加 payload 类型**

定义 discriminated union：

```ts
type ProjectEvent =
  | { type: "project_updated"; project_name: string }
  | { type: "task_status"; task_id: string; status: string };
  // ... 根据代码里实际处理的 event type 补齐
```

添加对应 type guard，按 `type` 字段窄化。

- [ ] **Step 4.3: 处理 useProjectEventsSSE.ts:330 的 exhaustive-deps**

阅读代码判断 `setLocation` 是否能安全加入依赖：
- 如 `setLocation` 引用稳定（wouter 保证），加依赖（零成本）
- 如加依赖会触发 SSE 重连，保留不变 + 添加：

```ts
  // eslint-disable-next-line react-hooks/exhaustive-deps -- setLocation 引用由 wouter 保证稳定，加依赖会导致 SSE 重连
```

同时处理 `useProjectEventsSSE.ts:175` 的 `no-unsafe-finally`：把 `finally` 块里的 `return` 挪到 `try` 尾部或 `catch` 末端。

- [ ] **Step 4.4: 运行 typecheck 与 lint**

```bash
cd frontend && pnpm typecheck && pnpm lint src/hooks/useTasksSSE.ts src/hooks/useProjectEventsSSE.ts 2>&1 | tail -20
```

Expected: 两文件 warning 清零（含其中的 `no-floating-promises` 如可用 `voidCall` 立即修）。

- [ ] **Step 4.5: 更新 `--max-warnings` 并 commit**

```bash
cd frontend && pnpm lint 2>&1 | tail -1
```

调 `--max-warnings`，commit：

```bash
git add frontend/src/hooks/useTasksSSE.ts frontend/src/hooks/useProjectEventsSSE.ts frontend/package.json
git commit -m "refactor(frontend): type SSE event payloads with discriminated unions

Runtime type guards replace JSON.parse + any coercion. Fixes
no-unsafe-* cluster on useTasksSSE / useProjectEventsSSE and
addresses no-unsafe-finally in the latter.

Refs #219"
```

---

## Task 5: Promise 处理替换（voidPromise / voidCall 应用）

**Files:**
- Modify: 所有命中 `no-misused-promises` / `no-floating-promises` 的文件（约 15-20 个）

- [ ] **Step 5.1: 枚举命中文件**

```bash
cd frontend && pnpm lint 2>&1 | grep -B1 -E "no-misused-promises|no-floating-promises" | grep "^/" | sort -u | sed 's|.*/frontend/||'
```

记下文件清单。

- [ ] **Step 5.2: 批量修复 `onClick={async ...}` 模式**

对每个命中文件，将：

```tsx
<button onClick={async () => { await save(); }}>Save</button>
```

改为（加 import）：

```tsx
import { voidPromise } from "@/utils/async";
// ...
<button onClick={voidPromise(async () => { await save(); })}>Save</button>
```

或如已有单独的 `handleSave` 函数返回 Promise：

```tsx
<button onClick={voidPromise(handleSave)}>Save</button>
```

**具体起点文件**（LoginPage.tsx:58 是典型）：

```diff
- <form onSubmit={handleSubmit}>
+ <form onSubmit={voidPromise(handleSubmit)}>
```

- [ ] **Step 5.3: 修 fire-and-forget 调用**

```tsx
// 之前
fetchData();

// 之后
import { voidCall } from "@/utils/async";
voidCall(fetchData());
```

**具体文件**：
- `frontend/src/i18n/index.ts:31` — `voidCall(i18n.init())`
- `frontend/src/test/setup.ts:6` — 按实际代码判断
- SSE onmessage = async 类：改外壳 `es.onmessage = (e) => voidCall(handleMessage(e))`

- [ ] **Step 5.4: 处理 `require-await` (1 条)**

定位：`pnpm lint 2>&1 | grep -B3 require-await`

按行判断：如函数体确无 await，删 `async` 关键字；如预期异步（future-proof），加：
```ts
  // eslint-disable-next-line @typescript-eslint/require-await -- 签名对齐 X 接口，当前实现同步但预留异步扩展
```

- [ ] **Step 5.5: 运行 typecheck + lint 验证**

```bash
cd frontend && pnpm typecheck && pnpm lint 2>&1 | tail -1
```

Expected: warning 数降 ~54 条（37 misused + 16 floating + 1 require-await）。

- [ ] **Step 5.6: 更新 `--max-warnings` 并 commit**

```bash
git add -A frontend/
git commit -m "refactor(frontend): wrap Promise-returning handlers with voidPromise/voidCall

Replaces onClick={async ...} and fire-and-forget calls across
~15 files. Default error path is console.error; UI-layer toast
routing left for future when notification infra is consolidated.

Refs #219"
```

---

## Task 6: 冗余断言 + 未使用变量（机械清理）

**Files:**
- Modify: 命中 `no-unnecessary-type-assertion` / `no-unused-vars` / `no-redundant-type-constituents` 的文件

- [ ] **Step 6.1: 尝试自动修复**

```bash
cd frontend && pnpm lint:fix 2>&1 | tail -5
```

记录 auto-fix 消除的 warning 数。

- [ ] **Step 6.2: 手动处理剩余 `no-unnecessary-type-assertion`**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 no-unnecessary-type-assertion | head -30
```

定位每条，删除断言（如 `x as string` → `x`）。

- [ ] **Step 6.3: 手动处理 `no-unused-vars` (5 条)**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 no-unused-vars
```

删除未使用的 import / 变量。如是预期的 `_unused` 参数，改名前缀 `_`。

- [ ] **Step 6.4: 手动处理 `no-redundant-type-constituents` (2 条)**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 no-redundant-type-constituents
```

删除冗余联合类型成员。

- [ ] **Step 6.5: 运行 typecheck + lint + 更新 max-warnings + commit**

```bash
cd frontend && pnpm typecheck && pnpm lint 2>&1 | tail -1
```

Expected: warning 数降 24 条（17 + 5 + 2）。

```bash
git add -A frontend/
git commit -m "chore(frontend): remove redundant type assertions and unused vars

Mechanical cleanup of no-unnecessary-type-assertion,
no-redundant-type-constituents, and no-unused-vars hits.

Refs #219"
```

---

## Task 7: React Hooks — set-state-in-effect (18 条)

**Files:**
- Modify: 命中 `react-hooks/set-state-in-effect` 的文件

- [ ] **Step 7.1: 枚举并分类命中点**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 set-state-in-effect | head -60
```

对每条记录：文件:行 + 模式判断（派生态 / 同步副本 / mount-only）。

- [ ] **Step 7.2: 按模式逐个修复**

**派生态改 useMemo：**

```tsx
// 之前
useEffect(() => { setSortedList(list.sort()); }, [list]);

// 之后
const sortedList = useMemo(() => list.sort(), [list]);
```

**同步受控副本改 key+useState 或 disable：**

```tsx
// 如是独立本地编辑副本，用 key 重置
<EditForm key={data.id} initialData={data} />

// 如必须保留 effect 同步，加 disable：
  // eslint-disable-next-line react-hooks/set-state-in-effect -- 表单必须跟随 props 同步，拷贝模式是有意设计
```

**Mount-only 信号：**

```tsx
  // eslint-disable-next-line react-hooks/set-state-in-effect -- mount 时初始化一次 ready 标志，不是派生态
```

- [ ] **Step 7.3: 硬约束检查**

每处修复 diff ≤10 行。超出的标 TODO：

```tsx
  // eslint-disable-next-line react-hooks/set-state-in-effect -- TODO(a11y-pr3): 此组件 state 模型需独立重构（见 issue #XXX）
```

**上限 3 条 TODO**；如超过，回到本 Task 重新规划或拆独立工单。

- [ ] **Step 7.4: 运行 typecheck + 测试**

```bash
cd frontend && pnpm typecheck && pnpm test
```

Expected: 零 error；测试全过。

- [ ] **Step 7.5: 记录 disable 表**

在一个临时文件 `frontend/.pr3-disables.md`（稍后手动合入 PR 描述）追加：

```markdown
## set-state-in-effect
| file:line | 理由 |
|---|---|
| src/components/.../X.tsx:YY | 表单拷贝必须跟随 props |
```

- [ ] **Step 7.6: 更新 `--max-warnings` 并 commit**

```bash
cd frontend && pnpm lint 2>&1 | tail -1
```

```bash
git add -A frontend/
git commit -m "refactor(frontend): resolve react-hooks/set-state-in-effect warnings

18 hits: derived state moved to useMemo; controlled-copy patterns
kept with documented disable + reason; mount-only signals disabled
with rationale. See PR body for full disable table.

Refs #219"
```

---

## Task 8: React Hooks — exhaustive-deps (17 条)

**Files:**
- Modify: 命中 `react-hooks/exhaustive-deps` 的文件（`useProjectEventsSSE.ts:330` 已在 Task 4 处理）

- [ ] **Step 8.1: 枚举并逐条判断**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 exhaustive-deps | head -60
```

对每条判断：
- 缺稳定 setter（React / wouter）→ 加依赖
- Mount-only → disable + 理由
- 真 bug（漏的 state 会导致陈旧闭包）→ 加依赖

- [ ] **Step 8.2: 批量修复**

**加依赖（默认选项，安全）：**

```diff
-  }, [prop]);
+  }, [prop, setState]);
```

**Mount-only disable：**

```ts
  // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only 初始化，刻意不追随 X 变化
```

- [ ] **Step 8.3: 浏览器冒烟：SSE + 路由关键流**

启动 `pnpm dev`，验证：
- 任务卡片状态流（TaskHud）实时更新
- 项目事件流（打开一个项目后修改，同步到 UI）
- 路由切换（wouter `setLocation`）

- [ ] **Step 8.4: 更新 disable 表 + `--max-warnings` + commit**

```bash
git add -A frontend/
git commit -m "refactor(frontend): resolve react-hooks/exhaustive-deps warnings

17 hits: most fixed by adding stable setters to dep arrays;
mount-only effects explicitly disabled with rationale.
Verified SSE streams and routing still behave correctly.

Refs #219"
```

---

## Task 9: React Hooks — refs + incompatible-library (7 条)

**Files:**
- Modify: 命中 `react-hooks/refs` / `react-hooks/incompatible-library` 的文件

- [ ] **Step 9.1: 定位命中点**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 -E "react-hooks/refs|react-hooks/incompatible-library"
```

- [ ] **Step 9.2: 修 refs (6 条)**

将 render 阶段的 `ref.current` 访问搬到：
- `useEffect(() => { ref.current?.doSomething(); }, [deps])`
- 事件回调
- 或改用 callback ref

- [ ] **Step 9.3: 修 incompatible-library (1 条)**

逐行阅读，理解 hook 冲突原因。能修则修（通常是包装在 useEffect 里）；确实不兼容则 disable + 理由。

- [ ] **Step 9.4: typecheck + lint + commit**

```bash
cd frontend && pnpm typecheck && pnpm lint 2>&1 | tail -1
git add -A frontend/
git commit -m "refactor(frontend): fix react-hooks/refs and incompatible-library

Move render-time ref.current access into effects or callback refs.

Refs #219"
```

---

## Task 10: a11y — `<div onClick>` 家族 (9 条)

**Files:**
- Modify: 命中 `no-static-element-interactions` / `click-events-have-key-events` / `no-noninteractive-element-interactions` 的文件

- [ ] **Step 10.1: 枚举命中点**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 -E "no-static-element-interactions|click-events-have-key-events|no-noninteractive-element-interactions"
```

- [ ] **Step 10.2: 逐处判断并修复**

对每处，先判断能否换 `<button>`：
- ✅ 可换：无嵌套交互、独立的点击块
- ❌ 不可换：含嵌套按钮、表格行、list item 等

**可换为 button：**

```diff
- <div className="cursor-pointer ..." onClick={handleClick}>
+ <button
+   type="button"
+   className="cursor-pointer appearance-none bg-transparent p-0 text-left ..."
+   onClick={handleClick}
+ >
    {children}
- </div>
+ </button>
```

**保留 div，加三件套：**

```diff
+ import { activateOnEnterSpace } from "@/utils/a11y";
  <div
+   role="button"
+   tabIndex={0}
    onClick={handleClick}
+   onKeyDown={activateOnEnterSpace(handleClick)}
  >
```

- [ ] **Step 10.3: 浏览器冒烟：键盘导航**

启动 `pnpm dev`，对修改的交互元素用 Tab 导航 + Enter/Space 触发，确认可访问性工作。

- [ ] **Step 10.4: typecheck + lint + commit**

```bash
cd frontend && pnpm typecheck && pnpm lint 2>&1 | tail -1
git add -A frontend/
git commit -m "fix(frontend-a11y): make div onClick elements keyboard-accessible

Converts static div onClick to button where possible; otherwise
adds role/tabIndex/onKeyDown via activateOnEnterSpace helper.

Refs #219"
```

---

## Task 11: a11y — autoFocus → useAutoFocus (5 条)

**Files:**
- Modify: 5 个含 `autoFocus` prop 的文件

- [ ] **Step 11.1: 枚举命中点**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 no-autofocus
```

- [ ] **Step 11.2: 替换模式**

```diff
+ import { useAutoFocus } from "@/hooks/useAutoFocus";

  export function LoginPage() {
+   const usernameRef = useAutoFocus<HTMLInputElement>();
    // ...
-   <input autoFocus type="text" ... />
+   <input ref={usernameRef} type="text" ... />
```

注意：
- 如原 `autoFocus` 只在某条件下生效（如弹窗打开时），传 `enabled` 参数：`useAutoFocus<HTMLInputElement>(isOpen)`
- 如元素已有 ref，用 `useAutoFocus` 返回的 ref 合并或改 `useCallback ref`

- [ ] **Step 11.3: 浏览器冒烟**

启动 `pnpm dev`，打开登录页和其他原本有 autoFocus 的弹窗/表单，确认焦点仍落在预期元素。

- [ ] **Step 11.4: typecheck + lint + commit**

```bash
cd frontend && pnpm typecheck && pnpm lint 2>&1 | tail -1
git add -A frontend/
git commit -m "fix(frontend-a11y): replace autoFocus prop with useAutoFocus hook

Removes 5 autoFocus props that jsx-a11y flags for accessibility
regression. UX preserved via programmatic .focus() in useEffect.

Refs #219"
```

---

## Task 12: a11y — media-has-caption (2 条)

**Files:**
- Modify: 2 个含 `<video>` / `<audio>` 的文件

- [ ] **Step 12.1: 定位**

```bash
cd frontend && pnpm lint 2>&1 | grep -B3 media-has-caption
```

- [ ] **Step 12.2: 加 disable + 理由**

对每处：

```tsx
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- 生成式预览视频暂无字幕源，将来如引入字幕生成则移除此 disable */}
<video src={videoUrl} controls />
```

- [ ] **Step 12.3: 记录 disable 表 + commit**

在 `.pr3-disables.md` 追加 media-has-caption 节。

```bash
cd frontend && pnpm lint 2>&1 | tail -1
git add -A frontend/
git commit -m "fix(frontend-a11y): disable media-has-caption for generative previews

Preview videos have no caption source. Disable is documented
inline with rationale for future reversal.

Refs #219"
```

---

## Task 13: 收尾 — flip to error + 清空 migration + 移除 ratchet

**Files:**
- Modify: `frontend/eslint.config.js`
- Modify: `frontend/package.json`
- Modify: `CONTRIBUTING.md`

- [ ] **Step 13.1: 验证前置 — warning 数已为 0**

```bash
cd frontend && pnpm lint 2>&1 | tail -1
```

Expected: `0 problems (0 errors, 0 warnings)` 或极少数（如不为 0 则回到对应 Task 补完）。

- [ ] **Step 13.2: 删除 migration 常量与展开块**

编辑 `frontend/eslint.config.js`：
- 删除 `MIGRATION_WARN_RULES_TYPED` 常量定义
- 删除 `MIGRATION_WARN_RULES_A11Y` 常量定义
- 删除 `MIGRATION_WARN_RULES_ALL` 常量定义
- 删除 config 数组中 `rules: { ...MIGRATION_WARN_RULES_* }` 的对应 block
- 删除顶部「PR 2 / PR 3 操作指引」段落注释

- [ ] **Step 13.3: 新增严格化 override**

在 `eslint.config.js` 最后（ignores 和 test override 之后）新增：

```js
  // 本项目严于 recommended：exhaustive-deps / incompatible-library 一律视为 error
  {
    rules: {
      "react-hooks/exhaustive-deps": "error",
      "react-hooks/incompatible-library": "error",
    },
  },
```

- [ ] **Step 13.4: 移除 `--max-warnings`**

编辑 `frontend/package.json`：

```diff
- "lint": "eslint . --max-warnings=0",
+ "lint": "eslint .",
```

- [ ] **Step 13.5: 运行最终 lint — 期望 0 errors 0 warnings**

```bash
cd frontend && pnpm lint
echo "exit code: $?"
```

Expected: `0 problems`，exit 0。如有 error，修复后再继续。

- [ ] **Step 13.6: 更新 CONTRIBUTING.md**

在 `CONTRIBUTING.md` 前端章节（「代码质量」或新建「前端 lint」小节）追加：

```markdown
### ESLint disable 使用规范

本项目在 PR 3（#219）后采用零 warning 政策，所有规则均为 error。如必须绕过，遵循：

- **形式**：`// eslint-disable-next-line <rule> -- <中文理由>`，`--` 后的理由**强制**
- **禁用**：文件级 `/* eslint-disable */`、无理由的 `// eslint-disable-line`、`@ts-ignore` 联用
- **PR 描述要求**：新增的 disable 必须在 PR body 以表格列出 `rule | file:line | 理由`
- **文件级关闭**只允许通过 `eslint.config.js` 的 `files` override，且须在 config 注释说明原因
- **不可接受的理由**：「太麻烦」「暂时这样」「later fix」
- **可接受的理由示例**：「React setter 引用稳定」「mount-only 初始化」「生成式预览视频无字幕源」
```

- [ ] **Step 13.7: Commit**

```bash
git add frontend/eslint.config.js frontend/package.json CONTRIBUTING.md
git commit -m "chore(frontend): flip all ESLint rules to error, remove ratchet

- Drop MIGRATION_WARN_RULES_TYPED / _A11Y / _ALL constants
- Elevate react-hooks/exhaustive-deps and incompatible-library
  to error (project is stricter than recommended)
- Remove --max-warnings from lint script
- Document disable-comment policy in CONTRIBUTING.md

Closes the PR 3 series of #219."
```

---

## Task 14: 全流程验证 + 清理

**Files:** 无新增

- [ ] **Step 14.1: 跑完整 frontend 流水**

```bash
cd frontend && pnpm typecheck && pnpm lint && pnpm test && pnpm build
```

Expected: 全部 0 error / 0 warning / 测试通过 / build 成功。

- [ ] **Step 14.2: 跑后端测试（确保未误触碰）**

```bash
uv run python -m pytest -x 2>&1 | tail -20
```

Expected: 全过（本 PR 不改后端，应无影响）。

- [ ] **Step 14.3: 浏览器最终回归**

启动 `pnpm dev`，过一遍：
- 登录（autoFocus 工作、登录成功跳转）
- 任务 HUD（SSE 状态流）
- 项目事件（修改项目名 → UI 同步）
- 至少一个代表性的 `<div onClick>` 改 `<button>` 的交互
- 键盘导航：Tab + Enter 能触发被改造的交互元素

- [ ] **Step 14.4: 合成 PR 描述 disable 表**

把 Task 7/8/9/12 累积的 `.pr3-disables.md` 汇总成最终 PR 描述中的表格，格式：

```markdown
## 新增 eslint-disable 清单

| rule | file:line | 理由 |
|---|---|---|
| react-hooks/set-state-in-effect | src/.../X.tsx:YY | ... |
| react-hooks/exhaustive-deps | src/hooks/useProjectEventsSSE.ts:330 | setLocation 引用稳定，加依赖会导致 SSE 重连 |
| jsx-a11y/media-has-caption | src/.../Video.tsx:NN | 生成式预览视频暂无字幕源 |
```

删除临时文件 `frontend/.pr3-disables.md`（别 commit 它）。

- [ ] **Step 14.5: 推送并开 PR**

```bash
git push -u origin fix/a11y-eslint-cleanup-293
```

用 `gh pr create` 开 PR，title `chore(frontend): clean up remaining ESLint warnings and flip rules to error`，body 包含：
- Summary（链 #219 issue + PR 1 #290 + PR 2 #292）
- 改动要点（6 类 warning 清零 + migration 清空 + strict override）
- **disable 清单表格**（Step 14.4 的输出）
- Test plan checklist（typecheck / lint / test / build / 手动回归）

---

## Self-Review 结果

对照 spec 各节：
- ✅ 类型源头改造 → Task 3 + Task 4
- ✅ voidPromise helper → Task 1 + Task 5
- ✅ React Hooks 语义逐条判断 → Task 7 / 8 / 9
- ✅ a11y 三类修复 → Task 10 / 11 / 12
- ✅ 测试文件 override → Task 2
- ✅ 未入账规则严格化 → Task 13
- ✅ 清空 migration + 移除 `--max-warnings` → Task 13
- ✅ `eslint-disable-*` 规范写入 CONTRIBUTING → Task 13
- ✅ PR 描述 disable 表格 → Task 14.4
- ✅ 验证流程（typecheck/lint/test/build/浏览器） → Task 14
- ✅ 风险缓解（rebase / typecheck 每步 / 手动回归 / TODO 上限 3） → 分布在 Task 3、7、8 中

无 placeholder；类型与方法签名在各 Task 间一致（`voidPromise` / `voidCall` / `useAutoFocus` / `activateOnEnterSpace` 四个 API 签名从 Task 1 贯穿到 Task 5/10/11）。
