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.
Files changed (36) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +409 -0
  3. package/THIRD-PARTY-NOTICES.md +32 -0
  4. package/package.json +51 -0
  5. package/skill/a11y-loop/SKILL.md +332 -0
  6. package/skill/a11y-loop/evals/evals.json +168 -0
  7. package/skill/a11y-loop/evals/trigger-evals.json +20 -0
  8. package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
  9. package/skill/a11y-loop/references/apg-patterns.md +264 -0
  10. package/skill/a11y-loop/references/manual-testing.md +224 -0
  11. package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
  12. package/src/cli.js +207 -0
  13. package/src/commands/audit.js +125 -0
  14. package/src/commands/contrast.js +141 -0
  15. package/src/commands/diff.js +65 -0
  16. package/src/lib/axe-runner.js +400 -0
  17. package/src/lib/browser-utils.js +221 -0
  18. package/src/lib/checks/dialog.js +341 -0
  19. package/src/lib/checks/div-button.js +87 -0
  20. package/src/lib/checks/focus-visible.js +296 -0
  21. package/src/lib/checks/keyboard.js +235 -0
  22. package/src/lib/checks/link-text.js +83 -0
  23. package/src/lib/checks/reduced-motion.js +139 -0
  24. package/src/lib/checks/reflow.js +101 -0
  25. package/src/lib/checks/target-size.js +128 -0
  26. package/src/lib/contrast-math.js +189 -0
  27. package/src/lib/diff.js +118 -0
  28. package/src/lib/finding.js +164 -0
  29. package/src/lib/fingerprint.js +0 -0
  30. package/src/lib/format/checklist.js +281 -0
  31. package/src/lib/format/human.js +175 -0
  32. package/src/lib/format/json.js +139 -0
  33. package/src/lib/format/sarif.js +111 -0
  34. package/src/lib/serve.js +189 -0
  35. package/src/lib/suggest-color.js +169 -0
  36. 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
+ }