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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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
|
+
}
|