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/output.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Output helpers: a tiny fr/en string table and the human-readable `audit`
|
|
2
|
+
// summary (the --json path prints the AuditResult verbatim instead).
|
|
3
|
+
import type { AuditResult, Lang, Severity } from "./types.js";
|
|
4
|
+
import type { CaptureCoverage } from "./capture.js";
|
|
5
|
+
import { guidelineTitle } from "./wcag.js";
|
|
6
|
+
import { resolveMessage } from "./messages.js";
|
|
7
|
+
|
|
8
|
+
type Key =
|
|
9
|
+
| "summaryTitle"
|
|
10
|
+
| "files"
|
|
11
|
+
| "autoConformance"
|
|
12
|
+
| "guideline"
|
|
13
|
+
| "findingsTitle"
|
|
14
|
+
| "noFindings"
|
|
15
|
+
| "residualTitle"
|
|
16
|
+
| "manualNote"
|
|
17
|
+
| "renderedNote"
|
|
18
|
+
| "sfcNote"
|
|
19
|
+
| "capturesNote";
|
|
20
|
+
|
|
21
|
+
const STR: Record<Lang, Record<Key, string>> = {
|
|
22
|
+
fr: {
|
|
23
|
+
summaryTitle: "Audit WCAG 2.2 AA (moteur statique ultra11y)",
|
|
24
|
+
files: "fichiers analysés",
|
|
25
|
+
autoConformance: "réussite automatique (vérifications statiques)",
|
|
26
|
+
guideline: "Règle WCAG",
|
|
27
|
+
findingsTitle: "Non-conformités détectées",
|
|
28
|
+
noFindings: "Aucune non-conformité détectée par le moteur statique.",
|
|
29
|
+
residualTitle: "À adjuger (jugement / rendu)",
|
|
30
|
+
manualNote: "critères non décidés par le moteur — à adjuger par l'agent IA (`verify --manual`, gaté), rendu via `scan`.",
|
|
31
|
+
renderedNote: "fichier(s) rendent des composants de bibliothèque non analysés en source — auditez le build (render) ou scan",
|
|
32
|
+
sfcNote:
|
|
33
|
+
"composant(s) .vue/.svelte/.astro audité(s) en SOURCE (template) — slots et liaisons dynamiques invisibles : verdict préliminaire, auditez le rendu (render/scan)",
|
|
34
|
+
capturesNote: "fichier(s) de capture rendus audités à pleine fidélité (DOM réel) — le vrai HTML produit",
|
|
35
|
+
},
|
|
36
|
+
en: {
|
|
37
|
+
summaryTitle: "WCAG 2.2 AA audit (ultra11y static engine)",
|
|
38
|
+
files: "files analysed",
|
|
39
|
+
autoConformance: "automatic static-check pass rate",
|
|
40
|
+
guideline: "WCAG guideline",
|
|
41
|
+
findingsTitle: "Non-conformities detected",
|
|
42
|
+
noFindings: "No non-conformity detected by the static engine.",
|
|
43
|
+
residualTitle: "To adjudicate (judgment / rendering)",
|
|
44
|
+
manualNote: "criteria the engine cannot decide — adjudicated by the AI agent (`verify --manual`, gated), rendering via `scan`.",
|
|
45
|
+
renderedNote: "file(s) render component-library output not analysed from source — audit the build (render) or scan",
|
|
46
|
+
sfcNote:
|
|
47
|
+
".vue/.svelte/.astro file(s) audited as SOURCE (template) — slots and dynamic bindings are invisible: preliminary verdict, audit the rendered output (render/scan)",
|
|
48
|
+
capturesNote: "rendered capture file(s) audited at full fidelity (real DOM) — the true produced HTML",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function t(lang: Lang, key: Key): string {
|
|
53
|
+
return STR[lang][key];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ICON: Record<Severity, string> = { bloquant: "🔴", majeur: "🟠", mineur: "🟡" };
|
|
57
|
+
|
|
58
|
+
export function auditSummary(r: AuditResult, lang: Lang): string {
|
|
59
|
+
const lines: string[] = [];
|
|
60
|
+
lines.push(`${t(lang, "summaryTitle")} — ${r.date}`);
|
|
61
|
+
lines.push(`${r.scope.files} ${t(lang, "files")} · ${r.conformancePct}% ${t(lang, "autoConformance")}`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push(`${t(lang, "guideline")} C NC NA ⏳`);
|
|
64
|
+
for (const g of r.guidelines) {
|
|
65
|
+
// `g.title` is the baked-in English title (JSON back-compat); resolve by key + lang.
|
|
66
|
+
const name = `${g.key} ${guidelineTitle(g.key, lang) ?? g.title}`.padEnd(28).slice(0, 28);
|
|
67
|
+
lines.push(`${name} ${String(g.c).padStart(2)} ${String(g.nc).padStart(2)} ${String(g.na).padStart(2)} ${String(g.manual).padStart(2)}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push("");
|
|
70
|
+
if (r.findings.length === 0) {
|
|
71
|
+
lines.push(t(lang, "noFindings"));
|
|
72
|
+
} else {
|
|
73
|
+
lines.push(`${t(lang, "findingsTitle")} (${r.findings.length}) :`);
|
|
74
|
+
for (const f of r.findings.slice(0, 20)) {
|
|
75
|
+
lines.push(` ${ICON[f.severity]} [${f.criteriaId}] ${f.file}:${f.line} ${resolveMessage(f, lang)}`);
|
|
76
|
+
}
|
|
77
|
+
if (r.findings.length > 20) lines.push(` … (+${r.findings.length - 20})`);
|
|
78
|
+
}
|
|
79
|
+
lines.push("");
|
|
80
|
+
lines.push(`${t(lang, "residualTitle")} : ${r.residualRisks.length} ${t(lang, "manualNote")}`);
|
|
81
|
+
if (r.scope.rendered) lines.push(`🧩 ${r.scope.rendered.files} ${t(lang, "renderedNote")} (${r.scope.rendered.opaqueLibraries.join(", ")}).`);
|
|
82
|
+
if (r.scope.sourceTemplate) lines.push(`🧩 ${r.scope.sourceTemplate.files} ${t(lang, "sfcNote")} (${r.scope.sourceTemplate.extensions.join(", ")}).`);
|
|
83
|
+
if (r.scope.captures) lines.push(`✅ ${r.scope.captures.files} ${t(lang, "capturesNote")}.`);
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Human-readable rendered-capture coverage: covered vs opaque-source-only blind spots.
|
|
88
|
+
* Reused by `audit --require-captures` (as a gate note) and `render --coverage`. */
|
|
89
|
+
export function captureCoverageSummary(cov: CaptureCoverage, lang: Lang): string {
|
|
90
|
+
const fr = lang === "fr";
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
if (cov.total === 0) {
|
|
93
|
+
lines.push(fr ? "Couverture captures : aucun composant à couvrir dans le périmètre." : "Capture coverage: no components to cover in scope.");
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(
|
|
96
|
+
fr
|
|
97
|
+
? `Couverture captures : ${cov.covered.length}/${cov.total} composant(s) couvert(s) par un rendu.`
|
|
98
|
+
: `Capture coverage: ${cov.covered.length}/${cov.total} component(s) covered by a render.`,
|
|
99
|
+
);
|
|
100
|
+
if (cov.blindSpots.length) {
|
|
101
|
+
lines.push(fr ? "Angles morts (audités sur source opaque uniquement) :" : "Blind spots (audited from opaque source only):");
|
|
102
|
+
for (const k of cov.blindSpots) lines.push(` · ${k}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cov.unattributed)
|
|
106
|
+
lines.push(fr ? `${cov.unattributed} capture(s) sans provenance (non rattachée·s).` : `${cov.unattributed} capture(s) without provenance (unattributed).`);
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
package/src/pack.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `pack` — the standards-pack toolbox and the anti-hallucination GATE for AI-assisted
|
|
2
|
+
// ingestion. `pack check <pack.json> [--guidance <g.json>]` validates a pack (via the
|
|
3
|
+
// shared validatePack) and, if given, its guidance dataset: every entry must resolve to a
|
|
4
|
+
// REAL pack criterion, map to well-formed SCs, and carry code examples that actually
|
|
5
|
+
// parse. `pack scaffold` prints a minimal valid StandardPack skeleton to fill in. This is
|
|
6
|
+
// how an external rule source (RGAA SocialGouv/etalab, or any other standard) is turned
|
|
7
|
+
// into a pack the runtime `--pack` loader can trust: the agent drafts, the gate refuses
|
|
8
|
+
// fabrication (a bogus criterion id or SC, or an unparseable example, fails loudly).
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readText } from "./util.js";
|
|
11
|
+
import { validatePack, classifySc } from "./standards/validate.js";
|
|
12
|
+
import { getCriterion } from "./standards/pack.js";
|
|
13
|
+
import { ruleIds as engineRuleIds } from "./rules/registry.js";
|
|
14
|
+
import type { StandardPack } from "./standards/types.js";
|
|
15
|
+
import type { GuidanceDataset, GuidanceExample } from "./guidance/types.js";
|
|
16
|
+
import { parseHtml } from "./parse/html.js";
|
|
17
|
+
import { parseJsxAst } from "./parse/jsx-ast.js";
|
|
18
|
+
|
|
19
|
+
export interface PackCheckResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
errors: string[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Confirm a guidance code example actually parses (JSX via the real Babel parser; HTML
|
|
26
|
+
* via htmlparser2). Returns an error message, or null when it parses. */
|
|
27
|
+
function exampleParses(ex: GuidanceExample): string | null {
|
|
28
|
+
for (const code of [ex.bad, ex.good]) {
|
|
29
|
+
if (!code) continue;
|
|
30
|
+
if (ex.lang === "jsx") {
|
|
31
|
+
if (!parseJsxAst(code)) return `unparseable JSX example: ${code.slice(0, 48)}…`;
|
|
32
|
+
} else if (ex.lang === "html") {
|
|
33
|
+
try {
|
|
34
|
+
parseHtml(code, "<guidance>.html", false);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return `unparseable HTML example (${e instanceof Error ? e.message : String(e)})`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// css: the engine only parses inline style — guidance css snippets are not gated.
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate a guidance dataset against the (already-validated) pack it augments. Shared by
|
|
45
|
+
* `pack check` and the runtime guidance loader (src/config.ts) so the same gate applies. */
|
|
46
|
+
export function checkGuidance(ds: unknown, pack: StandardPack): { errors: string[]; warnings: string[] } {
|
|
47
|
+
const errors: string[] = [];
|
|
48
|
+
const warnings: string[] = [];
|
|
49
|
+
if (typeof ds !== "object" || ds === null || !Array.isArray((ds as GuidanceDataset).entries)) {
|
|
50
|
+
errors.push("guidance: must be an object with an entries[] array");
|
|
51
|
+
return { errors, warnings };
|
|
52
|
+
}
|
|
53
|
+
const dataset = ds as GuidanceDataset;
|
|
54
|
+
if (dataset.pack && dataset.pack !== pack.key) warnings.push(`guidance: dataset.pack "${dataset.pack}" ≠ pack key "${pack.key}"`);
|
|
55
|
+
dataset.entries.forEach((e, i) => {
|
|
56
|
+
const at = `guidance.entries[${i}]${e.id ? ` (${e.id})` : ""}`;
|
|
57
|
+
const crit = e.criterionId ? getCriterion(pack, e.criterionId) : undefined;
|
|
58
|
+
if (!e.criterionId) {
|
|
59
|
+
errors.push(`${at}: criterionId is unresolved — resolve it to a real ${pack.key} criterion (no fabrication)`);
|
|
60
|
+
} else if (!crit) {
|
|
61
|
+
errors.push(`${at}: criterionId "${e.criterionId}" does not exist in ${pack.key}`);
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(e.wcag) || e.wcag.length === 0) {
|
|
64
|
+
errors.push(`${at}: must map to at least one WCAG SC`);
|
|
65
|
+
} else {
|
|
66
|
+
for (const sc of e.wcag) {
|
|
67
|
+
switch (classifySc(sc)) {
|
|
68
|
+
case "malformed":
|
|
69
|
+
errors.push(`${at}: malformed SC "${sc}"`);
|
|
70
|
+
break;
|
|
71
|
+
case "unknown":
|
|
72
|
+
errors.push(`${at}: SC "${sc}" is not a recognized WCAG success criterion — fabricated?`);
|
|
73
|
+
break;
|
|
74
|
+
case "out-of-core":
|
|
75
|
+
warnings.push(`${at}: SC "${sc}" is a real WCAG AAA success criterion, outside the WCAG 2.2 AA core`);
|
|
76
|
+
break;
|
|
77
|
+
case "removed":
|
|
78
|
+
warnings.push(`${at}: SC "${sc}" is a real but removed WCAG success criterion (obsolete)`);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (crit) {
|
|
83
|
+
const extra = e.wcag.filter((sc) => !crit.wcag.includes(sc));
|
|
84
|
+
if (extra.length) warnings.push(`${at}: SC(s) ${extra.join(", ")} not in criterion ${crit.id} mapping (${crit.wcag.join(", ")})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(e.examples) || e.examples.length === 0) {
|
|
88
|
+
warnings.push(`${at}: no code examples`);
|
|
89
|
+
} else {
|
|
90
|
+
e.examples.forEach((ex, j) => {
|
|
91
|
+
const bad = exampleParses(ex);
|
|
92
|
+
if (bad) errors.push(`${at}.examples[${j}]: ${bad}`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return { errors, warnings };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Run `pack check`: validate a pack file and (optionally) its guidance dataset. */
|
|
100
|
+
export function runPackCheck(packPath: string, guidancePath: string | undefined): PackCheckResult {
|
|
101
|
+
const errors: string[] = [];
|
|
102
|
+
const warnings: string[] = [];
|
|
103
|
+
if (!existsSync(packPath)) return { ok: false, errors: [`pack file not found: ${packPath}`], warnings };
|
|
104
|
+
let raw: unknown;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(readText(packPath));
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, errors: [`${packPath}: not valid JSON`], warnings };
|
|
109
|
+
}
|
|
110
|
+
// Lint the artifact's internal consistency — no registry-collision check (we are
|
|
111
|
+
// checking a file, not registering it), so the built-in rgaa.json passes `pack check`.
|
|
112
|
+
const v = validatePack(raw);
|
|
113
|
+
for (const issue of v.issues) (issue.severity === "error" ? errors : warnings).push(`${issue.path ? `${issue.path}: ` : ""}${issue.message}`);
|
|
114
|
+
// Advisory: an appliesTo ruleId unknown to the engine registry is a WARNING, not a hard
|
|
115
|
+
// error — a pack may target a renamed/future rule, and axe:*/dyn-*/agent:* namespaced ids
|
|
116
|
+
// (dynamic-tier + agent-adjudicated findings) are legitimately outside the static registry.
|
|
117
|
+
if (v.ok && v.pack) {
|
|
118
|
+
const known = new Set(engineRuleIds());
|
|
119
|
+
const isNamespaced = (r: string) => /^(axe:|dyn-|agent:)/.test(r);
|
|
120
|
+
// A pack's OWN declarative rules are legitimate ruleIds (`pack:<key>:<id>`).
|
|
121
|
+
const ownRuleIds = new Set((v.pack.rules ?? []).map((r) => `pack:${v.pack!.key}:${r.id}`));
|
|
122
|
+
const resolvable = (r: string) => isNamespaced(r) || known.has(r) || r.startsWith(`pack:${v.pack!.key}:`) || ownRuleIds.has(r);
|
|
123
|
+
for (const c of v.pack.criteria) {
|
|
124
|
+
for (const r of c.appliesTo?.ruleIds ?? []) {
|
|
125
|
+
if (!resolvable(r)) warnings.push(`criteria "${c.id}": appliesTo ruleId "${r}" is not a known engine rule (renamed/future?)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Overrides target a finding ruleId — a core engine rule, an axe:/dyn-/agent: id, or
|
|
129
|
+
// one of this pack's own declarative rules. An unresolvable key is advisory (a pack may
|
|
130
|
+
// target a future rule), never a hard error.
|
|
131
|
+
for (const key of Object.keys(v.pack.overrides ?? {})) {
|
|
132
|
+
if (!resolvable(key)) warnings.push(`overrides: ruleId "${key}" is not a known engine rule or a rule of this pack (renamed/future?)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (v.ok && v.pack && guidancePath) {
|
|
136
|
+
if (!existsSync(guidancePath)) {
|
|
137
|
+
errors.push(`guidance file not found: ${guidancePath}`);
|
|
138
|
+
} else {
|
|
139
|
+
let gRaw: unknown;
|
|
140
|
+
try {
|
|
141
|
+
gRaw = JSON.parse(readText(guidancePath));
|
|
142
|
+
} catch {
|
|
143
|
+
errors.push(`${guidancePath}: not valid JSON`);
|
|
144
|
+
}
|
|
145
|
+
if (gRaw !== undefined) {
|
|
146
|
+
const g = checkGuidance(gRaw, v.pack);
|
|
147
|
+
errors.push(...g.errors);
|
|
148
|
+
warnings.push(...g.warnings);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A minimal, valid StandardPack skeleton (for `pack scaffold` — the agent fills it). */
|
|
156
|
+
export function packScaffold(): string {
|
|
157
|
+
return JSON.stringify(
|
|
158
|
+
{
|
|
159
|
+
key: "mypack",
|
|
160
|
+
name: "MyPack",
|
|
161
|
+
fullName: "My national accessibility standard",
|
|
162
|
+
org: "Org",
|
|
163
|
+
country: "XX",
|
|
164
|
+
baseVersion: "1.0",
|
|
165
|
+
wcagVersion: "2.2",
|
|
166
|
+
locales: ["en"],
|
|
167
|
+
defaultLocale: "en",
|
|
168
|
+
license: "…",
|
|
169
|
+
source: "https://…",
|
|
170
|
+
attribution: "… © …",
|
|
171
|
+
idPattern: "^\\d+\\.\\d+$",
|
|
172
|
+
vocabulary: {
|
|
173
|
+
theme: { en: "Theme" },
|
|
174
|
+
criterion: { en: "Criterion" },
|
|
175
|
+
test: { en: "Test" },
|
|
176
|
+
conformant: { en: "Conformant" },
|
|
177
|
+
nonConformant: { en: "Non-conformant" },
|
|
178
|
+
notApplicable: { en: "Not applicable" },
|
|
179
|
+
auditorHeading: { en: "Accessibility criterion" },
|
|
180
|
+
},
|
|
181
|
+
themes: [{ number: 1, name: { en: "Theme one" }, count: 1 }],
|
|
182
|
+
criteria: [{ id: "1.1", theme: 1, title: { en: "Criterion one" }, titlePlain: { en: "Criterion one" }, wcag: ["1.1.1"] }],
|
|
183
|
+
},
|
|
184
|
+
null,
|
|
185
|
+
2,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// A DOM-lite over htmlparser2: parse HTML into a flat, position-aware element
|
|
2
|
+
// tree that the rules walk. Zero runtime deps beyond htmlparser2 (bundled).
|
|
3
|
+
import { parseDocument } from "htmlparser2";
|
|
4
|
+
import type { Document, ChildNode, Element as DhElement } from "domhandler";
|
|
5
|
+
|
|
6
|
+
export interface El {
|
|
7
|
+
type: "element";
|
|
8
|
+
tag: string; // lowercased intrinsic, or original-cased JSX component name
|
|
9
|
+
attribs: Record<string, string>;
|
|
10
|
+
children: HNode[];
|
|
11
|
+
parent: El | null;
|
|
12
|
+
line: number; // 1-based
|
|
13
|
+
col: number; // 1-based
|
|
14
|
+
start: number; // source offset
|
|
15
|
+
end: number;
|
|
16
|
+
// JSX only: the element carries a {...spread} attribute (props could inject a
|
|
17
|
+
// name/aria). Undefined for HTML. Used by the cross-file rules.
|
|
18
|
+
spread?: boolean;
|
|
19
|
+
// JSX only: identity of the conditional arm this element was lowered from
|
|
20
|
+
// (`{cond ? <A/> : <B/>}` / `{cond && <A/>}`). Elements in different arms of the same
|
|
21
|
+
// conditional are mutually exclusive at runtime, so order-sensitive rules
|
|
22
|
+
// (heading-order-skip) must not compare across arms. Undefined = unconditional.
|
|
23
|
+
branchArm?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Txt {
|
|
26
|
+
type: "text";
|
|
27
|
+
data: string;
|
|
28
|
+
parent: El | null;
|
|
29
|
+
}
|
|
30
|
+
export type HNode = El | Txt;
|
|
31
|
+
|
|
32
|
+
export interface Doc {
|
|
33
|
+
file: string;
|
|
34
|
+
source: string;
|
|
35
|
+
lossy: boolean;
|
|
36
|
+
// How the Doc was produced: a real HTML parse, a real JSX/TSX AST (offsets index
|
|
37
|
+
// the original file — fix-safe), the best-effort lossy JSX→HTML fallback, or a
|
|
38
|
+
// single-file component (.vue/.svelte/.astro) template — parsed as HTML but with
|
|
39
|
+
// component case PRESERVED (so PascalCase components are skipped like in JSX).
|
|
40
|
+
// Optional/additive so existing AuditResult JSON and direct parseHtml callers stay valid.
|
|
41
|
+
kind?: "html" | "jsx-ast" | "jsx-lossy" | "sfc";
|
|
42
|
+
// JSX only: import specifiers of component-LIBRARY components rendered in this
|
|
43
|
+
// file (e.g. "@codegouvfr/react-dsfr/Button"). Their real HTML output lives in
|
|
44
|
+
// node_modules and is invisible to source analysis — a source verdict is
|
|
45
|
+
// therefore incomplete for them (surfaced as a scope caveat, never silent).
|
|
46
|
+
opaqueComponents?: string[];
|
|
47
|
+
// Set when this Doc is a RENDERED capture file (real DOM serialized from a test/
|
|
48
|
+
// build) carrying a `<!-- ultra11y:capture … -->` provenance comment. Its findings
|
|
49
|
+
// are ground truth (not preliminary) and re-attributed to the source component via
|
|
50
|
+
// `Finding.origin`. Optional/additive; absent for ordinary source/HTML files.
|
|
51
|
+
capture?: CaptureProvenance;
|
|
52
|
+
roots: HNode[];
|
|
53
|
+
elements: El[];
|
|
54
|
+
byId: Map<string, El>;
|
|
55
|
+
lineStarts: number[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Provenance recorded in a rendered capture's leading HTML comment, linking the
|
|
59
|
+
// serialized DOM back to the source component that produced it. All fields but the
|
|
60
|
+
// format version are best-effort (the harvester derives them heuristically).
|
|
61
|
+
export interface CaptureProvenance {
|
|
62
|
+
v: number; // provenance format version (starts at 1)
|
|
63
|
+
sourceFile?: string; // repo-relative source, e.g. "src/Button.tsx"
|
|
64
|
+
component?: string; // best-effort component name, e.g. "Button"
|
|
65
|
+
test?: string; // originating test file, e.g. "src/Button.test.tsx"
|
|
66
|
+
name?: string; // originating test title
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
70
|
+
|
|
71
|
+
// Exported so the JSX-AST bridge (parse/jsx-bridge.ts) reuses the exact same
|
|
72
|
+
// line/col arithmetic over the original source as the HTML path.
|
|
73
|
+
export function lineStartsOf(source: string): number[] {
|
|
74
|
+
const starts = [0];
|
|
75
|
+
for (let i = 0; i < source.length; i++) if (source.charCodeAt(i) === 10) starts.push(i + 1);
|
|
76
|
+
return starts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function lineColAt(lineStarts: number[], offset: number): { line: number; col: number } {
|
|
80
|
+
// binary search for the greatest lineStart <= offset
|
|
81
|
+
let lo = 0;
|
|
82
|
+
let hi = lineStarts.length - 1;
|
|
83
|
+
while (lo < hi) {
|
|
84
|
+
const mid = (lo + hi + 1) >> 1;
|
|
85
|
+
if (lineStarts[mid]! <= offset) lo = mid;
|
|
86
|
+
else hi = mid - 1;
|
|
87
|
+
}
|
|
88
|
+
return { line: lo + 1, col: offset - lineStarts[lo]! + 1 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseHtml(source: string, file: string, lossy = false, sfc = false): Doc {
|
|
92
|
+
const dom: Document = parseDocument(source, {
|
|
93
|
+
withStartIndices: true,
|
|
94
|
+
withEndIndices: true,
|
|
95
|
+
// SFC: keep tag case so PascalCase components stay non-intrinsic (rules skip
|
|
96
|
+
// them). Attribute names stay lowercased (HTML attrs are case-insensitive, and
|
|
97
|
+
// the dynamic-binding prefixes `:`/`v-bind:`/`bind:` are already lowercase).
|
|
98
|
+
lowerCaseTags: !sfc,
|
|
99
|
+
lowerCaseAttributeNames: true,
|
|
100
|
+
recognizeSelfClosing: true,
|
|
101
|
+
});
|
|
102
|
+
const lineStarts = lineStartsOf(source);
|
|
103
|
+
const elements: El[] = [];
|
|
104
|
+
const byId = new Map<string, El>();
|
|
105
|
+
|
|
106
|
+
const convert = (node: ChildNode, parent: El | null): HNode | null => {
|
|
107
|
+
if (node.type === "text") {
|
|
108
|
+
return { type: "text", data: (node as { data: string }).data, parent };
|
|
109
|
+
}
|
|
110
|
+
if (node.type === "tag" || node.type === "script" || node.type === "style") {
|
|
111
|
+
const dh = node as DhElement;
|
|
112
|
+
const start = dh.startIndex ?? 0;
|
|
113
|
+
const { line, col } = lineColAt(lineStarts, start);
|
|
114
|
+
const el: El = {
|
|
115
|
+
type: "element",
|
|
116
|
+
tag: sfc ? dh.name : dh.name.toLowerCase(),
|
|
117
|
+
attribs: { ...dh.attribs },
|
|
118
|
+
children: [],
|
|
119
|
+
parent,
|
|
120
|
+
line,
|
|
121
|
+
col,
|
|
122
|
+
start,
|
|
123
|
+
end: dh.endIndex ?? start,
|
|
124
|
+
};
|
|
125
|
+
elements.push(el);
|
|
126
|
+
const id = el.attribs.id;
|
|
127
|
+
if (id && !byId.has(id)) byId.set(id, el);
|
|
128
|
+
for (const child of dh.children) {
|
|
129
|
+
const c = convert(child, el);
|
|
130
|
+
if (c) el.children.push(c);
|
|
131
|
+
}
|
|
132
|
+
return el;
|
|
133
|
+
}
|
|
134
|
+
return null; // comments, directives, etc. are ignored
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const roots: HNode[] = [];
|
|
138
|
+
for (const node of dom.children) {
|
|
139
|
+
const c = convert(node, null);
|
|
140
|
+
if (c) roots.push(c);
|
|
141
|
+
}
|
|
142
|
+
return { file, source, lossy, kind: sfc ? "sfc" : lossy ? "jsx-lossy" : "html", roots, elements, byId, lineStarts };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- helpers used by rules
|
|
146
|
+
|
|
147
|
+
export function attr(el: El, name: string): string | undefined {
|
|
148
|
+
return el.attribs[name.toLowerCase()];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function hasAttr(el: El, name: string): boolean {
|
|
152
|
+
return name.toLowerCase() in el.attribs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Framework dynamic-binding prefixes: Vue `:x`/`v-bind:x`, Alpine `x-bind:x`,
|
|
156
|
+
// Svelte `bind:x`. The HTML parser keeps these as literal attribute keys.
|
|
157
|
+
const BIND_PREFIXES = ["", ":", "v-bind:", "x-bind:", "bind:"];
|
|
158
|
+
|
|
159
|
+
/** Like `attr`, but also matches a dynamically-bound spelling (`:name`, `v-bind:name`,
|
|
160
|
+
* …). Returns the (possibly expression) value; the caller treats it as "present but
|
|
161
|
+
* value-unknown" so a missing-name/alt finding is suppressed rather than hallucinated. */
|
|
162
|
+
export function boundAttr(el: El, name: string): string | undefined {
|
|
163
|
+
const lower = name.toLowerCase();
|
|
164
|
+
for (const p of BIND_PREFIXES) {
|
|
165
|
+
const k = p + lower;
|
|
166
|
+
if (k in el.attribs) return el.attribs[k];
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function hasBoundAttr(el: El, name: string): boolean {
|
|
172
|
+
return boundAttr(el, name) !== undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The element carries a spread/shorthand that can inject arbitrary attributes at
|
|
176
|
+
* runtime — React `{...props}`, Vue `v-bind="…"`, Svelte `{...rest}` / `{shorthand}`.
|
|
177
|
+
* A "missing attribute/name" finding on such an element is unprovable, so rules skip it. */
|
|
178
|
+
export function hasDynamicSpread(el: El): boolean {
|
|
179
|
+
if (el.spread) return true;
|
|
180
|
+
for (const k in el.attribs) if (k === "v-bind" || k.startsWith("{")) return true;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function isVoid(tag: string): boolean {
|
|
185
|
+
return VOID.has(tag);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function textContent(node: HNode): string {
|
|
189
|
+
if (node.type === "text") return node.data;
|
|
190
|
+
let out = "";
|
|
191
|
+
for (const c of node.children) out += textContent(c);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Visible text content, whitespace-collapsed and trimmed. */
|
|
196
|
+
export function visibleText(el: El): string {
|
|
197
|
+
return textContent(el).replace(/\s+/g, " ").trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function descendants(el: El): El[] {
|
|
201
|
+
const out: El[] = [];
|
|
202
|
+
const stack = [...el.children];
|
|
203
|
+
while (stack.length) {
|
|
204
|
+
const n = stack.pop()!;
|
|
205
|
+
if (n.type === "element") {
|
|
206
|
+
out.push(n);
|
|
207
|
+
stack.push(...n.children);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function ancestors(el: El): El[] {
|
|
214
|
+
const out: El[] = [];
|
|
215
|
+
let p = el.parent;
|
|
216
|
+
while (p) {
|
|
217
|
+
out.push(p);
|
|
218
|
+
p = p.parent;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function closest(el: El, pred: (e: El) => boolean): El | undefined {
|
|
224
|
+
let p: El | null = el;
|
|
225
|
+
while (p) {
|
|
226
|
+
if (pred(p)) return p;
|
|
227
|
+
p = p.parent;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function elementsByTag(doc: Doc, ...tags: string[]): El[] {
|
|
233
|
+
const want = new Set(tags.map((t) => t.toLowerCase()));
|
|
234
|
+
return doc.elements.filter((e) => want.has(e.tag));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getById(doc: Doc, id: string): El | undefined {
|
|
238
|
+
return doc.byId.get(id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** All ids in the document, with duplicates preserved (for duplicate-id). */
|
|
242
|
+
export function allIds(doc: Doc): { id: string; el: El }[] {
|
|
243
|
+
const out: { id: string; el: El }[] = [];
|
|
244
|
+
for (const el of doc.elements) {
|
|
245
|
+
const id = el.attribs.id;
|
|
246
|
+
if (id) out.push({ id, el });
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The source line(s) the element starts on, trimmed and truncated. */
|
|
252
|
+
export function snippet(doc: Doc, el: El, max = 120): string {
|
|
253
|
+
const lineStart = doc.lineStarts[el.line - 1] ?? 0;
|
|
254
|
+
let end = doc.source.indexOf("\n", lineStart);
|
|
255
|
+
if (end === -1) end = doc.source.length;
|
|
256
|
+
const raw = doc.source.slice(lineStart, end).trim();
|
|
257
|
+
return raw.length > max ? raw.slice(0, max - 1) + "…" : raw;
|
|
258
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Parse JSX/TSX into a real Babel AST. Unlike the lossy regex transform
|
|
2
|
+
// (parse/jsx.ts), this gives accurate node positions (start/end byte offsets +
|
|
3
|
+
// loc) and a faithful tree — the basis for fix-safe ranges and the cross-file
|
|
4
|
+
// dependency graph. @babel/parser is pure JS and bundled into the engine, so the
|
|
5
|
+
// shipped scripts/ultra11y.mjs still runs with no install.
|
|
6
|
+
import { parse } from "@babel/parser";
|
|
7
|
+
|
|
8
|
+
// We deliberately describe only the node shape the bridge/graph read, instead of
|
|
9
|
+
// depending on @babel/types. Every Babel node carries a string `type`, numeric
|
|
10
|
+
// `start`/`end` offsets, and a `loc`; the rest is walked structurally.
|
|
11
|
+
export interface AstNode {
|
|
12
|
+
type: string;
|
|
13
|
+
start: number | null;
|
|
14
|
+
end: number | null;
|
|
15
|
+
loc?: { start: { line: number; column: number }; end: { line: number; column: number } } | null;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AstFile extends AstNode {
|
|
20
|
+
type: "File";
|
|
21
|
+
program: AstNode; // Program; .body is AstNode[]
|
|
22
|
+
errors?: unknown[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Parse with error recovery so a single malformed expression never aborts the
|
|
26
|
+
// audit. Returns null only on a catastrophic throw — the caller then falls back
|
|
27
|
+
// to the lossy HTML transform, so an audit always produces a result.
|
|
28
|
+
export function parseJsxAst(source: string): AstFile | null {
|
|
29
|
+
try {
|
|
30
|
+
return parse(source, {
|
|
31
|
+
sourceType: "module",
|
|
32
|
+
errorRecovery: true,
|
|
33
|
+
plugins: ["typescript", "jsx"],
|
|
34
|
+
}) as unknown as AstFile;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- generic AST helpers shared by the JSX bridge and the graph extractor
|
|
41
|
+
|
|
42
|
+
// Structural keys we never recurse into (position/comment/token metadata).
|
|
43
|
+
const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "leadingComments", "trailingComments", "innerComments", "extra", "comments", "tokens"]);
|
|
44
|
+
|
|
45
|
+
export function asNode(value: unknown): AstNode | undefined {
|
|
46
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as AstNode) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function asNodes(value: unknown): AstNode[] {
|
|
50
|
+
return Array.isArray(value) ? (value as AstNode[]) : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Depth-first visit every AST node under `root` (arrays and child nodes), skipping
|
|
54
|
+
* position/comment metadata. The visitor cannot prune; for scoped walks pass a subtree. */
|
|
55
|
+
export function walkAst(root: unknown, visit: (node: AstNode) => void): void {
|
|
56
|
+
if (Array.isArray(root)) {
|
|
57
|
+
for (const item of root) walkAst(item, visit);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const n = asNode(root);
|
|
61
|
+
if (!n || typeof n.type !== "string") return;
|
|
62
|
+
visit(n);
|
|
63
|
+
for (const key in n) {
|
|
64
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
65
|
+
walkAst(n[key], visit);
|
|
66
|
+
}
|
|
67
|
+
}
|