tewip-ai 0.34.0 → 0.35.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
@@ -574,6 +574,61 @@ 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
+
577
632
  ## Teach it your house rules (Phase 27)
578
633
 
579
634
  ```bash
package/dist/cli/help.js CHANGED
@@ -118,6 +118,16 @@ 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]
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
+ No browser, no network, no model, no cost.
121
131
  tewip standards [--diff main...HEAD] [--strict] [--out standards.md]
122
132
  Teach Tewip your house rules. Markdown files in
123
133
  .tewip/standards/ - a forbid or require line is checked and
@@ -20,6 +20,17 @@ 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');
26
+ runAccess({
27
+ root: process.cwd(),
28
+ only: paths[0],
29
+ out: flag(args, 'out'),
30
+ gate: args.includes('--strict'),
31
+ });
32
+ return true;
33
+ }
23
34
  case 'standards': {
24
35
  const { runStandards } = await import('../engine/standards/standards-command.js');
25
36
  runStandards({
@@ -0,0 +1,164 @@
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 { blocks, criterionOf, NOT_CHECKED, RULES } from './wcag.js';
17
+ const ORDER = { blocker: 0, serious: 1, moderate: 2, minor: 3 };
18
+ /** One line per finding a person has already judged: "<rule> <file>:<line> - reason". */
19
+ export function readAccepted(root) {
20
+ const file = join(root, '.tewip', 'access-accepted.txt');
21
+ if (!existsSync(file))
22
+ return new Set();
23
+ return new Set(readFileSync(file, 'utf8')
24
+ .split('\n')
25
+ .map((l) => l.split(' - ')[0].trim())
26
+ .filter((l) => l !== '' && !l.startsWith('#')));
27
+ }
28
+ export function keyOf(f) {
29
+ return `${f.rule} ${f.file}:${f.line}`;
30
+ }
31
+ export function worstFirst(a, b) {
32
+ const sa = ORDER[RULES[a.rule]?.severity ?? 'minor'];
33
+ const sb = ORDER[RULES[b.rule]?.severity ?? 'minor'];
34
+ if (sa !== sb)
35
+ return sa - sb;
36
+ if (a.file !== b.file)
37
+ return a.file < b.file ? -1 : 1;
38
+ return a.line - b.line;
39
+ }
40
+ /** Reads every markup file under `root` and returns what the markup alone can settle. */
41
+ /**
42
+ * Markup written to exercise code, not to be used by a person. An `<img>` with no alt in a
43
+ * clipboard test is not a barrier to anybody, and excalidraw's test suite produced exactly that.
44
+ */
45
+ const TEST_MARKUP = /(?:^|[\\/])(?:__tests__|__mocks__|tests?|spec|e2e|stories|fixtures?)[\\/]|\.(?:test|spec|stories|cy)\.[cm]?[jt]sx?$/i;
46
+ export function findBarriers(root, only) {
47
+ const base = only ? resolve(root, only) : root;
48
+ const all = existsSync(base) && MARKUP.test(base) ? [base] : sourceFiles(base).filter((f) => MARKUP.test(f));
49
+ // Asking for a path by name overrides the exclusion: if you point at a fixture, you want it read.
50
+ const files = only ? all : all.filter((f) => !TEST_MARKUP.test(relative(root, f)));
51
+ const findings = [];
52
+ let filesRead = 0;
53
+ for (const path of files) {
54
+ let source;
55
+ try {
56
+ source = readFileSync(path, 'utf8');
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ // A file too large to be hand-written is generated or minified, and accusing it helps nobody.
62
+ if (source.length > 400_000)
63
+ continue;
64
+ filesRead++;
65
+ const jsLike = JS_LIKE.test(path);
66
+ // One page, rather than a file of components: only then do repeated ids land in one document.
67
+ const isDocument = /\.html?$/i.test(path);
68
+ findings.push(...checkFile(source, tags(source, jsLike), relative(root, path).replaceAll('\\', '/'), isDocument));
69
+ }
70
+ return { findings, filesRead };
71
+ }
72
+ function group(findings) {
73
+ const by = new Map();
74
+ for (const f of findings) {
75
+ const list = by.get(f.rule);
76
+ if (list)
77
+ list.push(f);
78
+ else
79
+ by.set(f.rule, [f]);
80
+ }
81
+ return by;
82
+ }
83
+ function section(title, findings, lines) {
84
+ if (findings.length === 0)
85
+ return;
86
+ lines.push(`## ${title}`, '');
87
+ for (const [rule, found] of group(findings)) {
88
+ const meta = RULES[rule];
89
+ lines.push(`### ${rule} - ${found.length} ${found.length === 1 ? 'place' : 'places'}`, '');
90
+ lines.push(`${criterionOf(rule)}, ${meta?.severity ?? 'unrated'}.`, '');
91
+ if (meta)
92
+ lines.push(`Who it stops: ${meta.stops}.`, '');
93
+ for (const f of found.slice(0, 20)) {
94
+ lines.push(`- \`${f.file}:${f.line}\` ${f.element}`);
95
+ lines.push(` - ${f.why}`);
96
+ lines.push(` - do this: ${f.next}`);
97
+ }
98
+ if (found.length > 20)
99
+ lines.push(`- ...and ${found.length - 20} more of the same, in the JSON beside this report.`);
100
+ lines.push('');
101
+ }
102
+ }
103
+ export function renderAccess(result, scanned) {
104
+ const { barriers, questions, accepted, filesRead } = result;
105
+ const lines = ['# Tewip accessibility check', ''];
106
+ lines.push(`Read ${filesRead} markup ${filesRead === 1 ? 'file' : 'files'} under \`${scanned}\`, without rendering any of them.`, '');
107
+ if (barriers.length === 0 && questions.length === 0) {
108
+ lines.push('No barrier was found that reading the markup can settle, and nothing needs a decision.', '');
109
+ }
110
+ else {
111
+ const stopping = barriers.filter((f) => blocks(f.rule)).length;
112
+ const counts = [
113
+ `${barriers.length} ${barriers.length === 1 ? 'barrier' : 'barriers'} (${stopping} that stop somebody outright)`,
114
+ `${questions.length} for a person to settle`,
115
+ ];
116
+ if (accepted.length > 0)
117
+ counts.push(`${accepted.length} already accepted`);
118
+ lines.push(`**${counts.join(', ')}.**`, '');
119
+ }
120
+ section('Barriers', [...barriers].sort(worstFirst), lines);
121
+ section('Only a person can settle these', [...questions].sort(worstFirst), lines);
122
+ if (accepted.length > 0) {
123
+ lines.push('## Already accepted', '');
124
+ lines.push(`${accepted.length} ${accepted.length === 1 ? 'finding is' : 'findings are'} listed in \`.tewip/access-accepted.txt\`, so they are not repeated here.`, '');
125
+ }
126
+ 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)
129
+ lines.push(`- **${n.criterion}** - ${n.why}`);
130
+ lines.push('');
131
+ 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.', '');
134
+ lines.push('To accept a finding, add its key to `.tewip/access-accepted.txt`, one per line, with a reason after ` - `.', '');
135
+ return lines.join('\n');
136
+ }
137
+ export function runAccess(cmd) {
138
+ const root = resolve(cmd.root);
139
+ const { findings, filesRead } = findBarriers(root, cmd.only);
140
+ const alreadyJudged = readAccepted(root);
141
+ const accepted = findings.filter((f) => alreadyJudged.has(keyOf(f)));
142
+ 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 };
146
+ const report = renderAccess(result, cmd.only ? resolve(root, cmd.only) : root);
147
+ console.log(report);
148
+ // What fails a build is deliberately narrower than what gets reported. A question never fails it:
149
+ // a build that breaks because something needs a human decision teaches a team to stop asking. Nor
150
+ // does a moderate finding - bootstrap's example pages skip a heading level, which is worth knowing
151
+ // 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.`);
155
+ process.exitCode = 1;
156
+ }
157
+ if (cmd.out) {
158
+ const path = resolve(root, cmd.out);
159
+ mkdirSync(dirname(path), { recursive: true });
160
+ writeFileSync(path, report, 'utf8');
161
+ writeFileSync(path.replace(/\.md$/, '') + '.json', JSON.stringify({ barriers, questions, accepted, filesRead }, null, 2), 'utf8');
162
+ }
163
+ return { ...result, report };
164
+ }
@@ -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,125 @@
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
+ { criterion: '1.4.3 Contrast (Minimum)', why: 'needs the computed colours of a rendered page, not the markup' },
110
+ { criterion: '1.1.1 Non-text Content, quality', why: 'whether alt text actually describes the picture is a human judgement' },
111
+ { criterion: '2.4.7 Focus Visible', why: 'needs a rendered page to see whether the focus ring survives the stylesheet' },
112
+ { criterion: '1.3.2 Meaningful Sequence', why: 'needs the rendered order, which CSS can change from the source order' },
113
+ { criterion: '4.1.3 Status Messages', why: 'needs a running page to observe what a live region announces' },
114
+ { criterion: '2.2.1 Timing Adjustable', why: 'needs the running behaviour of timeouts' },
115
+ { criterion: '3.2.x Predictable', why: 'needs a person to use the page and judge it' },
116
+ ];
117
+ /** Rules whose finding, on its own, should stop a release. */
118
+ export function blocks(rule) {
119
+ return RULES[rule]?.severity === 'blocker';
120
+ }
121
+ /** `1.1.1 Non-text Content (A)` - how a finding names its criterion in a report. */
122
+ export function criterionOf(rule) {
123
+ const r = RULES[rule];
124
+ return r ? `WCAG 2.2 ${r.criterion} (${r.level})` : 'WCAG 2.2';
125
+ }
@@ -95,6 +95,29 @@ 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
+ },
105
+ annotations: readOnly,
106
+ }, ({ project: root, path }) => answer(async () => {
107
+ if (!isAbsolute(root) || !existsSync(root))
108
+ throw new Error(`project must be an existing absolute path, got "${root}"`);
109
+ // The pure functions, not runAccess: that one prints, and anything on stdout breaks JSON-RPC.
110
+ const { findBarriers, keyOf, readAccepted, renderAccess } = await import('../engine/access/access-command.js');
111
+ const { findings, filesRead } = findBarriers(root, path);
112
+ const judged = readAccepted(root);
113
+ const open = findings.filter((f) => !judged.has(keyOf(f)));
114
+ return renderAccess({
115
+ barriers: open.filter((f) => f.confidence === 'barrier'),
116
+ questions: open.filter((f) => f.confidence === 'ask-a-person'),
117
+ accepted: findings.filter((f) => judged.has(keyOf(f))),
118
+ filesRead,
119
+ }, path ?? root);
120
+ }));
98
121
  server.registerTool('project_standards', {
99
122
  title: "What are this project's own rules?",
100
123
  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.34.0",
3
+ "version": "0.35.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": [