tewip-ai 0.33.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 +67 -6
- package/dist/cli/help.js +10 -0
- package/dist/cli/tewip.js +4 -69
- package/dist/cli/verbs-prove.js +93 -0
- package/dist/engine/access/access-command.js +164 -0
- package/dist/engine/access/checks.js +231 -0
- package/dist/engine/access/markup.js +174 -0
- package/dist/engine/access/wcag.js +125 -0
- package/dist/engine/import/case.js +16 -0
- package/dist/engine/import/dotnet.js +58 -0
- package/dist/engine/import/junit.js +2 -64
- package/dist/mcp/server.js +2 -111
- package/dist/mcp/tools-prove.js +139 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Tewip
|
|
2
2
|
|
|
3
|
-
**Tewip** (Uyghur *téwip*, تېۋىپ: healer, doctor)
|
|
3
|
+
**Tewip** (Uyghur *téwip*, تېۋىپ: healer, doctor) is the layer that **proves software work** - written by a person or by an agent.
|
|
4
|
+
|
|
5
|
+
It finds why each failing test failed (a real bug, a flaky test, the environment, test data, or a changed selector), says whether a test could ever fail at all, breaks your code to find out whether the tests would notice, reads what a migration will do to a live database, and gives one verdict on whether a change is done. Every answer carries its evidence and names what decided it: a deterministic rule, the AI model, or a person's correction.
|
|
6
|
+
|
|
7
|
+
What makes it worth installing is what it refuses to say. It answers *"cannot say"* rather than guessing, it will not narrow a test run it cannot prove safe to narrow, and every report ends with what it did **not** check. Each number below was measured on strangers' real code, never on fixtures.
|
|
4
8
|
|
|
5
9
|
It works for every stack: Node.js (node:test, Jest, Vitest, Mocha), Python (pytest, unittest), Java and Kotlin (JUnit, TestNG), Go, .NET, Ruby, PHP and Rust, each checked against its real runner's report ([the full list](QUICKSTART.md#not-using-playwright-every-stack-works)), plus Playwright in depth with traces, selector drift and verified fixes. It also reads the failure wording of the browser and component-test tools on top of those runners (Selenium, WebdriverIO, Cypress, Testing Library for React, Vue and Angular, Vue Test Utils, jest-dom and Jasmine), checked against more than 500 real messages from public projects.
|
|
6
10
|
|
|
@@ -112,18 +116,20 @@ jobs:
|
|
|
112
116
|
|
|
113
117
|
The report reads the history cache of the branch it runs on, usually the default branch.
|
|
114
118
|
|
|
115
|
-
The repository needs "Allow GitHub Actions to create and approve pull requests" enabled for fix PRs. Pull requests opened by the workflow token don't trigger CI themselves; the agent verified each change by re-running the test before opening the PR.
|
|
119
|
+
The repository needs "Allow GitHub Actions to create and approve pull requests" enabled for fix PRs. Pull requests opened by the workflow token don't trigger CI themselves; the agent verified each change by re-running the test before opening the PR. It was first proved end to end on a private practice repository, which is why there is no link to click here. If this repository is private, other repositories can only use the action when Settings → Actions → Access allows it.
|
|
116
120
|
|
|
117
121
|
### As a Claude Code plugin (no account, no marketplace approval)
|
|
118
122
|
|
|
119
123
|
```bash
|
|
120
|
-
claude plugin marketplace add hmamut39/tewip
|
|
124
|
+
claude plugin marketplace add hmamut39/tewip-plugin
|
|
121
125
|
claude plugin install tewip@tewip
|
|
122
126
|
```
|
|
123
127
|
|
|
124
128
|
That is the whole publication process. A marketplace is a `.claude-plugin/marketplace.json` file in a git
|
|
125
|
-
repository, so Tewip
|
|
126
|
-
|
|
129
|
+
repository, so Tewip publishes one at
|
|
130
|
+
[hmamut39/tewip-plugin](https://github.com/hmamut39/tewip-plugin) - nobody has to approve it, and there
|
|
131
|
+
is nothing to pay for. That repository holds the plugin only: a manifest, three skills and a pointer to
|
|
132
|
+
the npm package, which is why this one can stay private. The plugin brings Tewip's MCP server and three skills that tell Claude Code *when* to use it:
|
|
127
133
|
before claiming a change is done, when a test fails, and when a change touches a migration. It costs
|
|
128
134
|
about 410 tokens per session until a skill is actually needed.
|
|
129
135
|
|
|
@@ -568,6 +574,61 @@ With `--json` the verdict is **sealed**: editing `not-ready` to `ready` in the f
|
|
|
568
574
|
fail, so a verdict that travels into a ticket or a release note can be checked rather than trusted. It is
|
|
569
575
|
also an MCP tool, so an agent can ask whether its own work is done before saying that it is.
|
|
570
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
|
+
|
|
571
632
|
## Teach it your house rules (Phase 27)
|
|
572
633
|
|
|
573
634
|
```bash
|
|
@@ -817,7 +878,7 @@ With `--act` it carries the decision out, and this is where the refusals matter
|
|
|
817
878
|
|
|
818
879
|
| Project | How | Result |
|
|
819
880
|
|---|---|---|
|
|
820
|
-
|
|
|
881
|
+
| A private practice repository (not linked: it is private) | Live GitHub Action on a release PR with 11 planted failures | Diagnosis comment, one environment issue, and a fix PR whose 5 locator changes pass when checked out. Traps left alone. Synthetic. |
|
|
821
882
|
| WordPress Gutenberg (public) | Offline, from 5 windows of real CI blob reports, two of them harvested after the pipeline was frozen | 84–98% accuracy on 1,441 labelled real failures; the only project with enough drift cases to measure that category (see [eval/README.md](eval/README.md) for caveats) |
|
|
822
883
|
| JupyterLab (public) | Offline, from its Galata UI suite | 90.2% on 41 labelled failures; the deterministic rules alone were right on 34 of 34 they decided |
|
|
823
884
|
| LXD UI (public) | Offline, from its pull-request suite | 91.3% on 69 labelled failures; rules right on 63 of 63. All 6 misses are cross-branch flakes read as real bugs |
|
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
|
package/dist/cli/tewip.js
CHANGED
|
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { flag } from './args.js';
|
|
14
14
|
import { HELP } from './help.js';
|
|
15
|
+
import { proveVerb } from './verbs-prove.js';
|
|
15
16
|
import { workVerb } from './verbs-work.js';
|
|
16
17
|
// cli/ in the source tree, dist/cli/ in the published package: package.json is one or two levels up.
|
|
17
18
|
const ROOT = [join(import.meta.dirname, '..'), join(import.meta.dirname, '..', '..')].find((d) => existsSync(join(d, 'package.json'))) ?? join(import.meta.dirname, '..');
|
|
@@ -21,6 +22,9 @@ async function main() {
|
|
|
21
22
|
// The verbs that reach into a tracker, a scan or a build live next door; the rest are here.
|
|
22
23
|
if (await workVerb(verb, args))
|
|
23
24
|
return;
|
|
25
|
+
// The verbs that prove a change live next door too.
|
|
26
|
+
if (await proveVerb(verb, args))
|
|
27
|
+
return;
|
|
24
28
|
switch (verb) {
|
|
25
29
|
case 'explain': {
|
|
26
30
|
// The explain script reads its arguments from process.argv, as when it is run directly.
|
|
@@ -185,75 +189,6 @@ async function main() {
|
|
|
185
189
|
runPursue({ root: process.cwd(), goal, act: args.includes('--act'), name: flag(args, 'name'), out: flag(args, 'out') });
|
|
186
190
|
return;
|
|
187
191
|
}
|
|
188
|
-
case 'done': {
|
|
189
|
-
const { runDone } = await import('../engine/verdict/verdict-command.js');
|
|
190
|
-
runDone({
|
|
191
|
-
root: process.cwd(),
|
|
192
|
-
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
193
|
-
prove: args.includes('--prove'),
|
|
194
|
-
proveBudgetSeconds: Number(flag(args, 'budget') ?? '300'),
|
|
195
|
-
out: flag(args, 'out'),
|
|
196
|
-
json: flag(args, 'json'),
|
|
197
|
-
name: flag(args, 'name'),
|
|
198
|
-
strict: args.includes('--strict'),
|
|
199
|
-
});
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
case 'standards': {
|
|
203
|
-
const { runStandards } = await import('../engine/standards/standards-command.js');
|
|
204
|
-
runStandards({
|
|
205
|
-
root: process.cwd(),
|
|
206
|
-
diff: flag(args, 'diff'),
|
|
207
|
-
files: args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--diff' && args[i - 1] !== '--out'),
|
|
208
|
-
out: flag(args, 'out'),
|
|
209
|
-
strict: args.includes('--strict'),
|
|
210
|
-
});
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
case 'schema': {
|
|
214
|
-
const { runSchema } = await import('../engine/schema/schema-command.js');
|
|
215
|
-
runSchema({
|
|
216
|
-
root: process.cwd(),
|
|
217
|
-
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
218
|
-
files: args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--diff' && args[i - 1] !== '--out'),
|
|
219
|
-
out: flag(args, 'out'),
|
|
220
|
-
strict: args.includes('--strict'),
|
|
221
|
-
});
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
case 'pick': {
|
|
225
|
-
const { runPick } = await import('../engine/pick/pick-command.js');
|
|
226
|
-
runPick({
|
|
227
|
-
root: process.cwd(),
|
|
228
|
-
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
229
|
-
files: args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--diff' && args[i - 1] !== '--out' && args[i - 1] !== '--recent' && args[i - 1] !== '--name'),
|
|
230
|
-
out: flag(args, 'out'),
|
|
231
|
-
list: args.includes('--list'),
|
|
232
|
-
recentRuns: Number(flag(args, 'recent') ?? '5'),
|
|
233
|
-
name: flag(args, 'name'),
|
|
234
|
-
});
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
case 'prove': {
|
|
238
|
-
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--diff', '--limit', '--budget'].includes(args[i - 1] ?? ''));
|
|
239
|
-
const { runProve } = await import('../engine/prove/prove-command.js');
|
|
240
|
-
runProve({
|
|
241
|
-
root: process.cwd(),
|
|
242
|
-
files: paths,
|
|
243
|
-
diff: flag(args, 'diff'),
|
|
244
|
-
limit: Number(flag(args, 'limit') ?? '6'),
|
|
245
|
-
budgetSeconds: Number(flag(args, 'budget') ?? '600'),
|
|
246
|
-
out: flag(args, 'out'),
|
|
247
|
-
strict: args.includes('--strict'),
|
|
248
|
-
});
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
case 'vouch': {
|
|
252
|
-
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--json'].includes(args[i - 1] ?? ''));
|
|
253
|
-
const { runVouch } = await import('../engine/vouch/run-vouch.js');
|
|
254
|
-
runVouch({ root: process.cwd(), paths: paths.length > 0 ? paths : ['.'], out: flag(args, 'out'), json: flag(args, 'json'), strict: args.includes('--strict') });
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
192
|
case 'trace': {
|
|
258
193
|
const { runTrace } = await import('../engine/trace/trace-command.js');
|
|
259
194
|
runTrace({ root: process.cwd(), only: flag(args, 'only'), name: flag(args, 'name'), out: flag(args, 'out') });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The verbs that prove a change rather than describe it: the verdict, the project's own standards,
|
|
2
|
+
// what a migration does to a live database, which tests could notice a change, whether the tests would
|
|
3
|
+
// catch a bug, and whether a test could ever fail. Split out of cli/tewip.ts the same way the work
|
|
4
|
+
// verbs were, to keep every file inside this project's own 300-line rule.
|
|
5
|
+
import { flag } from './args.js';
|
|
6
|
+
/** Runs `verb` if it is one of these, and says whether it did. */
|
|
7
|
+
export async function proveVerb(verb, args) {
|
|
8
|
+
switch (verb) {
|
|
9
|
+
case 'done': {
|
|
10
|
+
const { runDone } = await import('../engine/verdict/verdict-command.js');
|
|
11
|
+
runDone({
|
|
12
|
+
root: process.cwd(),
|
|
13
|
+
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
14
|
+
prove: args.includes('--prove'),
|
|
15
|
+
proveBudgetSeconds: Number(flag(args, 'budget') ?? '300'),
|
|
16
|
+
out: flag(args, 'out'),
|
|
17
|
+
json: flag(args, 'json'),
|
|
18
|
+
name: flag(args, 'name'),
|
|
19
|
+
strict: args.includes('--strict'),
|
|
20
|
+
});
|
|
21
|
+
return true;
|
|
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
|
+
}
|
|
34
|
+
case 'standards': {
|
|
35
|
+
const { runStandards } = await import('../engine/standards/standards-command.js');
|
|
36
|
+
runStandards({
|
|
37
|
+
root: process.cwd(),
|
|
38
|
+
diff: flag(args, 'diff'),
|
|
39
|
+
files: args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--diff' && args[i - 1] !== '--out'),
|
|
40
|
+
out: flag(args, 'out'),
|
|
41
|
+
strict: args.includes('--strict'),
|
|
42
|
+
});
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
case 'schema': {
|
|
46
|
+
const { runSchema } = await import('../engine/schema/schema-command.js');
|
|
47
|
+
runSchema({
|
|
48
|
+
root: process.cwd(),
|
|
49
|
+
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
50
|
+
files: args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--diff' && args[i - 1] !== '--out'),
|
|
51
|
+
out: flag(args, 'out'),
|
|
52
|
+
strict: args.includes('--strict'),
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
case 'pick': {
|
|
57
|
+
const { runPick } = await import('../engine/pick/pick-command.js');
|
|
58
|
+
runPick({
|
|
59
|
+
root: process.cwd(),
|
|
60
|
+
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
61
|
+
files: args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--diff' && args[i - 1] !== '--out' && args[i - 1] !== '--recent' && args[i - 1] !== '--name'),
|
|
62
|
+
out: flag(args, 'out'),
|
|
63
|
+
list: args.includes('--list'),
|
|
64
|
+
recentRuns: Number(flag(args, 'recent') ?? '5'),
|
|
65
|
+
name: flag(args, 'name'),
|
|
66
|
+
});
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
case 'prove': {
|
|
70
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--diff', '--limit', '--budget'].includes(args[i - 1] ?? ''));
|
|
71
|
+
const { runProve } = await import('../engine/prove/prove-command.js');
|
|
72
|
+
runProve({
|
|
73
|
+
root: process.cwd(),
|
|
74
|
+
files: paths,
|
|
75
|
+
diff: flag(args, 'diff'),
|
|
76
|
+
limit: Number(flag(args, 'limit') ?? '6'),
|
|
77
|
+
budgetSeconds: Number(flag(args, 'budget') ?? '600'),
|
|
78
|
+
out: flag(args, 'out'),
|
|
79
|
+
strict: args.includes('--strict'),
|
|
80
|
+
});
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case 'vouch': {
|
|
84
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--json'].includes(args[i - 1] ?? ''));
|
|
85
|
+
const { runVouch } = await import('../engine/vouch/run-vouch.js');
|
|
86
|
+
runVouch({ root: process.cwd(), paths: paths.length > 0 ? paths : ['.'], out: flag(args, 'out'), json: flag(args, 'json'), strict: args.includes('--strict') });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|