a11y-loop 0.1.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/LICENSE +22 -0
- package/README.md +409 -0
- package/THIRD-PARTY-NOTICES.md +32 -0
- package/package.json +51 -0
- package/skill/a11y-loop/SKILL.md +332 -0
- package/skill/a11y-loop/evals/evals.json +168 -0
- package/skill/a11y-loop/evals/trigger-evals.json +20 -0
- package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
- package/skill/a11y-loop/references/apg-patterns.md +264 -0
- package/skill/a11y-loop/references/manual-testing.md +224 -0
- package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
- package/src/cli.js +207 -0
- package/src/commands/audit.js +125 -0
- package/src/commands/contrast.js +141 -0
- package/src/commands/diff.js +65 -0
- package/src/lib/axe-runner.js +400 -0
- package/src/lib/browser-utils.js +221 -0
- package/src/lib/checks/dialog.js +341 -0
- package/src/lib/checks/div-button.js +87 -0
- package/src/lib/checks/focus-visible.js +296 -0
- package/src/lib/checks/keyboard.js +235 -0
- package/src/lib/checks/link-text.js +83 -0
- package/src/lib/checks/reduced-motion.js +139 -0
- package/src/lib/checks/reflow.js +101 -0
- package/src/lib/checks/target-size.js +128 -0
- package/src/lib/contrast-math.js +189 -0
- package/src/lib/diff.js +118 -0
- package/src/lib/finding.js +164 -0
- package/src/lib/fingerprint.js +0 -0
- package/src/lib/format/checklist.js +281 -0
- package/src/lib/format/human.js +175 -0
- package/src/lib/format/json.js +139 -0
- package/src/lib/format/sarif.js +111 -0
- package/src/lib/serve.js +189 -0
- package/src/lib/suggest-color.js +169 -0
- package/src/lib/wcag-map.js +271 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The finding record — the unit of everything downstream (dedupe, diff, JSON,
|
|
3
|
+
* human output, SARIF).
|
|
4
|
+
*
|
|
5
|
+
* Three severities, and the split matters:
|
|
6
|
+
* - `violation` — a success criterion is failed. Blocking; exit code 1.
|
|
7
|
+
* - `needsReview` — axe `incomplete`, or one of our own checks that found
|
|
8
|
+
* something real but cannot judge it. Never merged into
|
|
9
|
+
* passes, never blocking.
|
|
10
|
+
* - `bestPractice`— an axe best-practice rule with no criterion behind it.
|
|
11
|
+
* Never blocking, never counted as a violation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { fingerprint } from './fingerprint.js';
|
|
15
|
+
import { wcagFromTags, wcagForSc, actIdsForRule } from './wcag-map.js';
|
|
16
|
+
|
|
17
|
+
export const SEVERITY = {
|
|
18
|
+
VIOLATION: 'violation',
|
|
19
|
+
NEEDS_REVIEW: 'needsReview',
|
|
20
|
+
BEST_PRACTICE: 'bestPractice',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const HTML_TRUNCATE_AT = 200;
|
|
24
|
+
|
|
25
|
+
/** Collapse whitespace and cap length, so reports stay readable and small. */
|
|
26
|
+
export function truncateHtml(html, limit = HTML_TRUNCATE_AT) {
|
|
27
|
+
if (typeof html !== 'string') return '';
|
|
28
|
+
const flat = html.replace(/\s+/g, ' ').trim();
|
|
29
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} spec
|
|
34
|
+
* @param {string} spec.ruleId
|
|
35
|
+
* @param {'axe'|'a11y-loop'} spec.source
|
|
36
|
+
* @param {string} spec.severity one of SEVERITY
|
|
37
|
+
* @param {string} [spec.impact] minor | moderate | serious | critical
|
|
38
|
+
* @param {string} [spec.sc] success criterion number, for own checks
|
|
39
|
+
* @param {string[]} [spec.tags] axe tags, for axe findings
|
|
40
|
+
* @param {string[]} [spec.act] ACT rule ids (looked up from axe when omitted)
|
|
41
|
+
* @param {string|Array} spec.selector
|
|
42
|
+
* @param {string} [spec.html]
|
|
43
|
+
* @param {string} spec.message
|
|
44
|
+
* @param {string} [spec.helpUrl]
|
|
45
|
+
* @param {Array} [spec.suggestions]
|
|
46
|
+
* @param {string[]} [spec.passes] which emulation passes it appeared in
|
|
47
|
+
* @param {string|null} [spec.state] interaction state name, if any
|
|
48
|
+
* @param {object} [spec.data] extra check-specific detail
|
|
49
|
+
*/
|
|
50
|
+
export function makeFinding(spec) {
|
|
51
|
+
const {
|
|
52
|
+
ruleId,
|
|
53
|
+
source,
|
|
54
|
+
severity,
|
|
55
|
+
impact = null,
|
|
56
|
+
sc = null,
|
|
57
|
+
tags = [],
|
|
58
|
+
act,
|
|
59
|
+
selector,
|
|
60
|
+
html = '',
|
|
61
|
+
message,
|
|
62
|
+
helpUrl = null,
|
|
63
|
+
suggestions,
|
|
64
|
+
passes = [],
|
|
65
|
+
state = null,
|
|
66
|
+
data,
|
|
67
|
+
} = spec;
|
|
68
|
+
|
|
69
|
+
const wcag = sc ? wcagForSc(sc) : wcagFromTags(tags);
|
|
70
|
+
|
|
71
|
+
const finding = {
|
|
72
|
+
fingerprint: fingerprint({ ruleId, selector, html }),
|
|
73
|
+
ruleId,
|
|
74
|
+
source,
|
|
75
|
+
severity,
|
|
76
|
+
impact,
|
|
77
|
+
wcag: wcag ?? null,
|
|
78
|
+
act: act ?? (source === 'axe' ? actIdsForRule(ruleId) : []),
|
|
79
|
+
passes: [...passes],
|
|
80
|
+
state,
|
|
81
|
+
selector: Array.isArray(selector) ? selector.flat(Infinity).join(', ') : String(selector ?? ''),
|
|
82
|
+
html: truncateHtml(html),
|
|
83
|
+
message,
|
|
84
|
+
helpUrl,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (suggestions?.length) finding.suggestions = suggestions;
|
|
88
|
+
if (data) finding.data = data;
|
|
89
|
+
return finding;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge findings that the five emulation passes reported more than once,
|
|
94
|
+
* recording every pass a finding appeared in. Same fingerprint + same state is
|
|
95
|
+
* the same finding.
|
|
96
|
+
*
|
|
97
|
+
* @param {Array} findings
|
|
98
|
+
* @returns {Array}
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* Rules where a lone forced-colors-pass "violation" contradicting an
|
|
102
|
+
* already-established needs-review verdict from every other pass is not
|
|
103
|
+
* trusted. Verified directly (real repro, not assumed): under
|
|
104
|
+
* `forcedColors: 'active'`, a gradient background is correctly flattened by
|
|
105
|
+
* Chromium (backgroundImage becomes 'none', backgroundColor becomes the
|
|
106
|
+
* Canvas colour) but axe-core's color-contrast check evaluates the ORIGINAL,
|
|
107
|
+
* pre-forced-colors author foreground colour against that new background —
|
|
108
|
+
* not the actual rendered colour (confirmed by reading getComputedStyle
|
|
109
|
+
* directly: the real rendered text was black-on-white, 21:1, while axe
|
|
110
|
+
* reported the author's un-adjusted light colour and a 1.17:1 failure).
|
|
111
|
+
* Every other pass, and a plain default axe.run() with no emulation at all,
|
|
112
|
+
* correctly call the same element's background undeterminable. This is a
|
|
113
|
+
* gradient-plus-forced-colors-specific axe-core limitation, reproduced with
|
|
114
|
+
* a minimal two-line test page independent of any a11y-loop code, not
|
|
115
|
+
* something specific to one page or fixable by changing what gets served.
|
|
116
|
+
*/
|
|
117
|
+
const FORCED_COLORS_FRAGILE_RULES = new Set(['color-contrast', 'color-contrast-enhanced']);
|
|
118
|
+
|
|
119
|
+
export function dedupeFindings(findings) {
|
|
120
|
+
const byKey = new Map();
|
|
121
|
+
for (const finding of findings) {
|
|
122
|
+
const key = `${finding.fingerprint}::${finding.state ?? ''}`;
|
|
123
|
+
const existing = byKey.get(key);
|
|
124
|
+
if (!existing) {
|
|
125
|
+
byKey.set(key, { ...finding, passes: [...finding.passes] });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
for (const pass of finding.passes) {
|
|
129
|
+
if (!existing.passes.includes(pass)) existing.passes.push(pass);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isUncorroboratedForcedColorsPromotion =
|
|
133
|
+
FORCED_COLORS_FRAGILE_RULES.has(finding.ruleId) &&
|
|
134
|
+
finding.severity === SEVERITY.VIOLATION &&
|
|
135
|
+
existing.severity === SEVERITY.NEEDS_REVIEW &&
|
|
136
|
+
finding.passes.length === 1 &&
|
|
137
|
+
finding.passes[0] === 'forced-colors';
|
|
138
|
+
|
|
139
|
+
// A violation seen in any pass outranks a needs-review sighting of the same
|
|
140
|
+
// thing, and richer data (suggestions) should survive the merge — except
|
|
141
|
+
// the specific, verified forced-colors/gradient case above, where trusting
|
|
142
|
+
// the lone dissenting pass would replace four correct "undeterminable"
|
|
143
|
+
// verdicts with a false failure.
|
|
144
|
+
if (
|
|
145
|
+
existing.severity !== SEVERITY.VIOLATION &&
|
|
146
|
+
finding.severity === SEVERITY.VIOLATION &&
|
|
147
|
+
!isUncorroboratedForcedColorsPromotion
|
|
148
|
+
) {
|
|
149
|
+
existing.severity = SEVERITY.VIOLATION;
|
|
150
|
+
existing.message = finding.message;
|
|
151
|
+
}
|
|
152
|
+
if (!existing.suggestions && finding.suggestions) existing.suggestions = finding.suggestions;
|
|
153
|
+
}
|
|
154
|
+
return [...byKey.values()];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Split a flat finding list into the three report buckets. */
|
|
158
|
+
export function bucketFindings(findings) {
|
|
159
|
+
return {
|
|
160
|
+
violations: findings.filter((f) => f.severity === SEVERITY.VIOLATION),
|
|
161
|
+
needsReview: findings.filter((f) => f.severity === SEVERITY.NEEDS_REVIEW),
|
|
162
|
+
bestPractice: findings.filter((f) => f.severity === SEVERITY.BEST_PRACTICE),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manual-verification checklist.
|
|
3
|
+
*
|
|
4
|
+
* This is a first-class output, not a disclaimer. Automated rules cover a
|
|
5
|
+
* minority of WCAG — 57% of issue instances by volume on Deque's own numbers,
|
|
6
|
+
* but only ~31% of the 55 AA criteria have any ACT-approved automated rule and
|
|
7
|
+
* ~13% are reliably flagged. Nine A/AA criteria cannot be meaningfully tested by
|
|
8
|
+
* any tool.
|
|
9
|
+
*
|
|
10
|
+
* Items are generated from what was actually on the page, so the list is short
|
|
11
|
+
* and specific rather than a generic wall of text. An empty page gets three
|
|
12
|
+
* items; a page with a form, images and a dialog gets the ones that apply.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Always relevant, whatever the page contains. */
|
|
16
|
+
const UNIVERSAL = [
|
|
17
|
+
{
|
|
18
|
+
id: 'assistive-technology',
|
|
19
|
+
sc: null,
|
|
20
|
+
text: 'Test with a real screen reader (NVDA or JAWS on Windows, VoiceOver on macOS/iOS).',
|
|
21
|
+
why:
|
|
22
|
+
'No automated tool, and no simulator, reproduces real AT behaviour — NVDA, JAWS and ' +
|
|
23
|
+
'VoiceOver diverge from each other and from the specification.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'focus-order-logic',
|
|
27
|
+
sc: '2.4.3',
|
|
28
|
+
text: 'Tab through the page and confirm the focus order is logical, not merely present.',
|
|
29
|
+
why:
|
|
30
|
+
'a11y-loop can see that focus moves and where it goes, but whether the sequence makes ' +
|
|
31
|
+
'sense for this content is a human judgment.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'keyboard-only-walkthrough',
|
|
35
|
+
sc: '2.1.1',
|
|
36
|
+
text: 'Complete the page’s main task using only the keyboard.',
|
|
37
|
+
why:
|
|
38
|
+
'Automated checks probe individual controls; they do not know what the user is trying ' +
|
|
39
|
+
'to accomplish.',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Conditional items, keyed by a fact on the page.
|
|
45
|
+
* `when` receives the page inventory and returns whether the item applies.
|
|
46
|
+
*/
|
|
47
|
+
const CONDITIONAL = [
|
|
48
|
+
{
|
|
49
|
+
id: 'alt-text-quality',
|
|
50
|
+
sc: '1.1.1',
|
|
51
|
+
when: (f) => f.images > 0,
|
|
52
|
+
text: (f) =>
|
|
53
|
+
`Confirm the alt text on ${f.images} image${f.images === 1 ? '' : 's'} is accurate and ` +
|
|
54
|
+
'useful, and that decorative images use alt="" rather than a description.',
|
|
55
|
+
why:
|
|
56
|
+
'Automation can only see whether alt text exists. alt="decorative image" passes every ' +
|
|
57
|
+
'automated rule and is worse than alt="". If any alt text was generated by an AI, treat ' +
|
|
58
|
+
'it as a draft that a human must confirm.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'form-error-quality',
|
|
62
|
+
sc: '3.3.3',
|
|
63
|
+
when: (f) => f.formFields > 0,
|
|
64
|
+
text: (f) =>
|
|
65
|
+
`Submit the form with ${f.formFields} field${f.formFields === 1 ? '' : 's'} in an invalid ` +
|
|
66
|
+
'state and confirm each error is announced, identifies the field, and says how to fix it.',
|
|
67
|
+
why:
|
|
68
|
+
'SC 3.3.1 and 3.3.3 are about whether an error message is actionable, which no tool can ' +
|
|
69
|
+
'assess. Error states also usually only exist after interaction — audit them with ' +
|
|
70
|
+
'--interact.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'label-clarity',
|
|
74
|
+
sc: '2.4.6',
|
|
75
|
+
when: (f) => f.formFields > 0,
|
|
76
|
+
text: 'Confirm every field label describes what to enter, not just that a label exists.',
|
|
77
|
+
why: 'A label of "Field 1" satisfies every automated rule and tells the user nothing.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'link-purpose',
|
|
81
|
+
sc: '2.4.4',
|
|
82
|
+
when: (f) => f.links > 0,
|
|
83
|
+
text: 'Read each link text out of context and confirm the destination is still clear.',
|
|
84
|
+
why:
|
|
85
|
+
'SC 2.4.4 permits vague link text when the surrounding context supplies the purpose — ' +
|
|
86
|
+
'whether it does is a judgment call.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'heading-semantics',
|
|
90
|
+
sc: '1.3.1',
|
|
91
|
+
when: (f) => f.headings > 0,
|
|
92
|
+
text: 'Confirm the heading structure describes the content hierarchy, not the visual sizes.',
|
|
93
|
+
why:
|
|
94
|
+
'Automation can detect skipped levels but not whether the outline reflects the actual ' +
|
|
95
|
+
'structure of the page.',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'media-alternatives',
|
|
99
|
+
sc: '1.2.2',
|
|
100
|
+
when: (f) => f.videos > 0 || f.audios > 0,
|
|
101
|
+
text: 'Check caption accuracy, and provide a transcript and (for video) audio description.',
|
|
102
|
+
why:
|
|
103
|
+
'Caption and transcript quality is entirely a human assessment; the presence of a track ' +
|
|
104
|
+
'element says nothing about whether it is correct or synchronised.',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'table-reflow-meaning',
|
|
108
|
+
sc: '1.4.10',
|
|
109
|
+
when: (f) => f.tables > 0,
|
|
110
|
+
text: 'Confirm the table still conveys its meaning at a 320px viewport.',
|
|
111
|
+
why:
|
|
112
|
+
'SC 1.4.10 excepts content requiring two-dimensional layout. Whether a given table ' +
|
|
113
|
+
'qualifies, and whether it remains understandable, needs a person.',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'aria-appropriateness',
|
|
117
|
+
sc: '4.1.2',
|
|
118
|
+
when: (f) => f.ariaAttributes > 0,
|
|
119
|
+
text: (f) =>
|
|
120
|
+
`Review the ${f.ariaAttributes} ARIA attribute${f.ariaAttributes === 1 ? '' : 's'} on this ` +
|
|
121
|
+
'page: is each one appropriate, or merely valid?',
|
|
122
|
+
why:
|
|
123
|
+
'Pages using ARIA average more accessibility errors than pages without it (WebAIM ' +
|
|
124
|
+
'Million 2026: 59.1 vs 42.0). A role is a promise — role="button" commits to the full ' +
|
|
125
|
+
'keyboard behaviour of a button. No ARIA is better than bad ARIA.',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'dialog-behaviour',
|
|
129
|
+
sc: '2.1.2',
|
|
130
|
+
when: (f) => f.dialogs > 0,
|
|
131
|
+
text: 'Confirm each dialog announces itself on open and describes how to dismiss it.',
|
|
132
|
+
why:
|
|
133
|
+
'a11y-loop tests the focus trap, Escape, and focus return mechanically. Whether the ' +
|
|
134
|
+
'dialog is comprehensible when announced is a screen-reader test.',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'motion-essential',
|
|
138
|
+
sc: '2.3.3',
|
|
139
|
+
when: (f) => f.animations > 0,
|
|
140
|
+
text: 'Confirm any animation that survives prefers-reduced-motion is genuinely essential.',
|
|
141
|
+
why: 'The "essential" exception cannot be evaluated automatically.',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'color-not-only-cue',
|
|
145
|
+
sc: '1.4.1',
|
|
146
|
+
when: (f) => f.links > 0 || f.formFields > 0,
|
|
147
|
+
text: 'Confirm colour is never the only way information is conveyed.',
|
|
148
|
+
why:
|
|
149
|
+
'SC 1.4.1 is one of the criteria no tool can test: recognising that "the required fields ' +
|
|
150
|
+
'are in red" needs comprehension of the content.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'focus-not-obscured',
|
|
154
|
+
sc: '2.4.11',
|
|
155
|
+
when: (f) => f.stickyElements > 0,
|
|
156
|
+
text: 'Tab through the page and confirm the focused element is never hidden behind sticky or fixed content.',
|
|
157
|
+
why:
|
|
158
|
+
'SC 2.4.11 (new in WCAG 2.2) has no automated rule. This page has position:sticky or ' +
|
|
159
|
+
'position:fixed elements, which are the usual cause.',
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Engine limitations worth restating when the relevant rule actually turned up
|
|
165
|
+
* in axe's `incomplete` array — each one is a place axe knows it does not know.
|
|
166
|
+
*/
|
|
167
|
+
const ENGINE_NOTES = {
|
|
168
|
+
'color-contrast': {
|
|
169
|
+
id: 'contrast-undeterminable',
|
|
170
|
+
sc: '1.4.3',
|
|
171
|
+
text:
|
|
172
|
+
'Check the contrast of the elements axe could not measure by hand (WebAIM Contrast ' +
|
|
173
|
+
'Checker, or a11y-loop contrast <fg> <bg>).',
|
|
174
|
+
why:
|
|
175
|
+
'axe gives up on background images, gradients, pseudo-element backgrounds and ' +
|
|
176
|
+
'foreground opacity or occlusion, and reports them as incomplete. A computed ratio of ' +
|
|
177
|
+
'1:1 is the usual tell.',
|
|
178
|
+
},
|
|
179
|
+
'color-contrast-enhanced': {
|
|
180
|
+
id: 'contrast-enhanced-undeterminable',
|
|
181
|
+
sc: '1.4.6',
|
|
182
|
+
text: 'Check the enhanced-contrast candidates axe could not measure by hand.',
|
|
183
|
+
why: 'Same limitation as SC 1.4.3: computed backgrounds defeat the measurement.',
|
|
184
|
+
},
|
|
185
|
+
'frame-tested': {
|
|
186
|
+
id: 'cross-origin-frames',
|
|
187
|
+
sc: null,
|
|
188
|
+
text: 'Audit the contents of cross-origin iframes separately, at their own origin.',
|
|
189
|
+
why:
|
|
190
|
+
'axe cannot inject into a frame it cannot script, so that content was not tested at ' +
|
|
191
|
+
'all — its absence from this report means nothing.',
|
|
192
|
+
},
|
|
193
|
+
'aria-hidden-focus': {
|
|
194
|
+
id: 'aria-hidden-focus-review',
|
|
195
|
+
sc: '4.1.2',
|
|
196
|
+
text: 'Confirm that elements inside aria-hidden containers really are unreachable.',
|
|
197
|
+
why: 'axe could not determine focusability for some of these elements.',
|
|
198
|
+
},
|
|
199
|
+
'link-in-text-block': {
|
|
200
|
+
id: 'link-distinguishable',
|
|
201
|
+
sc: '1.4.1',
|
|
202
|
+
text: 'Confirm links inside body text are distinguishable without relying on colour alone.',
|
|
203
|
+
why: 'axe could not determine the surrounding text colour for some links.',
|
|
204
|
+
},
|
|
205
|
+
'target-size': {
|
|
206
|
+
id: 'target-size-axe',
|
|
207
|
+
sc: '2.5.8',
|
|
208
|
+
text: 'Re-measure the targets axe flagged for size by hand.',
|
|
209
|
+
why:
|
|
210
|
+
'axe ships target-size disabled by default because of a documented false-positive trail ' +
|
|
211
|
+
'with overlapping and translucent targets. a11y-loop enables it, and always reports it ' +
|
|
212
|
+
'as needs-review.',
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Checks that are never automated at all, listed so their absence is not read as a pass. */
|
|
217
|
+
const NEVER_AUTOMATED = [
|
|
218
|
+
{
|
|
219
|
+
id: 'hover-focus-contrast',
|
|
220
|
+
sc: '1.4.11',
|
|
221
|
+
text: 'Check the contrast of hover and focus states.',
|
|
222
|
+
why:
|
|
223
|
+
'No engine checks state contrast unless the state is driven and re-scanned. Use ' +
|
|
224
|
+
'--interact to add hover and focus states to the audit.',
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {object} input
|
|
230
|
+
* @param {object} input.facts page inventory from `surveyPageFacts`
|
|
231
|
+
* @param {string[]} [input.incompleteRuleIds] axe rules that returned incomplete
|
|
232
|
+
* @param {boolean} [input.statesRun] whether any --interact states were audited
|
|
233
|
+
* @returns {Array<{id:string, sc:string|null, text:string, why:string}>}
|
|
234
|
+
*/
|
|
235
|
+
export function buildChecklist({ facts = {}, incompleteRuleIds = [], statesRun = [] } = {}) {
|
|
236
|
+
const inventory = {
|
|
237
|
+
images: 0,
|
|
238
|
+
formFields: 0,
|
|
239
|
+
links: 0,
|
|
240
|
+
headings: 0,
|
|
241
|
+
videos: 0,
|
|
242
|
+
audios: 0,
|
|
243
|
+
tables: 0,
|
|
244
|
+
iframes: 0,
|
|
245
|
+
dialogs: 0,
|
|
246
|
+
ariaAttributes: 0,
|
|
247
|
+
animations: 0,
|
|
248
|
+
stickyElements: 0,
|
|
249
|
+
...facts,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const items = [...UNIVERSAL];
|
|
253
|
+
|
|
254
|
+
for (const item of CONDITIONAL) {
|
|
255
|
+
if (!item.when(inventory)) continue;
|
|
256
|
+
items.push({
|
|
257
|
+
id: item.id,
|
|
258
|
+
sc: item.sc,
|
|
259
|
+
text: typeof item.text === 'function' ? item.text(inventory) : item.text,
|
|
260
|
+
why: item.why,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const seen = new Set(items.map((i) => i.id));
|
|
265
|
+
for (const ruleId of incompleteRuleIds) {
|
|
266
|
+
const note = ENGINE_NOTES[ruleId];
|
|
267
|
+
if (note && !seen.has(note.id)) {
|
|
268
|
+
items.push(note);
|
|
269
|
+
seen.add(note.id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// If no interaction states were audited, hover/focus contrast is untested.
|
|
274
|
+
if (statesRun.length === 0) {
|
|
275
|
+
for (const item of NEVER_AUTOMATED) {
|
|
276
|
+
if (!seen.has(item.id)) items.push(item);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return items;
|
|
281
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terse human output.
|
|
3
|
+
*
|
|
4
|
+
* Findings are grouped by rule so a page with 34 contrast failures does not
|
|
5
|
+
* produce 34 identical citation lines, and every group leads with the success
|
|
6
|
+
* criterion, because the criterion is the source of the obligation — the ACT id
|
|
7
|
+
* and the axe rule id are secondary identifiers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { formatCitation } from '../wcag-map.js';
|
|
11
|
+
import { CLEAN_VERDICT, COVERAGE } from './json.js';
|
|
12
|
+
|
|
13
|
+
const BULLET = '·';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A one-line description of what was audited. An inline fragment is summarised
|
|
17
|
+
* rather than printed, because agents pass whole components on the command line.
|
|
18
|
+
*/
|
|
19
|
+
export function describeTarget(target) {
|
|
20
|
+
if (target.type === 'url') return target.value;
|
|
21
|
+
if (target.type === 'file') {
|
|
22
|
+
return `${target.value}${target.servedAt ? ` (served at ${target.servedAt})` : ''}`;
|
|
23
|
+
}
|
|
24
|
+
const size = String(target.value ?? '').length;
|
|
25
|
+
return `inline HTML fragment, ${size} chars${target.servedAt ? ` (served at ${target.servedAt})` : ''}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `SC 4.1.2 Name, Role, Value (Level A) · ACT 97a4e1 · axe: button-name` */
|
|
29
|
+
export function findingHeadline(finding) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
const citation = formatCitation(finding.wcag);
|
|
32
|
+
parts.push(citation ?? `${finding.source}: ${finding.ruleId}`);
|
|
33
|
+
if (finding.act?.length) parts.push(`ACT ${finding.act.join(', ')}`);
|
|
34
|
+
if (citation) {
|
|
35
|
+
parts.push(finding.source === 'axe' ? `axe: ${finding.ruleId}` : `a11y-loop: ${finding.ruleId}`);
|
|
36
|
+
}
|
|
37
|
+
if (finding.wcag?.wcag22Only) parts.push('WCAG 2.2 only');
|
|
38
|
+
return parts.join(` ${BULLET} `);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Group findings that share a headline, preserving first-seen order. */
|
|
42
|
+
export function groupFindings(findings) {
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
for (const finding of findings) {
|
|
45
|
+
const key = `${finding.ruleId}::${finding.state ?? ''}`;
|
|
46
|
+
if (!groups.has(key)) {
|
|
47
|
+
groups.set(key, { headline: findingHeadline(finding), state: finding.state, items: [] });
|
|
48
|
+
}
|
|
49
|
+
groups.get(key).items.push(finding);
|
|
50
|
+
}
|
|
51
|
+
return [...groups.values()];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatSuggestionLines(finding, indent) {
|
|
55
|
+
if (!finding.suggestions?.length) return [];
|
|
56
|
+
return finding.suggestions.map((s) => {
|
|
57
|
+
const what = s.role === 'background' ? 'background' : 'text';
|
|
58
|
+
return `${indent}fix: ${what} ${s.direction} → ${s.hex} (ratio ${s.newRatioDisplay ?? s.newRatio}:1)`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatPassNote(finding) {
|
|
63
|
+
const passes = finding.passes ?? [];
|
|
64
|
+
if (passes.length === 0 || passes.includes('default')) return '';
|
|
65
|
+
return ` [only under: ${passes.join(', ')}]`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatGroup(group, { indent = ' ' } = {}) {
|
|
69
|
+
const lines = [];
|
|
70
|
+
const inner = `${indent} `;
|
|
71
|
+
const count = group.items.length;
|
|
72
|
+
const stateNote = group.state ? ` ${BULLET} state: ${group.state}` : '';
|
|
73
|
+
lines.push(`${indent}${group.headline}${stateNote}${count > 1 ? ` (${count} elements)` : ''}`);
|
|
74
|
+
|
|
75
|
+
const messages = new Set(group.items.map((f) => f.message));
|
|
76
|
+
const shared = messages.size === 1 && count > 1;
|
|
77
|
+
if (shared) lines.push(`${inner}${group.items[0].message}`);
|
|
78
|
+
|
|
79
|
+
for (const finding of group.items) {
|
|
80
|
+
lines.push(`${inner}${BULLET} ${finding.selector || '(document)'}${formatPassNote(finding)}`);
|
|
81
|
+
if (!shared) lines.push(`${inner} ${finding.message}`);
|
|
82
|
+
lines.push(...formatSuggestionLines(finding, `${inner} `));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const helpUrl = group.items.find((f) => f.helpUrl)?.helpUrl;
|
|
86
|
+
if (helpUrl) lines.push(`${inner}${helpUrl}`);
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function section(title, findings, note) {
|
|
91
|
+
if (findings.length === 0) return [];
|
|
92
|
+
const lines = ['', `${title} (${findings.length})${note ? ` — ${note}` : ''}`];
|
|
93
|
+
for (const group of groupFindings(findings)) {
|
|
94
|
+
lines.push(...formatGroup(group));
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {object} report the JSON report
|
|
101
|
+
* @param {{quiet?:boolean}} [opts]
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function formatHuman(report, opts = {}) {
|
|
105
|
+
const { quiet = false } = opts;
|
|
106
|
+
const { summary, findings, tool, target, manualChecklist = [] } = report;
|
|
107
|
+
|
|
108
|
+
if (quiet) {
|
|
109
|
+
return [
|
|
110
|
+
`${summary.verdict} ${BULLET} ${summary.needsReview} to review ${BULLET} ` +
|
|
111
|
+
`${summary.bestPractice} best-practice ${BULLET} ${manualChecklist.length} manual checks`,
|
|
112
|
+
'',
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push(`a11y-loop audit ${BULLET} ${describeTarget(target)}`);
|
|
118
|
+
lines.push(`Target standard: ${summary.target}`);
|
|
119
|
+
|
|
120
|
+
lines.push(...section('VIOLATIONS', findings.violations));
|
|
121
|
+
lines.push(
|
|
122
|
+
...section(
|
|
123
|
+
'NEEDS REVIEW',
|
|
124
|
+
findings.needsReview,
|
|
125
|
+
'axe reported these as incomplete, or a11y-loop found something it cannot judge alone',
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
lines.push(...section('BEST PRACTICE', findings.bestPractice, 'no success criterion, non-blocking'));
|
|
129
|
+
|
|
130
|
+
if (manualChecklist.length > 0) {
|
|
131
|
+
lines.push('', `MANUAL CHECKS (${manualChecklist.length}) — automation cannot judge these`);
|
|
132
|
+
for (const item of manualChecklist) {
|
|
133
|
+
const sc = item.sc ? `SC ${item.sc}: ` : '';
|
|
134
|
+
lines.push(` ${BULLET} ${sc}${item.text}`);
|
|
135
|
+
lines.push(` why: ${item.why}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
lines.push('');
|
|
140
|
+
if (summary.violations === 0) {
|
|
141
|
+
lines.push(`${CLEAN_VERDICT} across ${tool.passesRun.length} rendering passes.`);
|
|
142
|
+
} else {
|
|
143
|
+
lines.push(
|
|
144
|
+
`${summary.verdict}, ${summary.needsReview} finding(s) needing review, ` +
|
|
145
|
+
`${summary.bestPractice} best-practice note(s).`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (summary.needsReview > 0) {
|
|
149
|
+
lines.push(
|
|
150
|
+
`The ${summary.needsReview} needs-review finding(s) are not passes — see the NEEDS REVIEW ` +
|
|
151
|
+
'section above, or the needsReview array in --json output.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push(provenanceLine(tool));
|
|
157
|
+
lines.push(COVERAGE.statement);
|
|
158
|
+
lines.push('');
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Per-report provenance: everything needed to reproduce or date the result. */
|
|
163
|
+
export function provenanceLine(tool) {
|
|
164
|
+
const parts = [
|
|
165
|
+
`${tool.name} ${tool.version}`,
|
|
166
|
+
`axe-core ${tool.axeCoreVersion}`,
|
|
167
|
+
`${tool.browser} ${tool.browserVersion}`,
|
|
168
|
+
`viewport ${tool.viewport.width}×${tool.viewport.height}`,
|
|
169
|
+
`passes: ${tool.passesRun.join(', ')}`,
|
|
170
|
+
];
|
|
171
|
+
if (tool.statesRun?.length) parts.push(`states: ${tool.statesRun.join(', ')}`);
|
|
172
|
+
else parts.push('states: none (use --interact to audit modal/error/route states)');
|
|
173
|
+
parts.push(tool.timestamp);
|
|
174
|
+
return `Provenance: ${parts.join(` ${BULLET} `)}`;
|
|
175
|
+
}
|