tewip-ai 0.34.0 → 0.36.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 +91 -0
- package/dist/cli/help.js +14 -0
- package/dist/cli/verbs-prove.js +12 -0
- package/dist/engine/access/access-command.js +233 -0
- package/dist/engine/access/checks.js +231 -0
- package/dist/engine/access/markup.js +174 -0
- package/dist/engine/access/merge.js +119 -0
- package/dist/engine/access/reports.js +206 -0
- package/dist/engine/access/wcag.js +135 -0
- package/dist/mcp/tools-prove.js +44 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -574,6 +574,97 @@ With `--json` the verdict is **sealed**: editing `not-ready` to `ready` in the f
|
|
|
574
574
|
fail, so a verdict that travels into a ticket or a release note can be checked rather than trusted. It is
|
|
575
575
|
also an MCP tool, so an agent can ask whether its own work is done before saying that it is.
|
|
576
576
|
|
|
577
|
+
## Can everybody use it? (Phase 28, new)
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
tewip access # every markup file in the project
|
|
581
|
+
tewip access src/components/Nav.tsx # or just one
|
|
582
|
+
tewip access --strict # fails the build on a barrier that stops somebody outright
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Accessibility is the one quality question that is also a legal one. The European Accessibility Act has
|
|
586
|
+
been enforceable since June 2025, and it lands on the people who already own testing.
|
|
587
|
+
|
|
588
|
+
The reason teams do not trust the tools they have is the reason they switch off every scanner: axe,
|
|
589
|
+
Lighthouse and pa11y hand back several hundred undifferentiated violations, a large share of which are
|
|
590
|
+
not barriers at all, and nobody has the afternoon it takes to sort them.
|
|
591
|
+
|
|
592
|
+
Tewip makes **two kinds of statement and no third**:
|
|
593
|
+
|
|
594
|
+
- **a barrier** — the element as written stops somebody, and reading it is enough to know that;
|
|
595
|
+
- **a question** — something here is missing or unreadable, and only you can settle it.
|
|
596
|
+
|
|
597
|
+
There is no "warning", because a warning is the category people learn to scroll past.
|
|
598
|
+
|
|
599
|
+
```
|
|
600
|
+
**6 barriers (5 that stop somebody outright), 18 for a person to settle.**
|
|
601
|
+
|
|
602
|
+
### button-name - 3 places
|
|
603
|
+
|
|
604
|
+
WCAG 2.2 4.1.2 Name, Role, Value (A), blocker.
|
|
605
|
+
|
|
606
|
+
Who it stops: a screen reader announces "button" and nothing else, so it cannot be used or spoken to.
|
|
607
|
+
|
|
608
|
+
- `components/VPNavBarAskAiButton.vue:2` <button type="button" class="VPNavBarAskAiButton">
|
|
609
|
+
- it contains no text and carries no aria-label, so it is announced with its role and nothing else
|
|
610
|
+
- do this: put text inside it, or add aria-label="..." saying what it does
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Every finding names its **WCAG 2.2 success criterion and level**, which is what turns it into something
|
|
614
|
+
you can put in front of a regulator instead of a note saying "bad contrast".
|
|
615
|
+
|
|
616
|
+
**What it refuses to decide is the reason it is worth running.** `alt={caption}` is not a missing alt.
|
|
617
|
+
A button whose text comes from a child component cannot be called empty. `:aria-label="label"` is a
|
|
618
|
+
label. `<slot />` is content this file cannot see. Each of those becomes a question, never an accusation
|
|
619
|
+
— because a tool that accuses `<img alt={alt} />` of having no alt text gets switched off in a day.
|
|
620
|
+
|
|
621
|
+
**And every report prints what it did not check** — contrast, focus order, meaningful sequence, whether
|
|
622
|
+
the alt text is any good — because those need a rendered page. A clean result means these checks found
|
|
623
|
+
nothing. It never means the product is accessible.
|
|
624
|
+
|
|
625
|
+
No browser, no server, no network, no model, no cost. 416 real files in 1.65 seconds.
|
|
626
|
+
|
|
627
|
+
Measured on four public projects, never on fixtures — excalidraw, bootstrap, vitepress and svelte:
|
|
628
|
+
**50 barriers, 29 of them the kind that stop somebody outright, and 90 questions.** Seven false-positive
|
|
629
|
+
classes were found and fixed along the way, every one of them a defect in Tewip rather than in the
|
|
630
|
+
projects, and none of them reachable from a fixture. They are listed in [ROADMAP.md](ROADMAP.md).
|
|
631
|
+
|
|
632
|
+
### Already run axe? Give Tewip the JSON (Phase 29, new)
|
|
633
|
+
|
|
634
|
+
```bash
|
|
635
|
+
tewip access --report axe-results.json
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Reading markup cannot see contrast. A browser can, so if your Playwright, Cypress or jest-axe suite
|
|
639
|
+
already writes an axe report, hand it over — and Tewip will triage it instead of relaying it.
|
|
640
|
+
|
|
641
|
+
This is where the numbers get uncomfortable. axe 4.14 over nine real pages returned **174 findings**:
|
|
642
|
+
|
|
643
|
+
| | |
|
|
644
|
+
|---|---|
|
|
645
|
+
| **53 barriers** | a real WCAG criterion, with the **EN 301 549** clause the EU Accessibility Act cites |
|
|
646
|
+
| **51 advice** | axe rules tagged `best-practice` that map to **no criterion at all** |
|
|
647
|
+
| **70 questions** | axe's own `incomplete` — it could not determine them |
|
|
648
|
+
|
|
649
|
+
**42% of what axe hands you tests no standard.** `region` alone — "all content should be contained by
|
|
650
|
+
landmarks" — was 40 findings, outnumbering every real barrier on the page. Tewip reports those as advice,
|
|
651
|
+
never as barriers, and they never fail a build. That separation is most of the point.
|
|
652
|
+
|
|
653
|
+
And the unchecked list shrinks, honestly: **one** of its seven entries — 1.4.3 Contrast — moves to
|
|
654
|
+
checked. The other six stay, because no tool settles them. The report also names how many pages the run
|
|
655
|
+
covered, and says plainly that nothing is known about a page it did not open or a state it did not reach.
|
|
656
|
+
|
|
657
|
+
```
|
|
658
|
+
## Checked here only because a browser run was given
|
|
659
|
+
|
|
660
|
+
- **1.4.3 Contrast (Minimum)** - would otherwise be unreachable: needs the computed colours of a
|
|
661
|
+
rendered page, not the markup
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
A source finding is set aside only when the run opened **that same file** and re-ran the equivalent rule.
|
|
665
|
+
In a React or Vue project that means nothing is set aside — the source and the rendered output are two
|
|
666
|
+
different artefacts, and neither is evidence about the other.
|
|
667
|
+
|
|
577
668
|
## Teach it your house rules (Phase 27)
|
|
578
669
|
|
|
579
670
|
```bash
|
package/dist/cli/help.js
CHANGED
|
@@ -118,6 +118,20 @@ Usage:
|
|
|
118
118
|
is what was asked for covered. Answers ready, not ready, or cannot
|
|
119
119
|
say - and cannot say is never rounded up to ready. --json writes a
|
|
120
120
|
sealed verdict that can be checked later. No model, no cost.
|
|
121
|
+
tewip access [<file or directory>] [--report axe.json] [--strict] [--out access.md]
|
|
122
|
+
Can everybody use this? Reads your markup and names the barriers
|
|
123
|
+
that reading alone can prove - a picture with no alt text, a field
|
|
124
|
+
with no label, a div only a mouse can use - each against its WCAG
|
|
125
|
+
2.2 criterion, which is what a regulator asks for. What only a
|
|
126
|
+
person can settle is kept apart from what is proved, and every
|
|
127
|
+
criterion it cannot reach without rendering the page (contrast,
|
|
128
|
+
focus order, whether the alt text is any good) is printed every
|
|
129
|
+
time. It never says a page is accessible, only what was checked.
|
|
130
|
+
Give it --report with the JSON from an axe run and it reads that
|
|
131
|
+
too: contrast moves out of the unchecked list, axe rules that map
|
|
132
|
+
to no WCAG criterion are reported as advice rather than barriers
|
|
133
|
+
(51 of 174 findings on nine real pages), and what axe could not
|
|
134
|
+
determine becomes a question. No browser, no network, no model.
|
|
121
135
|
tewip standards [--diff main...HEAD] [--strict] [--out standards.md]
|
|
122
136
|
Teach Tewip your house rules. Markdown files in
|
|
123
137
|
.tewip/standards/ - a forbid or require line is checked and
|
package/dist/cli/verbs-prove.js
CHANGED
|
@@ -20,6 +20,18 @@ export async function proveVerb(verb, args) {
|
|
|
20
20
|
});
|
|
21
21
|
return true;
|
|
22
22
|
}
|
|
23
|
+
case 'access': {
|
|
24
|
+
const { runAccess } = await import('../engine/access/access-command.js');
|
|
25
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--out' && args[i - 1] !== '--report');
|
|
26
|
+
runAccess({
|
|
27
|
+
root: process.cwd(),
|
|
28
|
+
only: paths[0],
|
|
29
|
+
out: flag(args, 'out'),
|
|
30
|
+
report: flag(args, 'report'),
|
|
31
|
+
gate: args.includes('--strict'),
|
|
32
|
+
});
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
23
35
|
case 'standards': {
|
|
24
36
|
const { runStandards } = await import('../engine/standards/standards-command.js');
|
|
25
37
|
runStandards({
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Phase 28: `tewip access` - the accessibility barriers that can be proved by reading the markup,
|
|
2
|
+
// separated from the questions only a person can answer, with everything it did not check named.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists when axe, Lighthouse and pa11y already do: those tools hand a team a few hundred
|
|
5
|
+
// undifferentiated violations, a large share of which are not barriers at all, and the team turns
|
|
6
|
+
// the check off. Tewip makes exactly two kinds of statement - "this stops somebody, here is the
|
|
7
|
+
// element" and "only you can settle this one" - and prints the criteria it cannot reach. It needs
|
|
8
|
+
// no browser, no server, no network and no model, so it runs on any repository in a second.
|
|
9
|
+
//
|
|
10
|
+
// It never says a page is accessible. It says what was checked.
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
|
+
import { sourceFiles } from '../secure/secrets.js';
|
|
14
|
+
import { JS_LIKE, MARKUP, tags } from './markup.js';
|
|
15
|
+
import { checkFile } from './checks.js';
|
|
16
|
+
import { readAxeReport } from './reports.js';
|
|
17
|
+
import { merge, nowChecked, pagesIn, renderBrowserSection, standardOf, stillNotChecked } from './merge.js';
|
|
18
|
+
import { blocks, criterionOf, NOT_CHECKED, RULES } from './wcag.js';
|
|
19
|
+
const ORDER = { blocker: 0, serious: 1, moderate: 2, minor: 3 };
|
|
20
|
+
/** One line per finding a person has already judged: "<rule> <file>:<line> - reason". */
|
|
21
|
+
export function readAccepted(root) {
|
|
22
|
+
const file = join(root, '.tewip', 'access-accepted.txt');
|
|
23
|
+
if (!existsSync(file))
|
|
24
|
+
return new Set();
|
|
25
|
+
return new Set(readFileSync(file, 'utf8')
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((l) => l.split(' - ')[0].trim())
|
|
28
|
+
.filter((l) => l !== '' && !l.startsWith('#')));
|
|
29
|
+
}
|
|
30
|
+
export function keyOf(f) {
|
|
31
|
+
return `${f.rule} ${f.file}:${f.line}`;
|
|
32
|
+
}
|
|
33
|
+
export function worstFirst(a, b) {
|
|
34
|
+
const sa = ORDER[RULES[a.rule]?.severity ?? 'minor'];
|
|
35
|
+
const sb = ORDER[RULES[b.rule]?.severity ?? 'minor'];
|
|
36
|
+
if (sa !== sb)
|
|
37
|
+
return sa - sb;
|
|
38
|
+
if (a.file !== b.file)
|
|
39
|
+
return a.file < b.file ? -1 : 1;
|
|
40
|
+
return a.line - b.line;
|
|
41
|
+
}
|
|
42
|
+
/** Reads every markup file under `root` and returns what the markup alone can settle. */
|
|
43
|
+
/**
|
|
44
|
+
* Markup written to exercise code, not to be used by a person. An `<img>` with no alt in a
|
|
45
|
+
* clipboard test is not a barrier to anybody, and excalidraw's test suite produced exactly that.
|
|
46
|
+
*/
|
|
47
|
+
const TEST_MARKUP = /(?:^|[\\/])(?:__tests__|__mocks__|tests?|spec|e2e|stories|fixtures?)[\\/]|\.(?:test|spec|stories|cy)\.[cm]?[jt]sx?$/i;
|
|
48
|
+
export function findBarriers(root, only) {
|
|
49
|
+
const base = only ? resolve(root, only) : root;
|
|
50
|
+
const all = existsSync(base) && MARKUP.test(base) ? [base] : sourceFiles(base).filter((f) => MARKUP.test(f));
|
|
51
|
+
// Asking for a path by name overrides the exclusion: if you point at a fixture, you want it read.
|
|
52
|
+
const files = only ? all : all.filter((f) => !TEST_MARKUP.test(relative(root, f)));
|
|
53
|
+
const findings = [];
|
|
54
|
+
let filesRead = 0;
|
|
55
|
+
for (const path of files) {
|
|
56
|
+
let source;
|
|
57
|
+
try {
|
|
58
|
+
source = readFileSync(path, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// A file too large to be hand-written is generated or minified, and accusing it helps nobody.
|
|
64
|
+
if (source.length > 400_000)
|
|
65
|
+
continue;
|
|
66
|
+
filesRead++;
|
|
67
|
+
const jsLike = JS_LIKE.test(path);
|
|
68
|
+
// One page, rather than a file of components: only then do repeated ids land in one document.
|
|
69
|
+
const isDocument = /\.html?$/i.test(path);
|
|
70
|
+
findings.push(...checkFile(source, tags(source, jsLike), relative(root, path).replaceAll('\\', '/'), isDocument));
|
|
71
|
+
}
|
|
72
|
+
return { findings, filesRead };
|
|
73
|
+
}
|
|
74
|
+
function group(findings) {
|
|
75
|
+
const by = new Map();
|
|
76
|
+
for (const f of findings) {
|
|
77
|
+
const list = by.get(f.rule);
|
|
78
|
+
if (list)
|
|
79
|
+
list.push(f);
|
|
80
|
+
else
|
|
81
|
+
by.set(f.rule, [f]);
|
|
82
|
+
}
|
|
83
|
+
return by;
|
|
84
|
+
}
|
|
85
|
+
function section(title, findings, lines) {
|
|
86
|
+
if (findings.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
lines.push(`## ${title}`, '');
|
|
89
|
+
for (const [rule, found] of group(findings)) {
|
|
90
|
+
const meta = RULES[rule];
|
|
91
|
+
lines.push(`### ${rule} - ${found.length} ${found.length === 1 ? 'place' : 'places'}`, '');
|
|
92
|
+
lines.push(`${criterionOf(rule)}, ${meta?.severity ?? 'unrated'}.`, '');
|
|
93
|
+
if (meta)
|
|
94
|
+
lines.push(`Who it stops: ${meta.stops}.`, '');
|
|
95
|
+
for (const f of found.slice(0, 20)) {
|
|
96
|
+
lines.push(`- \`${f.file}:${f.line}\` ${f.element}`);
|
|
97
|
+
lines.push(` - ${f.why}`);
|
|
98
|
+
lines.push(` - do this: ${f.next}`);
|
|
99
|
+
}
|
|
100
|
+
if (found.length > 20)
|
|
101
|
+
lines.push(`- ...and ${found.length - 20} more of the same, in the JSON beside this report.`);
|
|
102
|
+
lines.push('');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function renderAccess(result, scanned) {
|
|
106
|
+
const { barriers, questions, accepted, filesRead, browser } = result;
|
|
107
|
+
const lines = ['# Tewip accessibility check', ''];
|
|
108
|
+
lines.push(`Read ${filesRead} markup ${filesRead === 1 ? 'file' : 'files'} under \`${scanned}\`, without rendering any of them.`, '');
|
|
109
|
+
if (browser) {
|
|
110
|
+
lines.push(`Also read an axe run over ${browser.pages.length} rendered ${browser.pages.length === 1 ? 'page' : 'pages'}, ` +
|
|
111
|
+
'which settles what reading markup cannot.', '');
|
|
112
|
+
}
|
|
113
|
+
if (barriers.length === 0 && questions.length === 0 && !browser) {
|
|
114
|
+
lines.push('No barrier was found that reading the markup can settle, and nothing needs a decision.', '');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const stopping = barriers.filter((f) => blocks(f.rule)).length;
|
|
118
|
+
const counts = [
|
|
119
|
+
`${barriers.length} ${barriers.length === 1 ? 'barrier' : 'barriers'} (${stopping} that stop somebody outright)`,
|
|
120
|
+
`${questions.length} for a person to settle`,
|
|
121
|
+
];
|
|
122
|
+
if (accepted.length > 0)
|
|
123
|
+
counts.push(`${accepted.length} already accepted`);
|
|
124
|
+
if (browser) {
|
|
125
|
+
counts.unshift(`${browser.barriers.length} barriers the browser run proves`);
|
|
126
|
+
if (browser.questions.length > 0)
|
|
127
|
+
counts.push(`${browser.questions.length} the browser could not determine`);
|
|
128
|
+
}
|
|
129
|
+
lines.push(`**${counts.join(', ')}.**`, '');
|
|
130
|
+
}
|
|
131
|
+
if (browser) {
|
|
132
|
+
renderBrowserSection('Barriers seen in the browser', browser.barriers, lines);
|
|
133
|
+
renderBrowserSection('The browser could not determine these', browser.questions, lines);
|
|
134
|
+
}
|
|
135
|
+
section('Barriers read from the source', [...barriers].sort(worstFirst), lines);
|
|
136
|
+
section('Only a person can settle these', [...questions].sort(worstFirst), lines);
|
|
137
|
+
if (browser && browser.advice.length > 0) {
|
|
138
|
+
// Kept out of the counts above on purpose. These are axe's own best-practice rules, which test
|
|
139
|
+
// no standard: on nine real pages they were 51 of 174 findings, outnumbering every real barrier.
|
|
140
|
+
// Reporting them as equals is how a team learns to ignore the whole report.
|
|
141
|
+
const rules = [...new Set(browser.advice.map((f) => f.rule))].sort();
|
|
142
|
+
lines.push('## Advice, not barriers', '');
|
|
143
|
+
lines.push(`${browser.advice.length} findings come from axe rules that map to no WCAG criterion - ` +
|
|
144
|
+
`${rules.join(', ')}. They are somebody's good idea rather than a standard, so they are not counted as barriers ` +
|
|
145
|
+
'and never fail a build. Worth reading once; not worth a release.', '');
|
|
146
|
+
}
|
|
147
|
+
if (browser && browser.superseded.length > 0) {
|
|
148
|
+
lines.push('## Set aside, because the browser run checked them', '');
|
|
149
|
+
lines.push(`${browser.superseded.length} source ${browser.superseded.length === 1 ? 'finding' : 'findings'} ` +
|
|
150
|
+
'are not listed above: the axe run checked their criterion on a rendered page, so its answer is the better ' +
|
|
151
|
+
'one and repeating them here would count the same barrier twice. They are in the JSON beside this report.', '');
|
|
152
|
+
}
|
|
153
|
+
if (accepted.length > 0) {
|
|
154
|
+
lines.push('## Already accepted', '');
|
|
155
|
+
lines.push(`${accepted.length} ${accepted.length === 1 ? 'finding is' : 'findings are'} listed in \`.tewip/access-accepted.txt\`, so they are not repeated here.`, '');
|
|
156
|
+
}
|
|
157
|
+
// Coverage comes from every rule that ran, not from the ones that found something: a contrast
|
|
158
|
+
// check that ran and passed has still checked contrast.
|
|
159
|
+
const ran = browser?.rulesRun ?? new Set();
|
|
160
|
+
const settled = nowChecked(ran);
|
|
161
|
+
const remaining = stillNotChecked(ran);
|
|
162
|
+
if (settled.length > 0) {
|
|
163
|
+
lines.push('## Checked here only because a browser run was given', '');
|
|
164
|
+
for (const n of settled)
|
|
165
|
+
lines.push(`- **${n.criterion}** - would otherwise be unreachable: ${n.why}`);
|
|
166
|
+
lines.push('');
|
|
167
|
+
}
|
|
168
|
+
lines.push('## What was not checked', '');
|
|
169
|
+
lines.push(browser
|
|
170
|
+
? 'Even with the browser run, none of the following was tested:'
|
|
171
|
+
: 'This read the markup. It did not render a page, so none of the following was tested:', '');
|
|
172
|
+
for (const n of remaining)
|
|
173
|
+
lines.push(`- **${n.criterion}** - ${n.why}`);
|
|
174
|
+
if (browser) {
|
|
175
|
+
lines.push('');
|
|
176
|
+
lines.push(`The run covered ${browser.pages.length} ${browser.pages.length === 1 ? 'page' : 'pages'}. ` +
|
|
177
|
+
'Nothing is known about a page it did not open, or about a state it did not reach - a dialog that was never ' +
|
|
178
|
+
'opened, a form that was never submitted, an error that was never triggered.');
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('A clean result here means these checks found nothing, not that the product is accessible. ' +
|
|
182
|
+
'Roughly a third of WCAG can be judged by a machine at all' +
|
|
183
|
+
(browser ? '' : ', and reading source rather than a rendered page reaches less than that') +
|
|
184
|
+
'. The rest needs a person, ideally one who uses a screen reader.', '');
|
|
185
|
+
lines.push('To accept a finding, add its key to `.tewip/access-accepted.txt`, one per line, with a reason after ` - `.', '');
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
}
|
|
188
|
+
export function runAccess(cmd) {
|
|
189
|
+
const root = resolve(cmd.root);
|
|
190
|
+
const { findings, filesRead } = findBarriers(root, cmd.only);
|
|
191
|
+
const alreadyJudged = readAccepted(root);
|
|
192
|
+
const accepted = findings.filter((f) => alreadyJudged.has(keyOf(f)));
|
|
193
|
+
const open = findings.filter((f) => !alreadyJudged.has(keyOf(f)));
|
|
194
|
+
// Phase 29: a browser run, where one was given. It is the better witness for every criterion it
|
|
195
|
+
// checked, so the source findings for those criteria are set aside rather than repeated.
|
|
196
|
+
const run = cmd.report ? readAxeReport(resolve(root, cmd.report)) : undefined;
|
|
197
|
+
const graded = run ? merge(open, run, root) : undefined;
|
|
198
|
+
const kept = graded ? graded.kept : open;
|
|
199
|
+
const barriers = kept.filter((f) => f.confidence === 'barrier');
|
|
200
|
+
const questions = kept.filter((f) => f.confidence === 'ask-a-person');
|
|
201
|
+
const browser = run && graded
|
|
202
|
+
? {
|
|
203
|
+
barriers: run.findings.filter((f) => f.confidence === 'barrier'),
|
|
204
|
+
questions: run.findings.filter((f) => f.confidence === 'ask-a-person'),
|
|
205
|
+
advice: run.findings.filter((f) => f.confidence === 'advice'),
|
|
206
|
+
superseded: graded.superseded,
|
|
207
|
+
pages: pagesIn(run),
|
|
208
|
+
rulesRun: run.rulesRun,
|
|
209
|
+
}
|
|
210
|
+
: undefined;
|
|
211
|
+
const result = { barriers, questions, accepted, filesRead, ...(browser ? { browser } : {}) };
|
|
212
|
+
const report = renderAccess(result, cmd.only ? resolve(root, cmd.only) : root);
|
|
213
|
+
console.log(report);
|
|
214
|
+
// What fails a build is deliberately narrower than what gets reported. A question never fails it:
|
|
215
|
+
// a build that breaks because something needs a human decision teaches a team to stop asking. Nor
|
|
216
|
+
// does a moderate finding - bootstrap's example pages skip a heading level, which is worth knowing
|
|
217
|
+
// and not worth blocking a release over. Only the barriers that stop somebody outright.
|
|
218
|
+
// A browser barrier counts the same way: axe saw it on a rendered page, which is stronger
|
|
219
|
+
// evidence than reading source, not weaker. Advice never counts, whatever axe's impact says.
|
|
220
|
+
const blocking = barriers.filter((f) => blocks(f.rule)).length + (browser?.barriers.filter((f) => f.severity === 'blocker').length ?? 0);
|
|
221
|
+
if (cmd.gate && blocking > 0) {
|
|
222
|
+
const found = barriers.length + (browser?.barriers.length ?? 0);
|
|
223
|
+
console.error(`${String.fromCharCode(10)}${blocking} accessibility barrier(s) that stop somebody outright, out of ${found} found.`);
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
}
|
|
226
|
+
if (cmd.out) {
|
|
227
|
+
const path = resolve(root, cmd.out);
|
|
228
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
229
|
+
writeFileSync(path, report, 'utf8');
|
|
230
|
+
writeFileSync(path.replace(/\.md$/, '') + '.json', JSON.stringify({ barriers, questions, accepted, filesRead }, null, 2), 'utf8');
|
|
231
|
+
}
|
|
232
|
+
return { ...result, report };
|
|
233
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Phase 28: the checks. Each one is deterministic, each one names the element it is talking about,
|
|
2
|
+
// and each one has a shape it refuses rather than guesses on.
|
|
3
|
+
//
|
|
4
|
+
// The restraint matters more than the coverage. These are the barriers that can be decided by
|
|
5
|
+
// reading the markup - a picture with no alt attribute at all, a field with no label anywhere, a
|
|
6
|
+
// div that only answers to a mouse. The ones that need a rendered page (contrast, focus order,
|
|
7
|
+
// whether alt text is any good) are not attempted here and are listed in NOT_CHECKED instead.
|
|
8
|
+
import { inside, labelledBy } from './markup.js';
|
|
9
|
+
/** Elements that already answer to the keyboard and already carry a role. */
|
|
10
|
+
const INTERACTIVE = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'details']);
|
|
11
|
+
/** `role="presentation"` and friends: the author has said this is decoration. */
|
|
12
|
+
function decorative(t) {
|
|
13
|
+
return t.attrs['aria-hidden'] === 'true' || t.attrs.role === 'presentation' || t.attrs.role === 'none';
|
|
14
|
+
}
|
|
15
|
+
function show(t) {
|
|
16
|
+
const keep = ['src', 'href', 'type', 'name', 'id', 'class', 'role'];
|
|
17
|
+
const shown = keep
|
|
18
|
+
.filter((k) => typeof t.attrs[k] === 'string' && t.attrs[k] !== '')
|
|
19
|
+
.slice(0, 2)
|
|
20
|
+
.map((k) => ` ${k}="${t.attrs[k].slice(0, 40)}"`)
|
|
21
|
+
.join('');
|
|
22
|
+
return `<${t.name}${shown}${t.spread ? ' {...}' : ''}>`;
|
|
23
|
+
}
|
|
24
|
+
function at(t, file) {
|
|
25
|
+
return { file, line: t.line, element: show(t) };
|
|
26
|
+
}
|
|
27
|
+
/** Every real `aria-*` attribute. A name outside this set is a typo, and a typo does nothing at all. */
|
|
28
|
+
const ARIA = new Set([
|
|
29
|
+
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel', 'aria-brailleroledescription',
|
|
30
|
+
'aria-busy', 'aria-checked', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan',
|
|
31
|
+
'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled',
|
|
32
|
+
'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed', 'aria-haspopup',
|
|
33
|
+
'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
|
|
34
|
+
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder',
|
|
35
|
+
'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required', 'aria-roledescription',
|
|
36
|
+
'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected', 'aria-setsize',
|
|
37
|
+
'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext',
|
|
38
|
+
]);
|
|
39
|
+
/** A click handler, in any of the four dialects this scanner reads. */
|
|
40
|
+
function clickHandler(t) {
|
|
41
|
+
return ['onclick', '@click', 'v-on:click', 'on:click'].some((a) => a in t.attrs || t.dynamic.has(a));
|
|
42
|
+
}
|
|
43
|
+
function keyHandler(t) {
|
|
44
|
+
return Object.keys(t.attrs).concat([...t.dynamic]).some((a) => /key(down|up|press)/i.test(a));
|
|
45
|
+
}
|
|
46
|
+
function tabIndexOf(t) {
|
|
47
|
+
const raw = t.attrs.tabindex ?? t.attrs.tabIndex;
|
|
48
|
+
if (raw === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
const n = Number(raw);
|
|
51
|
+
return Number.isFinite(n) ? n : null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fields that take typing, and therefore need a label. Buttons and hidden fields are excluded: a
|
|
55
|
+
* submit button is named by its value, and a hidden field is not announced at all.
|
|
56
|
+
*/
|
|
57
|
+
const UNLABELLED_TYPES = new Set(['hidden', 'submit', 'reset', 'button', 'image']);
|
|
58
|
+
/** Checks that look at one element at a time. */
|
|
59
|
+
function perElement(t, file, labelFor, openLabels, ancestorKey) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const place = at(t, file);
|
|
62
|
+
const name = labelledBy(t);
|
|
63
|
+
if (t.name === 'img' && !t.component) {
|
|
64
|
+
if (decorative(t)) {
|
|
65
|
+
// Said to be decoration. That is a legitimate choice and not Tewip's to second-guess.
|
|
66
|
+
}
|
|
67
|
+
else if ('alt' in t.attrs || t.dynamic.has('alt')) {
|
|
68
|
+
// An alt attribute is present. Whether its words are any good is in NOT_CHECKED.
|
|
69
|
+
}
|
|
70
|
+
else if (t.spread) {
|
|
71
|
+
out.push({ rule: 'image-alt', confidence: 'ask-a-person', ...place,
|
|
72
|
+
why: 'no alt attribute is written here, but the element carries a spread, so alt may come from the props',
|
|
73
|
+
next: 'check what is spread in; if alt is not among it, add alt, or alt="" if the image is decoration' });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
out.push({ rule: 'image-alt', confidence: 'barrier', ...place,
|
|
77
|
+
why: 'no alt attribute at all, and nothing marks the image as decoration',
|
|
78
|
+
next: 'add alt="..." describing what the image conveys, or alt="" if it conveys nothing' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (t.name === 'input' && !UNLABELLED_TYPES.has(t.attrs.type ?? 'text')) {
|
|
82
|
+
const linked = typeof t.attrs.id === 'string' && labelFor.has(t.attrs.id);
|
|
83
|
+
if (!name.named && !linked && openLabels === 0) {
|
|
84
|
+
if (name.unknown || t.dynamic.has('id')) {
|
|
85
|
+
out.push({ rule: 'input-label', confidence: 'ask-a-person', ...place,
|
|
86
|
+
why: 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
|
|
87
|
+
next: 'confirm the field is labelled at runtime; if not, add aria-label or a <label for>' });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
out.push({ rule: 'input-label', confidence: 'barrier', ...place,
|
|
91
|
+
why: 'no aria-label, no title, no id matching a <label for>, and not inside a <label>',
|
|
92
|
+
next: 'wrap it in a <label>, or give it an id and point a <label for> at it' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (t.name === 'iframe' && !name.named && !decorative(t)) {
|
|
97
|
+
out.push({ rule: 'iframe-title', confidence: name.unknown ? 'ask-a-person' : 'barrier', ...place,
|
|
98
|
+
why: 'an iframe with no title and no aria-label',
|
|
99
|
+
next: 'add title="..." saying what the frame contains' });
|
|
100
|
+
}
|
|
101
|
+
const tabindex = tabIndexOf(t);
|
|
102
|
+
if (tabindex !== null && tabindex > 0) {
|
|
103
|
+
out.push({ rule: 'positive-tabindex', confidence: 'barrier', ...place,
|
|
104
|
+
why: `tabindex="${tabindex}" takes this element out of the document order and puts it before everything that has none`,
|
|
105
|
+
next: 'use tabindex="0" and let the source order decide, or move the element in the markup' });
|
|
106
|
+
}
|
|
107
|
+
if (t.attrs['aria-hidden'] === 'true' && (INTERACTIVE.has(t.name) || (tabindex !== null && tabindex >= 0))) {
|
|
108
|
+
out.push({ rule: 'aria-hidden-focusable', confidence: 'barrier', ...place,
|
|
109
|
+
why: 'hidden from screen readers but still reachable by the tab key',
|
|
110
|
+
next: 'also remove it from the tab order with tabindex="-1", or stop hiding it' });
|
|
111
|
+
}
|
|
112
|
+
// `ancestorKey`: an enclosing element already handles keys, which is how every dialog in the
|
|
113
|
+
// world is built - a backdrop div closes on click, and Escape closes it from the dialog itself.
|
|
114
|
+
// WCAG 2.1.1 asks that the *function* be reachable from the keyboard, not that this particular
|
|
115
|
+
// div be focusable. Excalidraw's Modal.tsx is exactly this shape, and calling it a blocker is wrong.
|
|
116
|
+
if (clickHandler(t) && !INTERACTIVE.has(t.name) && !t.component && !t.attrs.role && tabindex === null && !keyHandler(t) && !ancestorKey) {
|
|
117
|
+
out.push({ rule: 'click-without-keyboard', confidence: 'barrier', ...place,
|
|
118
|
+
why: 'it responds to a click, but it has no role, no tabindex and no key handler, so the keyboard cannot reach or fire it',
|
|
119
|
+
next: 'use a <button>, or add role="button", tabindex="0" and a key handler' });
|
|
120
|
+
}
|
|
121
|
+
if ((t.name === 'video' || t.name === 'audio') && 'autoplay' in t.attrs && !('muted' in t.attrs)) {
|
|
122
|
+
out.push({ rule: 'autoplay-sound', confidence: 'barrier', ...place,
|
|
123
|
+
why: 'it plays on load and is not muted, so it talks over a screen reader',
|
|
124
|
+
next: 'add muted, or remove autoplay and let the person press play' });
|
|
125
|
+
}
|
|
126
|
+
if (t.name === 'meta' && (t.attrs.name ?? '').toLowerCase() === 'viewport') {
|
|
127
|
+
const content = (t.attrs.content ?? '').toLowerCase();
|
|
128
|
+
const max = /maximum-scale\s*=\s*([\d.]+)/.exec(content);
|
|
129
|
+
if (/user-scalable\s*=\s*(no|0)/.test(content) || (max && Number(max[1]) < 2)) {
|
|
130
|
+
out.push({ rule: 'zoom-blocked', confidence: 'barrier', ...place,
|
|
131
|
+
why: `the viewport meta tag prevents zooming: ${content}`,
|
|
132
|
+
next: 'drop user-scalable=no and maximum-scale, or set maximum-scale to at least 2' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const a of Object.keys(t.attrs).concat([...t.dynamic])) {
|
|
136
|
+
if (a.startsWith('aria-') && !ARIA.has(a)) {
|
|
137
|
+
out.push({ rule: 'aria-unknown', confidence: 'barrier', ...place,
|
|
138
|
+
why: `${a} is not an ARIA attribute, so it is ignored entirely and whatever it was meant to do does not happen`,
|
|
139
|
+
next: 'correct the spelling, or remove it' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Everything that needs more than one element: names from content, nesting, ids, heading order.
|
|
146
|
+
*
|
|
147
|
+
* `isDocument` says this file is one page. It is false for a component file, and that distinction
|
|
148
|
+
* decides whether duplicate ids mean anything: excalidraw's icons.tsx holds fifty separate SVG
|
|
149
|
+
* icons, each with its own `<clipPath id="a">`, and they are never in one document together. A
|
|
150
|
+
* check that calls that a defect is wrong fifty times in one file.
|
|
151
|
+
*/
|
|
152
|
+
export function checkFile(source, all, file, isDocument = false) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const labelFor = new Set(all.filter((t) => t.name === 'label' && t.attrs.for).map((t) => t.attrs.for));
|
|
155
|
+
const seenIds = new Set();
|
|
156
|
+
let openLabels = 0;
|
|
157
|
+
let interactiveDepth = 0;
|
|
158
|
+
let lastHeading = 0;
|
|
159
|
+
/** The elements currently open around this one, and whether each handles keys. */
|
|
160
|
+
const open = [];
|
|
161
|
+
for (const [i, t] of all.entries()) {
|
|
162
|
+
if (t.name === 'label')
|
|
163
|
+
openLabels += t.closing ? -1 : t.selfClosing ? 0 : 1;
|
|
164
|
+
if (t.closing) {
|
|
165
|
+
if (INTERACTIVE.has(t.name) && interactiveDepth > 0)
|
|
166
|
+
interactiveDepth--;
|
|
167
|
+
// Unwind to the matching open element, tolerating markup that never closed it.
|
|
168
|
+
const from = open.map((o) => o.name).lastIndexOf(t.name);
|
|
169
|
+
if (from !== -1)
|
|
170
|
+
open.length = from;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys)));
|
|
174
|
+
if (!t.selfClosing)
|
|
175
|
+
open.push({ name: t.name, keys: keyHandler(t) });
|
|
176
|
+
const id = t.attrs.id;
|
|
177
|
+
if (isDocument && id && !t.dynamic.has('id')) {
|
|
178
|
+
if (seenIds.has(id)) {
|
|
179
|
+
out.push({ rule: 'duplicate-id', confidence: 'barrier', ...at(t, file),
|
|
180
|
+
why: `id="${id}" is used more than once in this file, so anything pointing at it reaches only the first`,
|
|
181
|
+
next: 'make the ids unique' });
|
|
182
|
+
}
|
|
183
|
+
seenIds.add(id);
|
|
184
|
+
}
|
|
185
|
+
const heading = /^h([1-6])$/.exec(t.name);
|
|
186
|
+
if (heading) {
|
|
187
|
+
const level = Number(heading[1]);
|
|
188
|
+
if (lastHeading > 0 && level > lastHeading + 1) {
|
|
189
|
+
out.push({ rule: 'heading-skip', confidence: 'barrier', ...at(t, file),
|
|
190
|
+
why: `h${level} follows h${lastHeading}, so a level is missing from the outline`,
|
|
191
|
+
next: `use h${lastHeading + 1} here, or add the missing heading above it` });
|
|
192
|
+
}
|
|
193
|
+
lastHeading = level;
|
|
194
|
+
}
|
|
195
|
+
// A name from content: a button or link whose text is the only thing announcing it.
|
|
196
|
+
if ((t.name === 'button' || (t.name === 'a' && ('href' in t.attrs || t.dynamic.has('href')))) && !t.component) {
|
|
197
|
+
const label = labelledBy(t);
|
|
198
|
+
if (!label.named) {
|
|
199
|
+
const body = inside(source, all, i);
|
|
200
|
+
const rule = t.name === 'button' ? 'button-name' : 'link-name';
|
|
201
|
+
if (body.text !== '') {
|
|
202
|
+
// Named by its own text. Nothing to say.
|
|
203
|
+
}
|
|
204
|
+
else if (label.unknown || body.unreadable) {
|
|
205
|
+
out.push({ rule, confidence: 'ask-a-person', ...at(t, file),
|
|
206
|
+
why: 'its name would come from content this scanner cannot read - an expression, a component, or an image whose alt is set elsewhere',
|
|
207
|
+
next: 'check what it announces with a screen reader; if it is silent, add aria-label' });
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
out.push({ rule, confidence: 'barrier', ...at(t, file),
|
|
211
|
+
why: 'it contains no text and carries no aria-label, so it is announced with its role and nothing else',
|
|
212
|
+
next: 'put text inside it, or add aria-label="..." saying what it does' });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (interactiveDepth > 0) {
|
|
216
|
+
out.push({ rule: 'nested-interactive', confidence: 'barrier', ...at(t, file),
|
|
217
|
+
why: 'a link or button inside another link or button',
|
|
218
|
+
next: 'move it out, so the two controls are siblings rather than nested' });
|
|
219
|
+
}
|
|
220
|
+
if (!t.selfClosing)
|
|
221
|
+
interactiveDepth++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const html = all.find((t) => t.name === 'html' && !t.closing);
|
|
225
|
+
if (html && !('lang' in html.attrs) && !html.dynamic.has('lang')) {
|
|
226
|
+
out.push({ rule: 'html-lang', confidence: 'barrier', ...at(html, file),
|
|
227
|
+
why: 'the <html> element has no lang attribute, so a screen reader guesses the language',
|
|
228
|
+
next: 'add lang="en", or whichever language the page is written in' });
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Phase 28: reading markup well enough to be honest about it.
|
|
2
|
+
//
|
|
3
|
+
// This is not an HTML parser and does not try to be. It is a tolerant tag scanner over the four
|
|
4
|
+
// shapes a real project writes its interface in - HTML, JSX/TSX, Vue and Svelte templates - and its
|
|
5
|
+
// one job is to know *when it does not know*. An attribute whose value is an expression
|
|
6
|
+
// (`alt={caption}`), an element carrying a spread (`<img {...props} />`), a component rather than an
|
|
7
|
+
// element (`<Image />`): in every one of those the markup alone cannot say whether a barrier exists,
|
|
8
|
+
// and the scanner says so rather than guessing. That refusal is the whole reason this is worth
|
|
9
|
+
// shipping; a tool that accuses `<img alt={alt} />` of having no alt text gets switched off in a day.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is deterministic and costs nothing: no browser, no network, no model.
|
|
12
|
+
/**
|
|
13
|
+
* Elements HTML closes for you. A scanner that expects `</img>` pairs everything wrong from the
|
|
14
|
+
* first image onwards, which is how naive checkers end up reporting a button as empty.
|
|
15
|
+
*/
|
|
16
|
+
const VOID = new Set([
|
|
17
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* A tag, with the three attribute-value shapes that appear in practice kept apart from `>` so an
|
|
21
|
+
* expression containing a greater-than sign does not end the tag early.
|
|
22
|
+
*/
|
|
23
|
+
// The lookahead after the name is load-bearing: a real tag is followed by whitespace, `/` or `>`.
|
|
24
|
+
// Without it `/<!doctype html|<html[\s>]/i` - a regex literal in excalidraw's App.tsx - parsed as an
|
|
25
|
+
// <html> element and was reported as a page with no lang attribute.
|
|
26
|
+
// The attribute group is lazy on purpose. Greedy, it absorbs the `/` of `<button ... />` into the
|
|
27
|
+
// attributes, the element is recorded as still open, and the next sibling control is reported as
|
|
28
|
+
// nested inside it. Quoted and braced values are matched as whole units in the alternation, so
|
|
29
|
+
// laziness cannot stop at a `>` that lives inside one.
|
|
30
|
+
const TAG = /<(\/?)([A-Za-z][A-Za-z0-9._:-]*)(?=[\s/>])((?:[^>"'{]|"[^"]*"|'[^']*'|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})*?)(\/?)>/g;
|
|
31
|
+
const ATTRIBUTE = /([@:#\[A-Za-z_][-A-Za-z0-9_:.@\]]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{((?:[^{}]|\{[^{}]*\})*)\}|([^\s"'<>`=]+)))?/g;
|
|
32
|
+
/** `{...props}`, `{...rest}` - the thing that makes every other judgement about the element unsafe. */
|
|
33
|
+
const SPREAD = /\{\s*\.\.\./;
|
|
34
|
+
/**
|
|
35
|
+
* Comments, and the bodies of script and style, hold things shaped like tags that are not tags.
|
|
36
|
+
*
|
|
37
|
+
* `jsLike` additionally blanks template literals, which is not optional in a .tsx file: real
|
|
38
|
+
* projects build HTML in backtick strings - an iframe's srcDoc, an email body, a test fixture - and
|
|
39
|
+
* reading those as the page's own markup produces findings about code that no browser ever renders
|
|
40
|
+
* as part of this component. Found on excalidraw, whose AI.tsx holds an entire error page in a
|
|
41
|
+
* template literal and was reported as a page with no lang attribute.
|
|
42
|
+
*
|
|
43
|
+
* Blanking rather than scanning is the conservative choice: a string may be a page, or it may be a
|
|
44
|
+
* SQL query, a documentation sample or an assertion. Tewip does not guess which.
|
|
45
|
+
*/
|
|
46
|
+
function withoutNonMarkup(source, jsLike) {
|
|
47
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
48
|
+
const stripped = source
|
|
49
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
50
|
+
.replace(/\{\/\*[\s\S]*?\*\/\}/g, blank)
|
|
51
|
+
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank)
|
|
52
|
+
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank);
|
|
53
|
+
return jsLike ? stripped.replace(/`(?:[^`\\]|\\[\s\S])*`/g, blank) : stripped;
|
|
54
|
+
}
|
|
55
|
+
/** Files whose markup is embedded in JavaScript, where a backtick string is not page markup. */
|
|
56
|
+
export const JS_LIKE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/i;
|
|
57
|
+
function readAttributes(blob) {
|
|
58
|
+
const attrs = {};
|
|
59
|
+
const dynamic = new Set();
|
|
60
|
+
const spread = SPREAD.test(blob);
|
|
61
|
+
// A spread's contents would otherwise be read as attribute names: `{...props}` becomes `props`.
|
|
62
|
+
const readable = blob.replace(/\{\s*\.\.\.(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
|
|
63
|
+
for (const m of readable.matchAll(ATTRIBUTE)) {
|
|
64
|
+
// A bound attribute is the same attribute. Vue writes `:aria-label="label"` and
|
|
65
|
+
// `v-bind:title="t"`, Angular writes `[attr.aria-label]="l"`, and reading those names
|
|
66
|
+
// literally means the element looks as though it has no label at all - which reported eight
|
|
67
|
+
// perfectly well-labelled buttons in vitepress as unnamed. The value is an expression, so the
|
|
68
|
+
// attribute is present but unreadable: that is `dynamic`, which asks rather than accuses.
|
|
69
|
+
const written = m[1].toLowerCase();
|
|
70
|
+
const binding = /^(?::|v-bind:|\[attr\.|\[)/.exec(written);
|
|
71
|
+
const name = binding ? written.slice(binding[0].length).replace(/\]$/, '') : written;
|
|
72
|
+
const quoted = m[2] ?? m[3] ?? m[5];
|
|
73
|
+
const expression = m[4];
|
|
74
|
+
if (binding && name !== '') {
|
|
75
|
+
dynamic.add(name);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (expression !== undefined) {
|
|
79
|
+
// A literal inside braces is still a literal: alt={"a cat"} and alt={''} are both readable.
|
|
80
|
+
const literal = /^\s*(?:"([^"]*)"|'([^']*)'|`([^`$]*)`)\s*$/.exec(expression);
|
|
81
|
+
if (literal)
|
|
82
|
+
attrs[name] = literal[1] ?? literal[2] ?? literal[3] ?? '';
|
|
83
|
+
else
|
|
84
|
+
dynamic.add(name);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
attrs[name] = quoted ?? '';
|
|
88
|
+
}
|
|
89
|
+
return { attrs, dynamic, spread };
|
|
90
|
+
}
|
|
91
|
+
/** Every tag in a file, in source order. `jsLike` says to ignore template literals. */
|
|
92
|
+
export function tags(source, jsLike = false) {
|
|
93
|
+
const text = withoutNonMarkup(source, jsLike);
|
|
94
|
+
const out = [];
|
|
95
|
+
let line = 1;
|
|
96
|
+
let read = 0;
|
|
97
|
+
for (const m of text.matchAll(TAG)) {
|
|
98
|
+
const at = m.index;
|
|
99
|
+
for (let i = read; i < at; i++)
|
|
100
|
+
if (text[i] === '\n')
|
|
101
|
+
line++;
|
|
102
|
+
read = at;
|
|
103
|
+
const raw = m[2];
|
|
104
|
+
const name = raw.toLowerCase();
|
|
105
|
+
out.push({
|
|
106
|
+
name,
|
|
107
|
+
component: /^[A-Z]/.test(raw) || raw.includes('.'),
|
|
108
|
+
...readAttributes(m[3]),
|
|
109
|
+
closing: m[1] === '/',
|
|
110
|
+
selfClosing: m[4] === '/' || VOID.has(name),
|
|
111
|
+
line,
|
|
112
|
+
start: at,
|
|
113
|
+
end: at + m[0].length,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Reads what is inside the element opened by `tags[index]`, pairing tags with a depth count. */
|
|
119
|
+
export function inside(source, all, index) {
|
|
120
|
+
const open = all[index];
|
|
121
|
+
if (open.selfClosing || open.closing)
|
|
122
|
+
return { text: '', unreadable: false, within: [] };
|
|
123
|
+
let depth = 1;
|
|
124
|
+
let close = -1;
|
|
125
|
+
const within = [];
|
|
126
|
+
for (let i = index + 1; i < all.length; i++) {
|
|
127
|
+
const t = all[i];
|
|
128
|
+
if (t.name === open.name && !t.selfClosing)
|
|
129
|
+
depth += t.closing ? -1 : 1;
|
|
130
|
+
if (depth === 0) {
|
|
131
|
+
close = i;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
within.push(t);
|
|
135
|
+
}
|
|
136
|
+
// No closing tag found: the file is a fragment, or the markup is broken. Either way, refuse.
|
|
137
|
+
if (close === -1)
|
|
138
|
+
return { text: '', unreadable: true, within };
|
|
139
|
+
const between = source.slice(open.end, all[close].start);
|
|
140
|
+
const literal = between
|
|
141
|
+
.replace(/<[^>]*>/g, ' ')
|
|
142
|
+
.replace(/\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ')
|
|
143
|
+
.replace(/\s+/g, ' ')
|
|
144
|
+
.trim();
|
|
145
|
+
const expressions = /\{(?:[^{}]|\{[^{}]*\})*\}/.test(between);
|
|
146
|
+
// An image inside a link or button *is* its accessible name when it has alt text. A logo link
|
|
147
|
+
// written `<a href="/"><img src="logo.png" alt="Home"></a>` is the commonest shape on the web,
|
|
148
|
+
// and reporting it as an unnamed link would be wrong on nearly every site there is.
|
|
149
|
+
const alts = within
|
|
150
|
+
.filter((t) => t.name === 'img' && typeof t.attrs.alt === 'string' && t.attrs.alt.trim() !== '')
|
|
151
|
+
.map((t) => t.attrs.alt.trim());
|
|
152
|
+
// `<slot />` is Vue's and Svelte's `{children}`: whatever is inside comes from whoever used this
|
|
153
|
+
// component, so the markup here cannot say whether the control ends up named. vitepress's
|
|
154
|
+
// VPSwitch is exactly that shape and was being accused rather than asked about.
|
|
155
|
+
const unknownChild = within.some((t) => t.component || t.name === 'slot' || t.dynamic.size > 0 || t.spread);
|
|
156
|
+
const text = [literal, ...alts].filter((s) => s !== '').join(' ');
|
|
157
|
+
return { text, unreadable: expressions || (unknownChild && alts.length === 0), within };
|
|
158
|
+
}
|
|
159
|
+
/** The accessible name an element carries on its own attributes, if one can be read. */
|
|
160
|
+
export function labelledBy(tag) {
|
|
161
|
+
const explicit = ['aria-label', 'aria-labelledby', 'title', 'alt'];
|
|
162
|
+
for (const a of explicit) {
|
|
163
|
+
if (tag.dynamic.has(a))
|
|
164
|
+
return { named: false, unknown: true };
|
|
165
|
+
const v = tag.attrs[a];
|
|
166
|
+
if (typeof v === 'string' && v.trim() !== '')
|
|
167
|
+
return { named: true, unknown: false };
|
|
168
|
+
}
|
|
169
|
+
if (tag.spread)
|
|
170
|
+
return { named: false, unknown: true };
|
|
171
|
+
return { named: false, unknown: false };
|
|
172
|
+
}
|
|
173
|
+
/** Files whose markup this scanner reads. */
|
|
174
|
+
export const MARKUP = /\.(?:html?|jsx|tsx|vue|svelte|astro|erb|php)$/i;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { EQUIVALENT_AXE_RULE } from './reports.js';
|
|
2
|
+
import { NOT_CHECKED, RULES } from './wcag.js';
|
|
3
|
+
/** `1.1.1 Non-text Content` -> `1.1.1`, so a criterion from either source can be compared. */
|
|
4
|
+
export function criterionNumber(criterion) {
|
|
5
|
+
return /^\d+(?:\.\d+)*/.exec(criterion.trim())?.[0] ?? '';
|
|
6
|
+
}
|
|
7
|
+
/** The criterion a source-read rule belongs to, as a bare number. */
|
|
8
|
+
export function criterionOfRule(rule) {
|
|
9
|
+
const r = RULES[rule];
|
|
10
|
+
return r ? criterionNumber(r.criterion) : '';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Sets aside the source findings the run actually re-checked, rule by rule.
|
|
14
|
+
*
|
|
15
|
+
* The test is whether the *equivalent axe rule ran*, not whether axe found something and not
|
|
16
|
+
* whether some other rule under the same criterion ran. A rule that ran and passed still
|
|
17
|
+
* supersedes - axe looked at the rendered page and disagreed, which is the better evidence. A rule
|
|
18
|
+
* with no axe equivalent, like `click-without-keyboard`, is never superseded by anything.
|
|
19
|
+
*/
|
|
20
|
+
export function merge(source, run, root) {
|
|
21
|
+
const opened = pagesAsFiles(run, root);
|
|
22
|
+
const kept = [];
|
|
23
|
+
const superseded = [];
|
|
24
|
+
for (const f of source) {
|
|
25
|
+
const equivalent = EQUIVALENT_AXE_RULE[f.rule];
|
|
26
|
+
// Both conditions, and the second is the one that was missing. A run of nine pages does not
|
|
27
|
+
// supersede a finding in the hundredth file it never opened: on bootstrap that silently
|
|
28
|
+
// discarded all 44 source findings, including every one in the example pages axe never saw.
|
|
29
|
+
// A component file is never a page a browser opens, so in a React or Vue project nothing is
|
|
30
|
+
// superseded at all - correctly, because the source and the rendered output are two artefacts
|
|
31
|
+
// and neither is evidence about the other.
|
|
32
|
+
if (equivalent !== undefined && run.rulesRun.has(equivalent) && opened.has(f.file))
|
|
33
|
+
superseded.push(f);
|
|
34
|
+
else
|
|
35
|
+
kept.push(f);
|
|
36
|
+
}
|
|
37
|
+
return { kept, superseded, covered: run.criteriaCovered };
|
|
38
|
+
}
|
|
39
|
+
/** The pages a run opened, as repository-relative paths, for the ones that were local files. */
|
|
40
|
+
export function pagesAsFiles(run, root) {
|
|
41
|
+
const out = new Set();
|
|
42
|
+
const base = root.replaceAll('\\', '/').replace(/\/$/, '').toLowerCase();
|
|
43
|
+
for (const page of run.pages) {
|
|
44
|
+
// `file:///C:/repo/a.html` -> `C:/repo/a.html`, but `file:///repo/a.html` -> `/repo/a.html`.
|
|
45
|
+
// Stripping every slash after the scheme is wrong on anything but Windows: the third one is
|
|
46
|
+
// the root, not padding.
|
|
47
|
+
const stripped = decodeURIComponent(page.replace(/^file:\/\//i, '')).replaceAll('\\', '/');
|
|
48
|
+
const path = /^\/[A-Za-z]:/.test(stripped) ? stripped.slice(1) : stripped;
|
|
49
|
+
const lower = path.toLowerCase();
|
|
50
|
+
if (lower.startsWith(`${base}/`))
|
|
51
|
+
out.add(path.slice(base.length + 1));
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What is still unchecked once the browser run is counted.
|
|
57
|
+
*
|
|
58
|
+
* This is the payoff of the phase and the reason it is worth building: the list of criteria Tewip
|
|
59
|
+
* admits it cannot reach should get shorter when real evidence arrives, rather than being printed
|
|
60
|
+
* unchanged beneath a browser run that already settled half of it.
|
|
61
|
+
*/
|
|
62
|
+
export function stillNotChecked(rulesRun) {
|
|
63
|
+
return NOT_CHECKED.filter((n) => n.settledBy === undefined || !rulesRun.has(n.settledBy));
|
|
64
|
+
}
|
|
65
|
+
/** The criteria from NOT_CHECKED that this run did settle, for the report to say so. */
|
|
66
|
+
export function nowChecked(rulesRun) {
|
|
67
|
+
return NOT_CHECKED.filter((n) => n.settledBy !== undefined && rulesRun.has(n.settledBy));
|
|
68
|
+
}
|
|
69
|
+
const SEVERITY_ORDER = { blocker: 0, serious: 1, moderate: 2, minor: 3 };
|
|
70
|
+
export function worstBrowserFirst(a, b) {
|
|
71
|
+
const d = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
72
|
+
if (d !== 0)
|
|
73
|
+
return d;
|
|
74
|
+
if (a.rule !== b.rule)
|
|
75
|
+
return a.rule < b.rule ? -1 : 1;
|
|
76
|
+
return a.selector < b.selector ? -1 : 1;
|
|
77
|
+
}
|
|
78
|
+
/** Groups by rule, keeping the order the comparator produced. */
|
|
79
|
+
function byRule(findings) {
|
|
80
|
+
const out = new Map();
|
|
81
|
+
for (const f of [...findings].sort(worstBrowserFirst)) {
|
|
82
|
+
const list = out.get(f.rule);
|
|
83
|
+
if (list)
|
|
84
|
+
list.push(f);
|
|
85
|
+
else
|
|
86
|
+
out.set(f.rule, [f]);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** How a browser finding names its standard: both WCAG and the clause the EAA cites. */
|
|
91
|
+
export function standardOf(f) {
|
|
92
|
+
if (!f.criterion)
|
|
93
|
+
return 'no criterion - this is a best-practice rule, not a standard';
|
|
94
|
+
const en = f.en301549 ? `, EN 301 549 clause ${f.en301549}` : '';
|
|
95
|
+
return `WCAG 2.2 ${f.criterion} (${f.level ?? 'A'})${en}`;
|
|
96
|
+
}
|
|
97
|
+
export function renderBrowserSection(title, findings, lines, limit = 8) {
|
|
98
|
+
if (findings.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
lines.push(`## ${title}`, '');
|
|
101
|
+
for (const [rule, found] of byRule(findings)) {
|
|
102
|
+
lines.push(`### ${rule} - ${found.length} ${found.length === 1 ? 'place' : 'places'}`, '');
|
|
103
|
+
lines.push(`${standardOf(found[0])}, ${found[0].severity}.`, '');
|
|
104
|
+
lines.push(`${found[0].why}`, '');
|
|
105
|
+
for (const f of found.slice(0, limit)) {
|
|
106
|
+
lines.push(`- \`${f.selector}\``);
|
|
107
|
+
lines.push(` - \`${f.html}\``);
|
|
108
|
+
if (f.next && f.next !== found[0].why)
|
|
109
|
+
lines.push(` - ${f.next}`);
|
|
110
|
+
}
|
|
111
|
+
if (found.length > limit)
|
|
112
|
+
lines.push(`- ...and ${found.length - limit} more of the same.`);
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The pages a browser run covered, which bounds everything it can be said to prove. */
|
|
117
|
+
export function pagesIn(run) {
|
|
118
|
+
return [...run.pages].sort();
|
|
119
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Phase 29: reading a real browser run, so the criteria Phase 28 says it cannot reach stop being
|
|
2
|
+
// unreachable.
|
|
3
|
+
//
|
|
4
|
+
// `tewip access` reads markup and is honest that contrast, focus order and anything else needing a
|
|
5
|
+
// rendered page is outside it. The answer is not for Tewip to ship a browser. It is to read the run
|
|
6
|
+
// a team already produces - axe, in a Playwright, Cypress or jest-axe suite - and do to its output
|
|
7
|
+
// what Phase 9 does to a security scanner's: decide which findings matter, and say why.
|
|
8
|
+
//
|
|
9
|
+
// Why that is worth doing, measured on axe 4.14 over nine real pages (bootstrap's own component
|
|
10
|
+
// pages and excalidraw's index): 122 findings, of which **51 carry no WCAG criterion at all**. Forty
|
|
11
|
+
// are `region` ("all content should be contained by landmarks") and nine are `landmark-one-main`.
|
|
12
|
+
// Both are axe's own `best-practice` rules, not failures of any standard, and they outnumber every
|
|
13
|
+
// real barrier on the page. A team that is handed all 122 as equals stops reading them, which is the
|
|
14
|
+
// reported reason accessibility tooling gets switched off.
|
|
15
|
+
//
|
|
16
|
+
// So the tags decide, not the impact:
|
|
17
|
+
// a wcag* tag -> a numbered criterion a regulator can cite, and a barrier;
|
|
18
|
+
// best-practice only -> advice, reported apart and never failing a build;
|
|
19
|
+
// `incomplete` -> axe could not determine it, which is exactly Tewip's "ask a person".
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
/** axe's impact, in Tewip's words. A WCAG-tagged `moderate` is still worth fixing, not a blocker. */
|
|
22
|
+
const SEVERITY = {
|
|
23
|
+
critical: 'blocker',
|
|
24
|
+
serious: 'blocker',
|
|
25
|
+
moderate: 'moderate',
|
|
26
|
+
minor: 'minor',
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `wcag143` -> `1.4.3`. The first digit is the principle, the second the guideline, the rest the
|
|
30
|
+
* criterion - so `wcag1411` is 1.4.11 and not 14.1.1, there being no fourteenth principle.
|
|
31
|
+
*/
|
|
32
|
+
function criterionFrom(tags) {
|
|
33
|
+
for (const tag of tags) {
|
|
34
|
+
const m = /^wcag(\d)(\d)(\d+)$/.exec(tag);
|
|
35
|
+
if (m)
|
|
36
|
+
return `${m[1]}.${m[2]}.${m[3]}`;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** `wcag2a`, `wcag21aa`, `wcag22aa` -> the conformance level being claimed. */
|
|
41
|
+
function levelFrom(tags) {
|
|
42
|
+
let best = null;
|
|
43
|
+
for (const tag of tags) {
|
|
44
|
+
const m = /^wcag\d{1,2}(a{1,3})$/.exec(tag);
|
|
45
|
+
if (!m)
|
|
46
|
+
continue;
|
|
47
|
+
const level = m[1].toUpperCase();
|
|
48
|
+
// The strictest statement wins, so a rule tagged both A and AA is reported as AA.
|
|
49
|
+
if (best === null || level.length > best.length)
|
|
50
|
+
best = level;
|
|
51
|
+
}
|
|
52
|
+
return best;
|
|
53
|
+
}
|
|
54
|
+
/** `EN-9.1.4.3` -> `9.1.4.3`. */
|
|
55
|
+
function enClauseFrom(tags) {
|
|
56
|
+
for (const tag of tags) {
|
|
57
|
+
const m = /^EN-(\d+(?:\.\d+)+)$/.exec(tag);
|
|
58
|
+
if (m)
|
|
59
|
+
return m[1];
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** axe writes its guidance as "Fix any of the following:\n <reason>". One line reads better. */
|
|
64
|
+
function tidySummary(summary, fallback) {
|
|
65
|
+
const cleaned = summary
|
|
66
|
+
.split(/\r?\n/)
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.filter((l) => l !== '' && !/^Fix (any|all) of the following:$/.test(l))
|
|
69
|
+
.join('; ');
|
|
70
|
+
return cleaned === '' ? fallback : cleaned;
|
|
71
|
+
}
|
|
72
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
73
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
74
|
+
function fromRule(rule, page, kind) {
|
|
75
|
+
const tags = strings(rule.tags);
|
|
76
|
+
const criterion = criterionFrom(tags);
|
|
77
|
+
const help = text(rule.help) || text(rule.description);
|
|
78
|
+
const id = text(rule.id);
|
|
79
|
+
// No criterion and axe's own best-practice tag: somebody's good idea, not a standard. Reported,
|
|
80
|
+
// but never as a barrier and never as something that fails a build.
|
|
81
|
+
const advice = criterion === null && tags.includes('best-practice');
|
|
82
|
+
const confidence = kind === 'incomplete' ? 'ask-a-person' : advice ? 'advice' : 'barrier';
|
|
83
|
+
const nodes = Array.isArray(rule.nodes) ? rule.nodes : [];
|
|
84
|
+
return nodes.map((node) => ({
|
|
85
|
+
rule: id,
|
|
86
|
+
tool: 'axe',
|
|
87
|
+
confidence,
|
|
88
|
+
criterion,
|
|
89
|
+
level: levelFrom(tags),
|
|
90
|
+
en301549: enClauseFrom(tags),
|
|
91
|
+
severity: advice ? 'minor' : (SEVERITY[text(rule.impact)] ?? 'moderate'),
|
|
92
|
+
html: text(node.html).replace(/\s+/g, ' ').slice(0, 160),
|
|
93
|
+
selector: strings(node.target).join(' '),
|
|
94
|
+
page,
|
|
95
|
+
why: kind === 'incomplete'
|
|
96
|
+
? `${help}. axe could not determine this one: ${tidySummary(text(node.failureSummary), 'no reason given')}`
|
|
97
|
+
: help,
|
|
98
|
+
next: tidySummary(text(node.failureSummary), help),
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
const OUTCOMES = ['violations', 'incomplete', 'passes', 'inapplicable'];
|
|
102
|
+
/** One axe result object: what `axe.run()` resolves to. */
|
|
103
|
+
function fromResult(result, page, run) {
|
|
104
|
+
// Recorded before any finding is read: a page on which every rule passed has still been opened,
|
|
105
|
+
// and leaving it out made a clean page look like one the browser never visited.
|
|
106
|
+
run.pages.add(page);
|
|
107
|
+
for (const outcome of OUTCOMES) {
|
|
108
|
+
for (const rule of (Array.isArray(result[outcome]) ? result[outcome] : [])) {
|
|
109
|
+
const id = text(rule.id);
|
|
110
|
+
if (id !== '')
|
|
111
|
+
run.rulesRun.add(id);
|
|
112
|
+
const criterion = criterionFrom(strings(rule.tags));
|
|
113
|
+
if (criterion)
|
|
114
|
+
run.criteriaCovered.add(criterion);
|
|
115
|
+
// Only the two outcomes that need a person's attention become findings. A rule that passed
|
|
116
|
+
// or did not apply is evidence of coverage, not something to report.
|
|
117
|
+
if (outcome === 'violations')
|
|
118
|
+
run.findings.push(...fromRule(rule, page, 'violation'));
|
|
119
|
+
if (outcome === 'incomplete')
|
|
120
|
+
run.findings.push(...fromRule(rule, page, 'incomplete'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** True of anything shaped like the object `axe.run()` resolves to. */
|
|
125
|
+
function looksLikeAxe(value) {
|
|
126
|
+
if (typeof value !== 'object' || value === null)
|
|
127
|
+
return false;
|
|
128
|
+
const v = value;
|
|
129
|
+
return Array.isArray(v.violations) || Array.isArray(v.incomplete) || Array.isArray(v.passes);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads an axe report, in any of the shapes real suites write it.
|
|
133
|
+
*
|
|
134
|
+
* One run; an array of runs, which is what a suite checking several pages produces; or an array of
|
|
135
|
+
* `{ url, result }` wrappers, which is what people write when they want to keep the page alongside
|
|
136
|
+
* its findings. Guessing wrong here means reporting a clean page, so an unreadable file throws
|
|
137
|
+
* rather than returning nothing.
|
|
138
|
+
*/
|
|
139
|
+
export function readAxeReport(path) {
|
|
140
|
+
let parsed;
|
|
141
|
+
const raw = readFileSync(path, 'utf8');
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new Error(`${path} is not JSON. Tewip reads the JSON an axe run writes.`);
|
|
147
|
+
}
|
|
148
|
+
const run = { findings: [], pages: new Set(), rulesRun: new Set(), criteriaCovered: new Set() };
|
|
149
|
+
const pageOf = (v, fallback) => text(v.url) || text(v.page) || fallback;
|
|
150
|
+
if (looksLikeAxe(parsed)) {
|
|
151
|
+
fromResult(parsed, pageOf(parsed, path), run);
|
|
152
|
+
return run;
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(parsed)) {
|
|
155
|
+
let read = 0;
|
|
156
|
+
for (const entry of parsed) {
|
|
157
|
+
if (looksLikeAxe(entry)) {
|
|
158
|
+
fromResult(entry, pageOf(entry, path), run);
|
|
159
|
+
read++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// A wrapper: { page | url, result: <axe run> }.
|
|
163
|
+
if (typeof entry !== 'object' || entry === null)
|
|
164
|
+
continue;
|
|
165
|
+
const wrapper = entry;
|
|
166
|
+
// axe records the page it actually ran against in `result.url`, which is authoritative.
|
|
167
|
+
// A wrapper's own label is only a fallback for runs that did not keep it.
|
|
168
|
+
if (looksLikeAxe(wrapper.result)) {
|
|
169
|
+
fromResult(wrapper.result, pageOf(wrapper.result, pageOf(wrapper, path)), run);
|
|
170
|
+
read++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (read > 0 || parsed.length === 0)
|
|
174
|
+
return run;
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`${path} is JSON, but not an axe report: Tewip looked for a "violations" array, a list of runs, ` +
|
|
177
|
+
'or a list of { page, result } wrappers. Write one with axe.run() in your Playwright, Cypress or jest-axe suite.');
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The axe rule that tests the same thing as one of Tewip's source-read rules.
|
|
181
|
+
*
|
|
182
|
+
* Supersession is decided here, rule by rule, rather than by criterion. By criterion it is too
|
|
183
|
+
* coarse and loses real findings: axe reporting `aria-required-children` means it checked *that*
|
|
184
|
+
* part of 1.3.1, not that it looked at heading structure, which axe treats as best-practice and
|
|
185
|
+
* tests under no criterion at all. Nineteen real heading findings disappeared that way before this
|
|
186
|
+
* map existed.
|
|
187
|
+
*
|
|
188
|
+
* `click-without-keyboard` has no entry on purpose: axe has no equivalent rule, so a source finding
|
|
189
|
+
* for it is never superseded by any run.
|
|
190
|
+
*/
|
|
191
|
+
export const EQUIVALENT_AXE_RULE = {
|
|
192
|
+
'image-alt': 'image-alt',
|
|
193
|
+
'input-label': 'label',
|
|
194
|
+
'button-name': 'button-name',
|
|
195
|
+
'link-name': 'link-name',
|
|
196
|
+
'html-lang': 'html-has-lang',
|
|
197
|
+
'iframe-title': 'frame-title',
|
|
198
|
+
'zoom-blocked': 'meta-viewport',
|
|
199
|
+
'nested-interactive': 'nested-interactive',
|
|
200
|
+
'aria-hidden-focusable': 'aria-hidden-focus',
|
|
201
|
+
'duplicate-id': 'duplicate-id',
|
|
202
|
+
'aria-unknown': 'aria-valid-attr',
|
|
203
|
+
'positive-tabindex': 'tabindex',
|
|
204
|
+
'autoplay-sound': 'no-autoplay-audio',
|
|
205
|
+
'heading-skip': 'heading-order',
|
|
206
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Phase 28: the catalogue. Which success criterion each check belongs to, how much it matters, and
|
|
2
|
+
// - the part no other tool writes down - which criteria this check deliberately does not touch.
|
|
3
|
+
//
|
|
4
|
+
// Why the criterion numbers are here at all: accessibility is the one quality question that is also
|
|
5
|
+
// a legal one. The European Accessibility Act has been enforceable since June 2025 and the US
|
|
6
|
+
// Section 508 and ADA rules point at the same standard, WCAG 2.2 level AA. A finding a team can
|
|
7
|
+
// trace to a numbered criterion is a finding they can put in front of a lawyer or a regulator; a
|
|
8
|
+
// finding that only says "bad contrast" is not. So every rule carries its criterion and its level.
|
|
9
|
+
export const RULES = {
|
|
10
|
+
'image-alt': {
|
|
11
|
+
criterion: '1.1.1 Non-text Content',
|
|
12
|
+
level: 'A',
|
|
13
|
+
severity: 'blocker',
|
|
14
|
+
stops: 'a screen reader reads the file name instead of the picture, or says nothing at all',
|
|
15
|
+
},
|
|
16
|
+
'input-label': {
|
|
17
|
+
criterion: '3.3.2 Labels or Instructions',
|
|
18
|
+
level: 'A',
|
|
19
|
+
severity: 'blocker',
|
|
20
|
+
stops: 'a screen reader announces "edit text" with no way to know what to type',
|
|
21
|
+
},
|
|
22
|
+
'button-name': {
|
|
23
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
24
|
+
level: 'A',
|
|
25
|
+
severity: 'blocker',
|
|
26
|
+
stops: 'a screen reader announces "button" and nothing else, so it cannot be used or spoken to',
|
|
27
|
+
},
|
|
28
|
+
'link-name': {
|
|
29
|
+
criterion: '2.4.4 Link Purpose',
|
|
30
|
+
level: 'A',
|
|
31
|
+
severity: 'blocker',
|
|
32
|
+
stops: 'a screen reader lists the page links as "link, link, link"',
|
|
33
|
+
},
|
|
34
|
+
'html-lang': {
|
|
35
|
+
criterion: '3.1.1 Language of Page',
|
|
36
|
+
level: 'A',
|
|
37
|
+
severity: 'serious',
|
|
38
|
+
stops: 'a screen reader reads the page in the wrong accent, which can make it unintelligible',
|
|
39
|
+
},
|
|
40
|
+
'click-without-keyboard': {
|
|
41
|
+
criterion: '2.1.1 Keyboard',
|
|
42
|
+
level: 'A',
|
|
43
|
+
severity: 'blocker',
|
|
44
|
+
stops: 'anyone who cannot use a mouse, including every screen reader user, cannot activate it',
|
|
45
|
+
},
|
|
46
|
+
'positive-tabindex': {
|
|
47
|
+
criterion: '2.4.3 Focus Order',
|
|
48
|
+
level: 'A',
|
|
49
|
+
severity: 'serious',
|
|
50
|
+
stops: 'the tab key jumps around the page in an order that does not match what is on screen',
|
|
51
|
+
},
|
|
52
|
+
'nested-interactive': {
|
|
53
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
54
|
+
level: 'A',
|
|
55
|
+
severity: 'serious',
|
|
56
|
+
stops: 'a control inside a control is announced and operated unpredictably, and often cannot be reached',
|
|
57
|
+
},
|
|
58
|
+
'iframe-title': {
|
|
59
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
60
|
+
level: 'A',
|
|
61
|
+
severity: 'serious',
|
|
62
|
+
stops: 'a screen reader announces an unnamed frame with no clue what is inside it',
|
|
63
|
+
},
|
|
64
|
+
'zoom-blocked': {
|
|
65
|
+
criterion: '1.4.4 Resize Text',
|
|
66
|
+
level: 'AA',
|
|
67
|
+
severity: 'serious',
|
|
68
|
+
stops: 'anyone with low vision cannot pinch to zoom the page on a phone',
|
|
69
|
+
},
|
|
70
|
+
'autoplay-sound': {
|
|
71
|
+
criterion: '1.4.2 Audio Control',
|
|
72
|
+
level: 'A',
|
|
73
|
+
severity: 'serious',
|
|
74
|
+
stops: 'sound plays over a screen reader, which makes the page unusable while it runs',
|
|
75
|
+
},
|
|
76
|
+
'duplicate-id': {
|
|
77
|
+
criterion: '4.1.1 Parsing',
|
|
78
|
+
level: 'A',
|
|
79
|
+
severity: 'moderate',
|
|
80
|
+
stops: 'a label or description points at the wrong element, so the wrong thing is announced',
|
|
81
|
+
},
|
|
82
|
+
'heading-skip': {
|
|
83
|
+
criterion: '1.3.1 Info and Relationships',
|
|
84
|
+
level: 'A',
|
|
85
|
+
severity: 'moderate',
|
|
86
|
+
stops: 'a screen reader user navigating by heading cannot tell how the page is organised',
|
|
87
|
+
},
|
|
88
|
+
'aria-unknown': {
|
|
89
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
90
|
+
level: 'A',
|
|
91
|
+
severity: 'moderate',
|
|
92
|
+
stops: 'nothing - a misspelled aria attribute is silently ignored, so the help it was meant to give never arrives',
|
|
93
|
+
},
|
|
94
|
+
'aria-hidden-focusable': {
|
|
95
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
96
|
+
level: 'A',
|
|
97
|
+
severity: 'serious',
|
|
98
|
+
stops: 'the tab key lands on something a screen reader has been told does not exist',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* What reading markup cannot settle, said plainly so nobody mistakes a clean run for a pass.
|
|
103
|
+
*
|
|
104
|
+
* This list is the honest half of the command. Roughly a third of WCAG is machine-checkable at all,
|
|
105
|
+
* and a scanner reading source rather than a rendered page checks less than that. Printing this
|
|
106
|
+
* every time is what stops "Tewip found no barriers" from being heard as "this is accessible".
|
|
107
|
+
*/
|
|
108
|
+
export const NOT_CHECKED = [
|
|
109
|
+
{
|
|
110
|
+
criterion: '1.4.3 Contrast (Minimum)',
|
|
111
|
+
why: 'needs the computed colours of a rendered page, not the markup',
|
|
112
|
+
// The one entry on this list a browser run genuinely closes (Phase 29).
|
|
113
|
+
settledBy: 'color-contrast',
|
|
114
|
+
},
|
|
115
|
+
// Everything below has no `settledBy` on purpose, and must not acquire one because a tool
|
|
116
|
+
// reports something under the same criterion number. axe's `image-alt` checks that an alt
|
|
117
|
+
// attribute exists; it has no opinion on whether the words describe the picture, which is the
|
|
118
|
+
// part named here. Matching on the number alone briefly made Tewip claim it had checked alt-text
|
|
119
|
+
// quality and timing behaviour, which no tool can.
|
|
120
|
+
{ criterion: '1.1.1 Non-text Content, quality', why: 'whether alt text actually describes the picture is a human judgement' },
|
|
121
|
+
{ criterion: '2.4.7 Focus Visible', why: 'needs a rendered page to see whether the focus ring survives the stylesheet' },
|
|
122
|
+
{ criterion: '1.3.2 Meaningful Sequence', why: 'needs the rendered order, which CSS can change from the source order' },
|
|
123
|
+
{ criterion: '4.1.3 Status Messages', why: 'needs a running page to observe what a live region announces' },
|
|
124
|
+
{ criterion: '2.2.1 Timing Adjustable', why: 'needs the running behaviour of timeouts' },
|
|
125
|
+
{ criterion: '3.2.x Predictable', why: 'needs a person to use the page and judge it' },
|
|
126
|
+
];
|
|
127
|
+
/** Rules whose finding, on its own, should stop a release. */
|
|
128
|
+
export function blocks(rule) {
|
|
129
|
+
return RULES[rule]?.severity === 'blocker';
|
|
130
|
+
}
|
|
131
|
+
/** `1.1.1 Non-text Content (A)` - how a finding names its criterion in a report. */
|
|
132
|
+
export function criterionOf(rule) {
|
|
133
|
+
const r = RULES[rule];
|
|
134
|
+
return r ? `WCAG 2.2 ${r.criterion} (${r.level})` : 'WCAG 2.2';
|
|
135
|
+
}
|
package/dist/mcp/tools-prove.js
CHANGED
|
@@ -95,6 +95,50 @@ export function registerProofTools(server, { answer, readOnly, project }) {
|
|
|
95
95
|
return 'No migration or schema file in that change.';
|
|
96
96
|
return renderSchema(migrations, stillUsed(root, migrations.flatMap((m) => m.findings)));
|
|
97
97
|
}));
|
|
98
|
+
server.registerTool('accessibility_barriers', {
|
|
99
|
+
title: 'Can everybody use this interface?',
|
|
100
|
+
description: "Accessibility barriers in a project's markup, each named against the WCAG 2.2 criterion a regulator would cite, with the evidence to check it. Two kinds of answer, kept apart on purpose: barriers that reading the markup proves (no alt attribute at all, a field with no label anywhere, a div that only a mouse can activate), and the ones only a person can settle, where something is written as an expression or a component and the markup cannot say. It also returns every criterion it did NOT check - contrast, focus order, whether alt text is any good - because those need a rendered page. Use it before shipping an interface, or when asked whether something is accessible; a clean result means these checks found nothing, never that the product is accessible. No browser, no network, no model.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
project,
|
|
103
|
+
path: z.string().optional().describe('A file or directory inside the project to check instead of all of it'),
|
|
104
|
+
axeReport: z
|
|
105
|
+
.string()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe('The JSON from an axe run, if the project has one. It settles contrast, which reading markup cannot, and its findings are triaged rather than relayed: axe rules that map to no WCAG criterion are reported as advice, and what axe could not determine becomes a question.'),
|
|
108
|
+
},
|
|
109
|
+
annotations: readOnly,
|
|
110
|
+
}, ({ project: root, path, axeReport }) => answer(async () => {
|
|
111
|
+
if (!isAbsolute(root) || !existsSync(root))
|
|
112
|
+
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
113
|
+
// The pure functions, not runAccess: that one prints, and anything on stdout breaks JSON-RPC.
|
|
114
|
+
const { findBarriers, keyOf, readAccepted, renderAccess } = await import('../engine/access/access-command.js');
|
|
115
|
+
const { merge, pagesIn } = await import('../engine/access/merge.js');
|
|
116
|
+
const { readAxeReport } = await import('../engine/access/reports.js');
|
|
117
|
+
const { findings, filesRead } = findBarriers(root, path);
|
|
118
|
+
const judged = readAccepted(root);
|
|
119
|
+
const open = findings.filter((f) => !judged.has(keyOf(f)));
|
|
120
|
+
const run = axeReport ? readAxeReport(isAbsolute(axeReport) ? axeReport : `${root}/${axeReport}`) : undefined;
|
|
121
|
+
const graded = run ? merge(open, run, root) : undefined;
|
|
122
|
+
const kept = graded ? graded.kept : open;
|
|
123
|
+
return renderAccess({
|
|
124
|
+
barriers: kept.filter((f) => f.confidence === 'barrier'),
|
|
125
|
+
questions: kept.filter((f) => f.confidence === 'ask-a-person'),
|
|
126
|
+
accepted: findings.filter((f) => judged.has(keyOf(f))),
|
|
127
|
+
filesRead,
|
|
128
|
+
...(run && graded
|
|
129
|
+
? {
|
|
130
|
+
browser: {
|
|
131
|
+
barriers: run.findings.filter((f) => f.confidence === 'barrier'),
|
|
132
|
+
questions: run.findings.filter((f) => f.confidence === 'ask-a-person'),
|
|
133
|
+
advice: run.findings.filter((f) => f.confidence === 'advice'),
|
|
134
|
+
superseded: graded.superseded,
|
|
135
|
+
pages: pagesIn(run),
|
|
136
|
+
rulesRun: run.rulesRun,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
}, path ?? root);
|
|
141
|
+
}));
|
|
98
142
|
server.registerTool('project_standards', {
|
|
99
143
|
title: "What are this project's own rules?",
|
|
100
144
|
description: "The house rules this team has written down in .tewip/standards/, and whether a change keeps them. Returns two things kept carefully apart: rules Tewip checks and enforces (a forbid or require pattern over a set of files), and guidance it can only carry - prose for the parts no pattern captures, which is passed on when something is written but never reported as verified. Use it before writing or changing code in somebody's project, so what you produce matches their conventions rather than yours.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|