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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.opencode/plugins/ultra11y.js +1 -1
  5. package/README.md +40 -4
  6. package/package.json +1 -1
  7. package/scripts/build-standards.mjs +290 -1
  8. package/scripts/ultra11y.mjs +4131 -786
  9. package/scripts/vendor/wcag-2.2-fr.json +945 -1
  10. package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
  11. package/scripts/vendor/wcag-2.2-text.json +749 -0
  12. package/skills/review-a11y/SKILL.md +1 -1
  13. package/skills/review-a11y/scripts/ultra11y.mjs +4131 -786
  14. package/skills/ultra11y/SKILL.md +13 -4
  15. package/skills/ultra11y/references/guidance.md +33 -7
  16. package/skills/ultra11y/references/harnesses.md +10 -0
  17. package/skills/ultra11y/references/mcp.md +215 -0
  18. package/skills/ultra11y/references/packs.md +8 -0
  19. package/skills/ultra11y/references/standards.md +18 -0
  20. package/skills/ultra11y/scripts/ultra11y.mjs +4131 -786
  21. package/src/adjudicate.ts +9 -15
  22. package/src/adjudication-data.ts +38 -0
  23. package/src/check.ts +51 -1
  24. package/src/cli.ts +6 -2
  25. package/src/config.ts +27 -4
  26. package/src/criteria-view.ts +463 -0
  27. package/src/criteria.ts +14 -11
  28. package/src/data/guidance/wcag.json +268 -0
  29. package/src/data/wcag-universe.json +1 -1
  30. package/src/data/wcag.json +1112 -55
  31. package/src/guidance/index.ts +11 -0
  32. package/src/guidance/resolve.ts +71 -0
  33. package/src/mcp/handlers.ts +160 -15
  34. package/src/mcp/prompts.ts +13 -2
  35. package/src/mcp/server.ts +18 -4
  36. package/src/mcp/standards-resources.ts +174 -0
  37. package/src/mcp/tools.ts +140 -6
  38. package/src/method-view.ts +227 -0
  39. package/src/prd.ts +5 -16
  40. package/src/standards/coverage.ts +237 -0
  41. package/src/standards/index.ts +1 -0
  42. package/src/standards/pack.ts +19 -0
  43. package/src/standards/registry.ts +115 -9
  44. package/src/types.ts +24 -1
  45. package/src/vendor/codeindex-engine.d.mts +324 -3
  46. package/src/vendor/codeindex-engine.mjs +1146 -175
  47. package/src/vendor/engine.meta.json +5 -5
  48. 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 adjudicationJson from "./data/adjudication.json";
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
- type LocaleText = { fr: string; en: string };
812
- const ADJUDICATION = adjudicationJson as Record<string, { decide: LocaleText; na?: LocaleText; questions: LocaleText[] }>;
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
- /** The glossary anchors a criterion's tests / notes / particular cases refer to, in order. */
874
- export function glossaryAnchorsOf(crit: { tests?: Record<string, string[]>; technicalNote?: string[]; particularCases?: string[] } | undefined): string[] {
875
- if (!crit) return [];
876
- const texts = [...Object.values(crit.tests ?? {}).flat(), ...(crit.technicalNote ?? []), ...(crit.particularCases ?? [])];
877
- const seen = new Set<string>();
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
- const loaded = loadRuntimeStandards(process.cwd(), packList, (m) => console.error(m), p.flags.override === true);
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
- export function loadRuntimeStandards(cwd: string, packFlags: string[], onWarn: (m: string) => void, override = false): LoadResult {
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")}`);