@a11y-lens/cli 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/bin/a11y-lens.mjs +166 -13
- package/package.json +2 -2
- package/skills/a11y-lens/SKILL.md +5 -3
- package/skills/a11y-lens/references/01-landmarks-headings.md +6 -4
- package/skills/a11y-lens/references/02-images-alt.md +22 -8
- package/skills/a11y-lens/references/03-forms-labels.md +24 -8
- package/skills/a11y-lens/references/04-aria-widgets.md +10 -8
- package/skills/a11y-lens/references/05-keyboard-interaction.md +8 -6
- package/skills/a11y-lens/references/06-focus-management.md +24 -12
- package/src/agent.mjs +19 -9
- package/src/config.mjs +83 -0
- package/src/pending.mjs +166 -0
- package/src/prompt.mjs +57 -18
- package/src/report.mjs +14 -3
- package/src/staged.mjs +1 -1
- package/templates/agents-snippet.md +2 -0
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ husky - pre-commit hook exited with code 1
|
|
|
31
31
|
|
|
32
32
|
**Infrastructure never blocks a commit.** No agent CLI, no network, agent crash → a11y-lens warns and exits 0. Only real accessibility findings gate.
|
|
33
33
|
|
|
34
|
+
**…but a skipped check does not pass for a clean one.** Exiting 0 means your hook runner shows the same ✔️ either way, so when a staged check could not review a file — the agent timed out or failed, its output could not be parsed, or the file was dropped for the prompt size budget — a11y-lens records it as *pending*. The next check warns about it, and `a11y-lens check --pending` reviews it later. See [Skipped checks](#skipped-checks).
|
|
35
|
+
|
|
34
36
|
## It samples; it does not audit
|
|
35
37
|
|
|
36
38
|
a11y-lens is an AI reviewer, not a deterministic linter. The same files reviewed twice can return different findings — even zero on a run that flagged issues a moment earlier. Read the output with that in mind:
|
|
@@ -41,6 +43,31 @@ a11y-lens is an AI reviewer, not a deterministic linter. The same files reviewed
|
|
|
41
43
|
|
|
42
44
|
This is deliberate: only clear `error`-severity violations gate and warnings never block, precisely because AI output varies run to run. (This note belongs here, in the tool's own README — not in the `AGENTS.md` rules block that `init` injects into a consuming project, which is reserved for the accessibility rules themselves.)
|
|
43
45
|
|
|
46
|
+
## Levels: what gets checked, and what gets shown
|
|
47
|
+
|
|
48
|
+
Not every project needs every check. Each check in the rule set is tagged by who it helps:
|
|
49
|
+
|
|
50
|
+
- **`core`**: checks that help everyone. They cover accessible names on icon-only controls, labels that are not placeholders, names that match the visible label, autocomplete on identity fields, full keyboard operation, and focus that is moved, returned and never lost.
|
|
51
|
+
- **`full`**: the core checks plus the ones that are specific to screen readers. They cover headings and landmarks, alt text, complete ARIA patterns, errors tied to their fields, and live-region announcements for async results and loading.
|
|
52
|
+
|
|
53
|
+
A `full` review is a superset of a `core` one, so code written to `full` passes `core`.
|
|
54
|
+
|
|
55
|
+
Set the level for the whole team in the repository:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
// a11y-lens.config.json at the repository root (or the "a11y-lens" field of the root package.json)
|
|
59
|
+
{ "level": "core", "report": "errors" }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Setting | Values | Default |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `level` | `core`: only `[core]` checks are sent to the agent. `full`: all checks. | `full` |
|
|
65
|
+
| `report` | `errors`: print errors, and say how many warnings were hidden. `all`: print everything. | `all` |
|
|
66
|
+
|
|
67
|
+
The defaults run the same checks and print the same output as earlier versions, so upgrading changes nothing a project would notice until it opts in. The rule text itself now carries the tags. `A11Y_LENS_LEVEL` and `A11Y_LENS_REPORT` override the file for one run. `--strict` always prints warnings, because it makes them fail the commit. An unknown value is warned about and replaced by the default.
|
|
68
|
+
|
|
69
|
+
These levels are not WCAG's A/AA/AAA. They sort checks by who benefits, not by conformance level.
|
|
70
|
+
|
|
44
71
|
## Install
|
|
45
72
|
|
|
46
73
|
a11y-lens has two layers — install either or both:
|
|
@@ -76,11 +103,44 @@ a11y-lens check --staged # what the git hook runs
|
|
|
76
103
|
a11y-lens check src/Modal.tsx # review specific files
|
|
77
104
|
a11y-lens check --staged --strict # warnings also fail
|
|
78
105
|
a11y-lens check --staged --agent codex
|
|
106
|
+
a11y-lens check --pending # review files an earlier check skipped
|
|
79
107
|
a11y-lens rules # list rule categories
|
|
80
108
|
```
|
|
81
109
|
|
|
82
110
|
Escape hatches: `A11Y_LENS_SKIP=1 git commit …` or `git commit --no-verify`.
|
|
83
111
|
|
|
112
|
+
| Environment | Effect |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `A11Y_LENS_AGENT` | same as `--agent` |
|
|
115
|
+
| `A11Y_LENS_MODEL` | model passed to `claude` |
|
|
116
|
+
| `A11Y_LENS_TIMEOUT_MS` | agent timeout in milliseconds (default `180000`) |
|
|
117
|
+
| `A11Y_LENS_LEVEL` | `core` or `full` for this run, over the project's setting |
|
|
118
|
+
| `A11Y_LENS_REPORT` | `errors` or `all` for this run, over the project's setting |
|
|
119
|
+
| `A11Y_LENS_SKIP=1` | skip the check entirely |
|
|
120
|
+
|
|
121
|
+
## Skipped checks
|
|
122
|
+
|
|
123
|
+
A staged check records a file as pending when it could not review it:
|
|
124
|
+
|
|
125
|
+
| Why the file was not reviewed | Recorded? |
|
|
126
|
+
|---|---|
|
|
127
|
+
| Agent timed out, crashed, or exited non-zero (including logged out or out of quota) | yes |
|
|
128
|
+
| Agent output could not be parsed as findings | yes |
|
|
129
|
+
| Dropped because the prompt size budget was spent | yes |
|
|
130
|
+
| `A11Y_LENS_SKIP=1`, no agent CLI installed, file over 48KB, no UI files staged | no: deliberate, or it would be skipped again |
|
|
131
|
+
|
|
132
|
+
An entry is cleared when `check --pending` reviews it, or when a later `check --staged` in the same worktree reviews the very same staged content (a commit that was aborted and retried). If the skipped commit landed, its change is no longer in the next diff, so only `--pending` clears it. `--pending` reviews the content that was **staged at the time**, with its staged diff. That way it reports on the skipped change, not on the whole file as it is now. It works even after the file has changed or its worktree is gone. Each skipped check is reviewed in its own agent call. Anything that times out or is dropped again stays pending.
|
|
133
|
+
|
|
134
|
+
**Layout (public contract, version 1).** Other tools may read this, for example a hook that reminds an agent to run `--pending`:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
<git rev-parse --git-common-dir>/a11y-lens/pending/<sha1>.json
|
|
138
|
+
{ "version": 1, "worktree": "/abs/path", "path": "src/A.tsx", "blob": "<index sha>",
|
|
139
|
+
"diff": "<staged diff>", "reason": "agent failed: …", "at": "2026-09-23T06:00:00.000Z" }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A non-empty directory means something was not reviewed. Any change to this layout bumps `version`. A record this version cannot read (another version wrote it, or it is damaged) is reported and never cleared automatically. Once it has been dealt with, delete the file by hand.
|
|
143
|
+
|
|
84
144
|
## Rule set
|
|
85
145
|
|
|
86
146
|
One markdown file per category in `skills/a11y-lens/references/`, consumed by both the skill and the CLI. Each separates the **static baseline** (what eslint/axe already catch — not re-reported) from the **semantic checks** this tool exists for.
|
package/bin/a11y-lens.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { installHook, detectRunner } from '../src/hooks.mjs';
|
|
|
7
7
|
import { detectAgent, runAgent } from '../src/agent.mjs';
|
|
8
8
|
import { buildPrompt } from '../src/prompt.mjs';
|
|
9
9
|
import { parseFindings, printReport, exitCodeFor } from '../src/report.mjs';
|
|
10
|
+
import { recordPending, listPending, clearReviewed, clearEntry, contentOf, pendingDir } from '../src/pending.mjs';
|
|
11
|
+
import { loadConfig } from '../src/config.mjs';
|
|
10
12
|
|
|
11
13
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
14
|
|
|
@@ -15,6 +17,7 @@ const HELP = `a11y-lens — AI-powered semantic accessibility linter
|
|
|
15
17
|
Usage:
|
|
16
18
|
a11y-lens check --staged review staged UI files (for git hooks)
|
|
17
19
|
a11y-lens check <files...> review specific files
|
|
20
|
+
a11y-lens check --pending review files an earlier check skipped
|
|
18
21
|
a11y-lens init install the pre-commit hook (lefthook/husky/git hooks,
|
|
19
22
|
auto-detected) + inject rules reference into ./AGENTS.md
|
|
20
23
|
a11y-lens rules list rule categories
|
|
@@ -30,15 +33,28 @@ Environment:
|
|
|
30
33
|
A11Y_LENS_AGENT same as --agent
|
|
31
34
|
A11Y_LENS_MODEL model override passed to claude (optional)
|
|
32
35
|
A11Y_LENS_SKIP=1 skip the check entirely (escape hatch)
|
|
36
|
+
A11Y_LENS_TIMEOUT_MS agent timeout in ms (default 180000)
|
|
37
|
+
A11Y_LENS_LEVEL=core|full override the project's level (see below)
|
|
38
|
+
A11Y_LENS_REPORT=errors|all override the project's report setting
|
|
39
|
+
|
|
40
|
+
Project settings, in a11y-lens.config.json at the repository root (or the
|
|
41
|
+
"a11y-lens" field of package.json):
|
|
42
|
+
{ "level": "core", "report": "errors" }
|
|
43
|
+
level core = checks that help everyone (names, keyboard, focus);
|
|
44
|
+
full = those plus screen-reader-specific checks (default)
|
|
45
|
+
report errors = print errors only, and count the hidden warnings;
|
|
46
|
+
all = print everything (default). --strict always prints all.
|
|
33
47
|
|
|
34
48
|
Infrastructure failures (no agent CLI, no network, agent error) never block:
|
|
35
|
-
a11y-lens warns and exits 0. Only accessibility findings gate
|
|
49
|
+
a11y-lens warns and exits 0. Only accessibility findings gate. When a staged
|
|
50
|
+
check could not review a file (agent failure or timeout, unparseable output,
|
|
51
|
+
prompt budget), the file is recorded as pending until a later run reviews it.`;
|
|
36
52
|
|
|
37
53
|
function parseArgs(argv) {
|
|
38
54
|
const args = { _: [], flags: {} };
|
|
39
55
|
for (let i = 0; i < argv.length; i++) {
|
|
40
56
|
const token = argv[i];
|
|
41
|
-
if (token === '--staged' || token === '--strict' || token === '--no-hook') {
|
|
57
|
+
if (token === '--staged' || token === '--strict' || token === '--no-hook' || token === '--pending') {
|
|
42
58
|
args.flags[token.slice(2)] = true;
|
|
43
59
|
}
|
|
44
60
|
else if (token === '--agent') args.flags.agent = argv[++i];
|
|
@@ -48,17 +64,66 @@ function parseArgs(argv) {
|
|
|
48
64
|
return args;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
const RECHECK = 'a11y-lens check --pending';
|
|
68
|
+
|
|
51
69
|
function softFail(message) {
|
|
52
70
|
console.warn(`a11y-lens: ${message} — skipping check (commits are never blocked by infrastructure).`);
|
|
53
71
|
process.exit(0);
|
|
54
72
|
}
|
|
55
73
|
|
|
74
|
+
/** Record what a staged run could not review, and say so where the committer will see it. */
|
|
75
|
+
const RUN_STARTED = new Date();
|
|
76
|
+
|
|
77
|
+
function notePending(files, reason) {
|
|
78
|
+
const result = recordPending(files, reason, { now: RUN_STARTED });
|
|
79
|
+
if (result.error) {
|
|
80
|
+
console.warn(`a11y-lens: could not record ${files.length} unreviewed file(s) as pending (${result.error}).`);
|
|
81
|
+
} else {
|
|
82
|
+
console.warn(`a11y-lens: recorded ${result.recorded} unreviewed file(s) as pending — review them later with: ${RECHECK}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function skipStaged(files, message) {
|
|
87
|
+
notePending(files, message);
|
|
88
|
+
softFail(message);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function warnUnreadable(count) {
|
|
92
|
+
console.warn(
|
|
93
|
+
`a11y-lens: ${count} pending record(s) in ${pendingDir()} could not be read (another a11y-lens version, or damaged). ` +
|
|
94
|
+
'This version never clears them; delete them by hand once you have dealt with them.',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Before anything can exit early (a skip, a commit with no UI files): a skipped check looks like a
|
|
100
|
+
* clean one, so the next run is the first chance to say it happened.
|
|
101
|
+
*/
|
|
102
|
+
function warnPending() {
|
|
103
|
+
const { entries, unreadable } = listPending();
|
|
104
|
+
if (entries.length) {
|
|
105
|
+
console.warn(
|
|
106
|
+
`a11y-lens: ${entries.length} file(s) from earlier commits were never reviewed (the check was skipped). Review them with: ${RECHECK}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (unreadable) {
|
|
110
|
+
warnUnreadable(unreadable);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Settings for this run; `--strict` gates on warnings, so it always shows them. */
|
|
115
|
+
function runSettings(args) {
|
|
116
|
+
const config = loadConfig();
|
|
117
|
+
return { level: config.level, report: args.flags.strict ? 'all' : config.report };
|
|
118
|
+
}
|
|
119
|
+
|
|
56
120
|
function commandCheck(args) {
|
|
121
|
+
if (args.flags.pending) return commandCheckPending(args);
|
|
122
|
+
warnPending();
|
|
57
123
|
if (process.env.A11Y_LENS_SKIP === '1') softFail('A11Y_LENS_SKIP=1');
|
|
58
124
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
: collectPathArgs(args._);
|
|
125
|
+
const staged = Boolean(args.flags.staged);
|
|
126
|
+
const { files, error } = staged ? collectStagedUIFiles() : collectPathArgs(args._);
|
|
62
127
|
if (error) softFail(error);
|
|
63
128
|
|
|
64
129
|
const reviewable = files.filter((f) => !f.skipped);
|
|
@@ -66,7 +131,7 @@ function commandCheck(args) {
|
|
|
66
131
|
console.warn(`a11y-lens: skipping ${f.path} (${f.skipped})`);
|
|
67
132
|
}
|
|
68
133
|
if (reviewable.length === 0) {
|
|
69
|
-
if (
|
|
134
|
+
if (staged) console.log('a11y-lens: no staged UI files, nothing to review.');
|
|
70
135
|
else console.log('a11y-lens: no reviewable files given. Try: a11y-lens check src/Component.tsx');
|
|
71
136
|
process.exit(0);
|
|
72
137
|
}
|
|
@@ -74,24 +139,112 @@ function commandCheck(args) {
|
|
|
74
139
|
const detection = detectAgent(args.flags.agent);
|
|
75
140
|
if (detection.error) softFail(detection.error);
|
|
76
141
|
|
|
77
|
-
const
|
|
142
|
+
const settings = runSettings(args);
|
|
143
|
+
const { prompt, dropped } = buildPrompt(reviewable, { level: settings.level });
|
|
78
144
|
for (const path of dropped) {
|
|
79
145
|
console.warn(`a11y-lens: dropped ${path} (prompt size budget exceeded)`);
|
|
80
146
|
}
|
|
147
|
+
const included = reviewable.filter((f) => !dropped.includes(f.path));
|
|
148
|
+
if (staged && dropped.length) {
|
|
149
|
+
notePending(reviewable.filter((f) => dropped.includes(f.path)), 'prompt size budget exceeded');
|
|
150
|
+
}
|
|
81
151
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
152
|
+
if (included.length === 0) process.exit(0);
|
|
153
|
+
console.log(`a11y-lens: reviewing ${included.length} file(s) with ${detection.name}…`);
|
|
85
154
|
const result = runAgent(detection.agent, prompt);
|
|
86
|
-
if (!result.ok)
|
|
155
|
+
if (!result.ok) {
|
|
156
|
+
if (staged) skipStaged(included, `agent failed: ${result.error}`);
|
|
157
|
+
softFail(`agent failed: ${result.error}`);
|
|
158
|
+
}
|
|
87
159
|
|
|
88
160
|
const parsed = parseFindings(result.output);
|
|
89
|
-
if (parsed.error)
|
|
161
|
+
if (parsed.error) {
|
|
162
|
+
if (staged) skipStaged(included, parsed.error);
|
|
163
|
+
softFail(parsed.error);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reviewed now, so an entry left by an earlier skip of the same path — say, a commit that
|
|
167
|
+
// typecheck aborted in parallel — is answered.
|
|
168
|
+
if (staged) clearReviewed(included.map((f) => f.path));
|
|
90
169
|
|
|
91
|
-
printReport(parsed.findings, { agentName: detection.name });
|
|
170
|
+
printReport(parsed.findings, { agentName: detection.name, report: settings.report });
|
|
92
171
|
process.exit(exitCodeFor(parsed.findings, { strict: args.flags.strict }));
|
|
93
172
|
}
|
|
94
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Review what earlier staged runs skipped. One agent call per recorded skip, not one for all of
|
|
176
|
+
* them: a bad session leaves dozens of entries, and a single prompt that big would time out again
|
|
177
|
+
* and never let the list shrink. Each batch clears only what it actually reviewed.
|
|
178
|
+
*/
|
|
179
|
+
function commandCheckPending(args) {
|
|
180
|
+
const { entries, unreadable } = listPending();
|
|
181
|
+
if (unreadable) {
|
|
182
|
+
warnUnreadable(unreadable);
|
|
183
|
+
}
|
|
184
|
+
if (entries.length === 0) {
|
|
185
|
+
console.log('a11y-lens: nothing pending.');
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const detection = detectAgent(args.flags.agent);
|
|
190
|
+
if (detection.error) softFail(`${detection.error}; ${entries.length} file(s) remain pending`);
|
|
191
|
+
const settings = runSettings(args);
|
|
192
|
+
|
|
193
|
+
const batches = new Map();
|
|
194
|
+
for (const item of [...entries].sort((a, b) => a.entry.at.localeCompare(b.entry.at))) {
|
|
195
|
+
const key = `${item.entry.worktree}\0${item.entry.at}`;
|
|
196
|
+
if (!batches.has(key)) batches.set(key, []);
|
|
197
|
+
batches.get(key).push(item);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const findings = [];
|
|
201
|
+
let remaining = 0;
|
|
202
|
+
for (const batch of batches.values()) {
|
|
203
|
+
const files = [];
|
|
204
|
+
for (const item of batch) {
|
|
205
|
+
const found = contentOf(item.entry);
|
|
206
|
+
if (found.gone) {
|
|
207
|
+
console.warn(`a11y-lens: dropping pending ${item.entry.path} (${found.gone})`);
|
|
208
|
+
clearEntry(item);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
files.push({ path: item.entry.path, content: found.content, diff: item.entry.diff, item });
|
|
212
|
+
}
|
|
213
|
+
if (files.length === 0) continue;
|
|
214
|
+
|
|
215
|
+
const { prompt, dropped } = buildPrompt(files, { level: settings.level });
|
|
216
|
+
const included = files.filter((f) => !dropped.includes(f.path));
|
|
217
|
+
if (included.length === 0) {
|
|
218
|
+
console.warn(`a11y-lens: every file skipped at ${batch[0].entry.at} exceeds the prompt budget — they stay pending.`);
|
|
219
|
+
remaining += files.length;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
console.log(
|
|
223
|
+
`a11y-lens: reviewing ${included.length} pending file(s) skipped at ${batch[0].entry.at} with ${detection.name}…`,
|
|
224
|
+
);
|
|
225
|
+
const result = runAgent(detection.agent, prompt);
|
|
226
|
+
const parsed = result.ok ? parseFindings(result.output) : { error: `agent failed: ${result.error}` };
|
|
227
|
+
if (parsed.error) {
|
|
228
|
+
console.warn(`a11y-lens: ${parsed.error} — ${files.length} file(s) stay pending.`);
|
|
229
|
+
remaining += files.length;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
findings.push(...parsed.findings);
|
|
233
|
+
for (const f of files) {
|
|
234
|
+
if (dropped.includes(f.path)) {
|
|
235
|
+
console.warn(`a11y-lens: dropped ${f.path} (prompt size budget exceeded) — it stays pending.`);
|
|
236
|
+
remaining++;
|
|
237
|
+
} else {
|
|
238
|
+
clearEntry(f.item);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
printReport(findings, { agentName: detection.name, report: settings.report });
|
|
244
|
+
if (remaining) console.warn(`a11y-lens: ${remaining} file(s) are still pending — run ${RECHECK} again.`);
|
|
245
|
+
process.exit(exitCodeFor(findings, { strict: args.flags.strict }));
|
|
246
|
+
}
|
|
247
|
+
|
|
95
248
|
function commandInit(args) {
|
|
96
249
|
// 1. Pre-commit hook (lefthook / husky / plain git hooks, auto-detected)
|
|
97
250
|
if (!args.flags['no-hook']) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11y-lens/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node --test test
|
|
22
|
+
"test": "node --test test/*.test.mjs"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"accessibility",
|
|
@@ -20,11 +20,13 @@ Before writing or reviewing UI code, read the reference file for each category t
|
|
|
20
20
|
| Click/hover handlers, shortcuts, drag, carousels | [references/05-keyboard-interaction.md](references/05-keyboard-interaction.md) |
|
|
21
21
|
| Overlays, route changes, async results, toasts, loading | [references/06-focus-management.md](references/06-focus-management.md) |
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
**Levels.** Every check is tagged `[core]` or `[full]`. `[core]` checks help everyone — accessible names, keyboard operation, focus that is never lost. `[full]` checks are screen-reader specific — headings and landmarks, alt text, ARIA patterns, live-region announcements. If the project sets `"level": "core"` (in `a11y-lens.config.json`, or the `"a11y-lens"` field of `package.json`), apply only the `[core]` checks and `[core]` examples; otherwise apply all of them.
|
|
24
|
+
|
|
25
|
+
Stances at every level (items that belong only to `full` are marked):
|
|
24
26
|
|
|
25
27
|
1. **Prefer native elements** (`button`, `select`, `details`, `dialog`) — they ship the complete pattern for free. A custom widget must implement the **whole** APG pattern; a partial pattern is worse than none.
|
|
26
|
-
2. **Placeholder is not a label. Hover is not a keyboard path
|
|
27
|
-
3. Severity discipline: `error` = clear violations (keyboard-dead interactive elements, unnamed icon-only controls, incomplete claimed ARIA patterns
|
|
28
|
+
2. **Placeholder is not a label. Hover is not a keyboard path.** At `full`, also: **CSS state is not ARIA state.**
|
|
29
|
+
3. Severity discipline: `error` = clear violations (keyboard-dead interactive elements, unnamed icon-only controls, unmanaged overlay focus; at `full`, also incomplete claimed ARIA patterns and silenced informative images). Judgment calls are `warning`.
|
|
28
30
|
|
|
29
31
|
## Commit-time gate (CLI)
|
|
30
32
|
|
|
@@ -13,16 +13,18 @@ sources: [WCAG 1.3.1, WCAG 2.4.1, WCAG 2.4.6, axe-core region/heading rules]
|
|
|
13
13
|
|
|
14
14
|
## Semantic checks (what you review)
|
|
15
15
|
|
|
16
|
-
1. **Heading hierarchy must describe the document outline, not the visual design.**
|
|
16
|
+
1. `[full]` **Heading hierarchy must describe the document outline, not the visual design.**
|
|
17
17
|
- Exactly one `h1` per page/view; levels must not skip downward (h1 → h3 with no h2 is an `error`).
|
|
18
18
|
- A heading chosen for its font size rather than its outline position is a violation — check whether the level makes sense relative to surrounding headings, not whether it "looks right".
|
|
19
19
|
- In SPAs, per-route views count as pages: a route component rendering only `h3`s is an `error` even if some layout file has an `h1` elsewhere — flag it as `warning` and say why (cannot see full composition).
|
|
20
|
-
2. **`section` needs an accessible name to be a landmark.** A bare `section` used as a styling wrapper should be a `div`; a `section` that truly groups content needs `aria-labelledby` pointing at its heading (`warning`).
|
|
21
|
-
3. **Landmark labels must be distinguishable.** Two `nav` elements require distinct `aria-label`s ("primary", "breadcrumb") — identical or missing labels on repeated landmarks is a `warning`.
|
|
22
|
-
4. **Visual titles that are not headings.** A styled `div`/`p` acting as an obvious section title (short text, followed by related content, styled prominently) should be a heading (`warning`).
|
|
20
|
+
2. `[full]` **`section` needs an accessible name to be a landmark.** A bare `section` used as a styling wrapper should be a `div`; a `section` that truly groups content needs `aria-labelledby` pointing at its heading (`warning`).
|
|
21
|
+
3. `[full]` **Landmark labels must be distinguishable.** Two `nav` elements require distinct `aria-label`s ("primary", "breadcrumb") — identical or missing labels on repeated landmarks is a `warning`.
|
|
22
|
+
4. `[full]` **Visual titles that are not headings.** A styled `div`/`p` acting as an obvious section title (short text, followed by related content, styled prominently) should be a heading (`warning`).
|
|
23
23
|
|
|
24
24
|
## Examples
|
|
25
25
|
|
|
26
|
+
### `[full]` Outline and landmark
|
|
27
|
+
|
|
26
28
|
Bad — outline skips and decorative section:
|
|
27
29
|
|
|
28
30
|
```jsx
|
|
@@ -13,27 +13,41 @@ sources: [WCAG 1.1.1, eslint-plugin-jsx-a11y alt-text/img-redundant-alt, axe-cor
|
|
|
13
13
|
|
|
14
14
|
## Semantic checks (what you review)
|
|
15
15
|
|
|
16
|
-
1. **Does the `alt` actually describe the image's function or content in context?**
|
|
16
|
+
1. `[full]` **Does the `alt` actually describe the image's function or content in context?**
|
|
17
17
|
- `alt="banner"`, `alt="icon"`, `alt="img_03"`, filename-derived alt → `error`. These pass static linters and fail humans.
|
|
18
18
|
- An image inside a link: alt must describe the **destination/action**, not the picture ("에이닷 전화 다운로드", not "스마트폰 그림") — mismatch is `warning`.
|
|
19
|
-
2. **Decorative images must be explicitly silenced**, not given filler alt. Purely decorative → `alt=""` (and `aria-hidden="true"` for inline SVG). Filler like `alt="장식"` is `warning`.
|
|
20
|
-
3. **Informative images must not be silenced.** `alt=""` on an image that plainly carries information (chart, badge with a number, screenshot referenced by the copy) is an `error`.
|
|
21
|
-
4. **Icon-only interactive elements.** A button/link whose only content is an icon (SVG, icon font, emoji) needs an accessible name (`aria-label` or visually-hidden text). Check the name describes the **action** ("닫기", not "X 아이콘"). Missing → `error`; vague → `warning`.
|
|
22
|
-
5. **Text baked into images** (event banners with dates/prices in pixels) — the alt or adjacent text must carry the same information; otherwise `warning`.
|
|
23
|
-
6. **CSS background-images conveying meaning** have no alt channel at all — if the diff shows meaningful content moved into a background-image, flag `warning` with the visually-hidden-text remedy.
|
|
19
|
+
2. `[full]` **Decorative images must be explicitly silenced**, not given filler alt. Purely decorative → `alt=""` (and `aria-hidden="true"` for inline SVG). Filler like `alt="장식"` is `warning`.
|
|
20
|
+
3. `[full]` **Informative images must not be silenced.** `alt=""` on an image that plainly carries information (chart, badge with a number, screenshot referenced by the copy) is an `error`.
|
|
21
|
+
4. `[core]` **Icon-only interactive elements.** A button/link whose only content is an icon (SVG, icon font, emoji) needs an accessible name (`aria-label` or visually-hidden text). Check the name describes the **action** ("닫기", not "X 아이콘"). Missing → `error`; vague → `warning`.
|
|
22
|
+
5. `[full]` **Text baked into images** (event banners with dates/prices in pixels) — the alt or adjacent text must carry the same information; otherwise `warning`.
|
|
23
|
+
6. `[full]` **CSS background-images conveying meaning** have no alt channel at all — if the diff shows meaningful content moved into a background-image, flag `warning` with the visually-hidden-text remedy.
|
|
24
24
|
|
|
25
25
|
## Examples
|
|
26
26
|
|
|
27
|
+
### `[core]` Icon-only button
|
|
28
|
+
|
|
29
|
+
Bad — no name:
|
|
30
|
+
|
|
31
|
+
```jsx
|
|
32
|
+
<button><CloseIcon /></button>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Good:
|
|
36
|
+
|
|
37
|
+
```jsx
|
|
38
|
+
<button aria-label="닫기"><CloseIcon aria-hidden="true" /></button>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### `[full]` Image inside a link
|
|
42
|
+
|
|
27
43
|
Bad — passes static linting, fails semantically:
|
|
28
44
|
|
|
29
45
|
```jsx
|
|
30
46
|
<a href="/download"><img src="/hero-phone.png" alt="phone image" /></a>
|
|
31
|
-
<button><CloseIcon /></button>
|
|
32
47
|
```
|
|
33
48
|
|
|
34
49
|
Good:
|
|
35
50
|
|
|
36
51
|
```jsx
|
|
37
52
|
<a href="/download"><img src="/hero-phone.png" alt="에이닷 전화 앱 다운로드" /></a>
|
|
38
|
-
<button aria-label="닫기"><CloseIcon aria-hidden="true" /></button>
|
|
39
53
|
```
|
|
@@ -12,27 +12,43 @@ sources: [WCAG 1.3.1, WCAG 3.3.1, WCAG 3.3.2, WCAG 4.1.2, eslint-plugin-jsx-a11y
|
|
|
12
12
|
|
|
13
13
|
## Semantic checks (what you review)
|
|
14
14
|
|
|
15
|
-
1. **Placeholder is not a label.** A control whose only "label" is `placeholder` is an `error` — it disappears on input and has no reliable AT exposure. Check the diff for inputs that visually rely on placeholder alone.
|
|
16
|
-
2. **The accessible name must match the visible label.** If a visible text label says "휴대폰 번호" but `aria-label="phone"`, voice-control users cannot target it (WCAG 2.5.3 Label in Name) → `warning`.
|
|
17
|
-
3. **Errors must be programmatically tied to their field.** Rendering an error message as a sibling `<p className="error">` with no `aria-describedby` on the input and no `aria-invalid` is `error` when the diff introduces validation UI. A live-updating error summary should use `role="alert"` or `aria-live="assertive"` — but only one, not both stacked.
|
|
18
|
-
4. **Required and disabled semantics.** Visually-marked required fields (asterisk, "필수") need `required` or `aria-required="true"` (`warning`). A visually "disabled" button that is actually a styled `div` or keeps focus without `disabled`/`aria-disabled` misleads AT → `warning`.
|
|
19
|
-
5. **Grouped controls need a group name.** Radio sets / related checkboxes introduced without `fieldset`+`legend` (or `role="radiogroup"` + `aria-labelledby`) → `warning`: each option is announced with no question attached.
|
|
20
|
-
6. **Autocomplete on identity fields.** Login/checkout fields for name, email, tel, address should carry `autocomplete` tokens (WCAG 1.3.5) → `warning` when the diff adds such fields bare.
|
|
15
|
+
1. `[core]` **Placeholder is not a label.** A control whose only "label" is `placeholder` is an `error` — it disappears on input and has no reliable AT exposure. Check the diff for inputs that visually rely on placeholder alone.
|
|
16
|
+
2. `[core]` **The accessible name must match the visible label.** If a visible text label says "휴대폰 번호" but `aria-label="phone"`, voice-control users cannot target it (WCAG 2.5.3 Label in Name) → `warning`.
|
|
17
|
+
3. `[full]` **Errors must be programmatically tied to their field.** Rendering an error message as a sibling `<p className="error">` with no `aria-describedby` on the input and no `aria-invalid` is `error` when the diff introduces validation UI. A live-updating error summary should use `role="alert"` or `aria-live="assertive"` — but only one, not both stacked.
|
|
18
|
+
4. `[full]` **Required and disabled semantics.** Visually-marked required fields (asterisk, "필수") need `required` or `aria-required="true"` (`warning`). A visually "disabled" button that is actually a styled `div` or keeps focus without `disabled`/`aria-disabled` misleads AT → `warning`.
|
|
19
|
+
5. `[full]` **Grouped controls need a group name.** Radio sets / related checkboxes introduced without `fieldset`+`legend` (or `role="radiogroup"` + `aria-labelledby`) → `warning`: each option is announced with no question attached.
|
|
20
|
+
6. `[core]` **Autocomplete on identity fields.** Login/checkout fields for name, email, tel, address should carry `autocomplete` tokens (WCAG 1.3.5) → `warning` when the diff adds such fields bare.
|
|
21
21
|
|
|
22
22
|
## Examples
|
|
23
23
|
|
|
24
|
+
### `[core]` Label and autocomplete
|
|
25
|
+
|
|
24
26
|
Bad:
|
|
25
27
|
|
|
26
28
|
```jsx
|
|
27
29
|
<input placeholder="이메일" value={email} onChange={...} />
|
|
28
|
-
{error && <p className="error">이메일 형식이 아닙니다</p>}
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
Good:
|
|
32
33
|
|
|
33
34
|
```jsx
|
|
34
35
|
<label htmlFor="email">이메일</label>
|
|
35
|
-
<input id="email" type="email" autoComplete="email" value={email}
|
|
36
|
+
<input id="email" type="email" autoComplete="email" value={email} onChange={...} />
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### `[full]` Error tied to its field
|
|
40
|
+
|
|
41
|
+
Bad:
|
|
42
|
+
|
|
43
|
+
```jsx
|
|
44
|
+
<input id="email" type="email" value={email} onChange={...} />
|
|
45
|
+
{error && <p className="error">이메일 형식이 아닙니다</p>}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Good:
|
|
49
|
+
|
|
50
|
+
```jsx
|
|
51
|
+
<input id="email" type="email" value={email} onChange={...}
|
|
36
52
|
aria-invalid={!!error} aria-describedby={error ? "email-error" : undefined} />
|
|
37
53
|
{error && <p id="email-error" role="alert">이메일 형식이 아닙니다</p>}
|
|
38
54
|
```
|
|
@@ -17,20 +17,22 @@ sources: [W3C WAI-ARIA APG patterns, WCAG 4.1.2, eslint-plugin-jsx-a11y role-* r
|
|
|
17
17
|
|
|
18
18
|
For any custom widget in the diff, identify which APG pattern it is imitating, then verify the pattern is **complete** — states, properties, and relationships all present. Missing pieces of a claimed pattern are `error`.
|
|
19
19
|
|
|
20
|
-
1. **Combobox / select-like** (custom dropdown, autocomplete):
|
|
20
|
+
1. `[full]` **Combobox / select-like** (custom dropdown, autocomplete):
|
|
21
21
|
- Trigger: `role="combobox"`, `aria-expanded` toggling, `aria-controls` → listbox id, `aria-haspopup="listbox"` where appropriate.
|
|
22
22
|
- Popup: `role="listbox"`, options `role="option"` with `aria-selected`; active option tracked via `aria-activedescendant` on the combobox (or roving focus — one or the other, not both).
|
|
23
23
|
- A styled `div` dropdown with only `onClick` handlers and none of the above is the classic failure → `error`.
|
|
24
|
-
2. **Dialog / modal**: `role="dialog"` + `aria-modal="true"`, labelled by its title. Background content must be inert or `aria-hidden` while open. (Focus behavior → rules/06.)
|
|
25
|
-
3. **Tabs**: `role="tablist"` / `tab` / `tabpanel`, `aria-selected` on the active tab, `aria-controls` ↔ `aria-labelledby` linkage between tab and panel.
|
|
26
|
-
4. **Menu**: `role="menu"`/`menuitem` is for **command menus**, not site navigation. Nav links wrapped in `role="menu"` is a misuse → `warning` (breaks expected keyboard model).
|
|
27
|
-
5. **Switch vs checkbox vs button**: a toggle announced as what it visually is — `role="switch"` needs `aria-checked`, not `aria-pressed`; mixing the two vocabularies is `warning`.
|
|
28
|
-
6. **State must live in ARIA, not only in CSS.** `className={isOpen ? 'open' : ''}` with no `aria-expanded` change means AT never hears the state change → `error` for expand/collapse triggers.
|
|
29
|
-
7. **`aria-hidden` on focusable content** or on an element containing focusable children → `error` (focusable but invisible to AT).
|
|
30
|
-
8. **Redundant/contradictory ARIA**: `role="button"` on `button`, `aria-label` duplicating identical visible text where unnecessary → `notice`-level `warning`; contradiction (label says one thing, visible text another) → follow rules/03 §2.
|
|
24
|
+
2. `[full]` **Dialog / modal**: `role="dialog"` + `aria-modal="true"`, labelled by its title. Background content must be inert or `aria-hidden` while open. (Focus behavior → rules/06.)
|
|
25
|
+
3. `[full]` **Tabs**: `role="tablist"` / `tab` / `tabpanel`, `aria-selected` on the active tab, `aria-controls` ↔ `aria-labelledby` linkage between tab and panel.
|
|
26
|
+
4. `[full]` **Menu**: `role="menu"`/`menuitem` is for **command menus**, not site navigation. Nav links wrapped in `role="menu"` is a misuse → `warning` (breaks expected keyboard model).
|
|
27
|
+
5. `[full]` **Switch vs checkbox vs button**: a toggle announced as what it visually is — `role="switch"` needs `aria-checked`, not `aria-pressed`; mixing the two vocabularies is `warning`.
|
|
28
|
+
6. `[full]` **State must live in ARIA, not only in CSS.** `className={isOpen ? 'open' : ''}` with no `aria-expanded` change means AT never hears the state change → `error` for expand/collapse triggers.
|
|
29
|
+
7. `[full]` **`aria-hidden` on focusable content** or on an element containing focusable children → `error` (focusable but invisible to AT).
|
|
30
|
+
8. `[full]` **Redundant/contradictory ARIA**: `role="button"` on `button`, `aria-label` duplicating identical visible text where unnecessary → `notice`-level `warning`; contradiction (label says one thing, visible text another) → follow rules/03 §2.
|
|
31
31
|
|
|
32
32
|
## Examples
|
|
33
33
|
|
|
34
|
+
### `[full]` Combobox
|
|
35
|
+
|
|
34
36
|
Bad — half a combobox:
|
|
35
37
|
|
|
36
38
|
```jsx
|
|
@@ -13,19 +13,21 @@ sources: [WCAG 2.1.1, WCAG 2.1.2, W3C WAI-ARIA APG keyboard patterns, eslint-plu
|
|
|
13
13
|
|
|
14
14
|
## Semantic checks (what you review)
|
|
15
15
|
|
|
16
|
-
1. **Every pointer interaction needs a keyboard equivalent — the right one.** Adding `onKeyDown` that only handles Enter on a `div` "button" is incomplete: native buttons fire on Enter **and** Space. Check the handler actually implements the expected key set, not just any key (`error` if a claimed interactive element is keyboard-dead, `warning` if the key set is partial).
|
|
17
|
-
2. **Widget key sets must match the APG pattern being imitated:**
|
|
16
|
+
1. `[core]` **Every pointer interaction needs a keyboard equivalent — the right one.** Adding `onKeyDown` that only handles Enter on a `div` "button" is incomplete: native buttons fire on Enter **and** Space. Check the handler actually implements the expected key set, not just any key (`error` if a claimed interactive element is keyboard-dead, `warning` if the key set is partial).
|
|
17
|
+
2. `[core]` **Widget key sets must match the APG pattern being imitated:**
|
|
18
18
|
- Combobox/listbox: `ArrowDown`/`ArrowUp` move the active option, `Enter` selects, `Escape` closes, `Home`/`End` jump, printable characters typeahead (typeahead is `warning`-level, the rest `error` if absent).
|
|
19
19
|
- Tabs: `ArrowLeft`/`ArrowRight` between tabs (roving tabindex), `Tab` leaves the tablist into the panel.
|
|
20
20
|
- Dialog: `Escape` closes; `Tab` cycles inside (focus trap — see rules/06).
|
|
21
21
|
- Menu: arrows navigate, `Escape` closes and returns focus to the trigger.
|
|
22
|
-
3. **Hover-only affordances.** Content or controls revealed only on `:hover`/`onMouseEnter` with no focus/keyboard path (tooltips, hover menus, card action buttons) → `error`: keyboard users can never reach them. Also flag `onMouseDown`-only handlers (skips keyboard AND breaks click-drag expectations).
|
|
23
|
-
4. **No keyboard traps.** Custom key handling that `preventDefault()`s Tab without providing an exit is an `error` (WCAG 2.1.2). Legitimate traps (modal dialogs) must be escapable via `Escape`.
|
|
24
|
-
5. **Scroll/drag-only interactions** (carousels, sliders, drag-to-reorder) need button or keyboard alternatives → `warning`.
|
|
25
|
-
6. **Global shortcuts on printable keys** without a modifier or an off-switch collide with AT and text input → `warning` (WCAG 2.1.4).
|
|
22
|
+
3. `[core]` **Hover-only affordances.** Content or controls revealed only on `:hover`/`onMouseEnter` with no focus/keyboard path (tooltips, hover menus, card action buttons) → `error`: keyboard users can never reach them. Also flag `onMouseDown`-only handlers (skips keyboard AND breaks click-drag expectations).
|
|
23
|
+
4. `[core]` **No keyboard traps.** Custom key handling that `preventDefault()`s Tab without providing an exit is an `error` (WCAG 2.1.2). Legitimate traps (modal dialogs) must be escapable via `Escape`.
|
|
24
|
+
5. `[core]` **Scroll/drag-only interactions** (carousels, sliders, drag-to-reorder) need button or keyboard alternatives → `warning`.
|
|
25
|
+
6. `[core]` **Global shortcuts on printable keys** without a modifier or an off-switch collide with AT and text input → `warning` (WCAG 2.1.4).
|
|
26
26
|
|
|
27
27
|
## Examples
|
|
28
28
|
|
|
29
|
+
### `[core]` Hover-only reveal
|
|
30
|
+
|
|
29
31
|
Bad — mouse-only reveal:
|
|
30
32
|
|
|
31
33
|
```jsx
|
|
@@ -12,18 +12,20 @@ sources: [WCAG 2.4.3, WCAG 2.4.7, WCAG 3.2.1, W3C WAI-ARIA APG dialog/disclosure
|
|
|
12
12
|
|
|
13
13
|
## Semantic checks (what you review)
|
|
14
14
|
|
|
15
|
-
1. **Opening an overlay must move focus into it; closing must return focus to the trigger.** A modal/drawer/popover in the diff that only toggles visibility state with no `focus()` call in either direction → `error`. Focus landing on the container is acceptable (`tabIndex={-1}` + label); focus landing nowhere (document.body) is the failure.
|
|
16
|
-
2. **Focus trap completeness.** While a modal is open, `Tab` from the last focusable element must wrap to the first (and Shift+Tab the reverse). A "trap" implemented by `aria-hidden` on the background but with tab order still escaping → `error`.
|
|
17
|
-
3. **Removing the focused element.** Deleting a list item, closing a tab, dismissing a toast that currently holds focus must move focus somewhere sensible (next item, list container, heading) — otherwise focus resets to `body` and keyboard users are lost → `warning`.
|
|
18
|
-
4. **Route changes in SPAs.** Client-side navigation that only swaps content leaves focus and screen-reader context on the old page. New route should move focus to the new view's heading or main container, or announce via live region → `warning` when the diff adds routing.
|
|
19
|
-
5. **Async results need announcement.** Content that appears after a delay (search results, form submission outcome, "저장되었습니다" toast) is silent to AT unless in an `aria-live` region (`polite` for results, `assertive`/`role="alert"` for errors) → `warning`; toast components with no live region → `error`.
|
|
20
|
-
6. **Loading states.** Spinner-only loading (`<Spinner />` with no text, no `aria-busy`, no live announcement) → `warning`. Skeleton screens should be `aria-hidden` so AT doesn't read placeholder noise.
|
|
21
|
-
7. **No focus stealing.** Auto-focusing an input on page load is acceptable for single-purpose pages (login); yanking focus on timers, carousel advance, or validation-while-typing → `warning` (WCAG 3.2.1 On Focus).
|
|
22
|
-
8. **`scrollIntoView`/anchor jumps without focus.** Scrolling a target into view visually while focus stays behind creates divergence between sighted and keyboard experience → `warning`.
|
|
15
|
+
1. `[core]` **Opening an overlay must move focus into it; closing must return focus to the trigger.** A modal/drawer/popover in the diff that only toggles visibility state with no `focus()` call in either direction → `error`. Focus landing on the container is acceptable (`tabIndex={-1}` + label); focus landing nowhere (document.body) is the failure.
|
|
16
|
+
2. `[core]` **Focus trap completeness.** While a modal is open, `Tab` from the last focusable element must wrap to the first (and Shift+Tab the reverse). A "trap" implemented by `aria-hidden` on the background but with tab order still escaping → `error`.
|
|
17
|
+
3. `[core]` **Removing the focused element.** Deleting a list item, closing a tab, dismissing a toast that currently holds focus must move focus somewhere sensible (next item, list container, heading) — otherwise focus resets to `body` and keyboard users are lost → `warning`.
|
|
18
|
+
4. `[full]` **Route changes in SPAs.** Client-side navigation that only swaps content leaves focus and screen-reader context on the old page. New route should move focus to the new view's heading or main container, or announce via live region → `warning` when the diff adds routing.
|
|
19
|
+
5. `[full]` **Async results need announcement.** Content that appears after a delay (search results, form submission outcome, "저장되었습니다" toast) is silent to AT unless in an `aria-live` region (`polite` for results, `assertive`/`role="alert"` for errors) → `warning`; toast components with no live region → `error`.
|
|
20
|
+
6. `[full]` **Loading states.** Spinner-only loading (`<Spinner />` with no text, no `aria-busy`, no live announcement) → `warning`. Skeleton screens should be `aria-hidden` so AT doesn't read placeholder noise.
|
|
21
|
+
7. `[core]` **No focus stealing.** Auto-focusing an input on page load is acceptable for single-purpose pages (login); yanking focus on timers, carousel advance, or validation-while-typing → `warning` (WCAG 3.2.1 On Focus).
|
|
22
|
+
8. `[core]` **`scrollIntoView`/anchor jumps without focus.** Scrolling a target into view visually while focus stays behind creates divergence between sighted and keyboard experience → `warning`.
|
|
23
23
|
|
|
24
24
|
## Examples
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
### `[core]` Modal focus contract
|
|
27
|
+
|
|
28
|
+
Bad — focus goes nowhere on open, and nowhere on close:
|
|
27
29
|
|
|
28
30
|
```jsx
|
|
29
31
|
{isOpen && <div className="modal"><h2>요금제 변경</h2>…</div>}
|
|
@@ -38,9 +40,19 @@ useEffect(() => {
|
|
|
38
40
|
}, [isOpen]);
|
|
39
41
|
|
|
40
42
|
{isOpen && (
|
|
41
|
-
<div ref={dialogRef}
|
|
42
|
-
|
|
43
|
-
<h2 id="plan-title">요금제 변경</h2>…
|
|
43
|
+
<div ref={dialogRef} tabIndex={-1} aria-label="요금제 변경">
|
|
44
|
+
<h2>요금제 변경</h2>…
|
|
44
45
|
</div>
|
|
45
46
|
)}
|
|
46
47
|
```
|
|
48
|
+
|
|
49
|
+
### `[full]` Modal semantics
|
|
50
|
+
|
|
51
|
+
The same modal, announced as one: `role="dialog"`, `aria-modal="true"` and a label (rules/04 §2).
|
|
52
|
+
|
|
53
|
+
```jsx
|
|
54
|
+
<div ref={dialogRef} role="dialog" aria-modal="true"
|
|
55
|
+
aria-labelledby="plan-title" tabIndex={-1}>
|
|
56
|
+
<h2 id="plan-title">요금제 변경</h2>…
|
|
57
|
+
</div>
|
|
58
|
+
```
|
package/src/agent.mjs
CHANGED
|
@@ -1,37 +1,47 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
4
|
+
|
|
5
|
+
/** A11Y_LENS_TIMEOUT_MS if it is a positive integer, else the default (with a warning for a bad value). */
|
|
6
|
+
export function timeoutMs(env = process.env) {
|
|
7
|
+
const raw = env.A11Y_LENS_TIMEOUT_MS;
|
|
8
|
+
if (raw === undefined || raw === '') return DEFAULT_TIMEOUT_MS;
|
|
9
|
+
const value = Number(raw);
|
|
10
|
+
if (Number.isInteger(value) && value > 0) return value;
|
|
11
|
+
console.warn(`a11y-lens: ignoring A11Y_LENS_TIMEOUT_MS="${raw}" (not a positive integer); using ${DEFAULT_TIMEOUT_MS}ms`);
|
|
12
|
+
return DEFAULT_TIMEOUT_MS;
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
const AGENTS = {
|
|
6
16
|
claude: {
|
|
7
17
|
bin: 'claude',
|
|
8
|
-
invoke(prompt) {
|
|
18
|
+
invoke(prompt, timeout) {
|
|
9
19
|
const args = ['-p', '--output-format', 'text'];
|
|
10
20
|
if (process.env.A11Y_LENS_MODEL) args.push('--model', process.env.A11Y_LENS_MODEL);
|
|
11
21
|
return spawnSync('claude', args, {
|
|
12
22
|
input: prompt,
|
|
13
23
|
encoding: 'utf8',
|
|
14
|
-
timeout
|
|
24
|
+
timeout,
|
|
15
25
|
maxBuffer: 10 * 1024 * 1024,
|
|
16
26
|
});
|
|
17
27
|
},
|
|
18
28
|
},
|
|
19
29
|
codex: {
|
|
20
30
|
bin: 'codex',
|
|
21
|
-
invoke(prompt) {
|
|
31
|
+
invoke(prompt, timeout) {
|
|
22
32
|
return spawnSync('codex', ['exec', prompt], {
|
|
23
33
|
encoding: 'utf8',
|
|
24
|
-
timeout
|
|
34
|
+
timeout,
|
|
25
35
|
maxBuffer: 10 * 1024 * 1024,
|
|
26
36
|
});
|
|
27
37
|
},
|
|
28
38
|
},
|
|
29
39
|
cursor: {
|
|
30
40
|
bin: 'cursor-agent',
|
|
31
|
-
invoke(prompt) {
|
|
41
|
+
invoke(prompt, timeout) {
|
|
32
42
|
return spawnSync('cursor-agent', ['-p', prompt, '--output-format', 'text'], {
|
|
33
43
|
encoding: 'utf8',
|
|
34
|
-
timeout
|
|
44
|
+
timeout,
|
|
35
45
|
maxBuffer: 10 * 1024 * 1024,
|
|
36
46
|
});
|
|
37
47
|
},
|
|
@@ -61,10 +71,10 @@ export function detectAgent(preferred) {
|
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
/** Run the review prompt through the agent. Returns { ok, output, error }. */
|
|
64
|
-
export function runAgent(agent, prompt) {
|
|
74
|
+
export function runAgent(agent, prompt, timeout = timeoutMs()) {
|
|
65
75
|
let result;
|
|
66
76
|
try {
|
|
67
|
-
result = agent.invoke(prompt);
|
|
77
|
+
result = agent.invoke(prompt, timeout);
|
|
68
78
|
} catch (err) {
|
|
69
79
|
return { ok: false, error: String(err) };
|
|
70
80
|
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Project settings: which checks run (`level`) and which findings are shown (`report`).
|
|
2
|
+
//
|
|
3
|
+
// Read from the repository, because what a team checks is a team decision: `a11y-lens.config.json`
|
|
4
|
+
// at the git top level, else the `"a11y-lens"` field of its `package.json`. Environment variables
|
|
5
|
+
// override either, for one person trying something out. A bad value is warned about and replaced by
|
|
6
|
+
// the default — never an error, since a commit is never blocked by infrastructure.
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const LEVELS = ['core', 'full'];
|
|
12
|
+
export const REPORTS = ['errors', 'all'];
|
|
13
|
+
// The defaults are what every version before 0.6.0 did, so upgrading changes nothing by itself.
|
|
14
|
+
export const DEFAULTS = { level: 'full', report: 'all' };
|
|
15
|
+
|
|
16
|
+
function repoRoot(cwd) {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
19
|
+
} catch {
|
|
20
|
+
return cwd;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `{ value }`, `{ missing: true }`, or `{ error }` — a file that is there but unreadable is worth a warning. */
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = readFileSync(path, 'utf8');
|
|
29
|
+
} catch {
|
|
30
|
+
return { missing: true };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return { value: JSON.parse(text) };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { error: String(err?.message ?? err) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
40
|
+
|
|
41
|
+
export function loadConfig({ cwd = process.cwd(), env = process.env, warn = (m) => console.warn(m) } = {}) {
|
|
42
|
+
const root = repoRoot(cwd);
|
|
43
|
+
let settings = {};
|
|
44
|
+
let source = 'defaults';
|
|
45
|
+
|
|
46
|
+
const file = join(root, 'a11y-lens.config.json');
|
|
47
|
+
const fromFile = readJson(file);
|
|
48
|
+
if (fromFile.error) {
|
|
49
|
+
warn(`a11y-lens: ignoring ${file} (${fromFile.error})`);
|
|
50
|
+
} else if (!fromFile.missing) {
|
|
51
|
+
if (isPlainObject(fromFile.value)) {
|
|
52
|
+
settings = fromFile.value;
|
|
53
|
+
source = 'a11y-lens.config.json';
|
|
54
|
+
} else {
|
|
55
|
+
warn(`a11y-lens: ignoring ${file} (expected an object)`);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
59
|
+
const field = pkg.value?.['a11y-lens'];
|
|
60
|
+
if (field !== undefined) {
|
|
61
|
+
if (isPlainObject(field)) {
|
|
62
|
+
settings = field;
|
|
63
|
+
source = 'package.json';
|
|
64
|
+
} else {
|
|
65
|
+
warn('a11y-lens: ignoring the "a11y-lens" field in package.json (expected an object)');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pick = (key, allowed, envName) => {
|
|
71
|
+
for (const [value, from] of [[env[envName], envName], [settings[key], source]]) {
|
|
72
|
+
if (value === undefined || value === '') continue;
|
|
73
|
+
if (allowed.includes(value)) return value;
|
|
74
|
+
warn(`a11y-lens: ignoring ${key} "${value}" from ${from} (expected ${allowed.join(' | ')})`);
|
|
75
|
+
}
|
|
76
|
+
return DEFAULTS[key];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
level: pick('level', LEVELS, 'A11Y_LENS_LEVEL'),
|
|
81
|
+
report: pick('report', REPORTS, 'A11Y_LENS_REPORT'),
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/pending.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Pending reviews: staged files a check could not review, kept so the skip is visible later.
|
|
2
|
+
//
|
|
3
|
+
// A skipped check exits 0 so a commit is never blocked by infrastructure — which also makes it look
|
|
4
|
+
// exactly like a clean one. Each file it failed to review is recorded here, and stays until a later
|
|
5
|
+
// run actually reviews it (`check --staged` on the same path, or `check --pending`).
|
|
6
|
+
//
|
|
7
|
+
// Layout (a public contract — other tools read it; bump PENDING_VERSION on any change):
|
|
8
|
+
// <git-common-dir>/a11y-lens/pending/<sha1(worktree + NUL + path)>.json
|
|
9
|
+
// { version, worktree, path, blob, diff, reason, at }
|
|
10
|
+
//
|
|
11
|
+
// - One file per entry: parallel commits in several worktrees record at the same moment, and a
|
|
12
|
+
// single shared file would lose one of them. Each is written to a temp file and renamed, so a
|
|
13
|
+
// reader never sees half an entry (untested: the race cannot be staged deterministically).
|
|
14
|
+
// - The common dir, so an entry recorded in a worktree is still visible after that worktree is gone.
|
|
15
|
+
// - `blob` is the index object that was staged. It lives in the shared object store, so the exact
|
|
16
|
+
// content that went unreviewed can still be read after the worktree is removed or the file edited.
|
|
17
|
+
// - `diff` is the staged diff, so a re-check reviews the change rather than the whole file and does
|
|
18
|
+
// not report pre-existing issues as if the skipped commit introduced them.
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export const PENDING_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
function git(args, cwd) {
|
|
27
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute pending directory, or null outside a git repository. */
|
|
31
|
+
export function pendingDir(cwd = process.cwd()) {
|
|
32
|
+
try {
|
|
33
|
+
const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd).trim();
|
|
34
|
+
return join(common, 'a11y-lens', 'pending');
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function worktreeOf(cwd) {
|
|
41
|
+
return git(['rev-parse', '--show-toplevel'], cwd).trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function entryName(worktree, path) {
|
|
45
|
+
return `${createHash('sha1').update(`${worktree}\0${path}`).digest('hex')}.json`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Record staged files that were not reviewed. `files` are staged entries ({ path, diff }).
|
|
50
|
+
* Returns { recorded } or { error } — never throws, because a failure here must not block a commit.
|
|
51
|
+
*/
|
|
52
|
+
export function recordPending(files, reason, { cwd = process.cwd(), now = new Date() } = {}) {
|
|
53
|
+
if (files.length === 0) return { recorded: 0 };
|
|
54
|
+
try {
|
|
55
|
+
const dir = pendingDir(cwd);
|
|
56
|
+
if (!dir) return { error: 'not a git repository' };
|
|
57
|
+
const worktree = worktreeOf(cwd);
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
const at = now.toISOString();
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
let blob = null;
|
|
62
|
+
try {
|
|
63
|
+
blob = git(['rev-parse', `:${file.path}`], worktree).trim();
|
|
64
|
+
} catch {
|
|
65
|
+
/* not in the index (non-staged run); the working-tree path is the only locator */
|
|
66
|
+
}
|
|
67
|
+
const entry = { version: PENDING_VERSION, worktree, path: file.path, blob, diff: file.diff ?? '', reason, at };
|
|
68
|
+
const target = join(dir, entryName(worktree, file.path));
|
|
69
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
70
|
+
writeFileSync(temp, JSON.stringify(entry));
|
|
71
|
+
renameSync(temp, target);
|
|
72
|
+
}
|
|
73
|
+
return { recorded: files.length };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { error: String(err?.message ?? err) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** All readable entries, each with its file name. Unreadable or foreign-version files are counted, not returned. */
|
|
80
|
+
export function listPending(cwd = process.cwd()) {
|
|
81
|
+
const dir = pendingDir(cwd);
|
|
82
|
+
if (!dir) return { entries: [], unreadable: 0 };
|
|
83
|
+
let names;
|
|
84
|
+
try {
|
|
85
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
86
|
+
} catch {
|
|
87
|
+
return { entries: [], unreadable: 0 };
|
|
88
|
+
}
|
|
89
|
+
const entries = [];
|
|
90
|
+
let unreadable = 0;
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
try {
|
|
93
|
+
const raw = readFileSync(join(dir, name), 'utf8');
|
|
94
|
+
const entry = JSON.parse(raw);
|
|
95
|
+
if (entry?.version !== PENDING_VERSION || typeof entry.path !== 'string') {
|
|
96
|
+
unreadable++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
entries.push({ name, raw, entry });
|
|
100
|
+
} catch {
|
|
101
|
+
unreadable++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { entries, unreadable };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drop entries a staged run has just reviewed — only when the recorded blob is the one staged now.
|
|
109
|
+
* That is a commit aborted (typecheck failed in parallel, say) and retried as it was: the skipped
|
|
110
|
+
* content is exactly what was reviewed. If the skipped commit landed instead, its change is in HEAD
|
|
111
|
+
* and absent from the new diff, so this review did not look at it and the entry must stay.
|
|
112
|
+
*/
|
|
113
|
+
export function clearReviewed(paths, cwd = process.cwd()) {
|
|
114
|
+
try {
|
|
115
|
+
const dir = pendingDir(cwd);
|
|
116
|
+
if (!dir) return;
|
|
117
|
+
const worktree = worktreeOf(cwd);
|
|
118
|
+
for (const path of paths) {
|
|
119
|
+
const file = join(dir, entryName(worktree, path));
|
|
120
|
+
let entry;
|
|
121
|
+
try {
|
|
122
|
+
entry = JSON.parse(readFileSync(file, 'utf8'));
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const staged = git(['rev-parse', `:${path}`], worktree).trim();
|
|
127
|
+
if (entry.blob && entry.blob === staged) rmSync(file, { force: true });
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
/* best effort: a stale entry is re-checked later, never lost */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Drop an entry read earlier, but only if it has not been re-recorded since — a commit that skipped
|
|
136
|
+
* the same path while `--pending` ran has content this run did not review.
|
|
137
|
+
*/
|
|
138
|
+
export function clearEntry({ name, raw }, cwd = process.cwd()) {
|
|
139
|
+
const dir = pendingDir(cwd);
|
|
140
|
+
if (!dir) return;
|
|
141
|
+
const file = join(dir, name);
|
|
142
|
+
try {
|
|
143
|
+
if (readFileSync(file, 'utf8') === raw) rmSync(file, { force: true });
|
|
144
|
+
} catch {
|
|
145
|
+
/* already gone */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The content an entry stands for: the staged blob, else the working-tree file.
|
|
151
|
+
* Returns { content } or { gone: reason }.
|
|
152
|
+
*/
|
|
153
|
+
export function contentOf(entry, cwd = process.cwd()) {
|
|
154
|
+
if (entry.blob) {
|
|
155
|
+
try {
|
|
156
|
+
return { content: git(['cat-file', '-p', entry.blob], cwd) };
|
|
157
|
+
} catch {
|
|
158
|
+
/* pruned by gc — fall through to the working tree */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return { content: readFileSync(join(entry.worktree, entry.path), 'utf8') };
|
|
163
|
+
} catch {
|
|
164
|
+
return { gone: 'neither the staged blob nor the file exists any more' };
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/prompt.mjs
CHANGED
|
@@ -8,12 +8,51 @@ const RULES_DIR = join(
|
|
|
8
8
|
);
|
|
9
9
|
const MAX_TOTAL_BYTES = 160_000;
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Named per level so the prompt never cites a check the level left out — an agent told that
|
|
12
|
+
// "incomplete claimed ARIA patterns" are errors reviews ARIA patterns whether or not they are listed.
|
|
13
|
+
const SEVERITY_EXAMPLES = {
|
|
14
|
+
core: 'keyboard-dead interactive elements, missing accessible names on icon-only controls, focus not managed on overlays',
|
|
15
|
+
full: 'keyboard-dead interactive elements, missing accessible names on icon-only controls, incomplete claimed ARIA patterns, focus not managed on overlays, informative images silenced',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const LEVEL_TAG = /^`\[(core|full)\]`/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One rule file cut down to a level. `full` is the file as written. `core` keeps the numbered
|
|
22
|
+
* semantic checks and the example subsections tagged `[core]`, and drops a file with none — so what
|
|
23
|
+
* the agent is shown is only what it is asked to check. Numbers are kept, because other files refer
|
|
24
|
+
* to checks by number ("rules/03 §2").
|
|
25
|
+
*/
|
|
26
|
+
export function filterRule(text, level) {
|
|
27
|
+
if (level === 'full') return text;
|
|
28
|
+
const sections = text.split(/(?=^## )/m);
|
|
29
|
+
let kept = 0;
|
|
30
|
+
const out = sections.map((section) => {
|
|
31
|
+
if (section.startsWith('## Semantic checks')) {
|
|
32
|
+
const [intro, ...items] = section.split(/(?=^\d+\. )/m);
|
|
33
|
+
const chosen = items.filter((item) => item.replace(/^\d+\. /, '').match(LEVEL_TAG)?.[1] === level);
|
|
34
|
+
kept += chosen.length;
|
|
35
|
+
return intro + chosen.join('');
|
|
36
|
+
}
|
|
37
|
+
if (section.startsWith('## Examples')) {
|
|
38
|
+
const [intro, ...subsections] = section.split(/(?=^### )/m);
|
|
39
|
+
const chosen = subsections.filter((sub) => sub.replace(/^### /, '').match(LEVEL_TAG)?.[1] === level);
|
|
40
|
+
return chosen.length ? intro + chosen.join('') : '';
|
|
41
|
+
}
|
|
42
|
+
return section;
|
|
43
|
+
});
|
|
44
|
+
return kept === 0 ? null : out.join('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The rule files at `level`, joined, and the ids of the categories that survived. */
|
|
48
|
+
export function loadRules(level = 'full') {
|
|
49
|
+
const files = readdirSync(RULES_DIR)
|
|
13
50
|
.filter((name) => name.endsWith('.md'))
|
|
14
51
|
.sort()
|
|
15
|
-
.map((name) => readFileSync(join(RULES_DIR, name), 'utf8'))
|
|
16
|
-
.
|
|
52
|
+
.map((name) => filterRule(readFileSync(join(RULES_DIR, name), 'utf8'), level))
|
|
53
|
+
.filter((text) => text !== null);
|
|
54
|
+
const ids = files.map((text) => text.match(/^id: (\S+)$/m)?.[1]).filter(Boolean);
|
|
55
|
+
return { text: files.join('\n\n---\n\n'), ids };
|
|
17
56
|
}
|
|
18
57
|
|
|
19
58
|
function numbered(content) {
|
|
@@ -27,26 +66,26 @@ function numbered(content) {
|
|
|
27
66
|
* Assemble the review prompt. Files over the total budget are dropped
|
|
28
67
|
* (caller already reported per-file skips); we report drops via the return value.
|
|
29
68
|
*/
|
|
30
|
-
export function buildPrompt(files) {
|
|
31
|
-
const rules = loadRules();
|
|
69
|
+
export function buildPrompt(files, { level = 'full' } = {}) {
|
|
70
|
+
const { text: rules, ids } = loadRules(level);
|
|
32
71
|
const included = [];
|
|
33
72
|
const dropped = [];
|
|
34
73
|
let budget = MAX_TOTAL_BYTES;
|
|
35
74
|
|
|
36
75
|
for (const file of files) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
76
|
+
const fileBlock = [`### FILE: ${file.path}`, '```', numbered(file.content), '```'].join('\n');
|
|
77
|
+
const withDiff = file.diff
|
|
78
|
+
? `${fileBlock}\n#### Staged diff for ${file.path} (focus your review here)\n\`\`\`diff\n${file.diff}\n\`\`\``
|
|
79
|
+
: fileBlock;
|
|
80
|
+
// Content is capped at 48KB and a diff is not, so a rewrite can make one file's block larger
|
|
81
|
+
// than the whole budget — dropped every time, including on every `--pending` re-check. Without
|
|
82
|
+
// its diff it always fits an empty budget, so it is reviewed whole rather than never.
|
|
83
|
+
const block = [withDiff, fileBlock].find((b) => Buffer.byteLength(b, 'utf8') <= budget);
|
|
84
|
+
if (!block) {
|
|
46
85
|
dropped.push(file.path);
|
|
47
86
|
continue;
|
|
48
87
|
}
|
|
49
|
-
budget -=
|
|
88
|
+
budget -= Buffer.byteLength(block, 'utf8');
|
|
50
89
|
included.push(block);
|
|
51
90
|
}
|
|
52
91
|
|
|
@@ -68,13 +107,13 @@ Respond with ONLY a JSON array — no prose, no markdown fences. Each finding:
|
|
|
68
107
|
{
|
|
69
108
|
"file": "path as given above",
|
|
70
109
|
"line": <number from the line-number prefix>,
|
|
71
|
-
"ruleId": "one of:
|
|
110
|
+
"ruleId": "one of: ${ids.join(' | ')}",
|
|
72
111
|
"severity": "error" | "warning",
|
|
73
112
|
"message": "what is wrong and why it matters, one or two sentences",
|
|
74
113
|
"suggestion": "the concrete fix, one sentence or a short code hint"
|
|
75
114
|
}
|
|
76
115
|
|
|
77
|
-
Severity discipline: "error" only for clear violations named as error in the rules (
|
|
116
|
+
Severity discipline: "error" only for clear violations named as error in the rules (${SEVERITY_EXAMPLES[level]}). Judgment calls are "warning". ${level === 'core' ? 'Check only the rules above; this project has chosen not to review the others, so do not report them. ' : ''}If the code is clean, respond with [].`;
|
|
78
117
|
|
|
79
118
|
return { prompt, dropped };
|
|
80
119
|
}
|
package/src/report.mjs
CHANGED
|
@@ -31,9 +31,20 @@ export function parseFindings(text) {
|
|
|
31
31
|
return { error: 'could not parse agent output as a findings array' };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `report: 'errors'` shows errors only and says how many warnings it held back — the count, so a
|
|
36
|
+
* clean-looking run is not mistaken for a run that found nothing.
|
|
37
|
+
*/
|
|
38
|
+
export function printReport(allFindings, { agentName, report = 'all' } = {}) {
|
|
39
|
+
const findings = report === 'errors' ? allFindings.filter((f) => f.severity === 'error') : allFindings;
|
|
40
|
+
const hidden = allFindings.length - findings.length;
|
|
41
|
+
// On the last line, beside the totals it qualifies: printed above the report, the count was
|
|
42
|
+
// followed by a footer saying "0 warning(s)" — a clean-looking run again.
|
|
43
|
+
const hiddenNote = hidden ? `, ${YELLOW}${hidden} warning(s) hidden${RESET} ${DIM}(report: errors)${RESET}` : '';
|
|
35
44
|
if (findings.length === 0) {
|
|
36
|
-
console.log(
|
|
45
|
+
console.log(hidden
|
|
46
|
+
? `a11y-lens: no errors${hiddenNote} ${DIM}(reviewed by ${agentName})${RESET}`
|
|
47
|
+
: `a11y-lens: no findings ${DIM}(reviewed by ${agentName})${RESET}`);
|
|
37
48
|
return;
|
|
38
49
|
}
|
|
39
50
|
const byFile = new Map();
|
|
@@ -56,7 +67,7 @@ export function printReport(findings, { agentName } = {}) {
|
|
|
56
67
|
const warnings = findings.length - errors;
|
|
57
68
|
console.log(
|
|
58
69
|
`\na11y-lens: ${errors ? RED : ''}${errors} error(s)${RESET}, ` +
|
|
59
|
-
`${warnings ? YELLOW : ''}${warnings} warning(s)${RESET} ` +
|
|
70
|
+
(hidden ? `${hiddenNote.slice(2)} ` : `${warnings ? YELLOW : ''}${warnings} warning(s)${RESET} `) +
|
|
60
71
|
`${DIM}(reviewed by ${agentName})${RESET}`,
|
|
61
72
|
);
|
|
62
73
|
}
|
package/src/staged.mjs
CHANGED
|
@@ -58,7 +58,7 @@ export function collectStagedUIFiles() {
|
|
|
58
58
|
|
|
59
59
|
let diff = '';
|
|
60
60
|
try {
|
|
61
|
-
diff = git(['diff', '--cached', '--unified=3', '--', path]);
|
|
61
|
+
diff = git(['diff', '--cached', '--unified=3', '--', `:(top)${path}`]);
|
|
62
62
|
} catch {
|
|
63
63
|
/* diff is best-effort context */
|
|
64
64
|
}
|
|
@@ -12,6 +12,8 @@ When writing or modifying UI code (JSX/TSX/HTML/Vue/Svelte), apply the rule set
|
|
|
12
12
|
- `05-keyboard-interaction.md` — full APG key sets, no hover-only affordances, no keyboard traps
|
|
13
13
|
- `06-focus-management.md` — overlays move and return focus; async results are announced via live regions
|
|
14
14
|
|
|
15
|
+
Each check is tagged `[core]` or `[full]`. If this project's `a11y-lens.config.json` (or the `"a11y-lens"` field of `package.json`) sets `"level": "core"`, apply only the `[core]` checks — the commit-time review checks nothing else.
|
|
16
|
+
|
|
15
17
|
Tip: agents with skills support get richer guidance via `npx skills add jo-duchan/a11y-lens`.
|
|
16
18
|
|
|
17
19
|
Self-check against these categories before finishing any UI task — it is cheaper than failing the pre-commit gate.
|