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/scan.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// `scan` — OPTIONAL dynamic tier. Runs axe-core in a real headless browser
|
|
2
|
+
// (Playwright) to decide the needs-rendering criteria the static engine can't:
|
|
3
|
+
// computed contrast (3.2/3.3) above all, plus a 320px reflow check (10.11) and a
|
|
4
|
+
// cross-check of the structural rules. Everything runs in a self-contained Docker
|
|
5
|
+
// image built on first use (the runner + Dockerfile are embedded below, so the
|
|
6
|
+
// skill stays a single distributable bundle). `--merge <audit.json>` folds the
|
|
7
|
+
// dynamic findings back into a static AuditResult, upgrading "manual" criteria.
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdtempSync, writeFileSync, existsSync, statSync, readdirSync, rmSync, readFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import type { AuditResult, DynamicEngine, DynamicFinding, DynamicResult, Finding, Lang, SamplePage, Severity } from "./types.js";
|
|
14
|
+
import { lineStartsOf, lineColAt } from "./parse/html.js";
|
|
15
|
+
import { allGuidelines } from "./wcag.js";
|
|
16
|
+
import { PROBE_SEVERITY, PROBE_WCAG, scForAxe, severityFromImpact, isAxeAdvisory } from "./axe-map.js";
|
|
17
|
+
import { parseSitemapUrls, crawlUrls } from "./crawl.js";
|
|
18
|
+
import { sampleScope } from "./sample.js";
|
|
19
|
+
import { today } from "./util.js";
|
|
20
|
+
|
|
21
|
+
export const IMAGE_TAG = "ultra11y-dyn:1";
|
|
22
|
+
const MOUNT = "/work/input.html";
|
|
23
|
+
|
|
24
|
+
// The browser runner — lives INSIDE the Docker image (its own deps), never bundled
|
|
25
|
+
// into the zero-dep engine. Injects axe.source, runs axe, then a 320px reflow probe.
|
|
26
|
+
// Mirrored to docker/runner.mjs (kept byte-identical by docker-sync.test).
|
|
27
|
+
export const RUNNER = `import { chromium } from "playwright";
|
|
28
|
+
import axe from "axe-core";
|
|
29
|
+
const target = process.argv[2];
|
|
30
|
+
const isFile = target.startsWith("/work/");
|
|
31
|
+
const browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
|
|
32
|
+
try {
|
|
33
|
+
const page = await browser.newPage();
|
|
34
|
+
await page.goto(isFile ? "file://" + target : target, { waitUntil: "load", timeout: 45000 });
|
|
35
|
+
await page.addScriptTag({ content: axe.source });
|
|
36
|
+
const axeRes = await page.evaluate(async () => await window.axe.run(document, { resultTypes: ["violations"] }));
|
|
37
|
+
await page.setViewportSize({ width: 320, height: 800 });
|
|
38
|
+
const reflow = await page.evaluate(() => {
|
|
39
|
+
const el = document.scrollingElement || document.documentElement;
|
|
40
|
+
return { horizontalScroll: el.scrollWidth > el.clientWidth + 2 };
|
|
41
|
+
});
|
|
42
|
+
const violations = axeRes.violations.map((v) => ({
|
|
43
|
+
id: v.id, impact: v.impact, help: v.help, tags: v.tags,
|
|
44
|
+
nodes: v.nodes.slice(0, 10).map((n) => ({ target: n.target, html: (n.html || "").slice(0, 200) })),
|
|
45
|
+
}));
|
|
46
|
+
console.log(JSON.stringify({ url: target, violations, reflow }));
|
|
47
|
+
} finally {
|
|
48
|
+
await browser.close();
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
export const PKG = JSON.stringify(
|
|
53
|
+
{ name: "ultra11y-dynamic", private: true, type: "module", dependencies: { playwright: "^1.49.0", "axe-core": "4.12.1" } },
|
|
54
|
+
null,
|
|
55
|
+
2,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const DOCKERFILE = `FROM node:22-bookworm-slim
|
|
59
|
+
WORKDIR /app
|
|
60
|
+
COPY package.json ./
|
|
61
|
+
RUN npm install --omit=dev && npx playwright install --with-deps chromium
|
|
62
|
+
COPY runner.mjs ./
|
|
63
|
+
WORKDIR /work
|
|
64
|
+
ENTRYPOINT ["node", "/app/runner.mjs"]
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
export function dockerAvailable(): boolean {
|
|
68
|
+
try {
|
|
69
|
+
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10000 });
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function imageExists(tag: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
execFileSync("docker", ["image", "inspect", tag], { stdio: "ignore" });
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const CTX_PREFIX = "ultra11y-dyn-";
|
|
86
|
+
|
|
87
|
+
/** Build the dynamic-tier image from the embedded context (first use only).
|
|
88
|
+
* The temp build context is always removed afterwards — the host stays clean. */
|
|
89
|
+
export function buildImage(tag = IMAGE_TAG): void {
|
|
90
|
+
const ctx = mkdtempSync(join(tmpdir(), CTX_PREFIX));
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(join(ctx, "runner.mjs"), RUNNER);
|
|
93
|
+
writeFileSync(join(ctx, "package.json"), PKG);
|
|
94
|
+
writeFileSync(join(ctx, "Dockerfile"), DOCKERFILE);
|
|
95
|
+
execFileSync("docker", ["build", "-t", tag, ctx], { stdio: "inherit", timeout: 900000 });
|
|
96
|
+
} finally {
|
|
97
|
+
rmSync(ctx, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Remove any leftover temp build contexts (pure fs; safe to call always). */
|
|
102
|
+
export function cleanTempContexts(): number {
|
|
103
|
+
let removed = 0;
|
|
104
|
+
const dir = tmpdir();
|
|
105
|
+
for (const name of readdirSync(dir)) {
|
|
106
|
+
if (!name.startsWith(CTX_PREFIX)) continue;
|
|
107
|
+
rmSync(join(dir, name), { recursive: true, force: true });
|
|
108
|
+
removed++;
|
|
109
|
+
}
|
|
110
|
+
return removed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface CleanResult {
|
|
114
|
+
imageRemoved: boolean;
|
|
115
|
+
tempContextsRemoved: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Tear down the dynamic tier: remove the image + any leftover build contexts.
|
|
119
|
+
* Answers "clean it up easily from the script" — nothing is left on the host. */
|
|
120
|
+
export function cleanDynamic(tag = IMAGE_TAG): CleanResult {
|
|
121
|
+
let imageRemoved = false;
|
|
122
|
+
if (dockerAvailable() && imageExists(tag)) {
|
|
123
|
+
try {
|
|
124
|
+
execFileSync("docker", ["rmi", "-f", tag], { stdio: "ignore" });
|
|
125
|
+
imageRemoved = true;
|
|
126
|
+
} catch {
|
|
127
|
+
/* image in use or already gone */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { imageRemoved, tempContextsRemoved: cleanTempContexts() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One observation from a residual-criteria probe (local runtime only). */
|
|
134
|
+
export interface ProbeHit {
|
|
135
|
+
selector: string;
|
|
136
|
+
html: string;
|
|
137
|
+
detail: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RunnerOutput {
|
|
141
|
+
url: string;
|
|
142
|
+
violations: { id: string; impact: string | null; help: string; tags?: string[]; nodes: { target: string[]; html: string }[] }[];
|
|
143
|
+
reflow: { horizontalScroll: boolean };
|
|
144
|
+
// Residual-criteria probes — populated ONLY by the local runtime (scan-local.ts);
|
|
145
|
+
// the Docker RUNNER never sets them, so its RunnerOutput is unchanged. Optional so
|
|
146
|
+
// every existing caller/test (which omits them) stays valid.
|
|
147
|
+
focusVisible?: ProbeHit[];
|
|
148
|
+
reflowZoom?: ProbeHit[];
|
|
149
|
+
textSpacing?: ProbeHit[];
|
|
150
|
+
hover?: ProbeHit[];
|
|
151
|
+
// Stateful probes (local runtime, interactions ON) — a filled input clipped under each
|
|
152
|
+
// stress, and content updated by a safe interaction outside any live region. Optional +
|
|
153
|
+
// Docker-never-sets, exactly like the residual probes above.
|
|
154
|
+
inputOverflowReflow?: ProbeHit[];
|
|
155
|
+
inputOverflowZoom?: ProbeHit[];
|
|
156
|
+
inputOverflowSpacing?: ProbeHit[];
|
|
157
|
+
liveRegion?: ProbeHit[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// RunnerOutput key ↔ probe engine, so a clean Docker output (no probe arrays) folds
|
|
161
|
+
// exactly as before and the local output adds the residual-criteria findings.
|
|
162
|
+
const PROBE_FIELDS: { key: keyof RunnerOutput; engine: Exclude<DynamicEngine, "axe" | "reflow"> }[] = [
|
|
163
|
+
{ key: "focusVisible", engine: "focus-visible" },
|
|
164
|
+
{ key: "reflowZoom", engine: "reflow-zoom" },
|
|
165
|
+
{ key: "textSpacing", engine: "text-spacing" },
|
|
166
|
+
{ key: "hover", engine: "hover" },
|
|
167
|
+
{ key: "inputOverflowReflow", engine: "input-overflow-reflow" },
|
|
168
|
+
{ key: "inputOverflowZoom", engine: "input-overflow-zoom" },
|
|
169
|
+
{ key: "inputOverflowSpacing", engine: "input-overflow-spacing" },
|
|
170
|
+
{ key: "liveRegion", engine: "live-region" },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
function runRunner(target: string, isFile: boolean, tag: string): RunnerOutput {
|
|
174
|
+
const args = ["run", "--rm"];
|
|
175
|
+
if (isFile) args.push("-v", `${resolve(target)}:${MOUNT}:ro`);
|
|
176
|
+
args.push(tag, isFile ? MOUNT : target);
|
|
177
|
+
let stdout: string;
|
|
178
|
+
try {
|
|
179
|
+
// Pipe (not ignore) the container's stderr so a failed run surfaces the real
|
|
180
|
+
// cause (e.g. ERR_NAME_NOT_RESOLVED / a navigation timeout) instead of a bare
|
|
181
|
+
// "Command failed: docker run …".
|
|
182
|
+
stdout = execFileSync("docker", args, { encoding: "utf8", timeout: 240000, maxBuffer: 32 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
const err = e as { stderr?: Buffer | string; message?: string };
|
|
185
|
+
const detail = (err.stderr ? String(err.stderr).trim() : "") || err.message || String(e);
|
|
186
|
+
throw new Error(`docker run failed — ${detail}`);
|
|
187
|
+
}
|
|
188
|
+
const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
|
|
189
|
+
return JSON.parse(line) as RunnerOutput;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Map the runner's reported page back to a HOST-meaningful citation: the container mount
|
|
193
|
+
* (`/work/input.html`) and a `file://` URL both resolve to the host file we scanned; a
|
|
194
|
+
* real served URL (http/https) is kept as-is. This is what lets a dynamic finding cite a
|
|
195
|
+
* path a reader (and `verify --semantic`) can actually open, instead of `/work/input.html`. */
|
|
196
|
+
function hostPageOf(url: string | undefined, target: string): string {
|
|
197
|
+
if (!url) return target;
|
|
198
|
+
if (url === MOUNT) return target; // docker file mount → the host file we mounted read-only
|
|
199
|
+
if (url.startsWith("file://")) {
|
|
200
|
+
try {
|
|
201
|
+
return fileURLToPath(url);
|
|
202
|
+
} catch {
|
|
203
|
+
return target;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return url; // a real served URL — no host file to map to
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function toDynamicResult(out: RunnerOutput, target: string, lang: Lang = "en", engine = "axe-core@playwright (docker)"): DynamicResult {
|
|
210
|
+
const page = hostPageOf(out.url, target);
|
|
211
|
+
const findings: DynamicFinding[] = [];
|
|
212
|
+
for (const v of out.violations) {
|
|
213
|
+
const criteriaId = scForAxe(v.id, v.tags);
|
|
214
|
+
const severity: Severity = severityFromImpact(v.impact);
|
|
215
|
+
// A best-practice-only axe violation (no `wcag<digits>` tag) evidences no testable SC
|
|
216
|
+
// → fold as an advisory recommendation, never a criterion NC (e.g. empty-table-header).
|
|
217
|
+
const advisory = isAxeAdvisory(v.id, v.tags);
|
|
218
|
+
for (const n of v.nodes.length ? v.nodes : [{ target: [], html: "" }]) {
|
|
219
|
+
findings.push({
|
|
220
|
+
criteriaId,
|
|
221
|
+
axeRule: v.id,
|
|
222
|
+
impact: v.impact ?? "minor",
|
|
223
|
+
severity,
|
|
224
|
+
message: `${v.help} (axe: ${v.id})`,
|
|
225
|
+
selector: n.target.join(" ") || "—",
|
|
226
|
+
snippet: n.html,
|
|
227
|
+
engine: "axe",
|
|
228
|
+
page,
|
|
229
|
+
...(advisory ? { advisory: true } : {}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (out.reflow?.horizontalScroll) {
|
|
234
|
+
findings.push({
|
|
235
|
+
criteriaId: "1.4.10",
|
|
236
|
+
axeRule: "reflow",
|
|
237
|
+
impact: "serious",
|
|
238
|
+
severity: "majeur",
|
|
239
|
+
message:
|
|
240
|
+
lang === "fr"
|
|
241
|
+
? "Défilement horizontal à 320px de large — le contenu ne se redistribue pas (reflow)."
|
|
242
|
+
: "Horizontal scrolling at 320px width — content does not reflow.",
|
|
243
|
+
selector: "document",
|
|
244
|
+
snippet: "",
|
|
245
|
+
engine: "reflow",
|
|
246
|
+
page,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
// Residual-criteria probes (local runtime). Each hit the probe OBSERVED becomes a
|
|
250
|
+
// definite NC on the SC it evidences; an absent/empty array contributes nothing.
|
|
251
|
+
for (const { key, engine: probe } of PROBE_FIELDS) {
|
|
252
|
+
const hits = out[key] as ProbeHit[] | undefined;
|
|
253
|
+
if (!hits) continue;
|
|
254
|
+
const severity = PROBE_SEVERITY[probe];
|
|
255
|
+
for (const h of hits) {
|
|
256
|
+
findings.push({
|
|
257
|
+
criteriaId: PROBE_WCAG[probe],
|
|
258
|
+
axeRule: probe,
|
|
259
|
+
impact: severity === "majeur" ? "serious" : "minor",
|
|
260
|
+
severity,
|
|
261
|
+
message: h.detail,
|
|
262
|
+
selector: h.selector || "—",
|
|
263
|
+
snippet: h.html ?? "",
|
|
264
|
+
engine: probe,
|
|
265
|
+
page,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { tool: "ultra11y", engine, target, date: today(), findings };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The needs-rendering SCs the DOCKER runner actually MEASURES: axe + the 320px reflow
|
|
273
|
+
// probe only — the local-only probes (200% zoom, text spacing, focus visibility, content
|
|
274
|
+
// on hover, live regions) never run in the container. Stamped on every Docker
|
|
275
|
+
// DynamicResult so the partial-audit advisory (src/report.ts untestedNeedsRendering)
|
|
276
|
+
// never claims a probe ran when it did not. The local superset: scan-local.ts
|
|
277
|
+
// `localTestedScs`.
|
|
278
|
+
export const DOCKER_TESTED_SCS: readonly string[] = ["1.4.10"];
|
|
279
|
+
|
|
280
|
+
export interface ScanOpts {
|
|
281
|
+
target: string;
|
|
282
|
+
tag?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Run the dynamic tier (builds the image on first use). Throws if Docker absent. */
|
|
286
|
+
export function runScan(opts: ScanOpts): DynamicResult {
|
|
287
|
+
// A non-URL target must exist as a file BEFORE we spend a Docker build on a typo
|
|
288
|
+
// (otherwise a mistyped path silently falls through to URL mode and fails deep
|
|
289
|
+
// inside `docker run`, leaking the raw command).
|
|
290
|
+
const isUrl = /^https?:\/\//i.test(opts.target);
|
|
291
|
+
if (!isUrl && !existsSync(opts.target)) {
|
|
292
|
+
throw new Error(`File not found: ${opts.target}. Pass an http(s):// URL or an existing HTML file.`);
|
|
293
|
+
}
|
|
294
|
+
if (!dockerAvailable()) {
|
|
295
|
+
throw new Error("Docker is not available. Start Docker, then re-run `scan --docker`.");
|
|
296
|
+
}
|
|
297
|
+
const tag = opts.tag ?? IMAGE_TAG;
|
|
298
|
+
if (!imageExists(tag)) buildImage(tag);
|
|
299
|
+
const isFile = !isUrl && existsSync(opts.target) && statSync(opts.target).isFile();
|
|
300
|
+
const out = runRunner(opts.target, isFile, tag);
|
|
301
|
+
return { ...toDynamicResult(out, opts.target), testedScs: [...DOCKER_TESTED_SCS] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Fetch a URL's served HTML (zero-dep, Node global fetch). Empty string on error. */
|
|
305
|
+
async function fetchHtml(url: string): Promise<string> {
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
308
|
+
if (!res.ok) return "";
|
|
309
|
+
return await res.text();
|
|
310
|
+
} catch {
|
|
311
|
+
return "";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface DiscoverOpts {
|
|
316
|
+
sitemap?: string; // sitemap.xml URL — scan every <loc>
|
|
317
|
+
crawl?: string; // start URL — BFS the served HTML for same-origin links
|
|
318
|
+
depth?: number; // crawl: link hops from the start URL (default 2)
|
|
319
|
+
max?: number; // cap on pages scanned (default 50)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Resolve the page URLs to scan from a sitemap or by crawling (zero-dep). */
|
|
323
|
+
export async function discoverUrls(opts: DiscoverOpts): Promise<string[]> {
|
|
324
|
+
const max = opts.max ?? 50;
|
|
325
|
+
if (opts.sitemap) {
|
|
326
|
+
return parseSitemapUrls(await fetchHtml(opts.sitemap)).slice(0, max);
|
|
327
|
+
}
|
|
328
|
+
if (opts.crawl) {
|
|
329
|
+
return crawlUrls(opts.crawl, { fetchHtml, depth: opts.depth ?? 2, max });
|
|
330
|
+
}
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Run the dynamic tier over many URLs and aggregate into one DynamicResult.
|
|
335
|
+
* Each URL is one container run (browser per page); slow but reuses the proven
|
|
336
|
+
* single-page runner. Findings keep the page they came from. */
|
|
337
|
+
export function runScanMany(urls: string[], tag = IMAGE_TAG): DynamicResult {
|
|
338
|
+
if (!dockerAvailable()) {
|
|
339
|
+
throw new Error("Docker is not available. Start Docker, then re-run `scan`.");
|
|
340
|
+
}
|
|
341
|
+
if (!imageExists(tag)) buildImage(tag);
|
|
342
|
+
const findings: DynamicFinding[] = [];
|
|
343
|
+
for (const url of urls) {
|
|
344
|
+
const out = runRunner(url, false, tag);
|
|
345
|
+
findings.push(...toDynamicResult(out, url).findings);
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
tool: "ultra11y",
|
|
349
|
+
engine: "axe-core@playwright (docker)",
|
|
350
|
+
target: `${urls.length} page(s)`,
|
|
351
|
+
date: today(),
|
|
352
|
+
findings,
|
|
353
|
+
testedScs: [...DOCKER_TESTED_SCS],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Stamp each dynamic finding with its originating sample page's provenance (id/name/auth/
|
|
358
|
+
* notes) so `mergeDynamic` can carry it onto the Finding for ticket rendering. Shared by
|
|
359
|
+
* the Docker and local sample runners. Mutates + returns the findings. */
|
|
360
|
+
export function tagSampleFindings(findings: DynamicFinding[], page: SamplePage): DynamicFinding[] {
|
|
361
|
+
for (const f of findings) {
|
|
362
|
+
f.sample = { id: page.id, name: page.name, ...(page.auth !== undefined ? { auth: page.auth } : {}), ...(page.notes ? { notes: page.notes } : {}) };
|
|
363
|
+
}
|
|
364
|
+
return findings;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Run the Docker dynamic tier over a NORMATIVE page SAMPLE and aggregate into one
|
|
368
|
+
* DynamicResult. No storageState support (the Docker runner has no session mechanism) — the
|
|
369
|
+
* CLI requires the local runtime whenever any sample page carries auth. Each finding keeps
|
|
370
|
+
* its sample-page provenance; the sample itself is recorded on the result. */
|
|
371
|
+
export function runSampleScan(pages: SamplePage[], tag = IMAGE_TAG): DynamicResult {
|
|
372
|
+
if (!dockerAvailable()) {
|
|
373
|
+
throw new Error("Docker is not available. Start Docker, then re-run `scan`.");
|
|
374
|
+
}
|
|
375
|
+
if (!imageExists(tag)) buildImage(tag);
|
|
376
|
+
const findings: DynamicFinding[] = [];
|
|
377
|
+
for (const page of pages) {
|
|
378
|
+
const out = runRunner(page.url, false, tag);
|
|
379
|
+
findings.push(...tagSampleFindings(toDynamicResult(out, page.url).findings, page));
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
tool: "ultra11y",
|
|
383
|
+
engine: "axe-core@playwright (docker)",
|
|
384
|
+
target: `${pages.length} page(s) (échantillon)`,
|
|
385
|
+
date: today(),
|
|
386
|
+
findings,
|
|
387
|
+
sample: sampleScope({ pages }),
|
|
388
|
+
testedScs: [...DOCKER_TESTED_SCS],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Discover URLs (sitemap/crawl) then scan them all through the dynamic tier. */
|
|
393
|
+
export async function runCrawlScan(opts: DiscoverOpts & { tag?: string }): Promise<DynamicResult> {
|
|
394
|
+
const urls = await discoverUrls(opts);
|
|
395
|
+
if (urls.length === 0) {
|
|
396
|
+
throw new Error("No URL to scan (empty/unreachable sitemap, or entry page with no same-origin link).");
|
|
397
|
+
}
|
|
398
|
+
return runScanMany(urls, opts.tag ?? IMAGE_TAG);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const sevRank: Record<Severity, number> = { bloquant: 3, majeur: 2, mineur: 1 };
|
|
402
|
+
|
|
403
|
+
/** Best-effort resolution of an axe outerHTML snippet to a host `file:line` + byte range.
|
|
404
|
+
* Only file-backed citations resolve (a served-URL scan has no source file). Tries the
|
|
405
|
+
* exact snippet, then just its opening tag (most likely verbatim), then a
|
|
406
|
+
* whitespace-normalized line scan. Returns null when nothing matches — the caller then
|
|
407
|
+
* keeps line 0 and relies on the selector/snippet, never inventing a line. */
|
|
408
|
+
function resolveHostAnchor(file: string, snippet: string): { line: number; col: number; start: number; end: number } | null {
|
|
409
|
+
const s = snippet?.trim();
|
|
410
|
+
if (!s || !existsSync(file)) return null;
|
|
411
|
+
let source: string;
|
|
412
|
+
try {
|
|
413
|
+
source = readFileSync(file, "utf8");
|
|
414
|
+
} catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
const starts = lineStartsOf(source);
|
|
418
|
+
const at = (start: number, len: number) => {
|
|
419
|
+
const { line, col } = lineColAt(starts, start);
|
|
420
|
+
return { line, col, start, end: start + len };
|
|
421
|
+
};
|
|
422
|
+
// 1. exact snippet
|
|
423
|
+
let idx = source.indexOf(s);
|
|
424
|
+
if (idx >= 0) return at(idx, s.length);
|
|
425
|
+
// 2. the opening tag alone (up to and including the first ">") — attributes/text after
|
|
426
|
+
// it may differ between the rendered DOM and source, the open tag rarely does.
|
|
427
|
+
const openMatch = /^<[^>]*>/.exec(s);
|
|
428
|
+
if (openMatch) {
|
|
429
|
+
idx = source.indexOf(openMatch[0]);
|
|
430
|
+
if (idx >= 0) return at(idx, openMatch[0].length);
|
|
431
|
+
}
|
|
432
|
+
// 3. whitespace-normalized line scan (collapse runs of whitespace on both sides).
|
|
433
|
+
const norm = (t: string) => t.replace(/\s+/g, " ").trim();
|
|
434
|
+
const needle = norm(openMatch ? openMatch[0] : s);
|
|
435
|
+
if (needle) {
|
|
436
|
+
const lines = source.split("\n");
|
|
437
|
+
for (let i = 0; i < lines.length; i++) {
|
|
438
|
+
if (norm(lines[i]!).includes(needle)) {
|
|
439
|
+
const start = starts[i] ?? 0;
|
|
440
|
+
return { line: i + 1, col: 1, start, end: start + lines[i]!.length };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Fold dynamic findings into a static AuditResult: a needs-rendering/manual or
|
|
448
|
+
* clean criterion that axe flags becomes NC; tallies + conformance recompute. */
|
|
449
|
+
export function mergeDynamic(audit: AuditResult, dynamic: DynamicResult, lang: Lang = "en"): AuditResult {
|
|
450
|
+
const merged: AuditResult = JSON.parse(JSON.stringify(audit)) as AuditResult;
|
|
451
|
+
// Record the normative page sample the dynamic tier ran over (Task 5) — drives the
|
|
452
|
+
// report's « Constats par page » section. Storage-state paths were already dropped upstream.
|
|
453
|
+
if (dynamic.sample) merged.scope.sample = dynamic.sample;
|
|
454
|
+
// Record which needs-rendering SCs this scan actually MEASURED (union across merges) —
|
|
455
|
+
// the partial-audit advisory keys on this, so a Docker run (reflow only) never silently
|
|
456
|
+
// suppresses the banner for the local-only probes it did not run.
|
|
457
|
+
if (dynamic.testedScs?.length) {
|
|
458
|
+
const tested = new Set([...(merged.scope.scan?.testedScs ?? []), ...dynamic.testedScs]);
|
|
459
|
+
merged.scope.scan = { testedScs: [...tested].sort() };
|
|
460
|
+
}
|
|
461
|
+
const byId = new Map(merged.criteria.map((c) => [c.id, c]));
|
|
462
|
+
const remediation =
|
|
463
|
+
lang === "fr" ? "Vérifié au rendu par axe-core ; corrigez l'élément cité." : "Verified at render time by axe-core; fix the cited element.";
|
|
464
|
+
|
|
465
|
+
for (const df of dynamic.findings) {
|
|
466
|
+
const c = byId.get(df.criteriaId);
|
|
467
|
+
if (!c) continue;
|
|
468
|
+
// Catalog id for RE-LOCALIZATION (src/messages.ts): "dyn-reflow" is ultra11y's
|
|
469
|
+
// own bilingual reflow prose; "dyn-remediation" covers axe-core + the residual
|
|
470
|
+
// probes, whose MESSAGE is the engine's own text (never translated — passed
|
|
471
|
+
// through verbatim via params.message) while the REMEDIATION is still ours.
|
|
472
|
+
const msg = df.engine === "reflow" ? { id: "dyn-reflow" } : { id: "dyn-remediation", params: { message: df.message } };
|
|
473
|
+
const file = df.page ?? dynamic.target;
|
|
474
|
+
// Anchor at a real host file:line by locating the cited outerHTML in the source (R3).
|
|
475
|
+
// selector + snippet are ALWAYS kept as the anchor of last resort; line stays 0 (never
|
|
476
|
+
// a fabricated line) when the snippet resolves nowhere (e.g. a served-URL scan, or DOM
|
|
477
|
+
// that differs from source).
|
|
478
|
+
const anchor = resolveHostAnchor(file, df.snippet);
|
|
479
|
+
const finding: Finding = {
|
|
480
|
+
ruleId: df.engine === "axe" ? `axe:${df.axeRule}` : `dyn-${df.engine}`,
|
|
481
|
+
criteriaId: df.criteriaId,
|
|
482
|
+
file,
|
|
483
|
+
line: anchor?.line ?? 0,
|
|
484
|
+
col: anchor?.col ?? 0,
|
|
485
|
+
selectorHint: df.selector,
|
|
486
|
+
severity: df.severity,
|
|
487
|
+
message: df.message,
|
|
488
|
+
remediation,
|
|
489
|
+
msg,
|
|
490
|
+
snippet: df.snippet,
|
|
491
|
+
...(anchor ? { sourceStart: anchor.start, sourceEnd: anchor.end } : {}),
|
|
492
|
+
...(df.advisory ? { advisory: true } : {}),
|
|
493
|
+
// Task 5: carry the sample-page provenance onto the merged Finding so the auditor
|
|
494
|
+
// ticket renders the page name + auth flag + reproduction notes.
|
|
495
|
+
...(df.sample
|
|
496
|
+
? {
|
|
497
|
+
sample: {
|
|
498
|
+
page: df.sample.name,
|
|
499
|
+
...(df.sample.auth !== undefined ? { authRequired: df.sample.auth } : {}),
|
|
500
|
+
...(df.sample.notes ? { notes: df.sample.notes } : {}),
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
: {}),
|
|
504
|
+
};
|
|
505
|
+
c.findings.push(finding);
|
|
506
|
+
merged.findings.push(finding);
|
|
507
|
+
// An ADVISORY dynamic finding (best-practice-only axe violation) is attached but never
|
|
508
|
+
// authoritative: it must NOT flip the criterion to NC nor clear its justification, and
|
|
509
|
+
// the criterion stays in residualRisks. A normative dynamic finding behaves as before.
|
|
510
|
+
if (!df.advisory) {
|
|
511
|
+
c.status = "NC"; // a rendered-tool finding is authoritative
|
|
512
|
+
delete c.justification;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// drop upgraded criteria from residual risks — NORMATIVE findings only (an advisory
|
|
517
|
+
// finding does not decide the criterion, so its residual risk must remain).
|
|
518
|
+
const nowNc = new Set(dynamic.findings.filter((d) => !d.advisory).map((d) => d.criteriaId));
|
|
519
|
+
merged.residualRisks = merged.residualRisks.filter((r) => !nowNc.has(r.criteriaId));
|
|
520
|
+
|
|
521
|
+
// recompute tallies + conformance (by WCAG guideline)
|
|
522
|
+
merged.guidelines = allGuidelines().map((g) => {
|
|
523
|
+
const inG = merged.criteria.filter((c) => c.guideline === g.number);
|
|
524
|
+
return {
|
|
525
|
+
key: g.number,
|
|
526
|
+
title: g.title,
|
|
527
|
+
c: inG.filter((c) => c.status === "C").length,
|
|
528
|
+
nc: inG.filter((c) => c.status === "NC").length,
|
|
529
|
+
na: inG.filter((c) => c.status === "NA").length,
|
|
530
|
+
manual: inG.filter((c) => c.status === "manual").length,
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
const decided = merged.criteria.filter((c) => c.status === "C" || c.status === "NC");
|
|
534
|
+
const conform = decided.filter((c) => c.status === "C").length;
|
|
535
|
+
merged.conformancePct = decided.length === 0 ? 100 : Math.round((conform / decided.length) * 100);
|
|
536
|
+
merged.findings.sort((a, b) => sevRank[b.severity] - sevRank[a.severity]);
|
|
537
|
+
return merged;
|
|
538
|
+
}
|