ultra11y 2.22.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
package/src/aria.ts ADDED
@@ -0,0 +1,230 @@
1
+ // WAI-ARIA 1.2 reference tables — the vocabulary the ARIA validation rules check against.
2
+ // Kept in one module (rather than scattered across rules) because these are FACTS about the
3
+ // specification, not policy: every entry is a state/property, value type, required-state or
4
+ // required-context relationship defined by https://www.w3.org/TR/wai-aria-1.2/.
5
+ //
6
+ // Deliberately conservative on two axes:
7
+ // • only relationships the spec states outright are listed — a "should" is not encoded
8
+ // here, because a rule built on one manufactures false positives;
9
+ // • deprecated-but-still-defined attributes (aria-dropeffect, aria-grabbed) count as
10
+ // DEFINED: reporting them as unknown would be wrong, and deprecation is not a WCAG
11
+ // failure.
12
+
13
+ /** Every state and property defined by WAI-ARIA 1.2. */
14
+ export const ARIA_ATTRS = new Set([
15
+ "aria-activedescendant",
16
+ "aria-atomic",
17
+ "aria-autocomplete",
18
+ "aria-braillelabel",
19
+ "aria-brailleroledescription",
20
+ "aria-busy",
21
+ "aria-checked",
22
+ "aria-colcount",
23
+ "aria-colindex",
24
+ "aria-colindextext",
25
+ "aria-colspan",
26
+ "aria-controls",
27
+ "aria-current",
28
+ "aria-describedby",
29
+ "aria-description",
30
+ "aria-details",
31
+ "aria-disabled",
32
+ "aria-dropeffect",
33
+ "aria-errormessage",
34
+ "aria-expanded",
35
+ "aria-flowto",
36
+ "aria-grabbed",
37
+ "aria-haspopup",
38
+ "aria-hidden",
39
+ "aria-invalid",
40
+ "aria-keyshortcuts",
41
+ "aria-label",
42
+ "aria-labelledby",
43
+ "aria-level",
44
+ "aria-live",
45
+ "aria-modal",
46
+ "aria-multiline",
47
+ "aria-multiselectable",
48
+ "aria-orientation",
49
+ "aria-owns",
50
+ "aria-placeholder",
51
+ "aria-posinset",
52
+ "aria-pressed",
53
+ "aria-readonly",
54
+ "aria-relevant",
55
+ "aria-required",
56
+ "aria-roledescription",
57
+ "aria-rowcount",
58
+ "aria-rowindex",
59
+ "aria-rowindextext",
60
+ "aria-rowspan",
61
+ "aria-selected",
62
+ "aria-setsize",
63
+ "aria-sort",
64
+ "aria-valuemax",
65
+ "aria-valuemin",
66
+ "aria-valuenow",
67
+ "aria-valuetext",
68
+ ]);
69
+
70
+ export type AriaValueType =
71
+ | { kind: "token"; values: string[] } // one of a fixed set
72
+ | { kind: "tokenlist"; values: string[] } // space-separated, each from a fixed set
73
+ | { kind: "integer" } // a whole number
74
+ | { kind: "number" } // a real number
75
+ | { kind: "string" } // anything, including empty
76
+ | { kind: "idref" } // a single element id
77
+ | { kind: "idrefs" }; // space-separated element ids
78
+
79
+ const BOOL = ["true", "false"];
80
+
81
+ /** Value type per attribute. Attributes absent from this map accept any string. */
82
+ export const ARIA_VALUE_TYPE: Record<string, AriaValueType> = {
83
+ "aria-atomic": { kind: "token", values: BOOL },
84
+ "aria-busy": { kind: "token", values: BOOL },
85
+ "aria-disabled": { kind: "token", values: BOOL },
86
+ "aria-modal": { kind: "token", values: BOOL },
87
+ "aria-multiline": { kind: "token", values: BOOL },
88
+ "aria-multiselectable": { kind: "token", values: BOOL },
89
+ "aria-readonly": { kind: "token", values: BOOL },
90
+ "aria-required": { kind: "token", values: BOOL },
91
+ // `undefined` is a legal literal value for these three (it means "not applicable").
92
+ "aria-hidden": { kind: "token", values: [...BOOL, "undefined"] },
93
+ "aria-expanded": { kind: "token", values: [...BOOL, "undefined"] },
94
+ "aria-selected": { kind: "token", values: [...BOOL, "undefined"] },
95
+ "aria-grabbed": { kind: "token", values: [...BOOL, "undefined"] },
96
+ // tristate
97
+ "aria-checked": { kind: "token", values: [...BOOL, "mixed", "undefined"] },
98
+ "aria-pressed": { kind: "token", values: [...BOOL, "mixed", "undefined"] },
99
+ // enumerations
100
+ "aria-autocomplete": { kind: "token", values: ["inline", "list", "both", "none"] },
101
+ "aria-current": { kind: "token", values: [...BOOL, "page", "step", "location", "date", "time"] },
102
+ "aria-haspopup": { kind: "token", values: [...BOOL, "menu", "listbox", "tree", "grid", "dialog"] },
103
+ "aria-invalid": { kind: "token", values: [...BOOL, "grammar", "spelling"] },
104
+ "aria-live": { kind: "token", values: ["off", "polite", "assertive"] },
105
+ "aria-orientation": { kind: "token", values: ["horizontal", "vertical", "undefined"] },
106
+ "aria-sort": { kind: "token", values: ["ascending", "descending", "none", "other"] },
107
+ "aria-dropeffect": { kind: "tokenlist", values: ["copy", "execute", "link", "move", "none", "popup"] },
108
+ "aria-relevant": { kind: "tokenlist", values: ["additions", "all", "removals", "text"] },
109
+ // numeric
110
+ "aria-colcount": { kind: "integer" },
111
+ "aria-colindex": { kind: "integer" },
112
+ "aria-colspan": { kind: "integer" },
113
+ "aria-level": { kind: "integer" },
114
+ "aria-posinset": { kind: "integer" },
115
+ "aria-rowcount": { kind: "integer" },
116
+ "aria-rowindex": { kind: "integer" },
117
+ "aria-rowspan": { kind: "integer" },
118
+ "aria-setsize": { kind: "integer" },
119
+ "aria-valuemax": { kind: "number" },
120
+ "aria-valuemin": { kind: "number" },
121
+ "aria-valuenow": { kind: "number" },
122
+ // references
123
+ "aria-activedescendant": { kind: "idref" },
124
+ "aria-details": { kind: "idref" },
125
+ "aria-errormessage": { kind: "idref" },
126
+ "aria-controls": { kind: "idrefs" },
127
+ "aria-describedby": { kind: "idrefs" },
128
+ "aria-flowto": { kind: "idrefs" },
129
+ "aria-labelledby": { kind: "idrefs" },
130
+ "aria-owns": { kind: "idrefs" },
131
+ };
132
+
133
+ /** Is `value` a legal literal for `attr`? Unknown attributes and free-form strings pass. */
134
+ export function isValidAriaValue(attrName: string, value: string): boolean {
135
+ const type = ARIA_VALUE_TYPE[attrName];
136
+ if (!type) return true;
137
+ const v = value.trim();
138
+ switch (type.kind) {
139
+ case "token":
140
+ return type.values.includes(v.toLowerCase());
141
+ case "tokenlist": {
142
+ const parts = v.split(/\s+/).filter(Boolean);
143
+ return parts.length > 0 && parts.every((p) => type.values.includes(p.toLowerCase()));
144
+ }
145
+ case "integer":
146
+ return /^-?\d+$/.test(v);
147
+ case "number":
148
+ return /^-?\d+(\.\d+)?$/.test(v);
149
+ case "idref":
150
+ // A single-reference property pointing at several ids resolves to nothing.
151
+ // (Whether the id EXISTS is aria-ref-missing-id's business, not this rule's.)
152
+ return v.split(/\s+/).filter(Boolean).length <= 1;
153
+ default:
154
+ // idrefs/string: emptiness and resolution are other rules' business.
155
+ return true;
156
+ }
157
+ }
158
+
159
+ /** Roles whose ARIA definition REQUIRES these states — without them the widget's state is
160
+ * simply not exposed. Only roles the spec marks "Required States and Properties". */
161
+ export const ARIA_REQUIRED_ATTRS: Record<string, string[]> = {
162
+ checkbox: ["aria-checked"],
163
+ combobox: ["aria-expanded", "aria-controls"],
164
+ heading: ["aria-level"],
165
+ menuitemcheckbox: ["aria-checked"],
166
+ menuitemradio: ["aria-checked"],
167
+ meter: ["aria-valuenow"],
168
+ option: ["aria-selected"],
169
+ radio: ["aria-checked"],
170
+ scrollbar: ["aria-controls", "aria-valuenow"],
171
+ slider: ["aria-valuenow"],
172
+ switch: ["aria-checked"],
173
+ };
174
+
175
+ /** Roles that are only meaningful inside a specific container role ("Required Context
176
+ * Role"). Values are the accepted container roles, including the implicit ones a native
177
+ * element provides. */
178
+ export const ARIA_REQUIRED_PARENT: Record<string, string[]> = {
179
+ columnheader: ["row"],
180
+ gridcell: ["row"],
181
+ listitem: ["list", "directory"],
182
+ menuitem: ["menu", "menubar", "group"],
183
+ menuitemcheckbox: ["menu", "menubar", "group"],
184
+ menuitemradio: ["menu", "menubar", "group"],
185
+ option: ["listbox", "group"],
186
+ row: ["grid", "rowgroup", "table", "treegrid"],
187
+ rowgroup: ["grid", "table", "treegrid"],
188
+ rowheader: ["row"],
189
+ tab: ["tablist"],
190
+ treeitem: ["tree", "group"],
191
+ };
192
+
193
+ /** Native elements that carry an implicit CONTAINER role, for the required-context check.
194
+ * Only the unambiguous cases — a role that depends on attributes or ancestry is left out. */
195
+ export const IMPLICIT_CONTAINER_ROLE: Record<string, string> = {
196
+ ul: "list",
197
+ ol: "list",
198
+ li: "listitem",
199
+ table: "table",
200
+ thead: "rowgroup",
201
+ tbody: "rowgroup",
202
+ tfoot: "rowgroup",
203
+ tr: "row",
204
+ td: "cell",
205
+ th: "columnheader",
206
+ datalist: "listbox",
207
+ optgroup: "group",
208
+ option: "option",
209
+ fieldset: "group",
210
+ };
211
+
212
+ /** Roles that PROHIBIT an author-provided name: naming them exposes text the platform has
213
+ * nowhere to put, and assistive tech either drops it or announces it out of context. */
214
+ export const NAME_PROHIBITED_ROLES = new Set([
215
+ "caption",
216
+ "code",
217
+ "deletion",
218
+ "emphasis",
219
+ "generic",
220
+ "insertion",
221
+ "mark",
222
+ "none",
223
+ "paragraph",
224
+ "presentation",
225
+ "strong",
226
+ "subscript",
227
+ "superscript",
228
+ "term",
229
+ "time",
230
+ ]);
package/src/audit.ts ADDED
@@ -0,0 +1,396 @@
1
+ // `audit` — run the static engine over the inputs and aggregate findings into an
2
+ // AuditResult: a preliminary, engine-only verdict per criterion (C/NC/NA for the
3
+ // static criteria it can decide; "manual" for everything needing rendering or
4
+ // judgment, surfaced as residual risks). `report` renders this; Claude completes
5
+ // the manual criteria.
6
+ import { createHash } from "node:crypto";
7
+ import type { AuditResult, CriterionResult, Finding, ResidualRisk, Status, GuidelineTally } from "./types.js";
8
+ import { VERSION, SCHEMA_VERSION } from "./types.js";
9
+ import { allSC, allGuidelines } from "./wcag.js";
10
+ import { parseSource } from "./parse/source.js";
11
+ import { attr, elementsByTag, type Doc, type CaptureProvenance } from "./parse/html.js";
12
+ import { computeCaptureCoverage, enrichCaptureOrigins, readCaptureDir, capturesForSources } from "./capture.js";
13
+ import { isFullDocument } from "./rules/rule.js";
14
+ import { runRules } from "./rules/registry.js";
15
+ import { runCrossRules } from "./rules/cross-registry.js";
16
+ import { listPacks } from "./standards/registry.js";
17
+ import { runPackRules } from "./standards/pack-rules.js";
18
+ import { buildGraphAndDocs } from "./graph/build.js";
19
+ import type { DepGraph } from "./graph/graph.js";
20
+ import { discover } from "./discover.js";
21
+ import { GRAPH_ONLY_EXT } from "./glob.js";
22
+ import { readText, today } from "./util.js";
23
+
24
+ export type DedupMode = "exact" | "normalized" | "off";
25
+
26
+ export interface AuditInput {
27
+ inputs: string[];
28
+ stdin?: string;
29
+ forceJsx?: boolean;
30
+ include?: string[];
31
+ exclude?: string[];
32
+ ext?: string[];
33
+ // scale controls
34
+ changed?: boolean; // audit only git-changed files
35
+ since?: string; // git ref to diff against (implies changed)
36
+ staged?: boolean; // audit exactly the staged index snapshot (strict pre-commit scope)
37
+ dedup?: DedupMode; // collapse identical files to one canonical audit (default exact)
38
+ maxFiles?: number; // hard cap on canonical files audited (logged truncation)
39
+ graph?: boolean; // also run cross-file rules over a dependency graph (--graph)
40
+ captureCoverage?: boolean; // compute scope.captureCoverage (implies a graph pass)
41
+ captureDir?: string; // dir scanned for the repo-wide capture set (coverage); default .ultra11y/captures
42
+ // In --changed/--since/--staged mode, also ingest the captures under `captureDir`
43
+ // whose provenance sourceFile matches one of the diffed files (capturesForSources) —
44
+ // a capture is rarely itself part of the diff, so the audit would otherwise stay
45
+ // blind to the real rendered DOM for a touched component. No-op outside diff mode
46
+ // (a full scan's capture ingestion is the CLI appending captureDir as a top-level
47
+ // input instead — see cmdAudit's `useCaptures`).
48
+ captureDiff?: boolean;
49
+ noDefaultExcludes?: boolean; // also audit test/spec/story/__tests__ markup
50
+ onWarn?: (msg: string) => void;
51
+ }
52
+
53
+ const has = (d: Doc, ...tags: string[]): boolean => d.elements.some((e) => tags.includes(e.tag));
54
+
55
+ // Applicability predicate per STATIC success criterion (the only SCs the engine
56
+ // reports Conforming when clean): is there any relevant element to check? If not,
57
+ // the SC is NA rather than a hollow "C". WCAG SCs are coarser than the rules, so the
58
+ // static set is deliberately tiny (see scripts/build-standards.mjs); every other
59
+ // mapped SC raises only DEFINITE non-conformities and stays "manual" otherwise.
60
+ const APPLICABLE: Record<string, (d: Doc) => boolean> = {
61
+ "1.4.2": (d) => has(d, "audio", "video"), // Audio Control — autoplay-media (audio branch)
62
+ "2.4.2": (d) => isFullDocument(d), // Page Titled — title-missing-empty
63
+ "3.1.1": (d) => isFullDocument(d), // Language of Page — html-lang-missing / lang-invalid
64
+ };
65
+
66
+ function residualReason(automatability: string): string {
67
+ return automatability === "needs-rendering"
68
+ ? "Needs a rendered DOM (contrast, focus visibility, zoom/reflow, target size) — decide via `scan`."
69
+ : "Judgement criterion — adjudicated by the agent from source/context (`verify --manual`, gated).";
70
+ }
71
+
72
+ // Streaming accumulator: parse → run rules → fold → DISCARD each Doc, so the
73
+ // engine never holds a whole repo in memory. The only cross-document state is
74
+ // (a) the flat findings list (bounded by finding count, not source size) and
75
+ // (b) per-criterion applicability, OR-folded across docs (NA only if NO doc made
76
+ // the criterion applicable — never because findings happened to be absent).
77
+ interface Accum {
78
+ byCriterion: Map<string, Finding[]>;
79
+ applicable: Map<string, boolean>; // static criteria only
80
+ allFindings: Finding[];
81
+ packFindings: Finding[]; // declarative pack-rule findings (namespaced pack:<key>:<id>)
82
+ fileCount: number;
83
+ opaqueLibs: Set<string>; // component-library specifiers rendered but not source-analysable
84
+ opaqueFiles: number; // count of source files that render such components
85
+ sfcFiles: number; // .vue/.svelte/.astro source templates audited (verdicts preliminary)
86
+ sfcExts: Set<string>; // which SFC extensions were seen
87
+ captures: { file: string; provenance: CaptureProvenance }[]; // rendered capture files audited
88
+ langCounts: Map<string, number>; // <html lang> primary subtags seen, for repo-language detection
89
+ }
90
+
91
+ // Precompute the static success criteria + their applicability predicates once.
92
+ const STATIC_PREDS: ReadonlyArray<readonly [string, (d: Doc) => boolean]> = allSC()
93
+ .filter((c) => c.automatability === "static")
94
+ .map((c) => [c.sc, APPLICABLE[c.sc] ?? isFullDocument] as const);
95
+
96
+ function newAccum(): Accum {
97
+ return {
98
+ byCriterion: new Map(),
99
+ applicable: new Map(),
100
+ allFindings: [],
101
+ packFindings: [],
102
+ fileCount: 0,
103
+ opaqueLibs: new Set(),
104
+ opaqueFiles: 0,
105
+ sfcFiles: 0,
106
+ sfcExts: new Set(),
107
+ captures: [],
108
+ langCounts: new Map(),
109
+ };
110
+ }
111
+
112
+ // Primary subtag of an `<html lang>`/`xml:lang` value: "fr-FR" → "fr", "en" → "en".
113
+ // Mirrors the BCP47 primary-subtag reading the html-lang-missing/lang-invalid rules use.
114
+ function primarySubtag(lang: string): string | undefined {
115
+ const m = lang.trim().match(/^[a-z]{1,8}/i);
116
+ return m ? m[0].toLowerCase() : undefined;
117
+ }
118
+
119
+ /** Fold one parsed document into the accumulator, then let it be GC'd. When a
120
+ * graph is supplied, cross-file findings are added and graph-proven false
121
+ * positives are suppressed (matched by ruleId + line on this same doc). */
122
+ export function foldDoc(acc: Accum, doc: Doc, graph?: DepGraph): void {
123
+ let findings = runRules(doc);
124
+ if (graph) {
125
+ const cross = runCrossRules(doc, graph);
126
+ if (cross.suppress.length) {
127
+ findings = findings.filter((f) => !cross.suppress.some((s) => s.ruleId === f.ruleId && s.line === f.line));
128
+ }
129
+ findings = findings.concat(cross.findings);
130
+ // A cross finding is evidence its criterion is applicable here (the per-doc
131
+ // static predicates only know native elements, not resolved components).
132
+ for (const f of cross.findings) acc.applicable.set(f.criteriaId, true);
133
+ }
134
+ for (const f of findings) {
135
+ acc.allFindings.push(f);
136
+ const arr = acc.byCriterion.get(f.criteriaId) ?? [];
137
+ arr.push(f);
138
+ acc.byCriterion.set(f.criteriaId, arr);
139
+ }
140
+ // Declarative pack rules run AFTER the core rules over the same Doc. Their findings are
141
+ // collected SEPARATELY (never into byCriterion/allFindings) so the core WCAG verdict is
142
+ // untouched — they surface only when a pack projection is derived. A pack without rules
143
+ // is an instant no-op.
144
+ for (const pack of listPacks()) {
145
+ if (pack.rules?.length) acc.packFindings.push(...runPackRules(doc, pack));
146
+ }
147
+ for (const [id, pred] of STATIC_PREDS) {
148
+ if (!acc.applicable.get(id) && pred(doc)) acc.applicable.set(id, true);
149
+ }
150
+ if (doc.opaqueComponents?.length) {
151
+ for (const lib of doc.opaqueComponents) acc.opaqueLibs.add(lib);
152
+ acc.opaqueFiles++;
153
+ }
154
+ if (doc.kind === "sfc") {
155
+ acc.sfcFiles++;
156
+ const e = doc.file.toLowerCase().match(/\.[a-z]+$/)?.[0];
157
+ if (e) acc.sfcExts.add(e);
158
+ }
159
+ if (doc.capture) acc.captures.push({ file: doc.file, provenance: doc.capture });
160
+ const html = elementsByTag(doc, "html")[0];
161
+ const htmlLang = html ? (attr(html, "lang") ?? attr(html, "xml:lang") ?? "").trim() : "";
162
+ const subtag = htmlLang ? primarySubtag(htmlLang) : undefined;
163
+ if (subtag) acc.langCounts.set(subtag, (acc.langCounts.get(subtag) ?? 0) + 1);
164
+ acc.fileCount++;
165
+ }
166
+
167
+ interface FinalizeExtra {
168
+ truncated?: { limit: number; total: number; skipped: number };
169
+ dedup?: { canonicalFiles: number; duplicateFiles: number };
170
+ }
171
+
172
+ function finalize(acc: Accum, inputs: string[], extra: FinalizeExtra = {}): AuditResult {
173
+ const criteria: CriterionResult[] = [];
174
+ const residualRisks: ResidualRisk[] = [];
175
+
176
+ for (const c of allSC()) {
177
+ const fs = acc.byCriterion.get(c.sc) ?? [];
178
+ // ONLY normative findings drive status + conformancePct. Advisory findings (non-normative
179
+ // recommendations) stay attached to the criterion and in the flat list, but can never
180
+ // flip it to NC — a criterion whose findings are ALL advisory keeps its automatability-
181
+ // driven status (C/NA for static, manual for judgment/needs-rendering).
182
+ const normativeFs = fs.filter((f) => !f.advisory);
183
+ let status: Status;
184
+ let justification: string | undefined;
185
+
186
+ if (c.automatability === "static") {
187
+ const applicable = acc.applicable.get(c.sc) ?? false;
188
+ if (!applicable) {
189
+ status = "NA";
190
+ justification = "No element in scope is concerned by this success criterion.";
191
+ } else if (normativeFs.length > 0) {
192
+ status = "NC";
193
+ } else {
194
+ status = "C";
195
+ }
196
+ } else if (normativeFs.length > 0) {
197
+ // a rule on a needs-rendering / judgment SC raised a DEFINITE failure
198
+ status = "NC";
199
+ } else {
200
+ // engine can't decide — leave it for the agent to adjudicate (`verify --manual`,
201
+ // gated) or the `scan` tier (rendering criteria); never a silent conforming.
202
+ status = "manual";
203
+ residualRisks.push({ criteriaId: c.sc, reason: residualReason(c.automatability), automatability: c.automatability });
204
+ }
205
+ criteria.push({ id: c.sc, guideline: c.guideline, status, findings: fs, ...(justification ? { justification } : {}) });
206
+ }
207
+
208
+ const guidelines: GuidelineTally[] = allGuidelines().map((g) => {
209
+ const inG = criteria.filter((c) => c.guideline === g.number);
210
+ return {
211
+ key: g.number,
212
+ title: g.title,
213
+ c: inG.filter((c) => c.status === "C").length,
214
+ nc: inG.filter((c) => c.status === "NC").length,
215
+ na: inG.filter((c) => c.status === "NA").length,
216
+ manual: inG.filter((c) => c.status === "manual").length,
217
+ };
218
+ });
219
+
220
+ const decided = criteria.filter((c) => c.status === "C" || c.status === "NC");
221
+ const conform = decided.filter((c) => c.status === "C").length;
222
+ const conformancePct = decided.length === 0 ? 100 : Math.round((conform / decided.length) * 100);
223
+
224
+ return {
225
+ tool: "ultra11y",
226
+ standard: "wcag",
227
+ version: VERSION,
228
+ schemaVersion: SCHEMA_VERSION,
229
+ date: today(),
230
+ scope: {
231
+ inputs,
232
+ files: acc.fileCount,
233
+ ...(extra.truncated ? { truncated: extra.truncated } : {}),
234
+ ...(extra.dedup ? { dedup: extra.dedup } : {}),
235
+ ...(acc.opaqueLibs.size ? { rendered: { opaqueLibraries: [...acc.opaqueLibs].sort(), files: acc.opaqueFiles } } : {}),
236
+ ...(acc.sfcFiles ? { sourceTemplate: { files: acc.sfcFiles, extensions: [...acc.sfcExts].sort() } } : {}),
237
+ ...(acc.captures.length
238
+ ? {
239
+ captures: {
240
+ files: acc.captures.length,
241
+ components: [...new Set(acc.captures.map((c) => c.provenance.component).filter((x): x is string => !!x))].sort(),
242
+ },
243
+ }
244
+ : {}),
245
+ ...(acc.langCounts.size ? { langs: [...acc.langCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([lang]) => lang) } : {}),
246
+ },
247
+ guidelines,
248
+ criteria,
249
+ findings: acc.allFindings,
250
+ ...(acc.packFindings.length ? { packFindings: acc.packFindings } : {}),
251
+ residualRisks,
252
+ conformancePct,
253
+ };
254
+ }
255
+
256
+ /** Build an AuditResult from already-parsed docs (eager path; used by tests and
257
+ * any in-memory caller). The streaming runAudit produces an identical result. */
258
+ export function buildAudit(docs: Doc[], inputs: string[]): AuditResult {
259
+ const acc = newAccum();
260
+ for (const d of docs) foldDoc(acc, d);
261
+ return finalize(acc, inputs);
262
+ }
263
+
264
+ function hashContent(content: string, mode: Exclude<DedupMode, "off">): string {
265
+ const norm = mode === "normalized" ? content.replace(/>\s+</g, "><").trim() : content;
266
+ return createHash("sha1").update(norm).digest("hex");
267
+ }
268
+
269
+ /** Resolve inputs and audit them in a single streaming pass: read → (dedup) →
270
+ * parse → fold → discard. Bounded memory, deterministic order. */
271
+ export function runAudit(opts: AuditInput): AuditResult {
272
+ const acc = newAccum();
273
+ // Content dedup is off in --changed/--staged mode: a changed file must always be
274
+ // audited, and it could otherwise collapse against an unchanged file we never read.
275
+ const dedupMode: DedupMode = opts.changed || opts.since || opts.staged ? "off" : (opts.dedup ?? "exact");
276
+ const seen = new Set<string>();
277
+ let duplicateFiles = 0;
278
+ let truncated: FinalizeExtra["truncated"];
279
+
280
+ // `--graph` expands the same inputs twice (markup, then markup + .ts/.js). The tree
281
+ // walk is the identical, extension-agnostic part of both — share it.
282
+ const walkCache = new Map<string, string[]>();
283
+
284
+ const {
285
+ files: discovered,
286
+ gitUnavailable,
287
+ stagedContent,
288
+ } = discover(opts.inputs, {
289
+ include: opts.include,
290
+ exclude: opts.exclude,
291
+ ext: opts.ext,
292
+ changed: opts.changed,
293
+ since: opts.since,
294
+ staged: opts.staged,
295
+ noDefaultExcludes: opts.noDefaultExcludes,
296
+ onWarn: opts.onWarn,
297
+ walkCache,
298
+ });
299
+ // In staged mode, read the index blob (from discovery) instead of the working tree.
300
+ const useStaged = opts.staged === true && !gitUnavailable;
301
+
302
+ // Diff-scoped mode (--changed/--since/--staged): a capture is rarely itself part of
303
+ // the diff (the SOURCE changed; its already-committed capture usually didn't), so
304
+ // pull in just the captures relevant to the diffed files (capturesForSources) — read
305
+ // from the working tree/disk even in --staged mode (an unchanged capture's staged
306
+ // blob and working-tree copy are identical). Extra files, never removed from the
307
+ // diff's own scope; a full scan's capture ingestion is the CLI appending captureDir
308
+ // as a top-level input instead (see cmdAudit's `useCaptures`).
309
+ const diffMode = opts.changed || opts.since || opts.staged;
310
+ const relevantCaptures =
311
+ opts.captureDiff && diffMode && opts.captureDir ? capturesForSources(opts.captureDir, discovered).filter((c) => !discovered.includes(c)) : [];
312
+ const files = relevantCaptures.length ? [...discovered, ...relevantCaptures] : discovered;
313
+
314
+ // Cross-file pass: build the dependency graph over the FULL scope (so a changed
315
+ // file's references resolve into unchanged definitions), then run cross rules in
316
+ // the audit loop below. Off by default — a plain audit is byte-identical. The
317
+ // graph's OWN discovery always widens to GRAPH_ONLY_EXT (.ts/.js/.mjs/.cjs) on top
318
+ // of whatever --ext adds — a barrel/plain-JS module is never an audit target (see
319
+ // `files` above), but it is real cross-file structure the graph resolves through.
320
+ let graph: DepGraph | undefined;
321
+ // Docs the graph pass already parsed, handed over so the audit loop below does not
322
+ // read and parse the very same markup a second time. Never used in --staged mode:
323
+ // there the audit must see the INDEX blob while the graph pass read the working
324
+ // tree, so the two can legitimately differ. Nor under --jsx, which forces a parser
325
+ // the graph pass does not apply.
326
+ let carried: Map<string, Doc> | undefined;
327
+ if (opts.graph || opts.captureCoverage) {
328
+ const graphExt = [...GRAPH_ONLY_EXT, ...(opts.ext ?? [])];
329
+ const graphFiles = discover(opts.inputs, {
330
+ include: opts.include,
331
+ exclude: opts.exclude,
332
+ ext: graphExt,
333
+ noDefaultExcludes: opts.noDefaultExcludes,
334
+ walkCache,
335
+ }).files;
336
+ const built = buildGraphAndDocs(graphFiles, { carryDocs: !useStaged && !opts.forceJsx });
337
+ graph = built.graph;
338
+ if (built.docs.size) carried = built.docs;
339
+ }
340
+
341
+ for (let i = 0; i < files.length; i++) {
342
+ if (opts.maxFiles && opts.maxFiles > 0 && acc.fileCount >= opts.maxFiles) {
343
+ // skipped = candidates not audited (reconciles: audited + skipped == total),
344
+ // counting both never-examined files and any read failures along the way.
345
+ const skipped = files.length - acc.fileCount;
346
+ truncated = { limit: opts.maxFiles, total: files.length, skipped };
347
+ opts.onWarn?.(
348
+ `ultra11y: --max-files=${opts.maxFiles} reached; audited ${acc.fileCount}/${files.length} files (highest-priority first). Skipped ${skipped}.`,
349
+ );
350
+ break;
351
+ }
352
+ const file = files[i]!;
353
+ let content: string;
354
+ const staged = useStaged ? stagedContent?.get(file) : undefined;
355
+ // A carried Doc already holds the file's source, so a hit skips the read entirely.
356
+ const reused = staged === undefined ? carried?.get(file) : undefined;
357
+ if (staged !== undefined) {
358
+ content = staged;
359
+ } else if (reused) {
360
+ content = reused.source;
361
+ } else {
362
+ try {
363
+ content = readText(file);
364
+ } catch {
365
+ continue; // unreadable / vanished between discovery and read
366
+ }
367
+ }
368
+ if (dedupMode !== "off") {
369
+ const h = hashContent(content, dedupMode);
370
+ if (seen.has(h)) {
371
+ duplicateFiles++;
372
+ continue; // identical to an already-audited file — cite the canonical one
373
+ }
374
+ seen.add(h);
375
+ }
376
+ foldDoc(acc, reused ?? parseSource(content, file, { forceJsx: opts.forceJsx }), graph);
377
+ }
378
+
379
+ const canonicalFiles = acc.fileCount;
380
+ // Respect --max-files for stdin too (don't let the stdin doc push past the cap).
381
+ if (opts.inputs.includes("-") && opts.stdin !== undefined && !(opts.maxFiles && opts.maxFiles > 0 && acc.fileCount >= opts.maxFiles)) {
382
+ foldDoc(acc, parseSource(opts.stdin, "<stdin>", { forceJsx: opts.forceJsx }), graph);
383
+ }
384
+
385
+ const result = finalize(acc, opts.inputs, {
386
+ ...(truncated ? { truncated } : {}),
387
+ ...(duplicateFiles > 0 ? { dedup: { canonicalFiles, duplicateFiles } } : {}),
388
+ });
389
+ if (graph) {
390
+ enrichCaptureOrigins(result.findings, graph); // anchor capture findings at the source component line
391
+ // Coverage is a repo-wide property: read the FULL capture set from the captures dir,
392
+ // not acc.captures (which is scoped to what THIS run audited — empty in --changed/--staged).
393
+ if (opts.captureCoverage) result.scope.captureCoverage = computeCaptureCoverage(graph, readCaptureDir(opts.captureDir ?? ".ultra11y/captures"));
394
+ }
395
+ return result;
396
+ }