用Claude Code修复React仪表盘412个可访问性问题,关闭78%,同时AI也自信生成了更糟的ARIA代码;文章总结了有效循环与防护rails。
我把一个 AI 编码 agent 指向了一个包含 412 个无障碍违规的 React 仪表盘,让它修复这些问题。它在约六个小时的 wall-clock 时间内关闭了其中 78%——但它也自信地生成了一些 ARIA,导致两个组件反而更差了。以下是有效的循环、捕捉错误输出的护栏,以及我给任何想尝试这种方式的人的五个建议。
我们快速上线了一个内部分析仪表盘。大约六十个 React 组件,三年的"我们之后再来修",然后一个客户在续约前要求提供 VPAT。🙃
我运行了自动化审计,得到了一个没人想看到的数字:
412 violations across 27 pages
23 distinct rule IDs
Worst offenders:
button-name 97
color-contrast 88
label 61
aria-required-attr 34
link-name 29
估算每个违规的手动修复时间为 2–4 分钟,总耗时在 14 到 27 小时之间,这是乐观的估计——它忽略了一个事实:其中一半需要你打开组件、理解按钮实际做什么,以及选出一个屏幕阅读器用户会觉得有用的名称。
所以我的第一反应是显而易见的。我打开了 Claude Code,指向这个仓库,然后输入:
Fix all the accessibility issues in src/components/.
这是个错误,而且值得详细解释为什么它是错的,因为这是我在看到人们在任何大型机械性任务上使用 agent 时不断碰到的同一种失败模式。
这个 agent 没有 ground truth。它不知道这 412 个东西中哪些是坏的——它只知道"accessibility"作为一个概念。所以它做了一个好心的新手在被交给一个模糊指令时会做的事:它在看到的所有东西上喷 aria-label,包括那些已经有可访问名称的元素,产生了一个 900 行的 diff,我无法审查。更糟糕的是,违规数增加到了 431,因为在一个 <div role="button"> 上加 aria-label 引入了之前没有的新的 required-attribute 失败。
这个教训狠狠地记住了:agent 并不擅长这个任务。是我没有给它任何方式来判断它是否在获胜。
解决方案是把这件事从"写一些代码"变成"关闭一个可衡量的差距"。做了三个改变。
审计工具的 HTML 报告是给人看的。agent 需要它能过滤、分组、与之对比的结构化数据。我写了一个大约 40 行的脚本(通过 Playwright 1.4x 在 Node.js 22.x 上驱动 axe-core 4.10.x),它遍历每条路由并输出 JSON:
// scripts/a11y-scan.mjs
import { chromium } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import { writeFileSync } from 'node:fs';
import { ROUTES } from './routes.mjs';
const browser = await chromium.launch();
const page = await browser.newPage();
const findings = [];
for (const route of ROUTES) {
await page.goto(`http://localhost:5173${route}`, { waitUntil: 'networkidle' });
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
for (const v of violations) {
for (const node of v.nodes) {
findings.push({
route,
rule: v.id,
impact: v.impact, // minor | moderate | serious | critical
selector: node.target.join(' '),
html: node.html,
fix: node.failureSummary, // axe's own remediation hint
});
}
}
}
writeFileSync('a11y-findings.json', JSON.stringify(findings, null, 2));
await browser.close();
console.log(`${findings.length} violations written`);
那个 failureSummary 字段被证明是整个设置中单线最高的杠杆。它告诉你在每个节点上具体缺了什么。把它喂给 agent 比把规则名喂给它要好得多。
这是我前两个小时做错的部分。我的本能是按文件逐个处理——这与你给人分配工作的方式一致。但一个 agent 的失败率主要受不同类型推理之间的上下文切换支配,而不是它接触了多少文件。
412 个违规缩减为 23 条规则。在同一条规则内,修复几乎每次都几乎相同。所以我每条规则运行一个 agent 会话,并把该规则下所有受影响的节点放入 prompt:
Rule: button-name (97 nodes, impact: critical)
Every node below is an interactive element with no accessible name.
Constraints:
- Prefer visible text content. Only use aria-label when the control is icon-only.
- Never add ARIA to an element that already has an accessible name.
- If you cannot determine the button's purpose from surrounding code,
add it to UNRESOLVED.md instead of guessing.
Nodes:
[ ...the filtered JSON... ]
第三个约束是关键。给 agent 一个合法的说"我不知道"的方式,是阻止它编造的原因。97 个按钮中有 31 个落入了 UNRESOLVED.md——主要是图表工具栏中的纯图标控件,其目的确实无法从 JSX 中推断出来。这些恰恰是我本来就想审查的那些。
agent 不能自行宣布胜利。scanner 可以:
flowchart TD
A[Scan: axe -> findings.json] --> B{Violations for this rule?}
B -- none --> F[Rule closed]
B -- some --> C[Agent fixes batch]
C --> D[Re-scan this rule only]
D --> E{Count decreased?}
E -- yes --> B
E -- no or increased --> G[Revert batch, flag for human]
G --> F
那个 no or increased -> revert 分支触发了四次。这个分支是整个项目没有变成比原始问题更大的清理工作的全部原因。其中两次是我前面提到的 ARIA 情况:agent 给一个 <div> 添加了 role="button" 加上 aria-pressed,而它本应直接就是一个 <button>,这满足了它心理模型中"可访问的"认知,同时引入了一个键盘陷阱。
这是护栏,而且它的设计是有意朴素的:
before=$(jq --arg r "$RULE" '[.[] | select(.rule==$r)] | length' a11y-findings.json)
node scripts/a11y-scan.mjs
after=$(jq --arg r "$RULE" '[.[] | select(.rule==$r)] | length' a11y-findings.json)
if [ "$after" -ge "$before" ]; then
echo "REGRESSION on $RULE: $before -> $after"
git checkout -- src/
exit 1
fi
三行 shell,让一个 autonomous agent 可以安全地无人看管。便宜的护栏永远比聪明的 prompt 有效。
剩下的 89 个几乎全是 color-contrast(这是一个设计 token 决策,不是代码修复)和那 31 个需要产品答案的未解决按钮。
1. 违规数不是任务列表。 412 听起来像 412 个问题,其实是 23 个问题乘以一个倍数。我做的最好的一件事就是在做任何事情之前先 group_by(rule)。如果你要给 agent 一大堆机械性工作,花前二十分钟找到那个能压缩它的轴——批次的形状比 prompt 的措辞更重要。
2. Agent 擅长机械规则,擅长语义规则时很危险。 link-name 且链接包裹了可见文本?完美,29 个全清。label 在一个表单输入上而标签文本需要描述一个 agent 从未见过的领域概念?那是一个穿着 lint error 外衣的产品决策。判断方法很简单:如果一个人需要问别人这个控件是做什么的,agent 会编造答案而不是去问。在一开始就先把那些路由到人工队列。
3. 给它一个失败的检查,而不是一个描述。 "让它可访问"产生了一个 900 行的无法审查的 diff。"这个节点 fail 了 button-name;这里有 axe 的 failure summary;之后检查必须通过"产生了小的、可验证的 diff。一个有可执行完成定义的 agent 表现得像另一个工具,而不是一个从散文工作的 agent。
4. 没有 ARIA 比糟糕的 ARIA 更好——而且 agent 不相信这一点。 这是我看到最自信的错误输出的地方,而且遥遥领先。ARIA 在训练数据中大量作为无障碍机制被代表,所以 agent 首先会抓住它,而正确的修复通常是删除 <div> 并使用原生元素。我不得不在 prompt 中把它作为一个硬性规则:原生元素优先;ARIA 仅在没有原生元素时使用;不要两者同时用。即便是这样它也会漂移,这就是 revert 分支存在的意义。
5. 零违规不等于可用。 自动化 pass 之后,我花了 40 分钟仅用 VoiceOver 和键盘驾驶仪表盘。发现了 6 个在自动化中得分为 0 的阻断性问题:一个没有捕获焦点的模态框、一个什么都没播报的 toast、一个每行都重新读出列标题的表格,以及三个跳到了错误位置的"skip to content"链接。自动化 a11y 工具大约捕获了机器可检查的三分之一问题。一个关闭了 100% 自动化发现的 agent 只关闭了大约三分之一你实际的无障碍债务。在有人把"AI-accessible"放到幻灯片上之前,先说出来。
第一,scanner 作为预算而不是门限进入 CI——如果违规数相比 base branch 增加了,构建就会失败,这是团队不会立即开始禁用的唯一版本的政策。第二,我想把同样的循环扩展到键盘导航:焦点顺序和焦点陷阱在 Playwright 中足够确定性,可以断言,而且它们是真正痛苦的 bug 所在。同样的模式——机器可读的发现,按失败类型批量,回归时 revert。
我不断重新学习的更广泛的要点:agent 在已经存在 oracle 的任务上最强。测试套件、类型检查器、linter、无障碍扫描器。如果你正盯着一个大 grind,而有一个工具可以客观地告诉你你是否更接近完成,你就已经有了一个 autonomous pipeline 的大部分——你只是缺了 40 行胶水和一条 revert 分支。
如果你要在自己的代码库上尝试这个,按这个顺序:
在打开 agent 之前把发现变成 JSON。🔧
按规则分组,而不是按文件。
给 agent 一个明确的"我不知道"逃生出口。
回归时自动 revert。
然后自己用屏幕阅读器驾驶它,因为工具做不到。⚠️
我正在把这些构建日志写出来——如果你想在后续键盘导航上线时收到通知,就在 Dev.to 上关注我。如果你让 agent 对抗 a11y 债务并遇到了我没有提到的失败模式,我真的很想听你说——尤其是 ARIA 的恐怖故事。💬