@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.
Files changed (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
package/src/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  // surea11y public API: export the generated core surface verbatim
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
- * Structurally adapted from the sibling cross-engine-diff project's own HTML
8
- * report tool (~/dev/surea11y/comparisons/cross-engine/render-html-report.js)
9
- * -- that tool's shell (self-contained single file, hero bar + legend,
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
- // Same 4-status vocabulary/palette as the reference tool (the dataviz
19
- // skill's validated status palette) -- maps 1:1 onto this engine's own
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
- // matching axe-html-reporter/accessibility-insights-report's own convention
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(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
37
+ return String(s == null ? '' : s).replace(
38
+ /[&<>"']/g,
39
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
- .map((c) => {
84
- const n = byOutcome[c];
85
- const info = OUTCOME_INFO[c];
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 = (r.meta && Array.isArray(r.meta.normativeMappings) && r.meta.normativeMappings[0]) || null;
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) => (a.mapping ? a.mapping.requirement : '').localeCompare(b.mapping ? b.mapping.requirement : '', undefined, { numeric: true }))
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 || '').replace(/\s+/g, ' ').trim();
180
- return collapsed.length > CARD_SNIPPET_MAX ? `${collapsed.slice(0, CARD_SNIPPET_MAX)}…` : collapsed;
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((r) => (r.outcome === 'fail' || r.outcome === 'cantTell') && Array.isArray(r.occurrences) && r.occurrences.length > 0);
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
- if (a.outcome !== b.outcome) return a.outcome === 'fail' ? -1 : 1;
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.map((r) => {
200
- const info = OUTCOME_INFO[r.outcome];
201
- const occ = r.occurrences[0];
202
- const wcagChips = ((r.meta && r.meta.normativeMappings) || [])
203
- .map((m) => `<span class="chip" style="background:${STATUS.neutral.bg};color:${STATUS.neutral.color}">WCAG ${esc(m.requirement)}</span>`)
204
- .join('');
205
- const countLabel = r.occurrences.length > 1 ? ` <span class="card-count">× ${r.occurrences.length.toLocaleString()}</span>` : '';
206
-
207
- return `<div class="card">
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(occ.selector || '(none)')}</code></div>
215
- <div class="card-snippet">${esc(occ.summary)}${occ.hint ? ` — ${esc(occ.hint)}` : ''}</div>
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
- }).join('\n');
275
+ })
276
+ .join('\n');
220
277
 
221
- const overflow = sorted.length > MAX_CARDS
222
- ? `<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>`
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', { dateStyle: 'medium', timeStyle: 'medium' });
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 };