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 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
- No browser, no network, no model, no cost.
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
@@ -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 (barriers.length === 0 && questions.length === 0) {
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
- section('Barriers', [...barriers].sort(worstFirst), lines);
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('This read the markup. It did not render a page, so none of the following was tested:', '');
128
- for (const n of 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)
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, and reading source rather than a rendered page reaches less than that. ' +
133
- 'The rest needs a person, ideally one who uses a screen reader.', '');
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
- const barriers = open.filter((f) => f.confidence === 'barrier');
144
- const questions = open.filter((f) => f.confidence === 'ask-a-person');
145
- const result = { barriers, questions, accepted, filesRead };
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
- const blocking = barriers.filter((f) => blocks(f.rule));
153
- if (cmd.gate && blocking.length > 0) {
154
- console.error(`${String.fromCharCode(10)}${blocking.length} accessibility barrier(s) that stop somebody outright, out of ${barriers.length} found.`);
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
- { criterion: '1.4.3 Contrast (Minimum)', why: 'needs the computed colours of a rendered page, not the markup' },
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' },
@@ -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: open.filter((f) => f.confidence === 'barrier'),
116
- questions: open.filter((f) => f.confidence === 'ask-a-person'),
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.35.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": [