ultra11y 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +40 -4
- package/package.json +1 -1
- package/scripts/build-standards.mjs +290 -1
- package/scripts/ultra11y.mjs +4131 -786
- package/scripts/vendor/wcag-2.2-fr.json +945 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/scripts/vendor/wcag-2.2-text.json +749 -0
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +4131 -786
- package/skills/ultra11y/SKILL.md +13 -4
- package/skills/ultra11y/references/guidance.md +33 -7
- package/skills/ultra11y/references/harnesses.md +10 -0
- package/skills/ultra11y/references/mcp.md +215 -0
- package/skills/ultra11y/references/packs.md +8 -0
- package/skills/ultra11y/references/standards.md +18 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +4131 -786
- package/src/adjudicate.ts +9 -15
- package/src/adjudication-data.ts +38 -0
- package/src/check.ts +51 -1
- package/src/cli.ts +6 -2
- package/src/config.ts +27 -4
- package/src/criteria-view.ts +463 -0
- package/src/criteria.ts +14 -11
- package/src/data/guidance/wcag.json +268 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/data/wcag.json +1112 -55
- package/src/guidance/index.ts +11 -0
- package/src/guidance/resolve.ts +71 -0
- package/src/mcp/handlers.ts +160 -15
- package/src/mcp/prompts.ts +13 -2
- package/src/mcp/server.ts +18 -4
- package/src/mcp/standards-resources.ts +174 -0
- package/src/mcp/tools.ts +140 -6
- package/src/method-view.ts +227 -0
- package/src/prd.ts +5 -16
- package/src/standards/coverage.ts +237 -0
- package/src/standards/index.ts +1 -0
- package/src/standards/pack.ts +19 -0
- package/src/standards/registry.ts +115 -9
- package/src/types.ts +24 -1
- package/src/vendor/codeindex-engine.d.mts +324 -3
- package/src/vendor/codeindex-engine.mjs +1146 -175
- package/src/vendor/engine.meta.json +5 -5
- package/src/wcag.ts +21 -1
package/src/adjudicate.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { readText } from "./util.js";
|
|
|
18
18
|
import { parseSource } from "./parse/source.js";
|
|
19
19
|
import { type Doc, type El, elementsByTag, attr, textContent, ancestors, snippet as elSnippet } from "./parse/html.js";
|
|
20
20
|
import { parseInlineStyle } from "./color.js";
|
|
21
|
-
import
|
|
21
|
+
import { ADJUDICATION } from "./adjudication-data.js";
|
|
22
22
|
import { scTitle, getSC, hasSC, techniquesFor, allSC, guidelineTitle } from "./wcag.js";
|
|
23
23
|
import { groundItems, type GroundingSummary } from "./grounding.js";
|
|
24
24
|
import {
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
hasId,
|
|
30
30
|
getCriterion,
|
|
31
31
|
derivePackResults,
|
|
32
|
+
glossaryAnchorsOf,
|
|
32
33
|
resolveGlossary,
|
|
33
34
|
titlePlain as packTitlePlain,
|
|
34
35
|
type StandardPack,
|
|
@@ -808,8 +809,8 @@ const T = {
|
|
|
808
809
|
// rule that decides Conforming vs Non-conforming, when NA is legitimate, and the concrete
|
|
809
810
|
// questions that get you there. Rendered per residual item in both languages — a criterion
|
|
810
811
|
// handed to the agent with no stated decision rule is where an audit turns into an opinion.
|
|
811
|
-
|
|
812
|
-
|
|
812
|
+
// The dataset itself lives in src/adjudication-data.ts, so a criterion lookup can read the
|
|
813
|
+
// decision protocol without importing this module's engine dependencies.
|
|
813
814
|
|
|
814
815
|
/** Cap on techniques listed per criterion: 1.1.1 alone carries 52, which would bury the
|
|
815
816
|
* decision rule under a wall of ids. The full list stays in `criteria <sc>`. */
|
|
@@ -866,21 +867,14 @@ export function renderAdjudicationReference(lang: Lang = "en"): string {
|
|
|
866
867
|
// pack's glossary — 119 entries for RGAA — which nothing used to read. Attaching the ones
|
|
867
868
|
// THIS criterion's tests actually cite makes the item self-sufficient: the agent no longer
|
|
868
869
|
// has to guess what the standard means by "image porteuse d'information".
|
|
869
|
-
const GLOSSARY_REF = /\[[^\]]+\]\(#([^)]+)\)/g;
|
|
870
870
|
const MAX_GLOSSARY_TERMS = 8;
|
|
871
871
|
const MAX_GLOSSARY_CHARS = 600;
|
|
872
872
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
for (const t of texts) {
|
|
879
|
-
GLOSSARY_REF.lastIndex = 0;
|
|
880
|
-
for (let m = GLOSSARY_REF.exec(t); m; m = GLOSSARY_REF.exec(t)) if (m[1]) seen.add(m[1]);
|
|
881
|
-
}
|
|
882
|
-
return [...seen];
|
|
883
|
-
}
|
|
873
|
+
// `glossaryAnchorsOf` now lives in src/standards/pack.ts, next to the glossary it reads —
|
|
874
|
+
// the criteria lookup and the MCP reference tools need it too, and none of them should
|
|
875
|
+
// have to import the adjudication engine to get at a pure function over a criterion.
|
|
876
|
+
// Re-exported here because it was part of this module's surface.
|
|
877
|
+
export { glossaryAnchorsOf };
|
|
884
878
|
|
|
885
879
|
function glossaryBlock(pack: StandardPack, crit: PackCriterion | undefined, lang: Lang): string[] {
|
|
886
880
|
const anchors = glossaryAnchorsOf(crit).slice(0, MAX_GLOSSARY_TERMS);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The SC-keyed adjudication protocol, on its own.
|
|
2
|
+
//
|
|
3
|
+
// `src/data/adjudication.json` (built by scripts/build-adjudication.mjs) states, for every
|
|
4
|
+
// criterion the static engine cannot settle, the rule that decides Conforming vs
|
|
5
|
+
// Non-conforming, when NA is legitimate, and the questions that get you there. It is the
|
|
6
|
+
// substitute for W3C's Understanding prose, which this repo deliberately does not vendor.
|
|
7
|
+
//
|
|
8
|
+
// It used to be reachable only through src/adjudicate.ts — which imports the discovery
|
|
9
|
+
// walker, the HTML parser and the PRD renderer. A criterion LOOKUP needs the protocol and
|
|
10
|
+
// none of that, so the dataset and its accessor live here and adjudicate.ts imports them.
|
|
11
|
+
import adjudicationJson from "./data/adjudication.json";
|
|
12
|
+
import type { Lang } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export type LocaleText = { fr: string; en: string };
|
|
15
|
+
|
|
16
|
+
export interface AdjudicationProtocol {
|
|
17
|
+
decide: LocaleText;
|
|
18
|
+
na?: LocaleText;
|
|
19
|
+
questions: LocaleText[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ADJUDICATION = adjudicationJson as Record<string, AdjudicationProtocol>;
|
|
23
|
+
|
|
24
|
+
/** The protocol for a WCAG success criterion, or undefined when the engine settles it. */
|
|
25
|
+
export function adjudicationFor(sc: string): AdjudicationProtocol | undefined {
|
|
26
|
+
return ADJUDICATION[sc];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The protocol in one language — the shape a reference lookup hands back. */
|
|
30
|
+
export function adjudicationText(sc: string, lang: Lang): { decide: string; na?: string; questions: string[] } | undefined {
|
|
31
|
+
const p = ADJUDICATION[sc];
|
|
32
|
+
if (!p) return undefined;
|
|
33
|
+
return {
|
|
34
|
+
decide: p.decide[lang],
|
|
35
|
+
...(p.na ? { na: p.na[lang] } : {}),
|
|
36
|
+
questions: p.questions.map((q) => q[lang]),
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/check.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import type { AuditResult, Lang } from "./types.js";
|
|
11
11
|
import { hasSC } from "./wcag.js";
|
|
12
|
-
import { type StandardId, isCore, loadPack, hasId, idCaptureSource, derivePackResults } from "./standards/index.js";
|
|
12
|
+
import { type StandardId, criterionCoverage, isCore, loadPack, hasId, idCaptureSource, derivePackResults } from "./standards/index.js";
|
|
13
13
|
import { buildWorklist, applyVerdicts, type VerifyItem } from "./verify.js";
|
|
14
14
|
import { groundItems } from "./grounding.js";
|
|
15
15
|
import { isPagesReport } from "./pages-report.js";
|
|
@@ -36,6 +36,8 @@ const M = {
|
|
|
36
36
|
semanticUnreadable: (p: string) => `Gate sémantique : artefact de verdicts illisible ou JSON invalide : ${p}.`,
|
|
37
37
|
semanticGate: (failed: number, total: number) => `Gate sémantique : ${failed}/${total} verdict(s) en échec (non statué, réfuté, non étayé ou non couvert).`,
|
|
38
38
|
semanticGround: (issue: string) => `Gate sémantique : ${issue}`,
|
|
39
|
+
unevidenced: (id: string, tier: string, tool: string) =>
|
|
40
|
+
`Conformité non étayée : ${id} est déclaré conforme, mais ce critère se tranche au ${tier} et cet audit n'a produit aucune preuve de ce type. Lancez ${tool}, puis ré-auditez — ou laissez le critère « à évaluer ».`,
|
|
39
41
|
},
|
|
40
42
|
en: {
|
|
41
43
|
section: (n: number) => `Section ${n} missing from the report.`,
|
|
@@ -53,6 +55,8 @@ const M = {
|
|
|
53
55
|
semanticUnreadable: (p: string) => `Semantic gate: verdicts artifact unreadable or invalid JSON: ${p}.`,
|
|
54
56
|
semanticGate: (failed: number, total: number) => `Semantic gate: ${failed}/${total} verdict(s) failing (unadjudicated, refuted, unsupported or uncovered).`,
|
|
55
57
|
semanticGround: (issue: string) => `Semantic gate: ${issue}`,
|
|
58
|
+
unevidenced: (id: string, tier: string, tool: string) =>
|
|
59
|
+
`Unevidenced conformity: ${id} is declared conformant, but this criterion is decided on the ${tier} and this audit produced no evidence of that kind. Run ${tool}, then re-audit — or leave the criterion "to assess".`,
|
|
56
60
|
},
|
|
57
61
|
} as const;
|
|
58
62
|
|
|
@@ -167,9 +171,55 @@ export function checkReport(md: string, standard: StandardId = "wcag", lang: Lan
|
|
|
167
171
|
for (const id of derivedNc) if (!reportNc.has(id)) issues.push(s.underProject(id));
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
// 6. evidence gate: a criterion the report declares CONFORMANT whose verdict can only
|
|
175
|
+
// come from a tier this audit never ran.
|
|
176
|
+
//
|
|
177
|
+
// Contrast, focus visibility and reflow are not decided by reading source — they are
|
|
178
|
+
// measured on a render. An audit that never rendered anything and still publishes them
|
|
179
|
+
// as conformant is making the one claim this tool must never make: a pass nobody tested.
|
|
180
|
+
//
|
|
181
|
+
// Scoped deliberately tight, so it refuses only what is unambiguously wrong:
|
|
182
|
+
// · needs `opts.audit` — without it there is no way to know what evidence exists;
|
|
183
|
+
// · reads only the ENGINE-decided half of section 3. An agent that rules a rendering
|
|
184
|
+
// criterion conformant has looked at something this audit cannot see, and section 3
|
|
185
|
+
// already separates that claim under its own heading;
|
|
186
|
+
// · treats an ABSENT `pagesAudited` as unknown, never as zero — an audit written
|
|
187
|
+
// before that field existed must not start failing.
|
|
188
|
+
if (opts.audit) {
|
|
189
|
+
const engineC = sectionBody(md, 3).split(/^###\s/m)[0] ?? "";
|
|
190
|
+
const cItem = new RegExp(`^-\\s+(?:[A-Za-z]+\\s+)?(${idGrammar})\\s*—`, "gm");
|
|
191
|
+
const scanned = hasScanEvidence(opts.audit);
|
|
192
|
+
const rendered = hasRenderedEvidence(opts.audit);
|
|
193
|
+
const claimed = new Set<string>();
|
|
194
|
+
let m: RegExpExecArray | null;
|
|
195
|
+
while ((m = cItem.exec(engineC))) claimed.add(m[1]!);
|
|
196
|
+
for (const id of claimed) {
|
|
197
|
+
const tier = criterionCoverage(standard, id)?.tier;
|
|
198
|
+
if (tier === "browser" && !scanned) issues.push(s.unevidenced(id, core ? "browser" : "navigateur", "`ultra11y scan <target> --merge`"));
|
|
199
|
+
else if (tier === "rendered-page" && rendered === false && !scanned) {
|
|
200
|
+
issues.push(s.unevidenced(id, core ? "rendered page" : "rendu de page", "`ultra11y render` (or an E2E capture)"));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
170
205
|
return { ok: issues.length === 0, issues };
|
|
171
206
|
}
|
|
172
207
|
|
|
208
|
+
/** Did a dynamic scan actually run? Either it recorded which SCs it measured, or its
|
|
209
|
+
* engines left findings behind. Same signal `untestedNeedsRendering` reads. */
|
|
210
|
+
function hasScanEvidence(r: AuditResult): boolean {
|
|
211
|
+
if ((r.scope.scan?.testedScs ?? []).length > 0) return true;
|
|
212
|
+
return r.findings.some((f) => f.ruleId.startsWith("dyn-") || f.ruleId.startsWith("axe:"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Was a page snapshot audited? `undefined` means UNKNOWN (an audit predating the field),
|
|
216
|
+
* and unknown must never be read as "no" — that would fail an old report on a technicality. */
|
|
217
|
+
function hasRenderedEvidence(r: AuditResult): boolean | undefined {
|
|
218
|
+
if (r.findings.some((f) => f.ruleId.startsWith("rendered-"))) return true;
|
|
219
|
+
const audited = r.scope.pagesAudited;
|
|
220
|
+
return audited === undefined ? undefined : audited.length > 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
/** The set of criterion ids the report presents as non-conformant — parsed from the
|
|
174
224
|
* section-2 auditor blocks' "**<criterion>** : <id> — …" lines (the theme line "8." can't
|
|
175
225
|
* match the 2-segment id grammar, so it's never mistaken for a criterion). */
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, writeFileSync, mkdirSync, existsSync, readFileSync, appendFileSync, copyFileSync } from "node:fs";
|
|
2
|
-
import { join, relative, sep, dirname } from "node:path";
|
|
2
|
+
import { join, relative, resolve, sep, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import {
|
|
5
5
|
VERSION,
|
|
@@ -3720,7 +3720,11 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
3720
3720
|
.map((s) => s.trim())
|
|
3721
3721
|
.filter(Boolean)
|
|
3722
3722
|
: [];
|
|
3723
|
-
|
|
3723
|
+
// `mcp --cwd <dir>` dedicates the server to another project, and a standards pack is
|
|
3724
|
+
// that project's configuration — read it from THERE, not from whichever directory node
|
|
3725
|
+
// happened to be started in. Every other command works on the process cwd.
|
|
3726
|
+
const configRoot = p.command === "mcp" && typeof p.flags.cwd === "string" && p.flags.cwd ? resolve(p.flags.cwd) : process.cwd();
|
|
3727
|
+
const loaded = loadRuntimeStandards(configRoot, packList, (m) => console.error(m), p.flags.override === true);
|
|
3724
3728
|
if (loaded.errors.length) {
|
|
3725
3729
|
for (const e of loaded.errors) console.error(`ultra11y: ${e}`);
|
|
3726
3730
|
return 2;
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, statSync } from "node:fs";
|
|
10
10
|
import { join, isAbsolute } from "node:path";
|
|
11
11
|
import { readText } from "./util.js";
|
|
12
|
-
import { registerRuntimePack, getPack, enableSecondaryMapping } from "./standards/registry.js";
|
|
12
|
+
import { registerRuntimePack, registerScoped, ensureScope, withScope, getPack, enableSecondaryMapping } from "./standards/registry.js";
|
|
13
13
|
import { formatIssues } from "./standards/validate.js";
|
|
14
14
|
import { checkGuidance } from "./pack.js";
|
|
15
15
|
import { registerRuntimeGuidance, hasGuidance } from "./guidance/index.js";
|
|
@@ -90,8 +90,31 @@ function packPaths(path: string): { pack: string; glossary?: string; guidance?:
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** Load + register every configured/flagged pack (and its guidance) into the registries.
|
|
93
|
-
* Must run BEFORE any `resolveStandard`/`loadPack` so a runtime pack is resolvable.
|
|
94
|
-
|
|
93
|
+
* Must run BEFORE any `resolveStandard`/`loadPack` so a runtime pack is resolvable.
|
|
94
|
+
*
|
|
95
|
+
* `opts.scope` confines the packs to one project root instead of the process-wide
|
|
96
|
+
* registry — what a long-lived MCP server needs, since it serves many projects and two
|
|
97
|
+
* of them may each declare a different pack under the same key. Guidance datasets stay
|
|
98
|
+
* process-wide: they are keyed by pack key and only ever ADD examples, so a stray one
|
|
99
|
+
* cannot change a verdict. */
|
|
100
|
+
export function loadRuntimeStandards(
|
|
101
|
+
cwd: string,
|
|
102
|
+
packFlags: string[],
|
|
103
|
+
onWarn: (m: string) => void,
|
|
104
|
+
override = false,
|
|
105
|
+
opts: { scope?: string } = {},
|
|
106
|
+
): LoadResult {
|
|
107
|
+
// Every READ below (`getPack` for a secondary mapping, for a guidance dataset's target
|
|
108
|
+
// pack) must see the packs this call just registered — which, under a scope, live in
|
|
109
|
+
// that scope's overlay and nowhere else.
|
|
110
|
+
return withScope(opts.scope, () => loadStandardsInto(cwd, packFlags, onWarn, override, opts));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadStandardsInto(cwd: string, packFlags: string[], onWarn: (m: string) => void, override: boolean, opts: { scope?: string }): LoadResult {
|
|
114
|
+
// Materialize the overlay up front, even when this project declares no pack of its own:
|
|
115
|
+
// a config that only enables a `secondaryMappings` entry still needs somewhere to put
|
|
116
|
+
// its copy-on-write clone of the built-in it edits.
|
|
117
|
+
if (opts.scope) ensureScope(opts.scope);
|
|
95
118
|
const result: LoadResult = { errors: [], loadedPacks: [], loadedGuidance: [] };
|
|
96
119
|
let config: Ultra11yConfig | null = null;
|
|
97
120
|
try {
|
|
@@ -125,7 +148,7 @@ export function loadRuntimeStandards(cwd: string, packFlags: string[], onWarn: (
|
|
|
125
148
|
onWarn(`ultra11y: ${paths.glossary} is not valid JSON — ignoring glossary.`);
|
|
126
149
|
}
|
|
127
150
|
}
|
|
128
|
-
const v = registerRuntimePack(packObj, glossary, { override });
|
|
151
|
+
const v = opts.scope ? registerScoped(opts.scope, packObj, glossary, { override }) : registerRuntimePack(packObj, glossary, { override });
|
|
129
152
|
for (const issue of v.issues) if (issue.severity === "warn") onWarn(`ultra11y: ${raw}: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`);
|
|
130
153
|
if (!v.ok || !v.pack) {
|
|
131
154
|
result.errors.push(`--pack ${paths.pack}: invalid pack\n${formatIssues(v.issues).join("\n")}`);
|