ultra11y 2.31.2 → 2.32.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/package.json +1 -1
- package/scripts/build-pack-rgaa.mjs +31 -0
- package/scripts/ultra11y.mjs +277 -63
- package/scripts/vendor/wcag-2.2-fr.json +1 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +277 -63
- package/skills/ultra11y/SKILL.md +11 -5
- package/skills/ultra11y/references/orchestrators.md +176 -0
- package/skills/ultra11y/references/pages.md +7 -3
- package/skills/ultra11y/scripts/ultra11y.mjs +277 -63
- package/src/adjudicate.ts +161 -14
- package/src/auditor.ts +2 -2
- package/src/cli.ts +70 -13
- package/src/data/standards/rgaa.json +10 -5
- package/src/data/wcag-universe.json +1 -1
- package/src/gh.ts +56 -9
- package/src/llm.ts +23 -2
- package/src/pages-report.ts +51 -12
- package/src/prd.ts +3 -2
- package/src/report.ts +70 -19
- package/src/standards/derive.ts +33 -2
- package/src/standards/pack.ts +13 -0
- package/src/standards/types.ts +13 -0
- package/src/types.ts +1 -1
package/src/llm.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// changes: the engine's "no keys, no install" promise holds everywhere else.
|
|
21
21
|
//
|
|
22
22
|
// Zero dependencies: global `fetch`, no SDK.
|
|
23
|
-
import type { AdjudicationItem, AgentFinding, CriterionVerdict } from "./adjudicate.js";
|
|
23
|
+
import type { AdjudicationItem, AgentFinding, CriterionVerdict, Evidence } from "./adjudicate.js";
|
|
24
24
|
|
|
25
25
|
export const DEFAULT_MODEL = "claude-sonnet-5";
|
|
26
26
|
const API_VERSION = "2023-06-01";
|
|
@@ -97,6 +97,21 @@ const VERDICT_TOOL = {
|
|
|
97
97
|
required: ["file", "line", "message", "normativeRef"],
|
|
98
98
|
},
|
|
99
99
|
},
|
|
100
|
+
citations: {
|
|
101
|
+
type: "array",
|
|
102
|
+
description:
|
|
103
|
+
"Required for C and NA when evidence was presented: the evidence items you cleared (or ruled out of scope). Copy `file`, `line` and `snippet` VERBATIM from the evidence list of this criterion — a citation that is not among them, or that no longer matches the source, is rejected.",
|
|
104
|
+
items: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
file: { type: "string" },
|
|
108
|
+
line: { type: "number" },
|
|
109
|
+
selector: { type: "string" },
|
|
110
|
+
snippet: { type: "string" },
|
|
111
|
+
},
|
|
112
|
+
required: ["file", "line"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
100
115
|
recommendations: {
|
|
101
116
|
type: "array",
|
|
102
117
|
description: "Non-normative good practices. They never change a status.",
|
|
@@ -126,7 +141,7 @@ const SYSTEM = `You are an accessibility auditor ruling on the criteria a static
|
|
|
126
141
|
Rules, in order of importance:
|
|
127
142
|
1. NEVER assert conformity you did not verify. "manual" with a reason is always available and is a correct answer.
|
|
128
143
|
2. An NC must cite a real file:line taken from the evidence you were given, and the criterion's OWN numbered test as normativeRef. A citation that does not resolve against the real source is rejected downstream and wastes the whole batch.
|
|
129
|
-
3. C and NA require a justification that says what you saw,
|
|
144
|
+
3. C and NA require a justification that says what you saw, AND a "citations" array naming the evidence items you cleared — file and line copied verbatim from the evidence presented for that criterion. A criterion presented with NO evidence cannot be C: record "manual" (reason "undecidable"), or NA if nothing in scope is concerned.
|
|
130
145
|
4. A criterion that needs a rendered page (computed contrast, visible focus, zoom, reflow) and was given only source evidence is "manual" with reason "needs-rendered-dom".
|
|
131
146
|
5. Rule only on the criteria presented. Never introduce another.`;
|
|
132
147
|
|
|
@@ -146,6 +161,7 @@ export interface RawVerdict {
|
|
|
146
161
|
justification?: string;
|
|
147
162
|
reason?: string;
|
|
148
163
|
findings?: AgentFinding[];
|
|
164
|
+
citations?: Evidence[];
|
|
149
165
|
recommendations?: AgentFinding[];
|
|
150
166
|
}
|
|
151
167
|
|
|
@@ -252,6 +268,11 @@ export function applyRawVerdicts(items: AdjudicationItem[], verdicts: RawVerdict
|
|
|
252
268
|
item.justification = v.justification ?? "";
|
|
253
269
|
item.reason = v.verdict === "manual" ? (v.reason ?? null) : null;
|
|
254
270
|
item.findings = v.verdict === "NC" ? (v.findings ?? []) : [];
|
|
271
|
+
// Citations belong to the clearing verdicts, the way findings belong to NC. Dropping
|
|
272
|
+
// them here (as this function once dropped everything a non-NC verdict carried) would
|
|
273
|
+
// make every model-produced C fail the gate that now requires them.
|
|
274
|
+
if (v.verdict === "C" || v.verdict === "NA") item.citations = v.citations ?? [];
|
|
275
|
+
else delete item.citations;
|
|
255
276
|
if (v.recommendations?.length) item.recommendations = v.recommendations;
|
|
256
277
|
filled++;
|
|
257
278
|
}
|
package/src/pages-report.ts
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
// `verify` (AUDITOR_OCCURRENCE, src/verify.ts).
|
|
20
20
|
//
|
|
21
21
|
// The screenshot is the one thing the page report has that no other output does. It is
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// passed in as an href, never resolved here: `.ultra11y/pages/<id>/screen.png` is already
|
|
23
|
+
// on disk, and the caller decides whether to reference it relatively or copy it beside the
|
|
24
|
+
// report (it does copy, so an uploaded `audits/` artefact keeps its images), which keeps
|
|
25
|
+
// this module pure and testable.
|
|
24
26
|
import { prdUnits } from "./prd.js";
|
|
25
27
|
import { renderAuditorUnit } from "./auditor.js";
|
|
26
28
|
import { pageView } from "./pages.js";
|
|
27
|
-
import { CORE, type StandardId, derivePackResults, isCore, loadPack, themeName, titlePlain } from "./standards/index.js";
|
|
29
|
+
import { CORE, type StandardId, derivePackResults, isCore, loadPack, packTestIds, themeName, titlePlain } from "./standards/index.js";
|
|
28
30
|
import type { AuditResult, Lang, PageResult, Status } from "./types.js";
|
|
29
31
|
import { compareSC, scTitle } from "./wcag.js";
|
|
30
32
|
|
|
@@ -61,9 +63,13 @@ const L = {
|
|
|
61
63
|
viewport: "Fenêtre",
|
|
62
64
|
producer: "Producteur",
|
|
63
65
|
auth: "Authentification requise",
|
|
64
|
-
rate: "Taux de
|
|
66
|
+
rate: "Taux de réussite automatique (vérifications statiques)",
|
|
65
67
|
rateNote: "sous-ensemble décidable : C ÷ (C + NC)",
|
|
66
68
|
tally: (c: number, nc: number, na: number, m: number) => `${c} conforme(s) · ${nc} non conforme(s) · ${na} non applicable(s) · ${m} à évaluer`,
|
|
69
|
+
coverage: (decided: number, total: number) =>
|
|
70
|
+
`Couverture : ${decided}/${total} critère(s) évalué(s) — le taux ci-dessus ne porte que sur eux, et ne dit rien des ${total - decided} autres.`,
|
|
71
|
+
tests: "Tests",
|
|
72
|
+
agentMark: "`C*` : conformité tranchée par l'agent IA à partir des évidences citées (gaté), et non prouvée par le moteur déterministe.",
|
|
67
73
|
screenshotAlt: (n: string) => `Capture d'écran de la page ${n}`,
|
|
68
74
|
noScreenshot: "Aucune capture d'écran pour cette page (le producteur n'en a pas fourni) — le tier pixel est donc inactif ici.",
|
|
69
75
|
gridTitle: "Grille des critères",
|
|
@@ -101,9 +107,13 @@ const L = {
|
|
|
101
107
|
viewport: "Viewport",
|
|
102
108
|
producer: "Producer",
|
|
103
109
|
auth: "Authentication required",
|
|
104
|
-
rate: "
|
|
110
|
+
rate: "Automatic static-check pass rate",
|
|
105
111
|
rateNote: "decidable subset: C ÷ (C + NC)",
|
|
106
112
|
tally: (c: number, nc: number, na: number, m: number) => `${c} conforming · ${nc} non-conforming · ${na} not applicable · ${m} to assess`,
|
|
113
|
+
coverage: (decided: number, total: number) =>
|
|
114
|
+
`Coverage: ${decided}/${total} criteria assessed — the rate above covers only those, and says nothing about the other ${total - decided}.`,
|
|
115
|
+
tests: "Tests",
|
|
116
|
+
agentMark: "`C*`: conformity ruled by the AI agent from the evidence it cited (gated), not proven by the deterministic engine.",
|
|
107
117
|
screenshotAlt: (n: string) => `Screenshot of the ${n} page`,
|
|
108
118
|
noScreenshot: "No screenshot for this page (the producer supplied none) — the pixel tier is therefore inactive here.",
|
|
109
119
|
gridTitle: "Criteria grid",
|
|
@@ -144,6 +154,12 @@ interface Row {
|
|
|
144
154
|
label: string;
|
|
145
155
|
group: string;
|
|
146
156
|
status: Status;
|
|
157
|
+
// The criterion's own numbered tests (pack standards only — the WCAG core's analogue is
|
|
158
|
+
// its techniques, which are advisory rather than the thing you rule on). Rendered in the
|
|
159
|
+
// grid so a sheet says WHAT has to be checked for every criterion, not only for the ones
|
|
160
|
+
// that happened to trigger a non-conformity.
|
|
161
|
+
tests: string[];
|
|
162
|
+
decidedBy?: "engine" | "agent" | "scan";
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
/** The criterion rows for ONE page, in the active standard's own vocabulary and order.
|
|
@@ -152,15 +168,24 @@ function rowsFor(result: AuditResult, page: PageResult, standard: StandardId, la
|
|
|
152
168
|
if (isCore(standard)) {
|
|
153
169
|
return [...page.criteria]
|
|
154
170
|
.sort((a, b) => compareSC(a.id, b.id))
|
|
155
|
-
.map((c) => ({
|
|
171
|
+
.map((c) => ({
|
|
172
|
+
id: c.id,
|
|
173
|
+
label: `${c.id} ${scTitle(c.id, lang) ?? ""}`.trim(),
|
|
174
|
+
group: c.guideline,
|
|
175
|
+
status: c.status,
|
|
176
|
+
tests: [],
|
|
177
|
+
decidedBy: c.decidedBy,
|
|
178
|
+
}));
|
|
156
179
|
}
|
|
157
180
|
const pack = loadPack(standard);
|
|
158
|
-
const byId = new Map(derivePackResults(pageView(result, page), standard).map((r) => [r.id, r
|
|
181
|
+
const byId = new Map(derivePackResults(pageView(result, page), standard).map((r) => [r.id, r]));
|
|
159
182
|
return pack.criteria.map((pc) => ({
|
|
160
183
|
id: pc.id,
|
|
161
184
|
label: `${pc.id} — ${titlePlain(pack, pc, lang)}`,
|
|
162
185
|
group: `${pc.theme}. ${themeName(pack, pc.theme, lang) ?? ""}`.trim(),
|
|
163
|
-
status: byId.get(pc.id) ?? "manual",
|
|
186
|
+
status: byId.get(pc.id)?.status ?? "manual",
|
|
187
|
+
tests: packTestIds(pack, pc.id),
|
|
188
|
+
decidedBy: byId.get(pc.id)?.decidedBy,
|
|
164
189
|
}));
|
|
165
190
|
}
|
|
166
191
|
|
|
@@ -202,7 +227,10 @@ export function renderPageReport(result: AuditResult, page: PageResult, opts: Pa
|
|
|
202
227
|
const rows = rowsFor(result, page, standard, lang);
|
|
203
228
|
const t = tally(rows);
|
|
204
229
|
out.push(`- **${s.rate}** : **${ratePct(rows)} %** _(${s.rateNote})_`);
|
|
205
|
-
out.push(`- ${s.tally(t.c, t.nc, t.na, t.manual)}
|
|
230
|
+
out.push(`- ${s.tally(t.c, t.nc, t.na, t.manual)}`);
|
|
231
|
+
// The rate alone reads as a verdict on the page. Naming the denominator next to it is
|
|
232
|
+
// what stops "100 %" over six decided criteria from being quoted as a conformant page.
|
|
233
|
+
out.push(`- ${s.coverage(t.c + t.nc, rows.length)}`, "");
|
|
206
234
|
|
|
207
235
|
// A source-only page cannot earn conformity by silence. Say so ON THE PAGE, not only in a
|
|
208
236
|
// legend the reader may never reach — this is the sentence that stops a clean-looking
|
|
@@ -213,17 +241,28 @@ export function renderPageReport(result: AuditResult, page: PageResult, opts: Pa
|
|
|
213
241
|
if (shot) out.push(``, "");
|
|
214
242
|
else out.push(`_${s.noScreenshot}_`, "");
|
|
215
243
|
|
|
244
|
+
// The grid carries the criterion's own numbered TESTS, not just its title and status:
|
|
245
|
+
// those tests are what has to be checked, and listing them only inside the
|
|
246
|
+
// non-conformity blocks meant a sheet said nothing about the work still to do on the
|
|
247
|
+
// ~99 criteria that never triggered. Test ids go in backticks and are never followed by
|
|
248
|
+
// an em dash — `check`'s criterion scanner matches `(\d+\.\d+)\s*—` and would capture
|
|
249
|
+
// "2.1" out of "6.2.1 — …".
|
|
250
|
+
const withTests = rows.some((r) => r.tests.length);
|
|
216
251
|
out.push(`${h}# ${s.gridTitle}`, "", `> ${s.gridNote}`, "");
|
|
217
|
-
out.push(`| ${s.criterion} | ${s.status}
|
|
252
|
+
out.push(withTests ? `| ${s.criterion} | ${s.tests} | ${s.status} |` : `| ${s.criterion} | ${s.status} |`);
|
|
253
|
+
out.push(withTests ? "| --- | --- | --- |" : "| --- | --- |");
|
|
218
254
|
let group = "";
|
|
219
255
|
for (const row of rows) {
|
|
220
256
|
if (row.group !== group) {
|
|
221
257
|
group = row.group;
|
|
222
|
-
out.push(`| **${group}** | |`);
|
|
258
|
+
out.push(withTests ? `| **${group}** | | |` : `| **${group}** | |`);
|
|
223
259
|
}
|
|
224
|
-
|
|
260
|
+
const mark = row.decidedBy === "agent" && row.status === "C" ? `${MARK[row.status]}*` : MARK[row.status];
|
|
261
|
+
if (withTests) out.push(`| ${row.label} | ${row.tests.map((t) => `\`${t}\``).join(" ")} | ${mark} |`);
|
|
262
|
+
else out.push(`| ${row.label} | ${mark} |`);
|
|
225
263
|
}
|
|
226
264
|
out.push("", `> ${s.manualWarn}`, "");
|
|
265
|
+
if (rows.some((r) => r.decidedBy === "agent" && r.status === "C")) out.push(`> ${s.agentMark}`, "");
|
|
227
266
|
|
|
228
267
|
// The non-conformities, rendered by the ONE auditor block (invariant 2). Feeding
|
|
229
268
|
// `prdUnits` the page view is what scopes them to this page without a second grouping.
|
package/src/prd.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
loadPack,
|
|
15
15
|
derivePackResults,
|
|
16
16
|
packConformancePct,
|
|
17
|
+
packTestIds,
|
|
17
18
|
standardLabel,
|
|
18
19
|
themeName,
|
|
19
20
|
titlePlain as packTitlePlain,
|
|
@@ -363,8 +364,8 @@ export function toRuleOnSection(r: AuditResult, standard: StandardId, lang: Lang
|
|
|
363
364
|
for (const pc of manual) {
|
|
364
365
|
const crit = pack.criteria.find((c) => c.id === pc.id);
|
|
365
366
|
const title = crit ? packTitlePlain(pack, crit, lang) : pc.id;
|
|
366
|
-
const tests =
|
|
367
|
-
out.push(`- [ ] **${pack.name} ${pc.id}** — ${title}${tests.length ? ` · ${s.tests}: ${tests.map((
|
|
367
|
+
const tests = packTestIds(pack, pc.id);
|
|
368
|
+
out.push(`- [ ] **${pack.name} ${pc.id}** — ${title}${tests.length ? ` · ${s.tests}: ${tests.map((t) => `\`${t}\``).join(" ")}` : ""}`);
|
|
368
369
|
}
|
|
369
370
|
out.push("");
|
|
370
371
|
return out;
|
package/src/report.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// WCAG-keyed result. Both keep the honest structure: per-guideline/theme synthesis,
|
|
5
5
|
// non-conformities, conforming + not-applicable lists, and the manual worklist
|
|
6
6
|
// (never silently C).
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join, relative } from "node:path";
|
|
9
9
|
import type { AuditResult, Finding, Lang, Severity, Status } from "./types.js";
|
|
10
10
|
import { guidelineTitle, scTitle } from "./wcag.js";
|
|
@@ -18,10 +18,10 @@ import {
|
|
|
18
18
|
CORE,
|
|
19
19
|
isCore,
|
|
20
20
|
loadPack,
|
|
21
|
-
getCriterion as getPackCriterion,
|
|
22
21
|
derivePackResults,
|
|
23
22
|
packCriteriaForFinding,
|
|
24
23
|
packConformancePct,
|
|
24
|
+
packTestIds,
|
|
25
25
|
title as packTitle,
|
|
26
26
|
themeName,
|
|
27
27
|
type StandardPack,
|
|
@@ -56,6 +56,9 @@ const L = {
|
|
|
56
56
|
sev: { bloquant: "Bloquant", majeur: "Majeur", mineur: "Mineur" } as Record<Severity, string>,
|
|
57
57
|
none: "Aucune non-conformité détectée par le moteur statique.",
|
|
58
58
|
cTitle: "3. Critères conformes (C)",
|
|
59
|
+
cAgentTitle: "Conformes par adjudication de l'agent (jugement, non prouvé par le moteur)",
|
|
60
|
+
cAgentNote:
|
|
61
|
+
"Ces critères ont été tranchés par l'agent IA à partir des évidences citées, non décidés par le moteur déterministe. Ils sont gatés (chaque verdict cite une évidence résolvable) mais restent un jugement : ils ne sont pas comptés dans le taux de réussite automatique ci-dessus.",
|
|
59
62
|
naTitle: "4. Critères non applicables (NA)",
|
|
60
63
|
manualTitle: "5. Critères à adjuger (jugement / rendu) — non décidés par le moteur statique",
|
|
61
64
|
manualWarn:
|
|
@@ -65,6 +68,7 @@ const L = {
|
|
|
65
68
|
"Générez la worklist : `verify --manual --in <audit.json> --standard <pack> --out <dir>`. Chaque item y porte l'énoncé complet de ses tests, sa note technique, ses cas particuliers, sa guidance et les termes que le référentiel définit.",
|
|
66
69
|
outOfScope: "Hors périmètre moteur — mappé sur des SC hors WCAG 2.2 AA ; vérification manuelle.",
|
|
67
70
|
scopedOut: "Les non-conformités WCAG relevées concernent des éléments hors du périmètre de ce critère — à évaluer séparément.",
|
|
71
|
+
judgment: "L’énoncé du critère demande davantage que les CS WCAG auxquels il est rattaché — le moteur n’y a pas répondu, à trancher.",
|
|
68
72
|
nothing: "Aucun.",
|
|
69
73
|
dedup: "Dédup",
|
|
70
74
|
canonical: "fichier(s) canonique(s) audité(s)",
|
|
@@ -117,6 +121,9 @@ const L = {
|
|
|
117
121
|
sev: { bloquant: "Blocking", majeur: "Major", mineur: "Minor" } as Record<Severity, string>,
|
|
118
122
|
none: "No non-conformity detected by the static engine.",
|
|
119
123
|
cTitle: "3. Conforming criteria (C)",
|
|
124
|
+
cAgentTitle: "Conforming by agent adjudication (judgement, not proven by the engine)",
|
|
125
|
+
cAgentNote:
|
|
126
|
+
"These criteria were ruled on by the AI agent from the evidence it cited, not decided by the deterministic engine. They are gated (every verdict cites resolvable evidence) but remain a judgement: they are not counted in the automatic pass rate above.",
|
|
120
127
|
naTitle: "4. Not-applicable criteria (NA)",
|
|
121
128
|
manualTitle: "5. Criteria to adjudicate (judgment / rendering) — not decided by the static engine",
|
|
122
129
|
manualWarn:
|
|
@@ -126,6 +133,7 @@ const L = {
|
|
|
126
133
|
"Generate the worklist: `verify --manual --in <audit.json> --standard <pack> --out <dir>`. Each item carries the full wording of its tests, its technical note, its particular cases, its guidance and the terms the standard defines.",
|
|
127
134
|
outOfScope: "Out of engine scope — mapped to SCs outside WCAG 2.2 AA; manual verification.",
|
|
128
135
|
scopedOut: "The WCAG failures found concern elements outside this criterion's scope — assess separately.",
|
|
136
|
+
judgment: "The criterion asks more than the WCAG SCs it maps to — the engine did not answer it; rule on it.",
|
|
129
137
|
nothing: "None.",
|
|
130
138
|
dedup: "Dedup",
|
|
131
139
|
canonical: "canonical file(s) audited",
|
|
@@ -194,6 +202,11 @@ interface Row {
|
|
|
194
202
|
status: Status;
|
|
195
203
|
findings: Finding[];
|
|
196
204
|
justification?: string;
|
|
205
|
+
// Who decided this criterion. Absent/"engine" = the deterministic engine; "agent" = an
|
|
206
|
+
// adjudication (gated, but a judgement call); "scan" = the rendered tier. Rendered so a
|
|
207
|
+
// conformity the engine PROVED is never presented as the same thing as one an agent
|
|
208
|
+
// RULED — see the split in section 3 and the header rate.
|
|
209
|
+
decidedBy?: "engine" | "agent" | "scan";
|
|
197
210
|
}
|
|
198
211
|
|
|
199
212
|
interface Group {
|
|
@@ -329,16 +342,27 @@ function render(
|
|
|
329
342
|
out.push(`- ${nc.length} ${s.ncCount}${adv.length ? ` · ${adv.length} ${s.advCount}` : ""}`);
|
|
330
343
|
const notes = pageScope.find((x) => x.id === pg.id)?.notes;
|
|
331
344
|
if (notes) out.push(`- _${notes}_`);
|
|
332
|
-
// The screenshot the snapshot already holds.
|
|
333
|
-
// directory
|
|
345
|
+
// The screenshot the snapshot already holds. Copied next to the report when there is
|
|
346
|
+
// an output directory, so the directory travels intact — CI uploads `audits/` alone,
|
|
347
|
+
// and a `../.ultra11y/…` reference is a broken image in the artifact the reviewer
|
|
348
|
+
// opens. Without an --out (stdout), stay relative: there is nothing to be
|
|
349
|
+
// self-contained about.
|
|
334
350
|
const shot = join(PAGES_DIR, pg.id, "screen.png");
|
|
335
|
-
if (existsSync(shot))
|
|
336
|
-
|
|
337
|
-
""
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
351
|
+
if (existsSync(shot)) {
|
|
352
|
+
let href = relative(opts.outDir ?? ".", shot)
|
|
353
|
+
.split("\\")
|
|
354
|
+
.join("/");
|
|
355
|
+
if (opts.outDir) {
|
|
356
|
+
try {
|
|
357
|
+
mkdirSync(join(opts.outDir, "assets"), { recursive: true });
|
|
358
|
+
copyFileSync(shot, join(opts.outDir, "assets", `${pg.id}.png`));
|
|
359
|
+
href = `./assets/${pg.id}.png`;
|
|
360
|
+
} catch {
|
|
361
|
+
// Unwritable output dir: keep the relative reference rather than lose the image.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
out.push("", ``);
|
|
365
|
+
}
|
|
342
366
|
for (const f of nc.slice(0, 30)) {
|
|
343
367
|
const crits = pack ? packCriteriaForFinding(pack, f) : [];
|
|
344
368
|
const label = crits.length ? crits.join(", ") : f.criteriaId; // graceful fallback to the WCAG SC
|
|
@@ -348,10 +372,23 @@ function render(
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
351
|
-
// 3. conforming
|
|
375
|
+
// 3. conforming — split by PROVENANCE. A criterion the deterministic engine decided and
|
|
376
|
+
// one an agent ruled on are both "C", but they are not the same claim, and merging them
|
|
377
|
+
// into one list (and one headline rate) is how an adjudication pass could publish dozens
|
|
378
|
+
// of conformities nobody had verified. The gate makes an agent C evidence-bound; this
|
|
379
|
+
// keeps it legible as an agent's judgement all the way to the reader.
|
|
352
380
|
out.push(`## ${s.cTitle}`, "");
|
|
353
381
|
const conform = rows.filter((x) => x.status === "C");
|
|
354
|
-
|
|
382
|
+
const byEngine = conform.filter((x) => x.decidedBy !== "agent");
|
|
383
|
+
const byAgent = conform.filter((x) => x.decidedBy === "agent");
|
|
384
|
+
if (!conform.length) out.push(s.nothing, "");
|
|
385
|
+
else {
|
|
386
|
+
if (byEngine.length) out.push(...byEngine.map((x) => `- ${x.label}`), "");
|
|
387
|
+
if (byAgent.length) {
|
|
388
|
+
out.push(`### ${s.cAgentTitle}`, "", `> ${s.cAgentNote}`, "");
|
|
389
|
+
out.push(...byAgent.map((x) => `- ${x.label}${x.justification ? ` — _${x.justification}_` : ""}`), "");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
355
392
|
|
|
356
393
|
// 4. not applicable
|
|
357
394
|
out.push(`## ${s.naTitle}`, "");
|
|
@@ -368,8 +405,8 @@ function render(
|
|
|
368
405
|
else {
|
|
369
406
|
const pack5 = isCore(opts.standard) ? undefined : loadPack(opts.standard);
|
|
370
407
|
for (const x of manual) {
|
|
371
|
-
const tests = pack5 ?
|
|
372
|
-
const testRef = tests.length ? ` — ${s.testsToRule}: ${tests.map((
|
|
408
|
+
const tests = pack5 ? packTestIds(pack5, x.id) : [];
|
|
409
|
+
const testRef = tests.length ? ` — ${s.testsToRule}: ${tests.map((t) => `\`${t}\``).join(" · ")}` : "";
|
|
373
410
|
out.push(`- ${x.label}${x.justification ? ` — _${x.justification}_` : ""}${testRef}`);
|
|
374
411
|
}
|
|
375
412
|
out.push("", `> ${s.manualHowTo}`, "");
|
|
@@ -384,7 +421,14 @@ export function renderReport(r: AuditResult, lang: Lang = "en", outDir?: string)
|
|
|
384
421
|
const byGuideline = new Map<string, Row[]>();
|
|
385
422
|
for (const c of r.criteria) {
|
|
386
423
|
const title = scTitle(c.id, lang);
|
|
387
|
-
const row: Row = {
|
|
424
|
+
const row: Row = {
|
|
425
|
+
id: c.id,
|
|
426
|
+
label: title ? `${c.id} — ${title}` : c.id,
|
|
427
|
+
status: c.status,
|
|
428
|
+
findings: c.findings,
|
|
429
|
+
justification: c.justification,
|
|
430
|
+
decidedBy: c.decidedBy,
|
|
431
|
+
};
|
|
388
432
|
(byGuideline.get(c.guideline) ?? byGuideline.set(c.guideline, []).get(c.guideline)!).push(row);
|
|
389
433
|
}
|
|
390
434
|
// `g.title` on the AuditResult's GuidelineTally is the baked-in English title (kept for
|
|
@@ -409,15 +453,18 @@ export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang
|
|
|
409
453
|
label: `${pack.name} ${pr.id} — ${packTitle(pack, pc, lang)}`,
|
|
410
454
|
status: pr.status,
|
|
411
455
|
findings: pr.findings,
|
|
456
|
+
...(pr.decidedBy ? { decidedBy: pr.decidedBy } : {}),
|
|
412
457
|
// outOfScope / scopedOut criteria are "manual" (not NA) with their own dedicated
|
|
413
458
|
// justification — never mixed with the ordinary NA reason (see the manual section above).
|
|
414
459
|
...(pr.outOfScope
|
|
415
460
|
? { justification: s.outOfScope }
|
|
416
461
|
: pr.scopedOut
|
|
417
462
|
? { justification: s.scopedOut }
|
|
418
|
-
: pr.
|
|
419
|
-
? { justification:
|
|
420
|
-
:
|
|
463
|
+
: pr.judgment
|
|
464
|
+
? { justification: s.judgment }
|
|
465
|
+
: pr.status === "NA"
|
|
466
|
+
? { justification: naReason }
|
|
467
|
+
: {}),
|
|
421
468
|
};
|
|
422
469
|
(byTheme.get(pr.theme) ?? byTheme.set(pr.theme, []).get(pr.theme)!).push(row);
|
|
423
470
|
}
|
|
@@ -434,6 +481,10 @@ export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang
|
|
|
434
481
|
standard: pack.key,
|
|
435
482
|
partialAudit: untestedNeedsRendering(r),
|
|
436
483
|
headerRatePct: packConformancePct(derived),
|
|
484
|
+
// Forwarded, unlike before: without it the per-page screenshots resolved against the
|
|
485
|
+
// CWD instead of the report's own directory, so a pack report written to `audits/`
|
|
486
|
+
// carried links that only worked when read from the repo root.
|
|
487
|
+
outDir,
|
|
437
488
|
});
|
|
438
489
|
}
|
|
439
490
|
|
package/src/standards/derive.ts
CHANGED
|
@@ -23,6 +23,16 @@ export interface PackCriterionResult {
|
|
|
23
23
|
// sibling's failure. It derives as `manual` (assess separately) with a dedicated
|
|
24
24
|
// scoped-out justification (see src/report.ts renderPackReport).
|
|
25
25
|
scopedOut?: boolean;
|
|
26
|
+
// Set when the pack marks this criterion `judgment` and the projection WOULD have
|
|
27
|
+
// inherited a `C` from a mapped SC that never answered the criterion's own question
|
|
28
|
+
// (RGAA 8.6 "is the title pertinent?" off WCAG 2.4.2 "a title exists"). It derives
|
|
29
|
+
// `manual` instead — see `judgmentGuard` and src/report.ts for the justification.
|
|
30
|
+
judgment?: boolean;
|
|
31
|
+
// Recorded on the adjudicated branch below (an agent verdict at the pack's own
|
|
32
|
+
// granularity wins over the derivation). Declared here because renderings distinguish a
|
|
33
|
+
// conformity the engine PROVED from one an agent RULED — they are not the same claim.
|
|
34
|
+
justification?: string;
|
|
35
|
+
decidedBy?: "engine" | "agent" | "scan";
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
// NC dominates (a real failure anywhere fails the criterion); then a decided C; then
|
|
@@ -68,7 +78,11 @@ export function packCriteriaForFinding(pack: StandardPack, finding: Finding): st
|
|
|
68
78
|
* same as core. Exists so a pack report's header rate can't drift from its own table once
|
|
69
79
|
* pack overrides (advisory/severity flips) make the two diverge from core `conformancePct`. */
|
|
70
80
|
export function packConformancePct(derived: PackCriterionResult[]): number {
|
|
71
|
-
|
|
81
|
+
// Agent-adjudicated conformities are deliberately excluded, mirroring the core
|
|
82
|
+
// (`recomputeTallies` in src/adjudicate.ts): this number is the AUTOMATIC pass rate, and
|
|
83
|
+
// a judgement — however well gated — is not an automatic verification. An agent NC still
|
|
84
|
+
// counts; lowering the rate off evidenced findings is the safe direction.
|
|
85
|
+
const c = derived.filter((d) => d.status === "C" && d.decidedBy !== "agent").length;
|
|
72
86
|
const nc = derived.filter((d) => d.status === "NC").length;
|
|
73
87
|
return c + nc === 0 ? 100 : Math.round((c / (c + nc)) * 100);
|
|
74
88
|
}
|
|
@@ -123,6 +137,23 @@ function applySecondaryMappings(
|
|
|
123
137
|
return { ...base, findings };
|
|
124
138
|
}
|
|
125
139
|
|
|
140
|
+
/** A criterion the pack marks `judgment` never INHERITS a `C` from its mapped WCAG SCs.
|
|
141
|
+
*
|
|
142
|
+
* The projection folds the SCs DINUM's own crosswalk cites, and `aggregate` returns `C`
|
|
143
|
+
* as soon as one of them is `C`. That is the right answer when the RGAA question and the
|
|
144
|
+
* SC ask the same thing, and a fabricated conformity when the RGAA wording asks more:
|
|
145
|
+
* RGAA 8.6 wants a *pertinent* page title, WCAG 2.4.2 only a present one; RGAA 13.3 wants
|
|
146
|
+
* an accessible version of a downloaded document, and no mapped SC ever opened it. Those
|
|
147
|
+
* derived `C`s were the one error this tool must not make, so they become `manual` and go
|
|
148
|
+
* to the agent instead.
|
|
149
|
+
*
|
|
150
|
+
* Only `C` is intercepted: an `NC` was evidenced by a rule that really fired on this
|
|
151
|
+
* criterion, and an `NA` means nothing in scope is concerned — both stay. */
|
|
152
|
+
function judgmentGuard(r: PackCriterionResult, pc: PackCriterion): PackCriterionResult {
|
|
153
|
+
if (!pc.judgment || r.status !== "C") return r;
|
|
154
|
+
return { ...r, status: "manual" as Status, judgment: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
export function derivePackResults(audit: AuditResult, packKey: string): PackCriterionResult[] {
|
|
127
158
|
const pack = loadPack(packKey);
|
|
128
159
|
const byScId = new Map(audit.criteria.map((c) => [c.id, c]));
|
|
@@ -207,7 +238,7 @@ export function derivePackResults(audit: AuditResult, packKey: string): PackCrit
|
|
|
207
238
|
decidedBy: "agent" as const,
|
|
208
239
|
};
|
|
209
240
|
}
|
|
210
|
-
const base = deriveBase(pc);
|
|
241
|
+
const base = judgmentGuard(deriveBase(pc), pc);
|
|
211
242
|
return enabledSecondary.length ? applySecondaryMappings(base, pc, enabledSecondary, secondarySources, pack.defaultLocale) : base;
|
|
212
243
|
});
|
|
213
244
|
}
|
package/src/standards/pack.ts
CHANGED
|
@@ -41,6 +41,19 @@ export function titlePlain(pack: StandardPack, c: PackCriterion, lang: Lang): st
|
|
|
41
41
|
return localize(pack, c.titlePlain, lang);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The criterion's own numbered test ids, in pack order: RGAA 11.1 → ["11.1.1", "11.1.2",
|
|
45
|
+
* "11.1.3"]. These are what an auditor actually rules on, and what a `normativeRef` must
|
|
46
|
+
* cite, so several surfaces render them (the auditor block, the report's to-rule-on list,
|
|
47
|
+
* the PRD, the adjudication worklist, the per-page grid). Centralised here so the id shape
|
|
48
|
+
* is defined once — it used to be re-derived inline at each call site.
|
|
49
|
+
*
|
|
50
|
+
* Callers render these inside backticks, never followed by an em dash: `check`'s criterion
|
|
51
|
+
* scanner matches `(\d+\.\d+)\s*—` and would capture "2.1" out of "6.2.1 — …". */
|
|
52
|
+
export function packTestIds(pack: StandardPack, id: string): string[] {
|
|
53
|
+
const tests = getCriterion(pack, id)?.tests;
|
|
54
|
+
return tests ? Object.keys(tests).map((k) => `${id}.${k}`) : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
export function resolveGlossary(packKey: string, anchor: string): GlossaryEntry | undefined {
|
|
45
58
|
return packGlossary(packKey)?.[anchor];
|
|
46
59
|
}
|
package/src/standards/types.ts
CHANGED
|
@@ -37,6 +37,19 @@ export interface PackCriterion {
|
|
|
37
37
|
// failure). Optional/additive: a pack WITHOUT `appliesTo` keeps the legacy fan-out
|
|
38
38
|
// (every mapped SC's findings attach) so third-party packs are unaffected.
|
|
39
39
|
appliesTo?: { ruleIds: string[] };
|
|
40
|
+
// The criterion's own wording asks MORE than the WCAG SCs it maps to, so a `C` on those
|
|
41
|
+
// SCs is not an answer to it. RGAA 8.6 asks whether the page title is *pertinent*; WCAG
|
|
42
|
+
// 2.4.2 only that a title exists. RGAA 13.3 asks whether a downloadable document has an
|
|
43
|
+
// accessible version; no mapped SC ever opened the document. Without this flag the
|
|
44
|
+
// projection returns `C` as soon as one mapped SC is `C` (see `aggregate` in derive.ts)
|
|
45
|
+
// and publishes a conformity nobody assessed.
|
|
46
|
+
//
|
|
47
|
+
// A flagged criterion can still derive `NC` (a rule actually fired on it) and `NA`
|
|
48
|
+
// (nothing applicable in scope); it simply never inherits a `C` — it derives `manual`
|
|
49
|
+
// and goes to the agent, who rules on it against the criterion's own numbered tests.
|
|
50
|
+
// `appliesTo` already carries the mirror-image promise for NC ("never NC from a
|
|
51
|
+
// sibling's failure"); this is the same guarantee on the conforming side.
|
|
52
|
+
judgment?: boolean;
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
// The localized DISPLAY vocabulary a standard uses when its audit is rendered for an
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single source of truth for shared types + the embedded version. sync-version.mjs
|
|
2
2
|
// rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
|
|
3
|
-
export const VERSION = "2.
|
|
3
|
+
export const VERSION = "2.32.0";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|