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
package/src/report.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// `report` — render an AuditResult into a dated compliance report (Markdown). The
|
|
2
|
+
// CANONICAL, gated report is WCAG 2.2 Level AA (renderReport). A country standards
|
|
3
|
+
// pack (RGAA, …) gets a DERIVED report (renderPackReport) projected from the same
|
|
4
|
+
// WCAG-keyed result. Both keep the honest structure: per-guideline/theme synthesis,
|
|
5
|
+
// non-conformities, conforming + not-applicable lists, and the manual worklist
|
|
6
|
+
// (never silently C).
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { AuditResult, Finding, Lang, Severity, Status } from "./types.js";
|
|
10
|
+
import { guidelineTitle, scTitle } from "./wcag.js";
|
|
11
|
+
import { prdUnits, partitionUnits } from "./prd.js";
|
|
12
|
+
import { renderAuditorUnit } from "./auditor.js";
|
|
13
|
+
import { resolveMessage } from "./messages.js";
|
|
14
|
+
import {
|
|
15
|
+
type StandardId,
|
|
16
|
+
CORE,
|
|
17
|
+
isCore,
|
|
18
|
+
loadPack,
|
|
19
|
+
derivePackResults,
|
|
20
|
+
packCriteriaForFinding,
|
|
21
|
+
packConformancePct,
|
|
22
|
+
title as packTitle,
|
|
23
|
+
themeName,
|
|
24
|
+
type StandardPack,
|
|
25
|
+
} from "./standards/index.js";
|
|
26
|
+
|
|
27
|
+
const ICON: Record<Severity, string> = { bloquant: "🔴", majeur: "🟠", mineur: "🟡" };
|
|
28
|
+
const SEV_ORDER: Severity[] = ["bloquant", "majeur", "mineur"];
|
|
29
|
+
|
|
30
|
+
const L = {
|
|
31
|
+
fr: {
|
|
32
|
+
title: (std: string) => `Rapport d'audit d'accessibilité — ${std}`,
|
|
33
|
+
wcagStd: "WCAG 2.2 niveau AA",
|
|
34
|
+
date: "Date",
|
|
35
|
+
tool: "Outil",
|
|
36
|
+
toolNote: "moteur statique — audit préliminaire, critères de jugement à adjuger par l'agent IA (statique, gaté), rendu via `scan`",
|
|
37
|
+
scope: "Périmètre",
|
|
38
|
+
files: "fichier(s)",
|
|
39
|
+
rate: "Taux de réussite automatique (vérifications statiques)",
|
|
40
|
+
rateNote: "sous-ensemble décidable par la machine : C ÷ (C + NC)",
|
|
41
|
+
warn: "Ce rapport couvre le sous-ensemble de critères vérifiables automatiquement. Les critères « à évaluer » (rendu / jugement) sont adjugés par l'agent IA (`verify --manual`, de façon gatée) ; le rendu passe par `scan` (voir la dernière section).",
|
|
42
|
+
derived: (std: string) =>
|
|
43
|
+
`Vue dérivée du ${std} : projection des critères de succès WCAG audités sur le référentiel. La vérification d'intégrité (\`check\`/\`verify\`) opère sur le rapport WCAG canonique.`,
|
|
44
|
+
synthTitle: (by: string) => `1. Synthèse par ${by}`,
|
|
45
|
+
byGuideline: "règle WCAG",
|
|
46
|
+
byTheme: "thématique",
|
|
47
|
+
th: (head: string) => [head, "C", "NC", "NA", "À évaluer"],
|
|
48
|
+
total: "Total",
|
|
49
|
+
ncTitle: "2. Non-conformités (par priorité)",
|
|
50
|
+
recTitle: "Recommandations (non normatives)",
|
|
51
|
+
recNote:
|
|
52
|
+
"Bonnes pratiques SANS test normatif du référentiel actif — ce ne sont PAS des non-conformités et elles n'entrent pas dans le taux de réussite. Libre à l'équipe de les suivre.",
|
|
53
|
+
sev: { bloquant: "Bloquant", majeur: "Majeur", mineur: "Mineur" } as Record<Severity, string>,
|
|
54
|
+
none: "Aucune non-conformité détectée par le moteur statique.",
|
|
55
|
+
cTitle: "3. Critères conformes (C)",
|
|
56
|
+
naTitle: "4. Critères non applicables (NA)",
|
|
57
|
+
manualTitle: "5. Critères à adjuger (jugement / rendu) — non décidés par le moteur statique",
|
|
58
|
+
manualWarn:
|
|
59
|
+
"Adjugez-les avec `verify --manual` (l'agent décide depuis la source, de façon gatée) ; les critères de rendu passent par `scan`. Aucun ne doit être marqué « conforme » sans justification enregistrée et gatée.",
|
|
60
|
+
outOfScope: "Hors périmètre moteur — mappé sur des SC hors WCAG 2.2 AA ; vérification manuelle.",
|
|
61
|
+
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.",
|
|
62
|
+
nothing: "Aucun.",
|
|
63
|
+
dedup: "Dédup",
|
|
64
|
+
canonical: "fichier(s) canonique(s) audité(s)",
|
|
65
|
+
duplicate: "doublon(s) identique(s) ignoré(s)",
|
|
66
|
+
truncated: (l: number, t: number, s: number) =>
|
|
67
|
+
`Périmètre tronqué : ${l}/${t} fichiers audités (priorité d'abord), ${s} ignoré(s). Élargir avec --max-files.`,
|
|
68
|
+
rendered: (n: number, libs: string) =>
|
|
69
|
+
`Verdict source préliminaire : ${n} fichier(s) rendent des composants de bibliothèque (${libs}) dont le HTML produit n'est pas visible en analyse statique. Auditez la sortie de build (\`render\` / \`audit <dist>\`) ou \`scan\` avant de conclure.`,
|
|
70
|
+
sourceTemplate: (n: number, exts: string) =>
|
|
71
|
+
`Verdict source préliminaire : ${n} composant(s) ${exts} audité(s) en SOURCE (template). Les slots, snippets et liaisons dynamiques (:attr, {@render}) sont invisibles en analyse statique — auditez le rendu (\`render\` / \`scan\`) avant de conclure.`,
|
|
72
|
+
captures: (n: number) => `${n} fichier(s) de capture rendus audités à pleine fidélité (DOM réel) — le vrai HTML produit, pas l'appel de composant.`,
|
|
73
|
+
blindSpots: (n: number) =>
|
|
74
|
+
`${n} composant(s) sans capture rendue (angles morts) — audités sur source opaque uniquement ; auditez leur DOM rendu (\`render --setup\`).`,
|
|
75
|
+
// Task 5 — partial-audit advisory (owner decision: scan stays opt-in but strongly advised).
|
|
76
|
+
// The list names ONLY the needs-rendering criteria still untested (real coverage).
|
|
77
|
+
partialAudit: (list: string) =>
|
|
78
|
+
`Audit partiel — les critères « à restituer » (${list}) n'ont pas été testés. Lancez \`ultra11y scan --sample\` (Playwright + axe + sondes) sur l'échantillon, puis fusionnez avec \`scan --merge\`.`,
|
|
79
|
+
// Task 5 — « Constats par page » (Ara-style per-sample-page synthesis).
|
|
80
|
+
perPageTitle: "Constats par page",
|
|
81
|
+
perPageNote: "Constats regroupés par page de l'échantillon audité (rendu dynamique).",
|
|
82
|
+
transverseNote: (list: string) => `Éléments transverses audités sur chaque page : ${list}.`,
|
|
83
|
+
authYes: "🔒 authentification requise",
|
|
84
|
+
authNo: "🌐 public",
|
|
85
|
+
ncCount: "non-conformité(s)",
|
|
86
|
+
advCount: "recommandation(s)",
|
|
87
|
+
},
|
|
88
|
+
en: {
|
|
89
|
+
title: (std: string) => `Accessibility audit report — ${std}`,
|
|
90
|
+
wcagStd: "WCAG 2.2 Level AA",
|
|
91
|
+
date: "Date",
|
|
92
|
+
tool: "Tool",
|
|
93
|
+
toolNote: "static engine — preliminary audit; judgment criteria adjudicated by the AI agent (statically, gated), rendering via `scan`",
|
|
94
|
+
scope: "Scope",
|
|
95
|
+
files: "file(s)",
|
|
96
|
+
rate: "Automatic static-check pass rate",
|
|
97
|
+
rateNote: "machine-decidable subset: C ÷ (C + NC)",
|
|
98
|
+
warn: "This report covers the subset of criteria checkable automatically. The “to assess” criteria (rendering / judgment) are adjudicated by the AI agent (`verify --manual`, gated); rendering goes through `scan` (see the last section).",
|
|
99
|
+
derived: (std: string) =>
|
|
100
|
+
`Derived view of ${std}: the audited WCAG success criteria projected onto this standard. The integrity gates (\`check\`/\`verify\`) operate on the canonical WCAG report.`,
|
|
101
|
+
synthTitle: (by: string) => `1. Synthesis by ${by}`,
|
|
102
|
+
byGuideline: "WCAG guideline",
|
|
103
|
+
byTheme: "theme",
|
|
104
|
+
th: (head: string) => [head, "C", "NC", "NA", "To assess"],
|
|
105
|
+
total: "Total",
|
|
106
|
+
ncTitle: "2. Non-conformities (by priority)",
|
|
107
|
+
recTitle: "Recommendations (non-normative)",
|
|
108
|
+
recNote:
|
|
109
|
+
"Good practices with NO normative test of the active standard — these are NOT non-conformities and do not enter the pass rate. The team may adopt them at will.",
|
|
110
|
+
sev: { bloquant: "Blocking", majeur: "Major", mineur: "Minor" } as Record<Severity, string>,
|
|
111
|
+
none: "No non-conformity detected by the static engine.",
|
|
112
|
+
cTitle: "3. Conforming criteria (C)",
|
|
113
|
+
naTitle: "4. Not-applicable criteria (NA)",
|
|
114
|
+
manualTitle: "5. Criteria to adjudicate (judgment / rendering) — not decided by the static engine",
|
|
115
|
+
manualWarn:
|
|
116
|
+
"Adjudicate these with `verify --manual` (the agent decides from source, gated); rendering criteria go to `scan`. None may be marked “conforming” without a recorded, gated justification.",
|
|
117
|
+
outOfScope: "Out of engine scope — mapped to SCs outside WCAG 2.2 AA; manual verification.",
|
|
118
|
+
scopedOut: "The WCAG failures found concern elements outside this criterion's scope — assess separately.",
|
|
119
|
+
nothing: "None.",
|
|
120
|
+
dedup: "Dedup",
|
|
121
|
+
canonical: "canonical file(s) audited",
|
|
122
|
+
duplicate: "identical duplicate(s) skipped",
|
|
123
|
+
truncated: (l: number, t: number, s: number) => `Scope truncated: ${l}/${t} files audited (highest-priority first), ${s} skipped. Widen with --max-files.`,
|
|
124
|
+
rendered: (n: number, libs: string) =>
|
|
125
|
+
`Preliminary source verdict: ${n} file(s) render component-library components (${libs}) whose produced HTML is invisible to static analysis. Audit the build output (\`render\` / \`audit <dist>\`) or \`scan\` before concluding.`,
|
|
126
|
+
sourceTemplate: (n: number, exts: string) =>
|
|
127
|
+
`Preliminary source verdict: ${n} ${exts} component(s) audited as SOURCE (template). Slots, snippets and dynamic bindings (:attr, {@render}) are invisible to static analysis — audit the rendered output (\`render\` / \`scan\`) before concluding.`,
|
|
128
|
+
captures: (n: number) => `${n} rendered capture file(s) audited at full fidelity (real DOM) — the true produced HTML, not the component call.`,
|
|
129
|
+
blindSpots: (n: number) =>
|
|
130
|
+
`${n} component(s) without a rendered capture (blind spots) — audited from opaque source only; audit their rendered DOM (\`render --setup\`).`,
|
|
131
|
+
// Task 5 — partial-audit advisory (owner decision: scan stays opt-in but strongly advised).
|
|
132
|
+
// The list names ONLY the needs-rendering criteria still untested (real coverage).
|
|
133
|
+
partialAudit: (list: string) =>
|
|
134
|
+
`Partial audit — the needs-rendering criteria (${list}) were not tested. Run \`ultra11y scan --sample\` (Playwright + axe + probes) on the sample, then merge with \`scan --merge\`.`,
|
|
135
|
+
// Task 5 — « Findings per page » (Ara-style per-sample-page synthesis).
|
|
136
|
+
perPageTitle: "Findings per page",
|
|
137
|
+
perPageNote: "Findings grouped by the audited sample page (dynamic rendering).",
|
|
138
|
+
transverseNote: (list: string) => `Transverse elements audited on every page: ${list}.`,
|
|
139
|
+
authYes: "🔒 authentication required",
|
|
140
|
+
authNo: "🌐 public",
|
|
141
|
+
ncCount: "non-conformity(ies)",
|
|
142
|
+
advCount: "recommendation(s)",
|
|
143
|
+
},
|
|
144
|
+
} as const;
|
|
145
|
+
|
|
146
|
+
// The needs-rendering criteria the scan tier decides, with the labels the partial-audit
|
|
147
|
+
// banner names them by. Coverage comes from scope.scan.testedScs (stamped by mergeDynamic:
|
|
148
|
+
// Docker measures 1.4.10 only; the local runtime adds zoom / spacing / focus / hover, and
|
|
149
|
+
// live regions when interactions are on) — so the banner only ever names criteria that
|
|
150
|
+
// genuinely lack a dynamic verdict, and drops only when every one of them is covered.
|
|
151
|
+
const NEEDS_RENDERING: readonly { sc: string; label: Record<Lang, string> }[] = [
|
|
152
|
+
{ sc: "1.4.4", label: { fr: "zoom 200 %", en: "200% zoom" } },
|
|
153
|
+
{ sc: "1.4.10", label: { fr: "reflow 320 px", en: "320px reflow" } },
|
|
154
|
+
{ sc: "1.4.12", label: { fr: "espacement du texte", en: "text spacing" } },
|
|
155
|
+
{ sc: "2.4.7", label: { fr: "visibilité du focus", en: "focus visibility" } },
|
|
156
|
+
{ sc: "1.4.13", label: { fr: "contenu au survol", en: "content on hover" } },
|
|
157
|
+
{ sc: "4.1.3", label: { fr: "régions live", en: "live regions" } },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/** The scan-tier needs-rendering SCs this audit has NO dynamic verdict for. Coverage =
|
|
161
|
+
* scope.scan.testedScs (the merge-time stamp); back-compat: a dyn-* probe finding proves
|
|
162
|
+
* its SC was measured even on an audit merged before the stamp existed. Non-empty ⇒ the
|
|
163
|
+
* partial-audit advisory shows, naming exactly these criteria. */
|
|
164
|
+
export function untestedNeedsRendering(r: AuditResult): string[] {
|
|
165
|
+
const tested = new Set(r.scope.scan?.testedScs ?? []);
|
|
166
|
+
for (const f of r.findings) if (f.ruleId.startsWith("dyn-")) tested.add(f.criteriaId);
|
|
167
|
+
return NEEDS_RENDERING.filter((c) => !tested.has(c.sc)).map((c) => c.sc);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The partial-audit advisory text (no leading `> `) — shared by the report banner and the
|
|
171
|
+
* CLI warning (src/cli.ts cmdReport) so the two can never drift. Names ONLY the criteria
|
|
172
|
+
* in `untested` (default: all of them — the no-scan-at-all case). */
|
|
173
|
+
export function partialAuditBanner(lang: Lang, untested: string[] = NEEDS_RENDERING.map((c) => c.sc)): string {
|
|
174
|
+
const set = new Set(untested);
|
|
175
|
+
const labels = NEEDS_RENDERING.filter((c) => set.has(c.sc)).map((c) => c.label[lang]);
|
|
176
|
+
return L[lang].partialAudit(labels.join(", "));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A normalized row the renderer is agnostic about: one labelled criterion + its status/findings.
|
|
180
|
+
interface Row {
|
|
181
|
+
id: string;
|
|
182
|
+
label: string; // "1.4.3 — Contrast (Minimum)" or "RGAA 1.1 — …"
|
|
183
|
+
status: Status;
|
|
184
|
+
findings: Finding[];
|
|
185
|
+
justification?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface Group {
|
|
189
|
+
key: string;
|
|
190
|
+
title: string;
|
|
191
|
+
rows: Row[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Shared renderer over normalized groups/rows — keeps the WCAG and pack reports identical
|
|
195
|
+
// in shape. `groupHead` labels the synthesis column ("WCAG guideline" / "theme"). `standard`
|
|
196
|
+
// drives the NC section below (`prdUnits`/`renderAuditorUnit` are standard-aware).
|
|
197
|
+
function render(
|
|
198
|
+
r: AuditResult,
|
|
199
|
+
lang: Lang,
|
|
200
|
+
opts: {
|
|
201
|
+
std: string;
|
|
202
|
+
groupHead: string;
|
|
203
|
+
groups: Group[];
|
|
204
|
+
standard: StandardId;
|
|
205
|
+
derivedOf?: string;
|
|
206
|
+
partialAudit?: string[];
|
|
207
|
+
headerRatePct?: number;
|
|
208
|
+
},
|
|
209
|
+
): string {
|
|
210
|
+
const s = L[lang];
|
|
211
|
+
const out: string[] = [];
|
|
212
|
+
out.push(`# ${s.title(opts.std)}`, "");
|
|
213
|
+
out.push(`- **${s.date}** : ${r.date}`);
|
|
214
|
+
out.push(`- **${s.tool}** : ultra11y v${r.version} (${s.toolNote})`);
|
|
215
|
+
out.push(`- **${s.scope}** : ${r.scope.files} ${s.files} — ${r.scope.inputs.join(", ")}`);
|
|
216
|
+
out.push(`- **${s.rate}** : ${opts.headerRatePct ?? r.conformancePct}% (${s.rateNote})`);
|
|
217
|
+
if (r.scope.dedup) out.push(`- **${s.dedup}** : ${r.scope.dedup.canonicalFiles} ${s.canonical}, ${r.scope.dedup.duplicateFiles} ${s.duplicate}`);
|
|
218
|
+
out.push("", `> ⚠️ ${s.warn}`, "");
|
|
219
|
+
// Partial-audit advisory banner (owner decision) — a pack audit whose scan coverage
|
|
220
|
+
// leaves needs-rendering criteria untested. Names EXACTLY the untested ones (a Docker
|
|
221
|
+
// run — reflow only — keeps the banner for the local-only probes it never ran). Placed
|
|
222
|
+
// in the header, before the derived-view note. Labels only — no criterion-shaped token,
|
|
223
|
+
// so the `check` structural gate accepts it.
|
|
224
|
+
if (opts.partialAudit?.length) out.push(`> 🚨 ${partialAuditBanner(lang, opts.partialAudit)}`, "");
|
|
225
|
+
if (opts.derivedOf) out.push(`> ↪️ ${s.derived(opts.derivedOf)}`, "");
|
|
226
|
+
if (r.scope.truncated) out.push(`> ✂️ ${s.truncated(r.scope.truncated.limit, r.scope.truncated.total, r.scope.truncated.skipped)}`, "");
|
|
227
|
+
if (r.scope.rendered) out.push(`> 🧩 ${s.rendered(r.scope.rendered.files, r.scope.rendered.opaqueLibraries.join(", "))}`, "");
|
|
228
|
+
if (r.scope.sourceTemplate) out.push(`> 🧩 ${s.sourceTemplate(r.scope.sourceTemplate.files, r.scope.sourceTemplate.extensions.join(", "))}`, "");
|
|
229
|
+
if (r.scope.captures) out.push(`> ✅ ${s.captures(r.scope.captures.files)}`, "");
|
|
230
|
+
if (r.scope.captureCoverage?.blindSpots.length) out.push(`> ⚠️ ${s.blindSpots(r.scope.captureCoverage.blindSpots.length)}`, "");
|
|
231
|
+
|
|
232
|
+
const rows = opts.groups.flatMap((g) => g.rows);
|
|
233
|
+
|
|
234
|
+
// 1. synthesis
|
|
235
|
+
const th = s.th(opts.groupHead);
|
|
236
|
+
out.push(`## ${s.synthTitle(opts.groupHead)}`, "");
|
|
237
|
+
out.push(`| ${th.join(" | ")} |`);
|
|
238
|
+
out.push(`|${"---|".repeat(th.length)}`);
|
|
239
|
+
const tot = { c: 0, nc: 0, na: 0, manual: 0 };
|
|
240
|
+
for (const g of opts.groups) {
|
|
241
|
+
const c = g.rows.filter((x) => x.status === "C").length;
|
|
242
|
+
const nc = g.rows.filter((x) => x.status === "NC").length;
|
|
243
|
+
const na = g.rows.filter((x) => x.status === "NA").length;
|
|
244
|
+
const manual = g.rows.filter((x) => x.status === "manual").length;
|
|
245
|
+
out.push(`| ${g.key} ${g.title} | ${c} | ${nc} | ${na} | ${manual} |`);
|
|
246
|
+
tot.c += c;
|
|
247
|
+
tot.nc += nc;
|
|
248
|
+
tot.na += na;
|
|
249
|
+
tot.manual += manual;
|
|
250
|
+
}
|
|
251
|
+
out.push(`| **${s.total}** | **${tot.c}** | **${tot.nc}** | **${tot.na}** | **${tot.manual}** |`, "");
|
|
252
|
+
|
|
253
|
+
// 2. non-conformities by priority — one auditor block per NC criterion (core or
|
|
254
|
+
// pack), the SAME human language `prd`/GitHub issues use (src/auditor.ts
|
|
255
|
+
// `renderAuditorUnit`), grouped by severity like `renderAuditorBacklog`. Reuses
|
|
256
|
+
// `prdUnits` so a criterion here is EXACTLY a `prd`/`gh` backlog item — no
|
|
257
|
+
// report-local re-grouping logic, and the two stay impossible to drift apart.
|
|
258
|
+
out.push(`## ${s.ncTitle}`, "");
|
|
259
|
+
const { nc: ncUnits, advisory: advisoryUnits } = partitionUnits(prdUnits(r, opts.standard, lang));
|
|
260
|
+
if (ncUnits.length === 0) {
|
|
261
|
+
out.push(s.none, "");
|
|
262
|
+
} else {
|
|
263
|
+
for (const sev of SEV_ORDER) {
|
|
264
|
+
const group = ncUnits.filter((u) => u.severity === sev);
|
|
265
|
+
if (!group.length) continue;
|
|
266
|
+
out.push(`### ${ICON[sev]} ${s.sev[sev]} (${group.length})`, "");
|
|
267
|
+
for (const u of group) out.push(...renderAuditorUnit(u, opts.standard, lang, { heading: "####" }));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Recommendations (non-normative) — advisory units, AFTER the non-conformities and
|
|
272
|
+
// BEFORE the numbered §3. An UNNUMBERED heading so the 1–5 section numbering `check`
|
|
273
|
+
// requires stays intact, and so the advisory blocks sit outside §2 (packReportNcIds /
|
|
274
|
+
// the NC over-under-projection gate never see them). Only emitted when present.
|
|
275
|
+
if (advisoryUnits.length) {
|
|
276
|
+
out.push(`## 💡 ${s.recTitle}`, "", `> ${s.recNote}`, "");
|
|
277
|
+
for (const u of advisoryUnits) out.push(...renderAuditorUnit(u, opts.standard, lang, { heading: "###" }));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// « Constats par page » — Ara-style per-sample-page synthesis (name + URL + auth badge +
|
|
281
|
+
// NC/advisory counts, then the page's findings). Only when a page sample was recorded
|
|
282
|
+
// (scan --sample merged in). UNNUMBERED heading so the 1–5 section numbering `check`
|
|
283
|
+
// requires stays intact and the packReportNcIds parser (section 2) never sees it.
|
|
284
|
+
if (r.scope.sample?.pages.length) {
|
|
285
|
+
out.push(`## 📄 ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
|
|
286
|
+
if (r.scope.sample.transverse?.length) out.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
|
|
287
|
+
// Standard-aware per-finding label: a pack report (RGAA, …) speaks its own criteria
|
|
288
|
+
// everywhere else, so this per-page line should too, rather than the raw WCAG SC id.
|
|
289
|
+
// `loadPack` once, outside the finding loop — never per-finding.
|
|
290
|
+
const pack = isCore(opts.standard) ? undefined : loadPack(opts.standard);
|
|
291
|
+
for (const pg of r.scope.sample.pages) {
|
|
292
|
+
const onPage = r.findings.filter((f) => f.file === pg.url || (f.sample?.page !== undefined && f.sample.page === pg.name));
|
|
293
|
+
const nc = onPage.filter((f) => !f.advisory);
|
|
294
|
+
const adv = onPage.filter((f) => f.advisory);
|
|
295
|
+
out.push(`### ${pg.name} — \`${pg.url}\` — ${pg.auth ? s.authYes : s.authNo}`, "");
|
|
296
|
+
out.push(`- ${nc.length} ${s.ncCount}${adv.length ? ` · ${adv.length} ${s.advCount}` : ""}`);
|
|
297
|
+
if (pg.notes) out.push(`- _${pg.notes}_`);
|
|
298
|
+
for (const f of nc.slice(0, 30)) {
|
|
299
|
+
const crits = pack ? packCriteriaForFinding(pack, f) : [];
|
|
300
|
+
const label = crits.length ? crits.join(", ") : f.criteriaId; // graceful fallback to the WCAG SC
|
|
301
|
+
out.push(` - [${label}] \`${f.selectorHint}\` — ${resolveMessage(f, lang)}`);
|
|
302
|
+
}
|
|
303
|
+
out.push("");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3. conforming
|
|
308
|
+
out.push(`## ${s.cTitle}`, "");
|
|
309
|
+
const conform = rows.filter((x) => x.status === "C");
|
|
310
|
+
out.push(conform.length ? conform.map((x) => `- ${x.label}`).join("\n") : s.nothing, "");
|
|
311
|
+
|
|
312
|
+
// 4. not applicable
|
|
313
|
+
out.push(`## ${s.naTitle}`, "");
|
|
314
|
+
const na = rows.filter((x) => x.status === "NA");
|
|
315
|
+
out.push(na.length ? na.map((x) => `- ${x.label}${x.justification ? ` — _${x.justification}_` : ""}`).join("\n") : s.nothing, "");
|
|
316
|
+
|
|
317
|
+
// 5. manual worklist
|
|
318
|
+
out.push(`## ${s.manualTitle}`, "", `> ${s.manualWarn}`, "");
|
|
319
|
+
const manual = rows.filter((x) => x.status === "manual");
|
|
320
|
+
out.push(manual.length ? manual.map((x) => `- ${x.label}${x.justification ? ` — _${x.justification}_` : ""}`).join("\n") : s.nothing, "");
|
|
321
|
+
|
|
322
|
+
return out.join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The canonical, gated WCAG 2.2 AA report. */
|
|
326
|
+
export function renderReport(r: AuditResult, lang: Lang = "en"): string {
|
|
327
|
+
const s = L[lang];
|
|
328
|
+
const byGuideline = new Map<string, Row[]>();
|
|
329
|
+
for (const c of r.criteria) {
|
|
330
|
+
const title = scTitle(c.id, lang);
|
|
331
|
+
const row: Row = { id: c.id, label: title ? `${c.id} — ${title}` : c.id, status: c.status, findings: c.findings, justification: c.justification };
|
|
332
|
+
(byGuideline.get(c.guideline) ?? byGuideline.set(c.guideline, []).get(c.guideline)!).push(row);
|
|
333
|
+
}
|
|
334
|
+
// `g.title` on the AuditResult's GuidelineTally is the baked-in English title (kept for
|
|
335
|
+
// JSON back-compat); resolve the localized label from the guideline KEY instead so
|
|
336
|
+
// `--lang fr` renders the French guideline name here too.
|
|
337
|
+
const groups: Group[] = r.guidelines.map((g) => ({ key: g.key, title: guidelineTitle(g.key, lang) ?? g.title, rows: byGuideline.get(g.key) ?? [] }));
|
|
338
|
+
return render(r, lang, { std: s.wcagStd, groupHead: s.byGuideline, groups, standard: CORE });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A derived report for a country standards pack (RGAA, …), projected from the WCAG audit. */
|
|
342
|
+
export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang = "en"): string {
|
|
343
|
+
const derived = derivePackResults(r, pack.key);
|
|
344
|
+
const std = `${pack.name} ${pack.baseVersion}`;
|
|
345
|
+
const s = L[lang];
|
|
346
|
+
const naReason =
|
|
347
|
+
lang === "fr" ? "Aucun critère de succès WCAG mappé n'est applicable dans le périmètre." : "No mapped WCAG success criterion is applicable in scope.";
|
|
348
|
+
const byTheme = new Map<number, Row[]>();
|
|
349
|
+
for (const pr of derived) {
|
|
350
|
+
const pc = pack.criteria.find((c) => c.id === pr.id)!;
|
|
351
|
+
const row: Row = {
|
|
352
|
+
id: pr.id,
|
|
353
|
+
label: `${pack.name} ${pr.id} — ${packTitle(pack, pc, lang)}`,
|
|
354
|
+
status: pr.status,
|
|
355
|
+
findings: pr.findings,
|
|
356
|
+
// outOfScope / scopedOut criteria are "manual" (not NA) with their own dedicated
|
|
357
|
+
// justification — never mixed with the ordinary NA reason (see the manual section above).
|
|
358
|
+
...(pr.outOfScope
|
|
359
|
+
? { justification: s.outOfScope }
|
|
360
|
+
: pr.scopedOut
|
|
361
|
+
? { justification: s.scopedOut }
|
|
362
|
+
: pr.status === "NA"
|
|
363
|
+
? { justification: naReason }
|
|
364
|
+
: {}),
|
|
365
|
+
};
|
|
366
|
+
(byTheme.get(pr.theme) ?? byTheme.set(pr.theme, []).get(pr.theme)!).push(row);
|
|
367
|
+
}
|
|
368
|
+
const groups: Group[] = pack.themes.map((t) => ({ key: `${t.number}.`, title: themeName(pack, t.number, lang) ?? "", rows: byTheme.get(t.number) ?? [] }));
|
|
369
|
+
// Owner decision: a pack (RGAA) report is flagged PARTIAL while any needs-rendering
|
|
370
|
+
// criterion lacks a dynamic verdict — the banner names exactly which ones (a Docker-only
|
|
371
|
+
// scan covers reflow but not the local probes). The core WCAG report carries its own §5
|
|
372
|
+
// manual worklist and is not flagged here.
|
|
373
|
+
return render(r, lang, {
|
|
374
|
+
std,
|
|
375
|
+
groupHead: L[lang].byTheme,
|
|
376
|
+
groups,
|
|
377
|
+
derivedOf: std,
|
|
378
|
+
standard: pack.key,
|
|
379
|
+
partialAudit: untestedNeedsRendering(r),
|
|
380
|
+
headerRatePct: packConformancePct(derived),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface ReportOpts {
|
|
385
|
+
out: string;
|
|
386
|
+
lang: Lang;
|
|
387
|
+
standard: StandardId;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Render and write the report; returns the written path. The WCAG report is canonical
|
|
391
|
+
* (`wcag-<date>.md`); a pack report is a derived `<pack>-<date>.md`. */
|
|
392
|
+
export function writeReport(r: AuditResult, opts: ReportOpts): string {
|
|
393
|
+
const core = isCore(opts.standard);
|
|
394
|
+
const md = core ? renderReport(r, opts.lang) : renderPackReport(r, loadPack(opts.standard), opts.lang);
|
|
395
|
+
mkdirSync(opts.out, { recursive: true });
|
|
396
|
+
const path = join(opts.out, `${core ? "wcag" : opts.standard}-${r.date}.md`);
|
|
397
|
+
writeFileSync(path, md);
|
|
398
|
+
return path;
|
|
399
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Theme 3 — Colours. Computed contrast (3.2/3.3) generally needs rendering, but the
|
|
2
|
+
// slice an author writes with LITERAL inline colours is decidable from the markup
|
|
3
|
+
// alone: text colour vs the nearest painted background, both resolved to opaque
|
|
4
|
+
// literals. Anything non-literal (var(), currentColor, external CSS, translucent)
|
|
5
|
+
// is left untouched so 3.2 stays a residual risk rather than a false non-conformity.
|
|
6
|
+
import type { Doc, El } from "../parse/html.js";
|
|
7
|
+
import { attr } from "../parse/html.js";
|
|
8
|
+
import type { Rule, RuleFinding } from "./rule.js";
|
|
9
|
+
import { parseColor, parseInlineStyle, contrastRatio, type RGBA } from "../color.js";
|
|
10
|
+
|
|
11
|
+
const SKIP_TAGS = new Set(["script", "style", "head", "title", "meta", "noscript", "link"]);
|
|
12
|
+
|
|
13
|
+
function styleOf(el: El): Map<string, string> {
|
|
14
|
+
const s = attr(el, "style");
|
|
15
|
+
return s ? parseInlineStyle(s) : new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** First colour literal in a declaration value (handles the `background` shorthand). */
|
|
19
|
+
function colorFromValue(v: string): RGBA | null {
|
|
20
|
+
const whole = parseColor(v);
|
|
21
|
+
if (whole) return whole;
|
|
22
|
+
for (const tok of v.split(/\s+/)) {
|
|
23
|
+
const c = parseColor(tok);
|
|
24
|
+
if (c) return c;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Foreground: nearest self-or-ancestor inline `color` (colour inherits). */
|
|
30
|
+
function fgOf(el: El): RGBA | null {
|
|
31
|
+
for (let p: El | null = el; p; p = p.parent) {
|
|
32
|
+
const v = styleOf(p).get("color");
|
|
33
|
+
if (v) {
|
|
34
|
+
const c = colorFromValue(v);
|
|
35
|
+
if (c) return c;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Background: nearest self-or-ancestor inline solid background. Background does not
|
|
42
|
+
* inherit, but text visually sits on the nearest painted ancestor. */
|
|
43
|
+
function bgOf(el: El): RGBA | null {
|
|
44
|
+
for (let p: El | null = el; p; p = p.parent) {
|
|
45
|
+
const st = styleOf(p);
|
|
46
|
+
const v = st.get("background-color") ?? st.get("background");
|
|
47
|
+
if (v) {
|
|
48
|
+
const c = colorFromValue(v);
|
|
49
|
+
if (c) return c;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolved px of the nearest declared font-size. Distinguishes three cases:
|
|
56
|
+
// number — resolvable (px/pt and the 16px-root approximation for rem/em/%)
|
|
57
|
+
// "unknown" — a font-size IS declared but in a unit we can't resolve statically
|
|
58
|
+
// (vw/vh/calc()/clamp()…); the size could be large, so we must not
|
|
59
|
+
// assume "normal" and apply the strict 4.5 threshold (that FP'd `200%`).
|
|
60
|
+
// null — no font-size anywhere → browser default 16px → normal text.
|
|
61
|
+
function fontPx(el: El): number | "unknown" | null {
|
|
62
|
+
for (let p: El | null = el; p; p = p.parent) {
|
|
63
|
+
const v = styleOf(p).get("font-size");
|
|
64
|
+
if (!v) continue;
|
|
65
|
+
const m = /^([\d.]+)(px|pt|rem|em|%)?$/.exec(v.trim());
|
|
66
|
+
if (!m) return "unknown"; // declared but unresolvable (vw, calc(), clamp(), …)
|
|
67
|
+
const n = parseFloat(m[1]!);
|
|
68
|
+
if (Number.isNaN(n)) return "unknown";
|
|
69
|
+
const unit = m[2] ?? "px";
|
|
70
|
+
if (unit === "pt") return (n * 4) / 3;
|
|
71
|
+
if (unit === "rem" || unit === "em") return n * 16;
|
|
72
|
+
if (unit === "%") return (n / 100) * 16;
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isBold(el: El): boolean {
|
|
79
|
+
for (let p: El | null = el; p; p = p.parent) {
|
|
80
|
+
const v = styleOf(p).get("font-weight");
|
|
81
|
+
if (v) return v === "bold" || Number(v) >= 700;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** WCAG 1.4.3's "large scale text": ≥ 24px (18pt) regular, or ≥ 18.66px (14pt) bold —
|
|
87
|
+
* the exact 14pt-bold cutoff, not the commonly-rounded 18.5px. */
|
|
88
|
+
function isLarge(el: El): boolean {
|
|
89
|
+
const px = fontPx(el);
|
|
90
|
+
if (px === null) return false; // no font-size → 16px default → normal
|
|
91
|
+
if (px === "unknown") return true; // size undeterminable → treat as large (3:1) to avoid inventing an NC
|
|
92
|
+
return px >= 24 || (px >= 18.66 && isBold(el));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasDirectText(el: El): boolean {
|
|
96
|
+
return el.children.some((c) => c.type === "text" && c.data.trim() !== "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const contrastLiteral: Rule = {
|
|
100
|
+
id: "contrast-literal",
|
|
101
|
+
criteria: ["1.4.3"],
|
|
102
|
+
severity: "majeur",
|
|
103
|
+
run(doc: Doc): RuleFinding[] {
|
|
104
|
+
const out: RuleFinding[] = [];
|
|
105
|
+
for (const el of doc.elements) {
|
|
106
|
+
if (SKIP_TAGS.has(el.tag) || !hasDirectText(el)) continue;
|
|
107
|
+
const fg = fgOf(el);
|
|
108
|
+
const bg = bgOf(el);
|
|
109
|
+
if (!fg || !bg || fg.a < 1 || bg.a < 1) continue; // need two opaque literals
|
|
110
|
+
const ratio = contrastRatio(fg, bg);
|
|
111
|
+
const large = isLarge(el);
|
|
112
|
+
const min = large ? 3 : 4.5;
|
|
113
|
+
if (ratio >= min) continue;
|
|
114
|
+
out.push({
|
|
115
|
+
criteriaId: "1.4.3",
|
|
116
|
+
el,
|
|
117
|
+
msgId: "contrast-literal",
|
|
118
|
+
params: { ratio: ratio.toFixed(2), min, textSize: large ? "large" : "normal" },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const colorsRules: Rule[] = [contrastLiteral];
|