tewip-ai 0.35.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 +36 -0
- package/dist/cli/help.js +6 -2
- package/dist/cli/verbs-prove.js +2 -1
- package/dist/engine/access/access-command.js +82 -13
- package/dist/engine/access/merge.js +119 -0
- package/dist/engine/access/reports.js +206 -0
- package/dist/engine/access/wcag.js +11 -1
- package/dist/mcp/tools-prove.js +24 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -629,6 +629,42 @@ Measured on four public projects, never on fixtures — excalidraw, bootstrap, v
|
|
|
629
629
|
classes were found and fixed along the way, every one of them a defect in Tewip rather than in the
|
|
630
630
|
projects, and none of them reachable from a fixture. They are listed in [ROADMAP.md](ROADMAP.md).
|
|
631
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
|
+
|
|
632
668
|
## Teach it your house rules (Phase 27)
|
|
633
669
|
|
|
634
670
|
```bash
|
package/dist/cli/help.js
CHANGED
|
@@ -118,7 +118,7 @@ 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>] [--strict] [--out access.md]
|
|
121
|
+
tewip access [<file or directory>] [--report axe.json] [--strict] [--out access.md]
|
|
122
122
|
Can everybody use this? Reads your markup and names the barriers
|
|
123
123
|
that reading alone can prove - a picture with no alt text, a field
|
|
124
124
|
with no label, a div only a mouse can use - each against its WCAG
|
|
@@ -127,7 +127,11 @@ Usage:
|
|
|
127
127
|
criterion it cannot reach without rendering the page (contrast,
|
|
128
128
|
focus order, whether the alt text is any good) is printed every
|
|
129
129
|
time. It never says a page is accessible, only what was checked.
|
|
130
|
-
|
|
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.
|
|
131
135
|
tewip standards [--diff main...HEAD] [--strict] [--out standards.md]
|
|
132
136
|
Teach Tewip your house rules. Markdown files in
|
|
133
137
|
.tewip/standards/ - a forbid or require line is checked and
|
package/dist/cli/verbs-prove.js
CHANGED
|
@@ -22,11 +22,12 @@ export async function proveVerb(verb, args) {
|
|
|
22
22
|
}
|
|
23
23
|
case 'access': {
|
|
24
24
|
const { runAccess } = await import('../engine/access/access-command.js');
|
|
25
|
-
const paths = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--out');
|
|
25
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--out' && args[i - 1] !== '--report');
|
|
26
26
|
runAccess({
|
|
27
27
|
root: process.cwd(),
|
|
28
28
|
only: paths[0],
|
|
29
29
|
out: flag(args, 'out'),
|
|
30
|
+
report: flag(args, 'report'),
|
|
30
31
|
gate: args.includes('--strict'),
|
|
31
32
|
});
|
|
32
33
|
return true;
|
|
@@ -13,6 +13,8 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
13
13
|
import { sourceFiles } from '../secure/secrets.js';
|
|
14
14
|
import { JS_LIKE, MARKUP, tags } from './markup.js';
|
|
15
15
|
import { checkFile } from './checks.js';
|
|
16
|
+
import { readAxeReport } from './reports.js';
|
|
17
|
+
import { merge, nowChecked, pagesIn, renderBrowserSection, standardOf, stillNotChecked } from './merge.js';
|
|
16
18
|
import { blocks, criterionOf, NOT_CHECKED, RULES } from './wcag.js';
|
|
17
19
|
const ORDER = { blocker: 0, serious: 1, moderate: 2, minor: 3 };
|
|
18
20
|
/** One line per finding a person has already judged: "<rule> <file>:<line> - reason". */
|
|
@@ -101,10 +103,14 @@ function section(title, findings, lines) {
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
export function renderAccess(result, scanned) {
|
|
104
|
-
const { barriers, questions, accepted, filesRead } = result;
|
|
106
|
+
const { barriers, questions, accepted, filesRead, browser } = result;
|
|
105
107
|
const lines = ['# Tewip accessibility check', ''];
|
|
106
108
|
lines.push(`Read ${filesRead} markup ${filesRead === 1 ? 'file' : 'files'} under \`${scanned}\`, without rendering any of them.`, '');
|
|
107
|
-
if (
|
|
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) {
|
|
108
114
|
lines.push('No barrier was found that reading the markup can settle, and nothing needs a decision.', '');
|
|
109
115
|
}
|
|
110
116
|
else {
|
|
@@ -115,22 +121,67 @@ export function renderAccess(result, scanned) {
|
|
|
115
121
|
];
|
|
116
122
|
if (accepted.length > 0)
|
|
117
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
|
+
}
|
|
118
129
|
lines.push(`**${counts.join(', ')}.**`, '');
|
|
119
130
|
}
|
|
120
|
-
|
|
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);
|
|
121
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
|
+
}
|
|
122
153
|
if (accepted.length > 0) {
|
|
123
154
|
lines.push('## Already accepted', '');
|
|
124
155
|
lines.push(`${accepted.length} ${accepted.length === 1 ? 'finding is' : 'findings are'} listed in \`.tewip/access-accepted.txt\`, so they are not repeated here.`, '');
|
|
125
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
|
+
}
|
|
126
168
|
lines.push('## What was not checked', '');
|
|
127
|
-
lines.push(
|
|
128
|
-
|
|
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)
|
|
129
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
|
+
}
|
|
130
180
|
lines.push('');
|
|
131
181
|
lines.push('A clean result here means these checks found nothing, not that the product is accessible. ' +
|
|
132
|
-
'Roughly a third of WCAG can be judged by a machine at all
|
|
133
|
-
|
|
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.', '');
|
|
134
185
|
lines.push('To accept a finding, add its key to `.tewip/access-accepted.txt`, one per line, with a reason after ` - `.', '');
|
|
135
186
|
return lines.join('\n');
|
|
136
187
|
}
|
|
@@ -140,18 +191,36 @@ export function runAccess(cmd) {
|
|
|
140
191
|
const alreadyJudged = readAccepted(root);
|
|
141
192
|
const accepted = findings.filter((f) => alreadyJudged.has(keyOf(f)));
|
|
142
193
|
const open = findings.filter((f) => !alreadyJudged.has(keyOf(f)));
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
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 } : {}) };
|
|
146
212
|
const report = renderAccess(result, cmd.only ? resolve(root, cmd.only) : root);
|
|
147
213
|
console.log(report);
|
|
148
214
|
// What fails a build is deliberately narrower than what gets reported. A question never fails it:
|
|
149
215
|
// a build that breaks because something needs a human decision teaches a team to stop asking. Nor
|
|
150
216
|
// does a moderate finding - bootstrap's example pages skip a heading level, which is worth knowing
|
|
151
217
|
// and not worth blocking a release over. Only the barriers that stop somebody outright.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.`);
|
|
155
224
|
process.exitCode = 1;
|
|
156
225
|
}
|
|
157
226
|
if (cmd.out) {
|
|
@@ -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
|
+
};
|
|
@@ -106,7 +106,17 @@ export const RULES = {
|
|
|
106
106
|
* every time is what stops "Tewip found no barriers" from being heard as "this is accessible".
|
|
107
107
|
*/
|
|
108
108
|
export const NOT_CHECKED = [
|
|
109
|
-
{
|
|
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.
|
|
110
120
|
{ criterion: '1.1.1 Non-text Content, quality', why: 'whether alt text actually describes the picture is a human judgement' },
|
|
111
121
|
{ criterion: '2.4.7 Focus Visible', why: 'needs a rendered page to see whether the focus ring survives the stylesheet' },
|
|
112
122
|
{ criterion: '1.3.2 Meaningful Sequence', why: 'needs the rendered order, which CSS can change from the source order' },
|
package/dist/mcp/tools-prove.js
CHANGED
|
@@ -101,21 +101,42 @@ export function registerProofTools(server, { answer, readOnly, project }) {
|
|
|
101
101
|
inputSchema: {
|
|
102
102
|
project,
|
|
103
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.'),
|
|
104
108
|
},
|
|
105
109
|
annotations: readOnly,
|
|
106
|
-
}, ({ project: root, path }) => answer(async () => {
|
|
110
|
+
}, ({ project: root, path, axeReport }) => answer(async () => {
|
|
107
111
|
if (!isAbsolute(root) || !existsSync(root))
|
|
108
112
|
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
109
113
|
// The pure functions, not runAccess: that one prints, and anything on stdout breaks JSON-RPC.
|
|
110
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');
|
|
111
117
|
const { findings, filesRead } = findBarriers(root, path);
|
|
112
118
|
const judged = readAccepted(root);
|
|
113
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;
|
|
114
123
|
return renderAccess({
|
|
115
|
-
barriers:
|
|
116
|
-
questions:
|
|
124
|
+
barriers: kept.filter((f) => f.confidence === 'barrier'),
|
|
125
|
+
questions: kept.filter((f) => f.confidence === 'ask-a-person'),
|
|
117
126
|
accepted: findings.filter((f) => judged.has(keyOf(f))),
|
|
118
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
|
+
: {}),
|
|
119
140
|
}, path ?? root);
|
|
120
141
|
}));
|
|
121
142
|
server.registerTool('project_standards', {
|
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": [
|