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
@@ -0,0 +1,630 @@
1
+ // `adjudicate` — the AI-adjudication workflow for the criteria the static engine cannot
2
+ // decide. Where the engine leaves a judgment/needs-rendering success criterion as a
3
+ // `manual` residual risk, this turns it into a WORKLIST: one entry per manual criterion,
4
+ // pre-loaded with the concrete evidence the engine already captured (every image's alt,
5
+ // every link's text + context, literal colour pairs, control labels…). The AI agent reads
6
+ // the evidence and records a verdict — C / NC / NA / manual — with a justification (for C
7
+ // and NA), a groundable finding (for NC), or a reason (for a still-`manual` residual that
8
+ // truly needs a rendered DOM via `scan`, or is genuinely undecidable). `applyAdjudication`
9
+ // folds the verdicts back into the audit, FAIL-CLOSED: no null verdict, no unjustified
10
+ // C/NA, no ungroundable NC, no reasonless manual, full coverage of the residual set. The
11
+ // decisions are the AGENT's, statically, gated — not a deferral to a human.
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type { AuditResult, Automatability, Finding, Lang, ResidualRisk, Severity, Status } from "./types.js";
15
+ import { SCHEMA_VERSION } from "./types.js";
16
+ import { discover } from "./discover.js";
17
+ import { readText } from "./util.js";
18
+ import { parseSource } from "./parse/source.js";
19
+ import { type Doc, type El, elementsByTag, attr, textContent, ancestors, snippet as elSnippet } from "./parse/html.js";
20
+ import { parseInlineStyle } from "./color.js";
21
+ import adjudicationJson from "./data/adjudication.json";
22
+ import { scTitle, getSC, hasSC, techniquesFor, allSC, guidelineTitle } from "./wcag.js";
23
+ import { groundItems, type GroundingSummary } from "./grounding.js";
24
+ import { type StandardId, isCore, loadPack, hasId, getCriterion } from "./standards/index.js";
25
+
26
+ /** Cap on evidence items harvested per criterion — bounded so a huge page can't produce an
27
+ * unreadable worklist; the honest overflow count is recorded in `evidenceTruncated`. */
28
+ export const ADJUDICATE_MAX_EVIDENCE = 30;
29
+
30
+ export interface Evidence {
31
+ file: string;
32
+ line: number;
33
+ selector: string;
34
+ snippet: string;
35
+ note?: string; // extra context the harvester surfaced (e.g. a link's nearest heading)
36
+ }
37
+
38
+ export type CriterionVerdict = "C" | "NC" | "NA" | "manual" | null;
39
+
40
+ /** An agent-declared non-conformity — same shape a `Finding` needs to render + re-gate. */
41
+ export interface AgentFinding {
42
+ file: string;
43
+ line: number;
44
+ selector?: string;
45
+ message: string;
46
+ snippet?: string;
47
+ severity?: Severity;
48
+ // The precise normative test the agent cites for an NC verdict — a WCAG SC id for the
49
+ // core (e.g. "1.1.1"), or a pack criterion / test id when adjudicating a pack standard
50
+ // (e.g. "1.1" or "1.1.1"). REQUIRED for any NC finding: `applyAdjudication` fail-closes
51
+ // when it is absent or does not resolve against the active standard. A recommendation
52
+ // (advisory) needs none — a good practice has no normative test by definition.
53
+ normativeRef?: string;
54
+ }
55
+
56
+ export interface AdjudicationItem {
57
+ criteriaId: string;
58
+ automatability: Automatability;
59
+ title?: string;
60
+ evidence: Evidence[];
61
+ evidenceTruncated?: { shown: number; total: number };
62
+ verdict: CriterionVerdict; // the agent fills this
63
+ justification: string; // REQUIRED for C and NA
64
+ reason: string | null; // REQUIRED for a still-`manual` verdict ("needs-rendered-dom" | "undecidable")
65
+ findings: AgentFinding[]; // REQUIRED (≥1, groundable, each with a normativeRef) for NC
66
+ // Non-normative good practices the agent noted on this criterion — folded back into the
67
+ // audit as ADVISORY findings (grounded exactly like an NC finding, but never affecting
68
+ // status: they cannot flip the criterion to NC nor enter conformancePct). Optional.
69
+ recommendations?: AgentFinding[];
70
+ decidedBy: "agent";
71
+ }
72
+
73
+ export interface AdjudicationFile {
74
+ tool: "ultra11y";
75
+ kind: "adjudication";
76
+ schemaVersion: number;
77
+ standard: StandardId;
78
+ auditDate: string;
79
+ items: AdjudicationItem[];
80
+ }
81
+
82
+ // ---- evidence harvesters ----
83
+ // Each harvester answers "for this SC, what did the engine see that the agent needs to
84
+ // rule?" — bounded, source-anchored, language-neutral. A criterion with no harvester gets
85
+ // an empty-evidence item (the agent decides from source, or leaves it manual with a reason).
86
+
87
+ const selectorFor = (el: El): string => {
88
+ const id = el.attribs.id ? `#${el.attribs.id}` : "";
89
+ const cls = el.attribs.class ? `.${el.attribs.class.trim().split(/\s+/)[0]}` : "";
90
+ return `${el.tag}${id}${cls}`;
91
+ };
92
+
93
+ const ev = (doc: Doc, el: El, note?: string): Evidence => ({
94
+ file: doc.file,
95
+ line: el.line,
96
+ selector: selectorFor(el),
97
+ snippet: elSnippet(doc, el, 160),
98
+ ...(note ? { note } : {}),
99
+ });
100
+
101
+ /** Nearest preceding heading text — the context a link/control is read in. */
102
+ function nearestHeading(doc: Doc, el: El): string | undefined {
103
+ const headings = elementsByTag(doc, "h1", "h2", "h3", "h4", "h5", "h6").filter((h) => h.start < el.start);
104
+ const h = headings[headings.length - 1];
105
+ return h ? textContent(h).trim().slice(0, 80) : undefined;
106
+ }
107
+
108
+ // Key/value display patterns (RGAA 8.9 div-presented fields / 9.3 <dl> semantics): a
109
+ // <dt>→<dd> pair, or a "label"-classed element immediately followed by a "value"-classed
110
+ // sibling (the "Mon profil" recap pattern). Surfaced so the agent can judge whether the
111
+ // relationship is only visual — never asserted an NC statically.
112
+ const LABEL_LIKE = /(^|[-_ ])(field-label|field-key|label|key|term)([-_ ]|$)/i;
113
+ const VALUE_LIKE = /(^|[-_ ])(field-value|field-data|value|data)([-_ ]|$)/i;
114
+ function keyValuePairs(doc: Doc): { key: El; label: string; value: string }[] {
115
+ const out: { key: El; label: string; value: string }[] = [];
116
+ for (const el of doc.elements) {
117
+ const isDt = el.tag === "dt";
118
+ const isLabelDiv = el.tag !== "label" && el.tag !== "dt" && LABEL_LIKE.test(attr(el, "class") ?? "");
119
+ if (!isDt && !isLabelDiv) continue;
120
+ const parent = el.parent;
121
+ if (!parent) continue;
122
+ const sibs = parent.children.filter((c): c is El => c.type === "element");
123
+ const next = sibs[sibs.indexOf(el) + 1];
124
+ if (!next) continue;
125
+ const paired = isDt ? next.tag === "dd" : VALUE_LIKE.test(attr(next, "class") ?? "");
126
+ if (!paired) continue;
127
+ out.push({ key: el, label: textContent(el).trim().slice(0, 40), value: textContent(next).trim().slice(0, 40) });
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // Download links whose destination is a document (RGAA 6.1): naming the format "(PDF)" is
133
+ // a RECOMMENDATION, not an NC — the harvester note says so explicitly.
134
+ const DOWNLOAD_HREF = /\.(pdf|docx?|xlsx?)(?:[?#]|$)/i;
135
+
136
+ // SPA signals feeding the RGAA 12.8 questions (page title / focus restitution on partial
137
+ // reload): a client-router import in the source. One synthetic evidence per doc, at the
138
+ // import line (no El to anchor on — these are source-level signals).
139
+ const ROUTER_IMPORT =
140
+ /['"](?:react-router(?:-dom)?|next\/(?:router|navigation)|vue-router|@remix-run\/[\w-]+|@tanstack\/[\w-]*router|@sveltejs\/kit|\$app\/(?:navigation|stores))['"]/;
141
+ function routerImportEvidence(doc: Doc): Evidence[] {
142
+ const m = ROUTER_IMPORT.exec(doc.source);
143
+ if (!m) return [];
144
+ const line = doc.source.slice(0, m.index).split("\n").length;
145
+ return [
146
+ {
147
+ file: doc.file,
148
+ line,
149
+ selector: "import",
150
+ snippet: (doc.source.split("\n")[line - 1] ?? "").trim().slice(0, 120),
151
+ note: "client-router import — verify page title + focus are restored on partial (SPA) navigation",
152
+ },
153
+ ];
154
+ }
155
+
156
+ // Status-message signal near a form: a status-ish class inside a <form> (dynamic feedback
157
+ // text feeding the RGAA 7.5 / WCAG 4.1.3 question).
158
+ const STATUS_CLASS = /(error|status|message|alert|notif|toast|feedback|live)/i;
159
+
160
+ type Harvester = (docs: Doc[]) => Evidence[];
161
+
162
+ const HARVESTERS: Record<string, Harvester> = {
163
+ // 1.1.1 Non-text Content — every image-like element's text alternative
164
+ "1.1.1": (docs) =>
165
+ docs.flatMap((d) =>
166
+ elementsByTag(d, "img", "svg", "area", "object", "embed", "canvas")
167
+ .concat(d.elements.filter((e) => attr(e, "role") === "img"))
168
+ .filter((e, i, a) => a.indexOf(e) === i)
169
+ .map((e) => ev(d, e, `alt="${attr(e, "alt") ?? ""}" aria-label="${attr(e, "aria-label") ?? ""}"`)),
170
+ ),
171
+ // 2.4.4 Link Purpose (In Context) — link text + destination + nearest heading. A
172
+ // document-download link's format mention "(PDF)" (RGAA 6.1) is a RECOMMENDATION, not an
173
+ // NC — the note says so, so the agent never files an NC for a missing format hint.
174
+ "2.4.4": (docs) =>
175
+ docs.flatMap((d) =>
176
+ elementsByTag(d, "a")
177
+ .filter((e) => attr(e, "href") !== undefined)
178
+ .map((e) => {
179
+ const href = attr(e, "href") ?? "";
180
+ const dl = DOWNLOAD_HREF.exec(href);
181
+ const note = dl ? ` download-format=${dl[1]!.toLowerCase()} (naming the format, e.g. "(PDF)", is a recommendation — not an NC)` : "";
182
+ return ev(d, e, `text="${textContent(e).trim().slice(0, 60)}" href="${href}" under="${nearestHeading(d, e) ?? ""}"${note}`);
183
+ }),
184
+ ),
185
+ // 1.4.3 Contrast (Minimum) — literal inline colour pairs (the ones statically visible)
186
+ "1.4.3": (docs) =>
187
+ docs.flatMap((d) =>
188
+ d.elements
189
+ .filter((e) => {
190
+ const st = parseInlineStyle(attr(e, "style") ?? "");
191
+ return st.has("color") || st.has("background-color") || st.has("background");
192
+ })
193
+ .map((e) => {
194
+ const st = parseInlineStyle(attr(e, "style") ?? "");
195
+ return ev(d, e, `color=${st.get("color") ?? "?"} background=${st.get("background-color") ?? st.get("background") ?? "?"}`);
196
+ }),
197
+ ),
198
+ // 2.4.6 Headings and Labels — heading + label + <caption> text to judge for
199
+ // descriptiveness/concision (captions feed the RGAA 5.5 title-concision question).
200
+ "2.4.6": (docs) =>
201
+ docs.flatMap((d) =>
202
+ elementsByTag(d, "h1", "h2", "h3", "h4", "h5", "h6", "label", "legend", "caption").map((e) =>
203
+ ev(d, e, `<${e.tag}> text="${textContent(e).trim().slice(0, 60)}"`),
204
+ ),
205
+ ),
206
+ // 3.3.2 Labels or Instructions — controls + their associated labels/placeholders
207
+ "3.3.2": (docs) =>
208
+ docs.flatMap((d) =>
209
+ elementsByTag(d, "input", "select", "textarea").map((e) => {
210
+ const id = attr(e, "id");
211
+ const lbl = id ? elementsByTag(d, "label").find((l) => attr(l, "for") === id) : undefined;
212
+ return ev(
213
+ d,
214
+ e,
215
+ `label="${lbl ? textContent(lbl).trim().slice(0, 40) : ""}" placeholder="${attr(e, "placeholder") ?? ""}" aria-label="${attr(e, "aria-label") ?? ""}"`,
216
+ );
217
+ }),
218
+ ),
219
+ // 1.3.1 Info and Relationships — heading outline + tables/lists (structure to judge for
220
+ // technique consistency), PLUS div-presented key/value pairs (RGAA 8.9 read-only fields /
221
+ // 9.3 <dl> semantics) so the "Mon profil" recap pattern is never silently conforming.
222
+ "1.3.1": (docs) =>
223
+ docs.flatMap((d) => [
224
+ ...elementsByTag(d, "h1", "h2", "h3", "h4", "h5", "h6", "table", "ul", "ol", "dl").map((e) =>
225
+ ev(d, e, `<${e.tag}> "${textContent(e).trim().slice(0, 50)}"`),
226
+ ),
227
+ ...keyValuePairs(d).map((p) =>
228
+ ev(d, p.key, `key/value pair — label="${p.label}" value="${p.value}" (div-presented field? verify the relationship isn't only visual — RGAA 8.9/9.3)`),
229
+ ),
230
+ ]),
231
+ // 4.1.3 Status Messages (RGAA 7.4/7.5) — live regions + status-ish text near a form
232
+ "4.1.3": (docs) =>
233
+ docs.flatMap((d) => {
234
+ const isRegion = (e: El) => attr(e, "aria-live") !== undefined || ["status", "alert", "log"].includes((attr(e, "role") ?? "").trim().toLowerCase());
235
+ const regions = d.elements.filter(isRegion);
236
+ const nearForm = d.elements.filter((e) => !isRegion(e) && STATUS_CLASS.test(attr(e, "class") ?? "") && ancestors(e).some((a) => a.tag === "form"));
237
+ return [
238
+ ...regions.map((e) => ev(d, e, `aria-live="${attr(e, "aria-live") ?? ""}" role="${attr(e, "role") ?? ""}"`)),
239
+ ...nearForm.map((e) =>
240
+ ev(
241
+ d,
242
+ e,
243
+ `status-like class="${(attr(e, "class") ?? "").slice(0, 40)}" in a form — verify async feedback is announced (role=status/alert or aria-live)`,
244
+ ),
245
+ ),
246
+ ];
247
+ }),
248
+ // 4.1.2 Name, Role, Value — elements carrying a role or ARIA state
249
+ "4.1.2": (docs) =>
250
+ docs.flatMap((d) =>
251
+ d.elements
252
+ .filter((e) => attr(e, "role") !== undefined || Object.keys(e.attribs).some((k) => k.startsWith("aria-")))
253
+ .map((e) => ev(d, e, `role="${attr(e, "role") ?? ""}"`)),
254
+ ),
255
+ // 2.4.3 Focus Order — explicit tabindex values in DOM order, PLUS SPA focus signals
256
+ // (RGAA 12.8): <dialog> usage and client-router imports (verify focus is moved on route
257
+ // change / dialog open, and a mobile menu's target receives focus).
258
+ "2.4.3": (docs) =>
259
+ docs.flatMap((d) => [
260
+ ...d.elements.filter((e) => attr(e, "tabindex") !== undefined).map((e) => ev(d, e, `tabindex="${attr(e, "tabindex")}"`)),
261
+ ...elementsByTag(d, "dialog").map((e) => ev(d, e, `<dialog> — verify focus moves in on open and is restored to the trigger on close`)),
262
+ ...routerImportEvidence(d),
263
+ ]),
264
+ // 3.1.2 Language of Parts — element-level lang overrides (not the root <html lang>)
265
+ "3.1.2": (docs) =>
266
+ docs.flatMap((d) => d.elements.filter((e) => e.tag !== "html" && attr(e, "lang") !== undefined).map((e) => ev(d, e, `lang="${attr(e, "lang")}"`))),
267
+ };
268
+
269
+ /** Resolve the audit's scope inputs back to parsed docs (harvesting reads the same files
270
+ * the audit did — run `verify --manual` from the audit's cwd). Best-effort: unreadable /
271
+ * vanished files are skipped, exactly like the audit's own read loop. */
272
+ function docsForAudit(audit: AuditResult, cwd?: string): Doc[] {
273
+ const inputs = audit.scope.inputs.filter((i) => i !== "-" && i !== "<stdin>");
274
+ if (!inputs.length) return [];
275
+ const { files } = discover(inputs, {});
276
+ const docs: Doc[] = [];
277
+ for (const f of files) {
278
+ try {
279
+ docs.push(parseSource(readText(cwd ? join(cwd, f) : f), f));
280
+ } catch {
281
+ /* unreadable — skip, mirrors runAudit */
282
+ }
283
+ }
284
+ return docs;
285
+ }
286
+
287
+ /** Build the adjudication worklist: one item per residual-risk (manual) criterion, with
288
+ * its harvested evidence (capped + honestly truncated). */
289
+ export function buildAdjudicationWorklist(audit: AuditResult, opts: { cwd?: string; standard?: StandardId } = {}): AdjudicationItem[] {
290
+ const docs = docsForAudit(audit, opts.cwd);
291
+ return audit.residualRisks.map((r: ResidualRisk) => {
292
+ const harvested = HARVESTERS[r.criteriaId]?.(docs) ?? [];
293
+ const evidence = harvested.slice(0, ADJUDICATE_MAX_EVIDENCE);
294
+ return {
295
+ criteriaId: r.criteriaId,
296
+ automatability: r.automatability,
297
+ title: scTitle(r.criteriaId) ?? undefined,
298
+ evidence,
299
+ ...(harvested.length > ADJUDICATE_MAX_EVIDENCE ? { evidenceTruncated: { shown: evidence.length, total: harvested.length } } : {}),
300
+ verdict: null,
301
+ justification: "",
302
+ reason: null,
303
+ findings: [],
304
+ recommendations: [],
305
+ decidedBy: "agent" as const,
306
+ };
307
+ });
308
+ }
309
+
310
+ export interface ApplyAdjudicationResult {
311
+ ok: boolean;
312
+ audit: AuditResult;
313
+ issues: string[];
314
+ applied: number;
315
+ stillManual: number;
316
+ grounding: GroundingSummary;
317
+ }
318
+
319
+ const NC_SEVERITY_DEFAULT: Severity = "majeur";
320
+ const MANUAL_REASONS = new Set(["needs-rendered-dom", "undecidable"]);
321
+
322
+ /** Does an NC finding's `normativeRef` resolve against the ACTIVE standard? For the WCAG
323
+ * core, it must be a real success-criterion id (reuses `hasSC`). For a pack, it must be a
324
+ * real pack criterion id, or a pack TEST id ("<criterionId>.<testKey>"). Fail-closed: an
325
+ * absent/blank/unresolvable ref makes the whole adjudication fail (mirrors verify.ts). */
326
+ function normativeRefResolves(ref: string | undefined, standard: StandardId): boolean {
327
+ const r = (ref ?? "").trim();
328
+ if (!r) return false;
329
+ if (isCore(standard)) return hasSC(r);
330
+ const pack = loadPack(standard);
331
+ if (hasId(pack, r)) return true; // a pack criterion id (e.g. RGAA "1.1")
332
+ // A pack test id "<criterionId>.<testKey>" (e.g. RGAA "1.1.1"): split at the last dot.
333
+ const dot = r.lastIndexOf(".");
334
+ if (dot <= 0) return false;
335
+ const crit = getCriterion(pack, r.slice(0, dot));
336
+ return !!crit?.tests && Object.hasOwn(crit.tests, r.slice(dot + 1));
337
+ }
338
+
339
+ /** Fold an adjudication file back into the audit. FAIL-CLOSED (see module header). Returns
340
+ * a NEW AuditResult with the decided statuses, agent findings, recomputed conformancePct,
341
+ * a shrunk residual set, and the `adjudicated` marker. */
342
+ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opts: { cwd?: string } = {}): ApplyAdjudicationResult {
343
+ const issues: string[] = [];
344
+ const byId = new Map(adj.items.map((it) => [it.criteriaId, it]));
345
+
346
+ // Coverage: every residual criterion must be adjudicated.
347
+ for (const r of audit.residualRisks) if (!byId.has(r.criteriaId)) issues.push(`criterion ${r.criteriaId}: missing from the adjudication (coverage gap)`);
348
+
349
+ // Per-item fail-closed validation.
350
+ const groundInputs: { file: string; line: number; selector?: string; snippet?: string }[] = [];
351
+ for (const it of adj.items) {
352
+ const v = it.verdict;
353
+ if (v === null) {
354
+ issues.push(`criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
355
+ } else if (v === "C" || v === "NA") {
356
+ if (!it.justification || !it.justification.trim()) issues.push(`criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
357
+ } else if (v === "NC") {
358
+ if (!it.findings || it.findings.length === 0) issues.push(`criterion ${it.criteriaId}: an NC verdict requires at least one groundable finding`);
359
+ for (const f of it.findings ?? []) {
360
+ // FAIL-CLOSED: every NC finding must cite a precise, resolvable test of the active
361
+ // standard. A good practice with no normative test is a recommendation, not an NC.
362
+ if (!f.normativeRef || !f.normativeRef.trim()) {
363
+ issues.push(`criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
364
+ } else if (!normativeRefResolves(f.normativeRef, adj.standard)) {
365
+ issues.push(`criterion ${it.criteriaId}: normativeRef "${f.normativeRef}" does not resolve to a test of ${adj.standard} (fabricated?)`);
366
+ }
367
+ groundInputs.push({ file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
368
+ }
369
+ } else if (v === "manual") {
370
+ if (!it.reason || !MANUAL_REASONS.has(it.reason))
371
+ issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason ∈ {needs-rendered-dom, undecidable}`);
372
+ } else {
373
+ issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}"`);
374
+ }
375
+ // Recommendations are independent of the verdict (a C criterion may still carry a good
376
+ // practice) and are grounded exactly like an NC finding — no normativeRef required, as
377
+ // a recommendation has no normative test by definition.
378
+ for (const rec of it.recommendations ?? []) groundInputs.push({ file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
379
+ }
380
+
381
+ // Content-level grounding of every agent NC finding + every recommendation.
382
+ const grounding = groundItems(groundInputs, { cwd: opts.cwd });
383
+ for (const gi of grounding.issues) issues.push(gi);
384
+
385
+ if (issues.length) {
386
+ return { ok: false, audit, issues, applied: 0, stillManual: 0, grounding };
387
+ }
388
+
389
+ // Apply: clone the audit, update the decided criteria + append agent findings.
390
+ const next: AuditResult = structuredClone(audit);
391
+ const critById = new Map(next.criteria.map((c) => [c.id, c]));
392
+ const newFindings: Finding[] = [];
393
+ let applied = 0;
394
+ let stillManual = 0;
395
+
396
+ for (const it of adj.items) {
397
+ const c = critById.get(it.criteriaId);
398
+ if (!c) continue; // an item for a non-residual criterion — ignore (coverage already gated)
399
+ if (it.verdict === "manual") {
400
+ c.status = "manual";
401
+ c.decidedBy = "agent";
402
+ c.justification = it.reason === "needs-rendered-dom" ? residualScanReason() : residualUndecidableReason();
403
+ stillManual++;
404
+ continue;
405
+ }
406
+ applied++;
407
+ c.status = it.verdict as Status;
408
+ c.decidedBy = "agent";
409
+ if (it.verdict === "C" || it.verdict === "NA") c.justification = it.justification.trim();
410
+ if (it.verdict === "NC") {
411
+ const fs: Finding[] = it.findings.map((f) => agentFinding(it.criteriaId, f));
412
+ c.findings = fs;
413
+ newFindings.push(...fs);
414
+ delete c.justification;
415
+ }
416
+ }
417
+
418
+ // Fold recommendations as ADVISORY findings on their criterion — status-neutral (they
419
+ // ride alongside whatever verdict was applied, incl. C/NA/manual) and never enter NC or
420
+ // conformancePct. A separate pass so a `manual` item (which `continue`s above) still
421
+ // gets its recommendations, and an NC item's reset `c.findings` keeps them appended last.
422
+ for (const it of adj.items) {
423
+ const c = critById.get(it.criteriaId);
424
+ if (!c) continue;
425
+ for (const rec of it.recommendations ?? []) {
426
+ const f = agentFinding(it.criteriaId, rec, true);
427
+ c.findings.push(f);
428
+ newFindings.push(f);
429
+ }
430
+ }
431
+
432
+ next.findings = [...next.findings, ...newFindings];
433
+ // Residual set now holds only the still-manual criteria.
434
+ next.residualRisks = next.residualRisks.filter((r) => byId.get(r.criteriaId)?.verdict === "manual");
435
+ recomputeTallies(next);
436
+ next.adjudicated = { date: adj.auditDate, applied, stillManual };
437
+ return { ok: true, audit: next, issues: [], applied, stillManual, grounding };
438
+ }
439
+
440
+ function agentFinding(criteriaId: string, f: AgentFinding, advisory = false): Finding {
441
+ return {
442
+ ruleId: `agent:${criteriaId}`,
443
+ criteriaId,
444
+ file: f.file,
445
+ line: f.line,
446
+ col: 1,
447
+ selectorHint: f.selector ?? "",
448
+ severity: f.severity ?? (advisory ? "mineur" : NC_SEVERITY_DEFAULT),
449
+ message: f.message,
450
+ remediation: getSC(criteriaId)?.understanding ? `See WCAG ${criteriaId}.` : "Address the reported non-conformity.",
451
+ snippet: f.snippet ?? "",
452
+ ...(advisory ? { advisory: true } : {}),
453
+ };
454
+ }
455
+
456
+ /** Recompute guideline tallies + conformancePct after statuses changed. Mirrors the
457
+ * finalize() logic in src/audit.ts so an adjudicated audit is internally consistent. */
458
+ function recomputeTallies(a: AuditResult): void {
459
+ for (const g of a.guidelines) {
460
+ const inG = a.criteria.filter((c) => c.guideline === g.key);
461
+ g.c = inG.filter((c) => c.status === "C").length;
462
+ g.nc = inG.filter((c) => c.status === "NC").length;
463
+ g.na = inG.filter((c) => c.status === "NA").length;
464
+ g.manual = inG.filter((c) => c.status === "manual").length;
465
+ }
466
+ const decided = a.criteria.filter((c) => c.status === "C" || c.status === "NC");
467
+ const conform = decided.filter((c) => c.status === "C").length;
468
+ a.conformancePct = decided.length === 0 ? 100 : Math.round((conform / decided.length) * 100);
469
+ }
470
+
471
+ const residualScanReason = () => "Rendering criterion — decide on the rendered DOM (`scan`).";
472
+ const residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
473
+
474
+ // ---- worklist file rendering ----
475
+ const T = {
476
+ fr: {
477
+ title: "# Adjudication des critères à évaluer (ultra11y)",
478
+ intro:
479
+ "Pour CHAQUE critère, lisez les évidences ci-dessous (extraites de la source auditée) et attribuez un verdict dans `ADJUDICATE.todo.json` (champ `verdict`) :",
480
+ verdicts: [
481
+ "- `C` — conforme (renseignez `justification`) ;",
482
+ "- `NC` — non conforme (ajoutez au moins un `findings[]` : file/line/message, avec un `snippet` groundable ET un `normativeRef` citant le test précis échoué) ;",
483
+ "- `NA` — non applicable (renseignez `justification`) ;",
484
+ "- `manual` — indécidable statiquement (renseignez `reason` : `needs-rendered-dom` → `scan`, ou `undecidable`).",
485
+ ],
486
+ rule: "> Ne signalez une NC que si un test précis du référentiel actif échoue — citez-le (`normativeRef`). Une bonne pratique sans test normatif est une recommandation (`recommendations[]`, non normative). Une simple préoccupation UX n'est ni l'un ni l'autre.",
487
+ then: "Puis : `ultra11y verify --apply ADJUDICATE.todo.json --in <audit.json> --out <dir>` (échoue si un verdict manque une justification/finding/reason).",
488
+ evidence: "Évidences",
489
+ none: "(aucune évidence automatique — décidez depuis la source, ou laissez `manual` avec une raison)",
490
+ questions: "À vérifier manuellement",
491
+ decide: "Règle de décision",
492
+ na: "Non applicable si",
493
+ refs: "Références normatives mobilisables (techniques/échecs W3C de ce critère)",
494
+ },
495
+ en: {
496
+ title: "# Criteria adjudication (ultra11y)",
497
+ intro: "For EACH criterion, read the evidence below (harvested from the audited source) and set a verdict in `ADJUDICATE.todo.json` (field `verdict`):",
498
+ verdicts: [
499
+ "- `C` — conformant (fill `justification`);",
500
+ "- `NC` — non-conformant (add at least one `findings[]`: file/line/message, with a groundable `snippet` AND a `normativeRef` citing the precise failed test);",
501
+ "- `NA` — not applicable (fill `justification`);",
502
+ "- `manual` — not statically decidable (fill `reason`: `needs-rendered-dom` → `scan`, or `undecidable`).",
503
+ ],
504
+ rule: "> Report NC only if a precise test of the active standard fails — cite it (`normativeRef`). A good practice without a normative test is a recommendation (`recommendations[]`, non-normative). A purely UX concern is neither.",
505
+ then: "Then: `ultra11y verify --apply ADJUDICATE.todo.json --in <audit.json> --out <dir>` (fails if any verdict lacks its justification/finding/reason).",
506
+ evidence: "Evidence",
507
+ none: "(no automatic evidence — decide from source, or leave `manual` with a reason)",
508
+ questions: "To verify manually",
509
+ decide: "Decision rule",
510
+ na: "Not applicable when",
511
+ refs: "Normative references you may cite (this criterion's W3C techniques/failures)",
512
+ },
513
+ } as const;
514
+
515
+ // SC-keyed adjudication protocol (src/data/adjudication.json, built by
516
+ // scripts/build-adjudication.mjs): for every criterion the static engine cannot settle, the
517
+ // rule that decides Conforming vs Non-conforming, when NA is legitimate, and the concrete
518
+ // questions that get you there. Rendered per residual item in both languages — a criterion
519
+ // handed to the agent with no stated decision rule is where an audit turns into an opinion.
520
+ type LocaleText = { fr: string; en: string };
521
+ const ADJUDICATION = adjudicationJson as Record<string, { decide: LocaleText; na?: LocaleText; questions: LocaleText[] }>;
522
+
523
+ /** Cap on techniques listed per criterion: 1.1.1 alone carries 52, which would bury the
524
+ * decision rule under a wall of ids. The full list stays in `criteria <sc>`. */
525
+ const MAX_REFS = 12;
526
+
527
+ /** Render the per-criterion decision protocol as a standalone reference
528
+ * (skills/ultra11y/references/adjudication.md). Generated from the same dataset the
529
+ * worklist uses, so the page an agent reads and the prompt it answers can never drift. */
530
+ export function renderAdjudicationReference(lang: Lang = "en"): string {
531
+ const out: string[] = [];
532
+ out.push("<!-- GENERATED from src/data/adjudication.json by `pnpm run build:adjudication` — do not edit by hand. -->", "");
533
+ out.push("# Deciding the criteria the engine hands you", "");
534
+ out.push(
535
+ "The static engine decides 3 of the 55 WCAG 2.2 AA success criteria outright. The other 52",
536
+ "come back as residual risks: 14 need a rendered page (the `scan` tier), 38 are judgment",
537
+ "calls. This page is the decision rule for each of them — what makes it Conforming, when",
538
+ "`NA` is legitimate, and the questions that get you there.",
539
+ "",
540
+ "It is the same dataset `verify --manual` loads into `ADJUDICATE.md`, so the worklist and",
541
+ "this page can never disagree. Two rules govern every verdict below:",
542
+ "",
543
+ "- **A non-conformity must cite a normative test that resolves.** The worklist proposes the",
544
+ " criterion's W3C techniques; `verify --apply` rejects a `normativeRef` that does not exist.",
545
+ "- **A good practice with no failing test is a recommendation**, not a non-conformity — it",
546
+ " never flips a criterion, never enters the conformance rate.",
547
+ "",
548
+ );
549
+ const byGuideline = new Map<string, string[]>();
550
+ for (const sc of allSC()) {
551
+ const protocol = ADJUDICATION[sc.sc];
552
+ if (!protocol) continue;
553
+ const lines = byGuideline.get(sc.guideline) ?? [];
554
+ lines.push(`### ${sc.sc} — ${scTitle(sc.sc, lang) ?? sc.title} · ${sc.level} · _${sc.automatability}_`, "", `**Decide.** ${protocol.decide[lang]}`, "");
555
+ if (protocol.na) lines.push(`**Not applicable when.** ${protocol.na[lang]}`, "");
556
+ if (protocol.questions.length) {
557
+ lines.push("**Ask.**", "");
558
+ for (const q of protocol.questions) lines.push(`- ${q[lang]}`);
559
+ lines.push("");
560
+ }
561
+ const refs = techniquesFor(sc.sc);
562
+ if (refs.length)
563
+ lines.push(`**Citable references.** ${refs.slice(0, MAX_REFS).join(", ")}${refs.length > MAX_REFS ? ` … (\`criteria ${sc.sc}\`)` : ""}`, "");
564
+ byGuideline.set(sc.guideline, lines);
565
+ }
566
+ for (const [guideline, lines] of byGuideline) {
567
+ out.push(`## ${guideline} ${guidelineTitle(guideline, lang) ?? ""}`.trimEnd(), "");
568
+ out.push(...lines);
569
+ }
570
+ return out.join("\n");
571
+ }
572
+
573
+ export function formatAdjudication(items: AdjudicationItem[], lang: Lang = "en"): string {
574
+ const s = T[lang];
575
+ const out: string[] = [s.title, "", s.intro, "", ...s.verdicts, "", s.rule, "", s.then, ""];
576
+ for (const it of items) {
577
+ out.push(`## ${it.criteriaId}${it.title ? ` — ${it.title}` : ""} _(${it.automatability})_`);
578
+ out.push("", `> ${s.evidence} (${it.evidence.length}${it.evidenceTruncated ? ` / ${it.evidenceTruncated.total}` : ""}):`, "");
579
+ if (!it.evidence.length) out.push(s.none, "");
580
+ else {
581
+ for (const e of it.evidence) out.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${e.note ? ` — ${e.note}` : ""}`);
582
+ out.push("");
583
+ }
584
+ const protocol = ADJUDICATION[it.criteriaId];
585
+ if (protocol) {
586
+ out.push(`> **${s.decide}** — ${protocol.decide[lang]}`, "");
587
+ if (protocol.na) out.push(`> **${s.na}** — ${protocol.na[lang]}`, "");
588
+ if (protocol.questions.length) {
589
+ out.push(`> ${s.questions}:`, "");
590
+ for (const q of protocol.questions) out.push(`- ${q[lang]}`);
591
+ out.push("");
592
+ }
593
+ }
594
+ // The techniques a NC on this criterion may legitimately cite. `verify --apply`
595
+ // rejects a normativeRef that does not resolve, so proposing the valid ones here is
596
+ // what keeps the agent from inventing one.
597
+ const refs = techniquesFor(it.criteriaId);
598
+ if (refs.length) {
599
+ out.push(`> ${s.refs}: ${refs.slice(0, MAX_REFS).join(", ")}${refs.length > MAX_REFS ? ` … (\`criteria ${it.criteriaId}\`)` : ""}`, "");
600
+ }
601
+ }
602
+ return out.join("\n");
603
+ }
604
+
605
+ export interface WriteAdjudicationResult {
606
+ todoPath: string;
607
+ mdPath: string;
608
+ count: number;
609
+ }
610
+
611
+ export function writeAdjudication(
612
+ items: AdjudicationItem[],
613
+ outDir: string,
614
+ opts: { standard: StandardId; auditDate: string; lang?: Lang },
615
+ ): WriteAdjudicationResult {
616
+ mkdirSync(outDir, { recursive: true });
617
+ const todoPath = join(outDir, "ADJUDICATE.todo.json");
618
+ const mdPath = join(outDir, "ADJUDICATE.md");
619
+ const file: AdjudicationFile = {
620
+ tool: "ultra11y",
621
+ kind: "adjudication",
622
+ schemaVersion: SCHEMA_VERSION,
623
+ standard: opts.standard,
624
+ auditDate: opts.auditDate,
625
+ items,
626
+ };
627
+ writeFileSync(todoPath, JSON.stringify(file, null, 2) + "\n");
628
+ writeFileSync(mdPath, formatAdjudication(items, opts.lang ?? "en"));
629
+ return { todoPath, mdPath, count: items.length };
630
+ }