@surea11y/core 1.2.0 → 1.4.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/CHANGELOG.md +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
package/src/index.js
CHANGED
package/src/report.js
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Renders one scan result into a single, self-contained HTML report (no
|
|
5
7
|
* external CSS/JS/fonts -- opens straight from disk, no server, no network).
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* grouped "worth reviewing" cards with an overflow cap, collapsible
|
|
11
|
-
* "full technical data" with a scorecard + searchable/filterable/paginated
|
|
12
|
-
* table, dark-mode CSS) is generic and reusable; everything here is written
|
|
13
|
-
* fresh for a single-engine scan result, not copied, since that tool's own
|
|
14
|
-
* organizing principle (a 7-way "does surea11y vs axe-core agree" taxonomy)
|
|
15
|
-
* has no single-engine analog.
|
|
9
|
+
* Self-contained single file: hero bar + legend, grouped "worth reviewing"
|
|
10
|
+
* cards with an overflow cap, collapsible "full technical data" with a
|
|
11
|
+
* scorecard + searchable/filterable/paginated table, dark-mode CSS.
|
|
16
12
|
*/
|
|
17
13
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// 4 outcomes, so no new palette is needed.
|
|
14
|
+
// Uses the dataviz skill's validated status palette, mapped 1:1 onto this
|
|
15
|
+
// engine's own 4 outcomes.
|
|
21
16
|
const STATUS = {
|
|
22
17
|
good: { color: '#0ca30c', bg: '#e9f7e9', icon: '✓' },
|
|
23
18
|
serious: { color: '#c1502e', bg: '#fdece5', icon: '⚠' },
|
|
@@ -26,8 +21,7 @@ const STATUS = {
|
|
|
26
21
|
};
|
|
27
22
|
|
|
28
23
|
// fail first: a QA tester scanning a report wants violations up front,
|
|
29
|
-
//
|
|
30
|
-
// of leading with violations, not passes.
|
|
24
|
+
// not passes.
|
|
31
25
|
const OUTCOME_ORDER = ['fail', 'cantTell', 'pass', 'notApplicable'];
|
|
32
26
|
const OUTCOME_INFO = {
|
|
33
27
|
fail: { label: 'Fail', status: 'serious', defaultOn: true },
|
|
@@ -40,7 +34,10 @@ for (const code of OUTCOME_ORDER) {
|
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
function esc(s) {
|
|
43
|
-
return String(s == null ? '' : s).replace(
|
|
37
|
+
return String(s == null ? '' : s).replace(
|
|
38
|
+
/[&<>"']/g,
|
|
39
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
|
40
|
+
);
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
// Neutralizes '<' so embedded JSON can never break out of its <script> tag,
|
|
@@ -61,6 +58,32 @@ function countByOutcome(checksResults) {
|
|
|
61
58
|
return counts;
|
|
62
59
|
}
|
|
63
60
|
|
|
61
|
+
function getOccurrenceOutcome(ruleResult, occurrence) {
|
|
62
|
+
const occurrenceOutcome =
|
|
63
|
+
occurrence &&
|
|
64
|
+
(occurrence.occurrenceOutcome === 'fail' || occurrence.occurrenceOutcome === 'cantTell'
|
|
65
|
+
? occurrence.occurrenceOutcome
|
|
66
|
+
: occurrence.outcome === 'fail' || occurrence.outcome === 'cantTell'
|
|
67
|
+
? occurrence.outcome
|
|
68
|
+
: null);
|
|
69
|
+
if (occurrenceOutcome) return occurrenceOutcome;
|
|
70
|
+
return (
|
|
71
|
+
ruleResult &&
|
|
72
|
+
(ruleResult.outcome === 'fail' || ruleResult.outcome === 'cantTell' ? ruleResult.outcome : null)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getCardOutcome(ruleResult) {
|
|
77
|
+
if (!ruleResult || !Array.isArray(ruleResult.occurrences)) return ruleResult.outcome;
|
|
78
|
+
let hasCantTell = false;
|
|
79
|
+
for (const occ of ruleResult.occurrences) {
|
|
80
|
+
const outcome = getOccurrenceOutcome(ruleResult, occ);
|
|
81
|
+
if (outcome === 'fail') return 'fail';
|
|
82
|
+
if (outcome === 'cantTell') hasCantTell = true;
|
|
83
|
+
}
|
|
84
|
+
return hasCantTell ? 'cantTell' : ruleResult.outcome;
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
// One plain-language headline + one horizontal stacked bar + a legend with
|
|
65
88
|
// icon+label+count (status color is never the only signal) -- the first
|
|
66
89
|
// thing a reader sees; exhaustive detail lives in the collapsed
|
|
@@ -69,8 +92,7 @@ function renderHeroBar(byOutcome) {
|
|
|
69
92
|
const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0) || 1;
|
|
70
93
|
const applicable = byOutcome.pass + byOutcome.fail + byOutcome.cantTell;
|
|
71
94
|
|
|
72
|
-
const segments = OUTCOME_ORDER
|
|
73
|
-
.filter((c) => byOutcome[c] > 0)
|
|
95
|
+
const segments = OUTCOME_ORDER.filter((c) => byOutcome[c] > 0)
|
|
74
96
|
.map((c) => {
|
|
75
97
|
const n = byOutcome[c];
|
|
76
98
|
const pct = (n / total) * 100;
|
|
@@ -79,17 +101,15 @@ function renderHeroBar(byOutcome) {
|
|
|
79
101
|
})
|
|
80
102
|
.join('');
|
|
81
103
|
|
|
82
|
-
const legend = OUTCOME_ORDER
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return `<div class="hero-legend-item">
|
|
104
|
+
const legend = OUTCOME_ORDER.map((c) => {
|
|
105
|
+
const n = byOutcome[c];
|
|
106
|
+
const info = OUTCOME_INFO[c];
|
|
107
|
+
return `<div class="hero-legend-item">
|
|
87
108
|
<span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
|
|
88
109
|
<span class="hero-legend-label">${esc(info.label)}</span>
|
|
89
110
|
<span class="hero-legend-count">${n.toLocaleString()} (${fmtPct(n, total)})</span>
|
|
90
111
|
</div>`;
|
|
91
|
-
|
|
92
|
-
.join('\n');
|
|
112
|
+
}).join('\n');
|
|
93
113
|
|
|
94
114
|
let headline;
|
|
95
115
|
if (byOutcome.fail > 0) {
|
|
@@ -134,7 +154,8 @@ function renderWcagRollup(rulesResults) {
|
|
|
134
154
|
|
|
135
155
|
const byLevel = { A: [], AA: [], AAA: [], other: [] };
|
|
136
156
|
for (const r of rulesResults) {
|
|
137
|
-
const mapping =
|
|
157
|
+
const mapping =
|
|
158
|
+
(r.meta && Array.isArray(r.meta.normativeMappings) && r.meta.normativeMappings[0]) || null;
|
|
138
159
|
const level = (mapping && mapping.level) || 'other';
|
|
139
160
|
(byLevel[level] || byLevel.other).push({ rule: r, mapping });
|
|
140
161
|
}
|
|
@@ -143,7 +164,13 @@ function renderWcagRollup(rulesResults) {
|
|
|
143
164
|
.filter((level) => byLevel[level].length)
|
|
144
165
|
.map((level) => {
|
|
145
166
|
const rows = byLevel[level]
|
|
146
|
-
.sort((a, b) =>
|
|
167
|
+
.sort((a, b) =>
|
|
168
|
+
(a.mapping ? a.mapping.requirement : '').localeCompare(
|
|
169
|
+
b.mapping ? b.mapping.requirement : '',
|
|
170
|
+
undefined,
|
|
171
|
+
{ numeric: true }
|
|
172
|
+
)
|
|
173
|
+
)
|
|
147
174
|
.map(({ rule, mapping }) => {
|
|
148
175
|
const info = OUTCOME_INFO[rule.outcome] || OUTCOME_INFO.notApplicable;
|
|
149
176
|
const metrics = (rule.data && rule.data.details && rule.data.details.metrics) || {};
|
|
@@ -176,8 +203,12 @@ function renderWcagRollup(rulesResults) {
|
|
|
176
203
|
// the findings table below shows the untruncated value.
|
|
177
204
|
const CARD_SNIPPET_MAX = 220;
|
|
178
205
|
function truncateForCard(s) {
|
|
179
|
-
const collapsed = String(s || '')
|
|
180
|
-
|
|
206
|
+
const collapsed = String(s || '')
|
|
207
|
+
.replace(/\s+/g, ' ')
|
|
208
|
+
.trim();
|
|
209
|
+
return collapsed.length > CARD_SNIPPET_MAX
|
|
210
|
+
? `${collapsed.slice(0, CARD_SNIPPET_MAX)}…`
|
|
211
|
+
: collapsed;
|
|
181
212
|
}
|
|
182
213
|
|
|
183
214
|
// One card per rule (not per occurrence) -- a rule with many occurrences of
|
|
@@ -185,42 +216,69 @@ function truncateForCard(s) {
|
|
|
185
216
|
const MAX_CARDS = 24;
|
|
186
217
|
|
|
187
218
|
function renderCards(checksResults) {
|
|
188
|
-
const withIssues = checksResults.filter(
|
|
219
|
+
const withIssues = checksResults.filter(
|
|
220
|
+
(r) =>
|
|
221
|
+
Array.isArray(r.occurrences) &&
|
|
222
|
+
r.occurrences.some((occ) => {
|
|
223
|
+
const outcome = getOccurrenceOutcome(r, occ);
|
|
224
|
+
return outcome === 'fail' || outcome === 'cantTell';
|
|
225
|
+
})
|
|
226
|
+
);
|
|
189
227
|
if (!withIssues.length) {
|
|
190
228
|
return '<p class="note">No fail/cantTell rules with occurrences on this scan.</p>';
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
const sorted = withIssues.slice().sort((a, b) => {
|
|
194
|
-
|
|
232
|
+
const aOutcome = getCardOutcome(a);
|
|
233
|
+
const bOutcome = getCardOutcome(b);
|
|
234
|
+
if (aOutcome !== bOutcome) return aOutcome === 'fail' ? -1 : 1;
|
|
195
235
|
return b.occurrences.length - a.occurrences.length;
|
|
196
236
|
});
|
|
197
237
|
const shown = sorted.slice(0, MAX_CARDS);
|
|
198
238
|
|
|
199
|
-
const cards = shown
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
const cards = shown
|
|
240
|
+
.map((r) => {
|
|
241
|
+
const cardOutcome = getCardOutcome(r);
|
|
242
|
+
const info = OUTCOME_INFO[cardOutcome] || OUTCOME_INFO.notApplicable;
|
|
243
|
+
const occurrenceCounts = { fail: 0, cantTell: 0 };
|
|
244
|
+
for (const occ of r.occurrences) {
|
|
245
|
+
const outcome = getOccurrenceOutcome(r, occ);
|
|
246
|
+
if (outcome === 'fail' || outcome === 'cantTell') occurrenceCounts[outcome] += 1;
|
|
247
|
+
}
|
|
248
|
+
const representative =
|
|
249
|
+
r.occurrences.find((occ) => getOccurrenceOutcome(r, occ) === cardOutcome) ||
|
|
250
|
+
r.occurrences[0];
|
|
251
|
+
const wcagChips = ((r.meta && r.meta.normativeMappings) || [])
|
|
252
|
+
.map(
|
|
253
|
+
(m) =>
|
|
254
|
+
`<span class="chip" style="background:${STATUS.neutral.bg};color:${STATUS.neutral.color}">WCAG ${esc(m.requirement)}</span>`
|
|
255
|
+
)
|
|
256
|
+
.join('');
|
|
257
|
+
const hasMixedOutcomes = occurrenceCounts.fail > 0 && occurrenceCounts.cantTell > 0;
|
|
258
|
+
const countLabel =
|
|
259
|
+
r.occurrences.length > 1
|
|
260
|
+
? ` <span class="card-count">× ${r.occurrences.length.toLocaleString()}${hasMixedOutcomes ? ` (${occurrenceCounts.fail.toLocaleString()} fail / ${occurrenceCounts.cantTell.toLocaleString()} needs review)` : ''}</span>`
|
|
261
|
+
: '';
|
|
262
|
+
|
|
263
|
+
return `<div class="card">
|
|
208
264
|
<div class="card-head">
|
|
209
265
|
<span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
|
|
210
266
|
<span class="card-title"><strong>${esc(r.ruleId)}</strong> (${esc(info.label)}, ${esc(r.severity)})${countLabel}</span>
|
|
211
267
|
</div>
|
|
212
268
|
<div class="card-body">
|
|
213
269
|
<div class="card-meta">${wcagChips}</div>
|
|
214
|
-
<div class="card-selector"><span class="card-selector-label">Selector:</span> <code>${esc(
|
|
215
|
-
<div class="card-snippet">${esc(
|
|
270
|
+
<div class="card-selector"><span class="card-selector-label">Selector:</span> <code>${esc(representative.selector || '(none)')}</code></div>
|
|
271
|
+
<div class="card-snippet">${esc(representative.summary)}${representative.hint ? ` — ${esc(representative.hint)}` : ''}</div>
|
|
216
272
|
${r.occurrences.length > 1 ? `<p class="card-note">Selector/summary above are from one representative occurrence — ${r.occurrences.length.toLocaleString()} total on this rule.</p>` : ''}
|
|
217
273
|
</div>
|
|
218
274
|
</div>`;
|
|
219
|
-
|
|
275
|
+
})
|
|
276
|
+
.join('\n');
|
|
220
277
|
|
|
221
|
-
const overflow =
|
|
222
|
-
|
|
223
|
-
|
|
278
|
+
const overflow =
|
|
279
|
+
sorted.length > MAX_CARDS
|
|
280
|
+
? `<p class="note">Showing the ${MAX_CARDS} highest-priority rules of ${sorted.length.toLocaleString()} with issues — see the full technical data below for the rest.</p>`
|
|
281
|
+
: '';
|
|
224
282
|
|
|
225
283
|
return `<div class="cards">${cards}</div>${overflow}`;
|
|
226
284
|
}
|
|
@@ -233,9 +291,10 @@ function flattenOccurrences(checksResults) {
|
|
|
233
291
|
for (const r of checksResults) {
|
|
234
292
|
if (!Array.isArray(r.occurrences)) continue;
|
|
235
293
|
for (const occ of r.occurrences) {
|
|
294
|
+
const occurrenceOutcome = getOccurrenceOutcome(r, occ);
|
|
236
295
|
rows.push({
|
|
237
296
|
ruleId: r.ruleId,
|
|
238
|
-
outcome: r.outcome,
|
|
297
|
+
outcome: occurrenceOutcome || r.outcome,
|
|
239
298
|
severity: r.severity,
|
|
240
299
|
selector: occ.selector || '',
|
|
241
300
|
html: occ.html || '',
|
|
@@ -251,7 +310,10 @@ function renderHtmlReport(result, options = {}) {
|
|
|
251
310
|
const checksResults = Array.isArray(result && result.checksResults) ? result.checksResults : [];
|
|
252
311
|
const rulesResults = Array.isArray(result && result.rulesResults) ? result.rulesResults : [];
|
|
253
312
|
const byOutcome = countByOutcome(checksResults);
|
|
254
|
-
const generatedAtLabel = new Date().toLocaleString('en-US', {
|
|
313
|
+
const generatedAtLabel = new Date().toLocaleString('en-US', {
|
|
314
|
+
dateStyle: 'medium',
|
|
315
|
+
timeStyle: 'medium'
|
|
316
|
+
});
|
|
255
317
|
const title = (options && options.title) || 'surea11y scan report';
|
|
256
318
|
|
|
257
319
|
const defaultOnList = OUTCOME_ORDER.filter((c) => OUTCOME_INFO[c].defaultOn);
|
package/src/sarif.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders one scan result as a SARIF 2.1.0 log (docs/SARIF.md) for GitHub
|
|
7
|
+
* Code Scanning / other SARIF-consuming dashboards -- a different output
|
|
8
|
+
* shape from --json's raw result, purpose-built for that ecosystem (see
|
|
9
|
+
* docs/CLI.md's --sarif flag).
|
|
10
|
+
*
|
|
11
|
+
* Only fail/cantTell occurrences become SARIF results (a pass/notApplicable
|
|
12
|
+
* result has no occurrences at all -- see docs/OUTPUT_SCHEMA.md), the same
|
|
13
|
+
* "violations only" framing docs/REPORT.md's HTML report already uses.
|
|
14
|
+
* fail -> SARIF level "error" (the CI-gating case); cantTell -> "warning"
|
|
15
|
+
* (surfaced, non-blocking -- this engine's own cantTell/manual-review mental
|
|
16
|
+
* model, docs/TROUBLESHOOTING.md).
|
|
17
|
+
*
|
|
18
|
+
* If `baselineEntries` is supplied (mirrors --baseline, docs/BASELINE.md),
|
|
19
|
+
* fail occurrences already recorded there are omitted entirely rather than
|
|
20
|
+
* downgraded -- a generic SARIF consumer has no "known, don't re-gate"
|
|
21
|
+
* concept of its own, so the only faithful way to honor a baseline here is
|
|
22
|
+
* to not report the occurrence at all. cantTell occurrences are never
|
|
23
|
+
* baseline-filtered (the baseline mechanism only ever tracks fail
|
|
24
|
+
* occurrences, matching --write-baseline).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { computeBaselineKey, getReasonCode } = require('./baseline.js');
|
|
29
|
+
|
|
30
|
+
const SARIF_SCHEMA_URI =
|
|
31
|
+
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/Schemata/sarif-schema-2.1.0.json';
|
|
32
|
+
const SARIF_VERSION = '2.1.0';
|
|
33
|
+
|
|
34
|
+
function artifactUriFromResult(result) {
|
|
35
|
+
const url = result && result.url;
|
|
36
|
+
if (!url) return 'about:blank';
|
|
37
|
+
if (url.startsWith('file://')) {
|
|
38
|
+
const filePath = url.slice('file://'.length);
|
|
39
|
+
const rel = path.relative(process.cwd(), filePath).split(path.sep).join('/');
|
|
40
|
+
// A file outside the cwd (e.g. an absolute path elsewhere on disk)
|
|
41
|
+
// produces a "../"-leading relative path -- still valid as a URI, just
|
|
42
|
+
// not resolvable as a repo-relative one by a SARIF consumer like GitHub
|
|
43
|
+
// Code Scanning (see docs/SARIF.md's known limitations).
|
|
44
|
+
return rel || filePath;
|
|
45
|
+
}
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRemainingBaselineMap(baselineEntries) {
|
|
50
|
+
const remaining = new Map();
|
|
51
|
+
for (const entry of Array.isArray(baselineEntries) ? baselineEntries : []) {
|
|
52
|
+
if (!entry) continue;
|
|
53
|
+
const key = computeBaselineKey(
|
|
54
|
+
entry.ruleId,
|
|
55
|
+
entry.reasonCode || 'DEFAULT',
|
|
56
|
+
typeof entry.html === 'string' ? entry.html : ''
|
|
57
|
+
);
|
|
58
|
+
remaining.set(key, (remaining.get(key) || 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
return remaining;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function wcagTags(check) {
|
|
64
|
+
const mappings = (check.meta && check.meta.normativeMappings) || [];
|
|
65
|
+
const tags = new Set(['accessibility', check.type === 'automatic' ? 'automatic' : 'manual']);
|
|
66
|
+
for (const m of mappings) {
|
|
67
|
+
if (m && m.requirement) tags.add(`wcag-${m.requirement}`);
|
|
68
|
+
}
|
|
69
|
+
return Array.from(tags);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildRule(check) {
|
|
73
|
+
return {
|
|
74
|
+
id: check.ruleId,
|
|
75
|
+
name: check.ruleId,
|
|
76
|
+
shortDescription: { text: check.title || check.ruleId },
|
|
77
|
+
fullDescription: { text: check.description || check.title || check.ruleId },
|
|
78
|
+
// type: "manual" rules are capped at cantTell (never fail), so their
|
|
79
|
+
// worst-case, rule-level default is "warning"; automatic rules can
|
|
80
|
+
// reach "error" -- see docs/OUTPUT_SCHEMA.md's outcome/type table.
|
|
81
|
+
defaultConfiguration: { level: check.type === 'automatic' ? 'error' : 'warning' },
|
|
82
|
+
properties: { tags: wcagTags(check) }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildResult(check, occurrence, level, artifactUri) {
|
|
87
|
+
const reasonCode = getReasonCode(occurrence);
|
|
88
|
+
const html = typeof occurrence.html === 'string' ? occurrence.html : '';
|
|
89
|
+
const message = occurrence.hint ? `${occurrence.summary} ${occurrence.hint}` : occurrence.summary;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ruleId: check.ruleId,
|
|
93
|
+
level,
|
|
94
|
+
message: { text: message },
|
|
95
|
+
locations: [
|
|
96
|
+
{
|
|
97
|
+
physicalLocation: { artifactLocation: { uri: artifactUri } },
|
|
98
|
+
...(occurrence.selector
|
|
99
|
+
? { logicalLocations: [{ fullyQualifiedName: occurrence.selector, kind: 'element' }] }
|
|
100
|
+
: {})
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
partialFingerprints: {
|
|
104
|
+
'surea11y/violation/v1': computeBaselineKey(check.ruleId, reasonCode, html)
|
|
105
|
+
},
|
|
106
|
+
properties: {
|
|
107
|
+
severity: check.severity,
|
|
108
|
+
confidence: check.confidence,
|
|
109
|
+
reasonCode,
|
|
110
|
+
html
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getOccurrenceOutcome(check, occurrence) {
|
|
116
|
+
const occurrenceOutcome =
|
|
117
|
+
occurrence &&
|
|
118
|
+
(occurrence.occurrenceOutcome === 'fail' || occurrence.occurrenceOutcome === 'cantTell'
|
|
119
|
+
? occurrence.occurrenceOutcome
|
|
120
|
+
: occurrence.outcome === 'fail' || occurrence.outcome === 'cantTell'
|
|
121
|
+
? occurrence.outcome
|
|
122
|
+
: null);
|
|
123
|
+
if (occurrenceOutcome) return occurrenceOutcome;
|
|
124
|
+
return check && (check.outcome === 'fail' || check.outcome === 'cantTell') ? check.outcome : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderSarifReport(result, options = {}) {
|
|
128
|
+
const { toolVersion, informationUri, baselineEntries } = options;
|
|
129
|
+
const artifactUri = artifactUriFromResult(result);
|
|
130
|
+
const remaining = buildRemainingBaselineMap(baselineEntries);
|
|
131
|
+
|
|
132
|
+
const rules = [];
|
|
133
|
+
const seenRuleIds = new Set();
|
|
134
|
+
const failResults = [];
|
|
135
|
+
const cantTellResults = [];
|
|
136
|
+
|
|
137
|
+
for (const check of (result && result.checksResults) || []) {
|
|
138
|
+
if (!check || !Array.isArray(check.occurrences)) continue;
|
|
139
|
+
|
|
140
|
+
if (!seenRuleIds.has(check.ruleId)) {
|
|
141
|
+
seenRuleIds.add(check.ruleId);
|
|
142
|
+
rules.push(buildRule(check));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (check.outcome !== 'fail' && check.outcome !== 'cantTell') continue;
|
|
146
|
+
|
|
147
|
+
for (const occurrence of check.occurrences) {
|
|
148
|
+
if (!occurrence) continue;
|
|
149
|
+
|
|
150
|
+
const occurrenceOutcome = getOccurrenceOutcome(check, occurrence);
|
|
151
|
+
if (occurrenceOutcome === 'fail') {
|
|
152
|
+
const reasonCode = getReasonCode(occurrence);
|
|
153
|
+
const html = typeof occurrence.html === 'string' ? occurrence.html : '';
|
|
154
|
+
const key = computeBaselineKey(check.ruleId, reasonCode, html);
|
|
155
|
+
const left = remaining.get(key) || 0;
|
|
156
|
+
if (left > 0) {
|
|
157
|
+
remaining.set(key, left - 1);
|
|
158
|
+
continue; // already known via the baseline -- omit, don't re-gate
|
|
159
|
+
}
|
|
160
|
+
failResults.push(buildResult(check, occurrence, 'error', artifactUri));
|
|
161
|
+
} else if (occurrenceOutcome === 'cantTell') {
|
|
162
|
+
cantTellResults.push(buildResult(check, occurrence, 'warning', artifactUri));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sarifLog = {
|
|
168
|
+
$schema: SARIF_SCHEMA_URI,
|
|
169
|
+
version: SARIF_VERSION,
|
|
170
|
+
runs: [
|
|
171
|
+
{
|
|
172
|
+
tool: {
|
|
173
|
+
driver: {
|
|
174
|
+
name: 'surea11y',
|
|
175
|
+
informationUri: informationUri || 'https://github.com/SureA11y/core',
|
|
176
|
+
version: toolVersion || '0.0.0',
|
|
177
|
+
rules
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
// fail first: matches docs/REPORT.md's own "violations before advisory
|
|
181
|
+
// findings" ordering.
|
|
182
|
+
results: [...failResults, ...cantTellResults]
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return JSON.stringify(sarifLog, null, 2) + '\n';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { renderSarifReport };
|