@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,237 @@
1
+ # Output schema reference
2
+
3
+ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `runa11yCoreInPage(...)` (see [`INTEGRATION.md`](./INTEGRATION.md) for which one to call). Every example on this page is real output from the current engine (`schemaVersion: "1.0.0"`), not hand-written. `runa11yCoreAcrossFrames` returns a different, recursive shape wrapping this one — see [Cross-frame result](#cross-frame-result-runa11ycoreacrossframes) below.
4
+
5
+ - [Top-level result](#top-level-result)
6
+ - [Cross-frame result (`runa11yCoreAcrossFrames`)](#cross-frame-result-runa11ycoreacrossframes)
7
+ - [A check result (`checksResults[i]`)](#a-check-result-checksresultsi)
8
+ - [An occurrence (`occurrences[i]`)](#an-occurrence-occurrencesi)
9
+ - [A composite result (`rulesResults[i]`)](#a-composite-result-rulesresultsi)
10
+ - [Outcome values](#outcome-values)
11
+ - [Severity and confidence values](#severity-and-confidence-values)
12
+ - [Worked example](#worked-example)
13
+
14
+ ## Top-level result
15
+
16
+ ```ts
17
+ {
18
+ engine: { tag: string, schemaVersion: string },
19
+ url: string | null,
20
+ title: string | null,
21
+ timestamp: string | null,
22
+ perfStats: object | null,
23
+ contextSelector: string | string[] | null,
24
+ checksResults: CheckResult[],
25
+ rulesResults: CompositeResult[],
26
+ overriddenBuiltinIds: string[]
27
+ }
28
+ ```
29
+
30
+ | Field | Meaning |
31
+ |---|---|
32
+ | `engine.tag` | The engine's own identity tag, currently `"a11ycore"`. Every rule (built-in or custom) carries it in `meta.tags` — rule `ruleId`s themselves are bare (no prefix). |
33
+ | `engine.schemaVersion` | The result-schema version (`"1.0.0"`). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. |
34
+ | `url` | The `pageUrl` argument you passed in, or `document.location.href` if you passed `null`/omitted it, or `null` if neither is available. |
35
+ | `title` | `document.title` at scan time, or `null`. |
36
+ | `timestamp` | **Not auto-generated.** Only set if you pass `engineOptions.timestamp` as a non-empty string — the engine has no built-in clock (deterministic-by-design). If you want a scan timestamp in the result, supply it yourself. |
37
+ | `perfStats` | `null` unless `engineOptions.perfStats: true`. Internal timing/counters — shape not covered by this document, treat as debug-only. |
38
+ | `contextSelector` | The (trimmed) `contextSelector` argument you passed — a string, an array of strings (multi-region scanning, see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)), or `null` if none/empty. |
39
+ | `checksResults` | One entry per **atomic rule** that ran (every rule not filtered out by `runOnly` — see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)). **Every loaded rule produces an entry, even ones that outcome `notApplicable`** — this is not a "violations only" list. |
40
+ | `rulesResults` | One entry per **composite (WCAG-SC rollup) rule** that ran — see [Composite result](#a-composite-result-rulesresultsi) and [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md). Empty array if no composite matched the current `runOnly`/tag filter. |
41
+ | `overriddenBuiltinIds` | Rule ids where an `engineOptions.customRules` entry shared its `id` with a built-in rule, so the custom implementation replaced the built-in one for this scan (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)). Always an array; empty when no collision occurred. Also logged via `console.warn` at scan time, since a same-named custom rule is as likely to be an accidental collision as a deliberate override. |
42
+
43
+ ## Cross-frame result (`runa11yCoreAcrossFrames`)
44
+
45
+ `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) returns a different, recursive shape instead of a plain top-level result:
46
+
47
+ ```ts
48
+ {
49
+ topFrame: <the normal top-level result shape above>,
50
+ frames: Array<
51
+ | { url: string | null, topFrame: <top-level result>, frames: [...same shape, recursively] }
52
+ | { url: string | null, error: string }
53
+ >
54
+ }
55
+ ```
56
+
57
+ - `topFrame` is exactly the [top-level result](#top-level-result) shape, for the frame the function was called in.
58
+ - `frames` has one entry per direct child `<iframe>`/`<frame>` in the scanned scope. A reachable child (one that called `a11yCoreEnableFrameResponder()`) contributes its own complete `{ url, topFrame, frames }` — including *its own* nested `frames`, recursively, since a further-nested grandchild is only reachable through its immediate parent. An unreachable child (the common case for most third-party embeds — no cooperating responder, or it timed out) contributes `{ url, error }` instead, and does not abort the rest of the scan.
59
+ - This is a **tree, not a flat list** — a deliberate difference from the `surea11y-playwright` binding's `.frames(true)`, which *can* flatten because Playwright's `page.frames()` already gives every frame regardless of nesting depth; a `postMessage` relay has no such global view, so nesting is expressed structurally instead.
60
+
61
+ ## A check result (`checksResults[i]`)
62
+
63
+ ```ts
64
+ {
65
+ ruleId: string,
66
+ outcome: "pass" | "fail" | "cantTell" | "notApplicable",
67
+ outcomeNormalized: "pass" | "fail" | "cantTell" | "inapplicable",
68
+ severity: "minor" | "moderate" | "serious" | "critical",
69
+ confidence: "high" | "medium" | "low",
70
+ type: "automatic" | "manual",
71
+ occurrences: Occurrence[],
72
+ title: string,
73
+ description: string,
74
+ i18n: { titleKey: string, descriptionKey: string } | null,
75
+ meta: {
76
+ ruleId: string,
77
+ ruleInterfaceVersion: string,
78
+ ruleVersion: string,
79
+ normative: boolean,
80
+ atomic: boolean,
81
+ category: "perceivable" | "operable" | "understandable" | "robust" | null,
82
+ normativeMappings: Array<{ standard: string, version: string, requirement: string, title: string, conformanceLevel: string }>,
83
+ standard: string | null,
84
+ applicability: string,
85
+ expectation: string,
86
+ references: string[],
87
+ requirements: object | null,
88
+ mappings: object | null
89
+ },
90
+ engineOptions: object, // the resolved engineOptions this rule actually ran under
91
+ schemaVersion: string,
92
+ error?: string // present only if the rule threw — see below
93
+ }
94
+ ```
95
+
96
+ Notes:
97
+
98
+ - **`outcome` vs `outcomeNormalized`**: identical except `notApplicable` becomes `"inapplicable"` in `outcomeNormalized`. Both are provided so you can match either your own vocabulary or the engine's internal one.
99
+ - **`type: "manual"` rules can never report `outcome: "fail"`.** If a manual rule's own logic would have said `fail`, the engine coerces it to `cantTell` and appends an explanatory note to `error` — this is enforced centrally (`policy.coerceManualFailToCantTell`, on by default under the `a11y` policy contract; see [`POLICY.md`](./POLICY.md)), not something each rule has to remember. `fail` is reserved for deterministic, high-confidence, `type: "automatic"` findings only.
100
+ - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (other engines call these "Best Practices"; this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
101
+ - **`error`**: only present if the rule implementation threw an uncaught exception, or if the manual-fail coercion above fired. A thrown rule always surfaces as `outcome: "cantTell"` with `occurrences: []` and `error` set to the exception message — the engine never lets one broken rule crash the whole scan.
102
+ - **`engineOptions`** on each result is the *resolved* options object (after locale/contrast defaults were applied), not literally what you passed in — useful for confirming what a given rule actually saw, especially the resolved `locale` and `contrast.mode`/`contrast.rootCanvasFallback`.
103
+
104
+ ## An occurrence (`occurrences[i]`)
105
+
106
+ Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` result has `occurrences: []` — this engine does not enumerate the elements it passed, only the ones it flagged; see the note in `docs/RULE_AUTHORING.md` on why "silence" from a `pass` rule is not the same as an enumerated list of passing elements).
107
+
108
+ ```ts
109
+ {
110
+ selector: string,
111
+ html: string,
112
+ structuralPath: number[] | null,
113
+ summary: string,
114
+ hint: string,
115
+ i18n: { summaryKey: string, hintKey: string, params: object } | null,
116
+ data: {
117
+ visibilityFilter?: { targetSet: string, accEligible: boolean | null, reasons: string[] },
118
+ details?: object // rule-specific, non-normative — see below
119
+ }
120
+ }
121
+ ```
122
+
123
+ | Field | Meaning |
124
+ |---|---|
125
+ | `selector` | A best-effort CSS selector built to resolve back to the flagged element (see `helpers.buildSelector` in `RULE_AUTHORING.md`). Not guaranteed unique in adversarial DOM shapes, but the engine actively verifies it resolves to the reported element before using it. |
126
+ | `html` | An outer-HTML snippet of the flagged element — use this as your primary "which element" signal when `includeShadowDom: true` (selectors don't pierce shadow boundaries). |
127
+ | `structuralPath` | The flagged element's sibling-index path from `documentElement` down to it (e.g. `[1, 0, 2]`) — `[]` if the element *is* `documentElement`, `null` if it couldn't be determined. A more robust element-identity mechanism than `selector` alone: it survives DOM changes a selector string wouldn't (an id/class rename, for instance), at the cost of not being usable as an actual CSS selector. Computed from the element reference when the rule kept one, otherwise by re-resolving `selector` against the document (same caveat as `selector` itself: a non-unique selector could resolve to a different element than intended). |
128
+ | `summary` | Human-readable, already localized ("This button has no accessible name."). |
129
+ | `hint` | Human-readable remediation guidance, already localized. |
130
+ | `i18n` | The raw translation keys behind `summary`/`hint`, if you want to re-render them in a different locale yourself without re-running the scan. `null` if the occurrence didn't use key-based i18n. |
131
+ | `data.visibilityFilter` | Present on most occurrences: why the engine considered this element eligible for accessibility-tree evaluation (or not). `reasons` is a list of machine-readable exclusion codes when `accEligible: false`. |
132
+ | `data.details` | Rule-specific structured data (e.g. `reasonCode`, computed metrics, resolved references) — **non-normative**: useful for building richer UI or debugging, but never changes what `outcome`/`severity` mean. Shape varies per rule; treat as best-effort extra context, not a stable contract. |
133
+
134
+ ## A composite result (`rulesResults[i]`)
135
+
136
+ Composites roll multiple atomic rules up to one WCAG Success Criterion (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules). Shape is the same envelope as a check result, with composite-specific `data.details`:
137
+
138
+ ```ts
139
+ {
140
+ ruleId: string, // e.g. "wcag-1.1.1-non-text-content"
141
+ outcome: "pass" | "fail" | "cantTell" | "notApplicable",
142
+ severity, confidence, type, title, description, meta, engineOptions, schemaVersion, // same as a check result
143
+ occurrences: [], // always empty — composites are rollups, not element-level findings
144
+ data: {
145
+ details: {
146
+ reasonCode: string, // e.g. "composite.rollup.fail.anyFail"
147
+ checksIds: string[], // every atomic ruleId this composite rolls up
148
+ contributors: Array<{ testId: string, outcome: string, severity: string | null }>,
149
+ metrics: { failCount, cantTellCount, notApplicableCount, passCount, missingCount }
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ Rollup precedence (deterministic, in this order): **any contributor `fail` → composite `fail`**; else **any `cantTell` (or a contributor rule that didn't run at all, `missingCount > 0`) → composite `cantTell`**; else **all contributors `notApplicable` → composite `notApplicable`**; else **`pass`**. See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for what this means for an overall conformance claim.
156
+
157
+ ## Outcome values
158
+
159
+ | Outcome | Meaning | Can appear on `type: "manual"`? |
160
+ |---|---|---|
161
+ | `fail` | Deterministic, high-confidence, normative violation — no heuristics, no guessing. | No (coerced to `cantTell`) |
162
+ | `pass` | The rule's applicable target(s) exist and none were flagged. | Yes |
163
+ | `cantTell` | Requires human judgment — either genuinely ambiguous, or a `manual` rule's advisory finding. | Yes |
164
+ | `notApplicable` | The rule found no elements it applies to on this page/scope. | Yes |
165
+
166
+ `fail` is intentionally the narrowest, highest-bar outcome in this engine: reserved for deterministic, normative violations; chasing rule coverage must never dilute this.
167
+
168
+ ## Severity and confidence values
169
+
170
+ - `severity`: `minor` < `moderate` < `serious` < `critical` — the rule author's assessment of user impact, independent of `confidence`.
171
+ - `confidence`: `low` < `medium` < `high` — how certain the engine is that a `fail`/`cantTell` verdict is correct. Both are informational metadata for prioritization; neither changes `outcome`'s meaning.
172
+
173
+ ## Worked example
174
+
175
+ Scanning `<img src="logo.png">` (no `alt`) and `<button></button>` (no accessible name), scoped to just those two rules via `runOnly: { includeRuleIds: [...] }` (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md) — this is **not** a bare array):
176
+
177
+ ```js
178
+ const result = runDomRulesInPage(
179
+ 'https://example.test/',
180
+ null,
181
+ {},
182
+ { includeRuleIds: ['img-alt-present', 'button-name-present'] }
183
+ );
184
+ ```
185
+
186
+ ```json
187
+ {
188
+ "engine": { "tag": "a11ycore", "schemaVersion": "1.0.0" },
189
+ "url": "https://example.test/",
190
+ "title": "Example",
191
+ "timestamp": null,
192
+ "perfStats": null,
193
+ "contextSelector": null,
194
+ "checksResults": [
195
+ {
196
+ "ruleId": "button-name-present",
197
+ "outcome": "fail",
198
+ "severity": "serious",
199
+ "confidence": "high",
200
+ "type": "automatic",
201
+ "occurrences": [
202
+ {
203
+ "selector": "html > body > button",
204
+ "html": "<button></button>",
205
+ "structuralPath": [1, 0],
206
+ "summary": "This button has no accessible name.",
207
+ "hint": "Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.",
208
+ "data": {
209
+ "visibilityFilter": { "eligible": true, "reasons": [], "targetSet": "acc", "accEligible": true },
210
+ "details": { "reasonCode": "name_missing" }
211
+ }
212
+ }
213
+ ]
214
+ },
215
+ {
216
+ "ruleId": "img-alt-present",
217
+ "outcome": "fail",
218
+ "severity": "serious",
219
+ "confidence": "high",
220
+ "type": "automatic",
221
+ "occurrences": [
222
+ {
223
+ "selector": "html > body > img",
224
+ "html": "<img src=\"logo.png\">",
225
+ "structuralPath": [1, 1],
226
+ "summary": "Missing alt attribute on <img>.",
227
+ "hint": "Add an alt attribute (use alt=\"\" only for decorative images)."
228
+ }
229
+ ]
230
+ }
231
+ ],
232
+ "rulesResults": [],
233
+ "overriddenBuiltinIds": []
234
+ }
235
+ ```
236
+
237
+ (Trimmed for readability — the real result also includes `title`/`description`/`i18n`/`meta`/`engineOptions`/`schemaVersion` on every entry, per the full shape above. `rulesResults` is empty here because `runOnly.includeRuleIds` scoped the scan to two atomic rules and no composite's own ID was included.)
package/docs/POLICY.md ADDED
@@ -0,0 +1,71 @@
1
+ # Policy & contracts guide
2
+
3
+ A policy controls two things, independent of any individual rule's logic: **which outcome/confidence values are allowed to reach the result at all**, and **whether a `manual` rule's would-be `fail` gets coerced to `cantTell`**. It never changes what a rule decides — only how that decision is allowed to be represented.
4
+
5
+ ## The two built-in contracts
6
+
7
+ ```js
8
+ {
9
+ a11y: {
10
+ id: 'a11y',
11
+ allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
12
+ allowedConfidence: ['high', 'medium', 'low'],
13
+ coerceManualFailToCantTell: true // ← the only difference
14
+ },
15
+ generic: {
16
+ id: 'generic',
17
+ allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
18
+ allowedConfidence: ['high', 'medium', 'low'],
19
+ coerceManualFailToCantTell: false
20
+ }
21
+ }
22
+ ```
23
+
24
+ (Source of truth: `src/policy/contracts.js`.)
25
+
26
+ - **`a11y`** (the default): enforces the engine's core non-negotiable — a `type: 'manual'` rule (advisory/judgment-required, see [`RULE_CATALOG.md`](./RULE_CATALOG.md)) can never produce a `fail`. If a manual rule's own logic decides `fail`, the policy coerces it to `cantTell` and appends a note to the result's `error` field. Use this contract for anything where a `fail` result carries weight — CI gating, compliance reporting, anywhere someone might treat `fail` as "definitely broken."
27
+ - **`generic`**: identical outcome/confidence vocabulary, but does **not** coerce manual `fail`s. Only meaningful if you've deliberately reconfigured a manual rule to be more assertive than its default and want that respected — not a general-purpose "looser" mode.
28
+
29
+ ## Selecting a contract
30
+
31
+ ```js
32
+ runDomRulesInPage(url, null, { policyContract: 'a11y' }, null); // default if omitted
33
+ runDomRulesInPage(url, null, { policyContract: 'generic' }, null);
34
+ ```
35
+
36
+ Or supply an inline contract object instead of a name:
37
+
38
+ ```js
39
+ runDomRulesInPage(url, null, {
40
+ policyContract: {
41
+ id: 'my-custom-policy',
42
+ allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
43
+ allowedConfidence: ['high', 'medium'], // drop 'low' — anything low-confidence becomes cantTell
44
+ coerceManualFailToCantTell: true
45
+ }
46
+ }, null);
47
+ ```
48
+
49
+ Any field you omit from an inline contract object falls back to the `a11y` contract's value for that field — you're overriding, not replacing wholesale.
50
+
51
+ ## Fine-grained overrides
52
+
53
+ `engineOptions.policy` overrides individual fields on top of whichever contract you selected, without defining a whole new contract:
54
+
55
+ ```js
56
+ runDomRulesInPage(url, null, {
57
+ policyContract: 'a11y',
58
+ policy: { coerceManualFailToCantTell: false } // keep everything else about 'a11y', just flip this one flag
59
+ }, null);
60
+ ```
61
+
62
+ ## What happens when a value isn't allowed
63
+
64
+ - **`outcome` not in `allowedOutcomes`**: silently coerced to `cantTell`. (In practice this only matters for custom contracts that narrow the outcome list — the two built-in contracts allow all four values.)
65
+ - **`confidence` not in `allowedConfidence`**: silently replaced with the rule's own `defaultConfidence`.
66
+
67
+ Neither of these ever throws — policy resolution is designed to always produce a valid result, per the engine's "safe-by-default" principle.
68
+
69
+ ## Why this exists as a separate layer
70
+
71
+ Keeping outcome-integrity rules (like "manual rules can't fail") in a policy layer — rather than hard-coded into every rule, or worse, left to each rule author's discretion — means the guarantee holds even if a rule's own logic has a bug, and means different consumers can have different appetites for risk (a CI gate vs. an internal audit dashboard) without forking the rule set itself. This protects the engine's core guarantee: `fail` must always mean "deterministic, high-confidence, normative violation," full stop.
@@ -0,0 +1,375 @@
1
+ # RULE_AUTHORING.md — a11yCore DOM Rule Authoring (Canonical, repo-derived)
2
+
3
+ This guide is derived from the **actual rule modules, helpers, build pipeline, and tests** in this codebase.
4
+ Follow it literally when adding or modifying rules.
5
+
6
+ > Key principle: **Atomic + deterministic + standards-traceable**.
7
+ > One rule = one normative decision.
8
+
9
+ ---
10
+
11
+ ## 1) Where rules run (critical mental model)
12
+
13
+ Rules are bundled into the generated core and executed inside the **page/DOM context**.
14
+
15
+ - `runInPage(ctx)` is **serialized** and evaluated later from its source text.
16
+ - Therefore it must be **self-contained** (no outer-scope references).
17
+
18
+ ### 1.1 Forbidden inside `runInPage`
19
+ ❌ Don’t reference anything defined outside the function body, including:
20
+ - `id`
21
+ - `meta`
22
+ - imported modules
23
+ - closure variables
24
+
25
+ This is a known, **recurring** footgun (“meta is not defined” incident).
26
+
27
+ ⚠️ **Why this is dangerous, not just annoying: the build does NOT fail.** `runInPage` is serialized via `fn.toString()` and re-evaluated as source text later, in the page context — `build-core.js` never parses that source for free variables, and `npm run build`/`npm test`'s own tests only verify serialization round-trips correctly, not that every identifier resolves. The break only surfaces when the rule actually *runs*: the reference throws a `ReferenceError` inside `runInPage`, the runner's own `try/catch` (`src/core/dom-runner.js`) catches it silently, and the rule's result becomes `{ outcome: 'cantTell', occurrences: [], error: '<name> is not defined' }` — a normal-looking result, not a crash. A rule broken this way can sit unnoticed indefinitely unless something specifically asserts its `outcome`/`error`, which is why every rule's fixture-coverage test (§11) matters: it's often the *only* thing that would catch this.
28
+
29
+ **If you add a module-scope `const`/helper function to a rule file, move it inside `runInPage` itself** (or route the value through `ctx.rule`/`ctx.helpers` if it must be engine-provided) — do not leave it at module scope and reference it from inside `runInPage`, even though nothing will complain until you actually run the rule and check its `error` field.
30
+
31
+ ✅ Use `ctx.rule.*` instead:
32
+ - `ctx.rule.ruleId`
33
+ - `ctx.rule.defaultSeverity`
34
+ - `ctx.rule.defaultConfidence`
35
+ - `ctx.rule.type`
36
+
37
+ ---
38
+
39
+ ## 2) Rule module contract (exact)
40
+
41
+ Each rule file is a CommonJS module exporting exactly:
42
+
43
+ ```js
44
+ 'use strict';
45
+
46
+ const id = 'some-rule-id';
47
+
48
+ const meta = { /* see Meta Contract */ };
49
+
50
+ function runInPage(ctx) { /* see Runtime Contract */ }
51
+
52
+ module.exports = { id, meta, runInPage };
53
+ ```
54
+
55
+ No other exports.
56
+
57
+ ---
58
+
59
+ ## 3) Rule ID conventions (repo reality)
60
+
61
+ IDs are kebab-case, bare (no engine prefix).
62
+
63
+ Common pattern used in this ruleset:
64
+ ```
65
+ <target>-<topic>-<intent>
66
+ ```
67
+
68
+ Examples observed:
69
+ - `img-alt-present`
70
+ - `img-alt-quality`
71
+ - `img-alt-decorative`
72
+ - `canvas-text-alternative-present`
73
+ - `video-poster-text-alternative-present`
74
+
75
+ **Manual vs automatic is NOT encoded in the id** in this repo; it is encoded by `meta.type`.
76
+
77
+ ---
78
+
79
+ ## 4) Meta Contract (all keys used by current rules)
80
+
81
+ Every rule defines a `meta` object. In the rule set you uploaded, the union of meta keys is:
82
+
83
+ ### 4.1 Required top-level keys
84
+
85
+ ```js
86
+ const meta = {
87
+ title: '…',
88
+ description: '…',
89
+
90
+ i18n: {
91
+ titleKey: '…',
92
+ descriptionKey: '…'
93
+ },
94
+
95
+ helpUrl: null, // or URL string
96
+
97
+ tags: [ '…' ],
98
+ wcagSc: [ '1.1.1' ],
99
+
100
+ normativeMappings: [
101
+ {
102
+ standard: 'WCAG',
103
+ version: '2.2',
104
+ requirement: '1.1.1',
105
+ title: 'Non-text Content',
106
+ conformanceLevel: 'A'
107
+ }
108
+ ],
109
+
110
+ defaultSeverity: 'minor' | 'moderate' | 'serious' | 'critical',
111
+ category: 'perceivable' | 'operable' | 'understandable' | 'robust',
112
+ type: 'automatic' | 'manual',
113
+ defaultConfidence: 'high' | 'medium' | 'low',
114
+
115
+ coverage: {
116
+ facetsBySc: {
117
+ '1.1.1': ['facet-a', 'facet-b']
118
+ }
119
+ }
120
+ };
121
+ ```
122
+
123
+ ### 4.2 Notes on specific meta keys
124
+
125
+ #### `meta.i18n`
126
+ This repo uses **key-based i18n**:
127
+ - `titleKey`, `descriptionKey` are dictionary keys.
128
+ - `title` and `description` remain as **English fallbacks**.
129
+
130
+ The build/runtime resolves i18n by:
131
+ 1) looking up the requested locale dictionary,
132
+ 2) falling back to `en` if missing,
133
+ 3) falling back to the literal `title`/`description` strings if still missing.
134
+
135
+ #### `meta.tags`
136
+ Tags are used for grouping/filtering. Typical tag families in this ruleset include:
137
+ - WCAG tagging: `wcag2a`, `wcag111`
138
+ - domain: `nontext`, `images`, plus element-specific tags
139
+ - nature: `atomic`, plus `automatic` or `manual`
140
+
141
+ #### `meta.coverage.facetsBySc`
142
+ This is the repo’s explicit **coverage model** for an SC.
143
+ Each atomic rule declares which “facet(s)” of an SC it covers.
144
+
145
+ Keep facet naming consistent across a family.
146
+
147
+ ---
148
+
149
+ ## 5) i18n in occurrences (repo reality)
150
+
151
+ Occurrences also support i18n via keys + params.
152
+
153
+ ### 5.1 Occurrence i18n shape
154
+
155
+ Every occurrence may include:
156
+
157
+ ```js
158
+ i18n: {
159
+ summaryKey: '…',
160
+ hintKey: '…',
161
+ params: { /* string substitutions */ }
162
+ }
163
+ ```
164
+
165
+ At normalization time, the engine:
166
+ - ensures `summary`, `hint`, and `html` are strings,
167
+ - ensures `i18n` is either a normalized object or `null`,
168
+ - resolves `summary` and `hint` using i18n keys (with locale → `en` fallback → literal fallback).
169
+
170
+ ### 5.2 Param interpolation
171
+
172
+ Translation strings use `{{paramName}}` placeholders.
173
+ `params` is shallow-copied and passed into interpolation.
174
+
175
+ ---
176
+
177
+ ## 6) Helpers contract used by rules (ctx.helpers)
178
+
179
+ Rules use helpers returned by `createDomHelpers()`.
180
+
181
+ Helpers observed in this repo include:
182
+ - `queryAll`, `queryAllDeep`, `queryAllSmart`
183
+ - `getOuterHtmlSnippet`
184
+ - `buildSimpleSelector`, `buildSelector`
185
+ - `isAccTreeEligible`, `getEligibilityInfo`
186
+ - `resolveIdRefs`, `getTextFromIdRefs`
187
+ - `getAccessibleNameInfo`, `getAccessibleDescriptionInfo`
188
+ - `getTextAlternativeInfo`
189
+ - `getRoleInfo`, `getFocusableInfo`
190
+
191
+ ### 6.1 Shadow DOM scanning
192
+
193
+ Rules that need to work with open Shadow DOM should prefer:
194
+
195
+ ```js
196
+ const nodes = helpers.queryAllSmart
197
+ ? helpers.queryAllSmart('img')
198
+ : helpers.queryAll('img');
199
+ ```
200
+
201
+ Shadow traversal is opt-in via engine option:
202
+ ```js
203
+ engineOptions: { includeShadowDom: true }
204
+ ```
205
+
206
+ ### 6.2 Reporting note for Shadow DOM
207
+
208
+ Selectors do not pierce shadow boundaries, so a `selector` may not uniquely locate nodes in Shadow DOM.
209
+ Therefore: **always include `html` in occurrences**.
210
+
211
+ ---
212
+
213
+ ## 7) Eligibility logging (required in this ruleset)
214
+
215
+ This repo requires rules to attach an eligibility/visibility trace in each occurrence:
216
+
217
+ ```js
218
+ data: {
219
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
220
+ }
221
+ ```
222
+
223
+ This is consistent across your uploaded rule family.
224
+
225
+ ---
226
+
227
+ ## 8) `runInPage(ctx)` runtime contract (repo reality)
228
+
229
+ ### 8.1 Expected return shape
230
+
231
+ The rule must return:
232
+ - `ruleId` (must be `rule.ruleId`)
233
+ - `outcome`: `"pass" | "fail" | "cantTell" | "notApplicable"`
234
+ - `severity`: string
235
+ - `occurrences`: array
236
+
237
+ Examples:
238
+
239
+ ### 8.2 Outcome conventions used by these rules
240
+
241
+ Automatic:
242
+ - `notApplicable` if no applicable targets
243
+ - `pass` if applicable targets exist and no occurrences
244
+ - `fail` if occurrences exist
245
+
246
+ Manual:
247
+ - `notApplicable` if no applicable targets
248
+ - `cantTell` if at least one target requires review
249
+
250
+ ---
251
+
252
+ ## 9) Occurrence object shape (repo reality)
253
+
254
+ Typical pattern:
255
+
256
+ ```js
257
+ occurrences.push({
258
+ selector,
259
+ html,
260
+ summary: '…',
261
+ hint: '…',
262
+ i18n: { summaryKey: '…', hintKey: '…', params: { element: 'img' } },
263
+ data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
264
+ });
265
+ ```
266
+
267
+ Observed properties:
268
+ - `selector` (or sometimes `selectorStr`)
269
+ - `html`
270
+ - `summary`
271
+ - `hint`
272
+ - `i18n` (`summaryKey`, `hintKey`, `params`)
273
+ - `data` (includes `visibilityFilter`)
274
+
275
+ ---
276
+
277
+ ## 10) Structured doc comment block
278
+
279
+ Keep the structured header comment (`@rule`, `@atomic`, `@summary`, `@standard`, `@sc`, `@applicability`, `@expectation`).
280
+
281
+ ---
282
+
283
+ ## 11) Scenario fixture + fixture-coverage test (required for every rule)
284
+
285
+ Every rule — automatic or manual, no exceptions — needs a standalone, loadable HTML
286
+ scenario page in addition to its inline unit tests. This is not optional polish: the
287
+ project's test fixtures are meant to be usable directly by external tooling (loaded and
288
+ exercised as real pages), not just embedded as strings inside `.test.js` files.
289
+
290
+ ### 11.1 The fixture file
291
+
292
+ - Path: `tests/fixtures/<rule-slug>-all-scenarios.html`, where `<rule-slug>` is the rule
293
+ id itself (e.g. `tab-name-present` → `tab-name-present-all-scenarios.html`).
294
+ - Structure: a real HTML page (`<!doctype html>`, `<title>`, minimal inline `<style>`)
295
+ containing numbered scenario blocks, each:
296
+ ```html
297
+ <div class="case" id="case_NN">
298
+ <div class="case-title">NN — PASS: role=tab, visible text content</div>
299
+ <div role="tab" tabindex="0" id="<slug>_case_NN">Apple</div>
300
+ </div>
301
+ ```
302
+ - The `.case-title` text MUST start with `NN — MARKER:` where `MARKER` is one of
303
+ `PASS`, `FAIL`, `CANTTELL`, or `NEUTRAL`/`INELIGIBLE` (the fixture-index generator,
304
+ §11.3, parses this to count scenarios per outcome — see
305
+ `scripts/generate-fixture-index.js`'s `parseFixtureCases`).
306
+ - The actual test target gets its own stable id of the form `<slug>_case_NN` (short,
307
+ memorable abbreviation of the rule name — see existing fixtures for precedent, e.g.
308
+ `tab_case_01`, `binctl_case_01`).
309
+ - Group related cases under `<h2>` sections (e.g. "A. Named (eligible)", "B. Unnamed
310
+ (eligible, FAIL)", "C. Ineligible (excluded from accessibility tree, skipped)").
311
+ - Cover every branch the rule's own logic distinguishes: pass, fail (each distinct
312
+ `reasonCode`), notApplicable/skipped, and — for manual rules — cantTell.
313
+
314
+ ### 11.2 Known, acceptable exceptions to "one fixture, many cases"
315
+
316
+ A few rule shapes genuinely cannot express every branch as a single static page. When
317
+ you hit one of these, still create the fixture (covering whatever branches ARE
318
+ expressible statically) and add an explicit `<p class="note">` in the fixture, plus a
319
+ comment in the `.test.js` fixture-coverage test, stating which branch is NOT covered and
320
+ why:
321
+
322
+ - **Whole-document checks** (e.g. `aria-hidden-body`, `page-title-present`,
323
+ `meta-viewport-zoom-enabled`, `bypass-blocks-present`): the property being checked
324
+ exists once per page (one `<body>`, one `<title>`, one viewport meta), so only one
325
+ outcome is demonstrable per fixture file. Pick the most illustrative FAIL case; note
326
+ that PASS/other branches are covered by the rule's inline unit tests instead of
327
+ minting near-duplicate fixture files.
328
+ - **Runtime-mutation-only branches** (e.g. `iframe-focusable-content`'s FAIL branch,
329
+ which requires mutating `iframe.contentDocument` after parse — jsdom does not
330
+ populate `srcdoc` synchronously): cover every branch that IS expressible statically;
331
+ leave the rest to the existing programmatic test.
332
+ - **Rules with no branching logic at all** (e.g. `manual-review`, which always returns
333
+ `cantTell` regardless of page content): a single trivial case is fine, purely for
334
+ index completeness — say so in the fixture's note.
335
+
336
+ Do not force a false "PASS" demonstration or fabricate a scenario that doesn't actually
337
+ exercise the code path it claims to.
338
+
339
+ ### 11.3 The fixture-coverage test
340
+
341
+ Add one test to the rule's existing `tests/engine-checks/**/<rule>.test.js` (do not
342
+ create a separate file):
343
+
344
+ ```js
345
+ const fs = require('node:fs');
346
+ const path = require('node:path');
347
+
348
+ test(`${RULE_ID}: fixture coverage (tests/fixtures/<rule-slug>-all-scenarios.html)`, () => {
349
+ const fixturePath = path.join(__dirname, '../..', 'fixtures', '<rule-slug>-all-scenarios.html');
350
+ const fixtureHtml = fs.readFileSync(fixturePath, 'utf8');
351
+ const result = runa11yCoreOnHtml(fixtureHtml, { runOnly: [RULE_ID] });
352
+
353
+ const rule = assertRule(result, RULE_ID, 'fail', { minOccurrences: N, maxOccurrences: N });
354
+ // assert the exact expected-fail ids (and, if useful, expected-no-occurrence ids)
355
+ });
356
+ ```
357
+
358
+ The file MUST declare `const RULE_ID = '...'` near the top (the fixture-index
359
+ generator discovers a rule's test file and fixture by scanning for that constant —
360
+ tests using only inline string literals won't be picked up; see
361
+ `tests/engine-checks/manual-review.test.js` for the fix applied when this was missed).
362
+
363
+ ### 11.4 Keeping the index current
364
+
365
+ After adding or changing any fixture, regenerate the index:
366
+
367
+ ```
368
+ npm run fixtures:index
369
+ ```
370
+
371
+ This writes `tests/fixtures/INDEX.md` (human-readable) and `tests/fixtures/index.json`
372
+ (machine-readable — every rule, its fixture path, and parsed pass/fail/cantTell case
373
+ counts, for external tooling to enumerate and load fixtures directly). Commit both
374
+ alongside the fixture and test changes. A rule shipped without its fixture is treated
375
+ the same as a rule shipped without tests — not done.