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