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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The GUIDANCE registry — parallel to the standards registry. The built-in RGAA
|
|
2
|
+
// implementation guidance is inlined by tsup (like wcag.json / rgaa.json); external
|
|
3
|
+
// guidance datasets are added at runtime by the `--pack` / .ultra11yrc.json loader
|
|
4
|
+
// (src/config.ts). Lookups are by WCAG SC (core view) or by pack criterion (pack view),
|
|
5
|
+
// so the report/PRD can attach concrete before/after examples without touching the
|
|
6
|
+
// canonical AuditResult.
|
|
7
|
+
import rgaaGuidance from "../data/guidance/rgaa.json";
|
|
8
|
+
import type { GuidanceDataset, GuidanceEntry } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const datasets = new Map<string, GuidanceDataset>();
|
|
11
|
+
|
|
12
|
+
function add(ds: GuidanceDataset): void {
|
|
13
|
+
datasets.set(ds.pack, ds);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
add(rgaaGuidance as unknown as GuidanceDataset);
|
|
17
|
+
|
|
18
|
+
/** Register an external guidance dataset at runtime (from --pack / .ultra11yrc.json). */
|
|
19
|
+
export function registerRuntimeGuidance(ds: GuidanceDataset): void {
|
|
20
|
+
add(ds);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every guidance entry (across datasets) whose criterion implements this WCAG SC,
|
|
24
|
+
* in a stable order (dataset registration order, then entry order) for reproducibility. */
|
|
25
|
+
export function guidanceForWcag(sc: string): GuidanceEntry[] {
|
|
26
|
+
const out: GuidanceEntry[] = [];
|
|
27
|
+
// Defensive: a runtime dataset entry may omit `wcag` — never let a lookup throw.
|
|
28
|
+
for (const ds of datasets.values()) for (const e of ds.entries) if (Array.isArray(e.wcag) && e.wcag.includes(sc)) out.push(e);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Guidance entries pinned to a specific pack criterion (e.g. rgaa "1.2"). */
|
|
33
|
+
export function guidanceForCriterion(packKey: string, criterionId: string): GuidanceEntry[] {
|
|
34
|
+
return datasets.get(packKey)?.entries.filter((e) => e.criterionId === criterionId) ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hasGuidance(packKey: string): boolean {
|
|
38
|
+
return datasets.has(packKey);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getDataset(packKey: string): GuidanceDataset | undefined {
|
|
42
|
+
return datasets.get(packKey);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Types for the GUIDANCE layer — concrete, how-to-implement accessibility rules
|
|
2
|
+
// (good/bad code patterns) ingested from an external source such as the SocialGouv /
|
|
3
|
+
// etalab RGAA rule packs. Guidance is NOT the standard itself: a StandardPack
|
|
4
|
+
// (src/standards/) is a localized criterion set mapped onto WCAG SCs, whereas guidance
|
|
5
|
+
// is the *implementation rule* attached to those criteria — the before/after examples
|
|
6
|
+
// and remediation prose the agent and the PRD draw on. It is looked up at RENDER time
|
|
7
|
+
// by criterion / WCAG SC, so the canonical AuditResult JSON stays untouched.
|
|
8
|
+
import type { Lang } from "../types.js";
|
|
9
|
+
|
|
10
|
+
export type GuidanceLang = "html" | "jsx" | "css";
|
|
11
|
+
|
|
12
|
+
export interface GuidanceExample {
|
|
13
|
+
lang: GuidanceLang;
|
|
14
|
+
bad?: string; // a non-compliant snippet (the RGAA "non conforme" example)
|
|
15
|
+
good?: string; // the compliant fix (the RGAA "conforme" example)
|
|
16
|
+
note?: Partial<Record<Lang, string>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GuidanceEntry {
|
|
20
|
+
id: string; // source rule id, e.g. "images-decorative-alt"
|
|
21
|
+
criterionId?: string; // pack-local criterion id (e.g. RGAA "1.2"); absent ⇒ unresolved ⇒ pack check fails
|
|
22
|
+
wcag: string[]; // bare WCAG SC ids (derived from the criterion this rule implements)
|
|
23
|
+
title: Partial<Record<Lang, string>>;
|
|
24
|
+
summary: Partial<Record<Lang, string>>; // one-line "how to implement it" guidance
|
|
25
|
+
impact?: "high" | "medium" | "low";
|
|
26
|
+
examples: GuidanceExample[];
|
|
27
|
+
reference: string; // the official criterion URL the mapping was derived from
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GuidanceDataset {
|
|
31
|
+
pack: string; // the pack key this guidance augments, e.g. "rgaa"
|
|
32
|
+
source: string;
|
|
33
|
+
license: string;
|
|
34
|
+
attribution: string;
|
|
35
|
+
entries: GuidanceEntry[];
|
|
36
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// `init` — wire ultra11y into a repo so accessibility is enforced automatically (a git
|
|
2
|
+
// pre-commit hook and/or a CI job) and not only on demand. Zero dependencies (no husky):
|
|
3
|
+
// plain POSIX `sh` hooks + a GitHub Actions workflow. The default pre-commit hook audits
|
|
4
|
+
// the STRICT STAGED SNAPSHOT (`audit --staged`), auto-applies safe fixes (`fix --staged
|
|
5
|
+
// --write --safe`) and re-stages them, blocking only on issues that need judgment. The
|
|
6
|
+
// legacy baseline regression gate (`audit --changed --baseline … --fail-on …`, blocking
|
|
7
|
+
// only NEW findings) stays available via `init --baseline` and the CI workflow.
|
|
8
|
+
import { writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { Severity } from "./types.js";
|
|
12
|
+
|
|
13
|
+
// English --fail-on token written into generated hooks/CI (the tool is English-first;
|
|
14
|
+
// parseFailOn still accepts both fr and en).
|
|
15
|
+
const EN_SEV: Record<Severity, string> = { bloquant: "blocking", majeur: "major", mineur: "minor" };
|
|
16
|
+
|
|
17
|
+
export function repoRoot(): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** POSIX `sh` pre-commit hook (default, strict staged snapshot). Operates on EXACTLY the
|
|
26
|
+
* staged index blobs (what the commit records, not the working tree): first auto-applies
|
|
27
|
+
* SAFE deterministic fixes and re-stages them, then blocks the commit only if issues >=
|
|
28
|
+
* failOn remain — these are the judgment ones (alt text, labels, link purpose) that a
|
|
29
|
+
* codemod must not guess. `enginePath` resolved at generation time, overridable via
|
|
30
|
+
* $ULTRA11Y; bypass once with SKIP_A11Y=1. Quiet on a clean commit. */
|
|
31
|
+
export function stagedHookScript(enginePath: string, failOn: Severity): string {
|
|
32
|
+
const sev = EN_SEV[failOn];
|
|
33
|
+
return `#!/bin/sh
|
|
34
|
+
# ultra11y accessibility gate (strict staged snapshot) — generated by \`ultra11y init --hook\`.
|
|
35
|
+
# Operates on EXACTLY the staged index blobs (what the commit records, not the working
|
|
36
|
+
# tree): first auto-applies SAFE deterministic fixes and re-stages them, then blocks the
|
|
37
|
+
# commit only if issues >= ${sev} remain that need judgment (alt text, link purpose,
|
|
38
|
+
# labels). Bypass once with: SKIP_A11Y=1 git commit ...
|
|
39
|
+
[ -n "$SKIP_A11Y" ] && exit 0
|
|
40
|
+
ULTRA11Y=\${ULTRA11Y:-'${enginePath}'}
|
|
41
|
+
command -v node >/dev/null 2>&1 || { echo "ultra11y: node not found — skipping a11y gate." >&2; exit 0; }
|
|
42
|
+
# 1) Auto-apply safe deterministic fixes to the staged snapshot and re-stage them
|
|
43
|
+
# (silent no-op when nothing is auto-fixable; warnings, if any, go to stderr).
|
|
44
|
+
node "$ULTRA11Y" fix --staged --write --safe >/dev/null
|
|
45
|
+
# 2) Gate: allow the commit unless issues >= ${sev} remain (these need judgment).
|
|
46
|
+
node "$ULTRA11Y" audit --staged --fail-on ${sev} >/dev/null 2>&1 && exit 0
|
|
47
|
+
echo "ultra11y: accessibility issues >= ${sev} remain in staged changes — they need judgment:" >&2
|
|
48
|
+
node "$ULTRA11Y" audit --staged --fail-on ${sev} >&2
|
|
49
|
+
echo " Apply the real fix on the staged files (or invoke the ultra11y skill), re-stage, and commit again." >&2
|
|
50
|
+
echo " Bypass once with: SKIP_A11Y=1 git commit ..." >&2
|
|
51
|
+
exit 1
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** POSIX `sh` pre-commit hook (baseline regression gate — opt-in via `init --baseline`).
|
|
56
|
+
* `enginePath` is resolved at generation time; overridable at runtime via $ULTRA11Y.
|
|
57
|
+
* Bypass once with SKIP_A11Y=1. */
|
|
58
|
+
export function hookScript(enginePath: string, failOn: Severity): string {
|
|
59
|
+
return `#!/bin/sh
|
|
60
|
+
# ultra11y accessibility regression gate — generated by \`ultra11y init --hook\`.
|
|
61
|
+
# Blocks a commit only on NEW non-conformities >= ${EN_SEV[failOn]} in staged changes
|
|
62
|
+
# (vs audits/baseline.json). Bypass once with: SKIP_A11Y=1 git commit ...
|
|
63
|
+
[ -n "$SKIP_A11Y" ] && exit 0
|
|
64
|
+
ULTRA11Y=\${ULTRA11Y:-'${enginePath}'}
|
|
65
|
+
command -v node >/dev/null 2>&1 || { echo "ultra11y: node not found — skipping a11y gate." >&2; exit 0; }
|
|
66
|
+
if ! node "$ULTRA11Y" audit --changed --baseline audits/baseline.json --fail-on ${EN_SEV[failOn]}; then
|
|
67
|
+
echo "ultra11y: new accessibility regression in staged changes (>= ${EN_SEV[failOn]})." >&2
|
|
68
|
+
echo " Fix it, run: node \\"$ULTRA11Y\\" fix --changed --write" >&2
|
|
69
|
+
echo " or bypass once with: SKIP_A11Y=1 git commit ..." >&2
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
exit 0
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** GitHub Actions workflow auditing the PR diff against the committed baseline. */
|
|
77
|
+
export function ciWorkflow(enginePath: string, failOn: Severity): string {
|
|
78
|
+
return `name: a11y
|
|
79
|
+
# Generated by \`ultra11y init --ci\`. Fails only on NEW non-conformities >= ${EN_SEV[failOn]}
|
|
80
|
+
# introduced by the PR (vs audits/baseline.json) — not the existing backlog.
|
|
81
|
+
on:
|
|
82
|
+
pull_request:
|
|
83
|
+
jobs:
|
|
84
|
+
ultra11y:
|
|
85
|
+
runs-on: ubuntu-latest
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
with:
|
|
89
|
+
fetch-depth: 0
|
|
90
|
+
- uses: actions/setup-node@v4
|
|
91
|
+
with:
|
|
92
|
+
node-version: 20
|
|
93
|
+
- name: ultra11y accessibility regression gate
|
|
94
|
+
run: node "${enginePath}" audit --since "origin/\${{ github.base_ref }}" --baseline audits/baseline.json --fail-on ${EN_SEV[failOn]}
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function writeHook(root: string, enginePath: string, failOn: Severity, mode: "staged" | "baseline" = "staged"): string {
|
|
99
|
+
const dir = join(root, ".git", "hooks");
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
const path = join(dir, "pre-commit");
|
|
102
|
+
writeFileSync(path, mode === "baseline" ? hookScript(enginePath, failOn) : stagedHookScript(enginePath, failOn));
|
|
103
|
+
chmodSync(path, 0o755);
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeCi(root: string, enginePath: string, failOn: Severity): string {
|
|
108
|
+
const dir = join(root, ".github", "workflows");
|
|
109
|
+
mkdirSync(dir, { recursive: true });
|
|
110
|
+
const path = join(dir, "a11y.yml");
|
|
111
|
+
writeFileSync(path, ciWorkflow(enginePath, failOn));
|
|
112
|
+
return path;
|
|
113
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { runAudit } from "../audit.js";
|
|
4
|
+
import { buildAdjudicationWorklist, formatAdjudication } from "../adjudicate.js";
|
|
5
|
+
import { checkReport } from "../check.js";
|
|
6
|
+
import { formatSC } from "../criteria.js";
|
|
7
|
+
import { runFix, fixSummary } from "../fix.js";
|
|
8
|
+
import { writeHook, ciWorkflow } from "../init.js";
|
|
9
|
+
import { runPackCheck } from "../pack.js";
|
|
10
|
+
import { prdUnits, partitionUnits } from "../prd.js";
|
|
11
|
+
import { renderReport, renderPackReport } from "../report.js";
|
|
12
|
+
import { buildWorklist, formatWorklist } from "../verify.js";
|
|
13
|
+
import { allSC, getSC } from "../wcag.js";
|
|
14
|
+
import { loadPack } from "../standards/index.js";
|
|
15
|
+
import { withProjectLock } from "../project-lock.js";
|
|
16
|
+
import type { AuditResult, Lang } from "../types.js";
|
|
17
|
+
import type { StandardId } from "../standards/index.js";
|
|
18
|
+
|
|
19
|
+
// Where a tool name becomes work. Every handler calls the same library
|
|
20
|
+
// functions the CLI does — nothing here shells out to `ultra11y`, and nothing
|
|
21
|
+
// here calls cli.ts, whose failure path would take the server process down with
|
|
22
|
+
// a process.exit on a bad argument.
|
|
23
|
+
|
|
24
|
+
export interface HandlerDefaults {
|
|
25
|
+
defaultCwd?: string;
|
|
26
|
+
allowWrite?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ToolError extends Error {}
|
|
30
|
+
|
|
31
|
+
export interface ToolOutcome {
|
|
32
|
+
text: string;
|
|
33
|
+
artifact?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MAX_READ_LINES = 2000;
|
|
37
|
+
const MAX_READ_BYTES = 8 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
// What to audit when the caller names nothing. The CLI makes globs positional
|
|
40
|
+
// and required; a tool call with no globs is far likelier to mean "this
|
|
41
|
+
// project" than to be a mistake, and a wrong-looking empty audit is worse than
|
|
42
|
+
// a broad one.
|
|
43
|
+
const DEFAULT_GLOBS = ["**/*.html", "**/*.htm", "**/*.jsx", "**/*.tsx", "**/*.vue", "**/*.svelte"];
|
|
44
|
+
|
|
45
|
+
const WRITE_TOOL_NAMES = new Set(["ultra11y_fix", "ultra11y_scan", "ultra11y_init"]);
|
|
46
|
+
|
|
47
|
+
// --------------------------------------------------------------------------
|
|
48
|
+
// Argument coercion
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function str(v: unknown): string | undefined {
|
|
52
|
+
return typeof v === "string" && v.trim() !== "" ? v : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function num(v: unknown): number | undefined {
|
|
56
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
|
|
57
|
+
return Number.isFinite(n) ? n : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function bool(v: unknown): boolean {
|
|
61
|
+
return v === true || v === "true";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function strArray(v: unknown): string[] | undefined {
|
|
65
|
+
const a = Array.isArray(v) && v.every((x) => typeof x === "string") ? (v as string[]) : undefined;
|
|
66
|
+
return a && a.length ? a : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function positive(v: unknown, key: string): number | undefined {
|
|
70
|
+
const n = num(v);
|
|
71
|
+
if (n === undefined) return undefined;
|
|
72
|
+
if (n <= 0) throw new ToolError(`\`${key}\` must be greater than 0.`);
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requiredCwd(args: Record<string, unknown>, defaults: HandlerDefaults): string {
|
|
77
|
+
const cwd = str(args.cwd) ?? defaults.defaultCwd;
|
|
78
|
+
if (!cwd) throw new ToolError("`cwd` is required: an absolute path to the project root.");
|
|
79
|
+
const abs = resolve(cwd);
|
|
80
|
+
if (!existsSync(abs)) throw new ToolError(`project root not found: ${abs}`);
|
|
81
|
+
if (!statSync(abs).isDirectory()) throw new ToolError(`\`cwd\` is not a directory: ${abs}`);
|
|
82
|
+
return abs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function standardOf(args: Record<string, unknown>): StandardId {
|
|
86
|
+
const s = str(args.standard) ?? "wcag";
|
|
87
|
+
if (s !== "wcag" && s !== "rgaa") throw new ToolError(`\`standard\` must be one of: wcag, rgaa (got "${s}")`);
|
|
88
|
+
return s as StandardId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function langOf(args: Record<string, unknown>): Lang {
|
|
92
|
+
const l = str(args.lang) ?? "en";
|
|
93
|
+
if (l !== "en" && l !== "fr") throw new ToolError(`\`lang\` must be one of: en, fr (got "${l}")`);
|
|
94
|
+
return l as Lang;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --------------------------------------------------------------------------
|
|
98
|
+
// Dispatch
|
|
99
|
+
// --------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export async function callTool(name: string, args: Record<string, unknown>, defaults: HandlerDefaults = {}): Promise<ToolOutcome> {
|
|
102
|
+
if (WRITE_TOOL_NAMES.has(name) && !defaults.allowWrite) {
|
|
103
|
+
throw new ToolError(`${name} changes files in your project and is disabled — start the server with --allow-write to enable it.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The only tool that needs no project at all: it reads the vendored standard.
|
|
107
|
+
if (name === "ultra11y_criteria") return outcome(handleCriteria(args));
|
|
108
|
+
|
|
109
|
+
const cwd = requiredCwd(args, defaults);
|
|
110
|
+
|
|
111
|
+
// Serialized per project root. `fix --write` rewrites source files and then
|
|
112
|
+
// re-audits them; an audit running concurrently would read a half-written
|
|
113
|
+
// tree. Different projects stay fully parallel.
|
|
114
|
+
return await withProjectLock(cwd, async () => outcome(await dispatch(name, args, cwd)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function dispatch(name: string, args: Record<string, unknown>, cwd: string): Promise<unknown> {
|
|
118
|
+
switch (name) {
|
|
119
|
+
case "ultra11y_audit":
|
|
120
|
+
return handleAudit(args, cwd);
|
|
121
|
+
case "ultra11y_report":
|
|
122
|
+
return handleReport(args, cwd);
|
|
123
|
+
case "ultra11y_prd":
|
|
124
|
+
return handlePrd(args, cwd);
|
|
125
|
+
case "ultra11y_check":
|
|
126
|
+
return handleCheck(args, cwd);
|
|
127
|
+
case "ultra11y_verify":
|
|
128
|
+
return handleVerify(args, cwd);
|
|
129
|
+
case "ultra11y_adjudicate":
|
|
130
|
+
return handleAdjudicate(args, cwd);
|
|
131
|
+
case "ultra11y_pack_check":
|
|
132
|
+
return handlePackCheck(args, cwd);
|
|
133
|
+
case "ultra11y_sample_check":
|
|
134
|
+
return handleSampleCheck(cwd);
|
|
135
|
+
case "ultra11y_read":
|
|
136
|
+
return handleRead(args, cwd);
|
|
137
|
+
case "ultra11y_fix":
|
|
138
|
+
return handleFix(args, cwd);
|
|
139
|
+
case "ultra11y_init":
|
|
140
|
+
return handleInit(args, cwd);
|
|
141
|
+
case "ultra11y_scan":
|
|
142
|
+
return handleScan(args, cwd);
|
|
143
|
+
default:
|
|
144
|
+
// Unreachable: the server rejects an unknown tool before dispatch.
|
|
145
|
+
throw new ToolError(`unknown tool: ${name}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function outcome(result: unknown): ToolOutcome {
|
|
150
|
+
return { text: JSON.stringify(result, null, 2) + "\n" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --------------------------------------------------------------------------
|
|
154
|
+
// Handlers
|
|
155
|
+
// --------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
// The audit every reporting tool is built on. Run from the project root so the
|
|
158
|
+
// globs resolve the way the CLI resolves them.
|
|
159
|
+
function audit(args: Record<string, unknown>, cwd: string): AuditResult {
|
|
160
|
+
const prev = process.cwd();
|
|
161
|
+
const warnings: string[] = [];
|
|
162
|
+
try {
|
|
163
|
+
process.chdir(cwd);
|
|
164
|
+
return runAudit({
|
|
165
|
+
inputs: strArray(args.globs) ?? DEFAULT_GLOBS,
|
|
166
|
+
include: strArray(args.include),
|
|
167
|
+
exclude: strArray(args.exclude),
|
|
168
|
+
forceJsx: bool(args.jsx),
|
|
169
|
+
changed: bool(args.changed),
|
|
170
|
+
since: str(args.since),
|
|
171
|
+
staged: bool(args.staged),
|
|
172
|
+
graph: bool(args.graph),
|
|
173
|
+
maxFiles: positive(args.max_files, "max_files"),
|
|
174
|
+
onWarn: (m) => warnings.push(m),
|
|
175
|
+
});
|
|
176
|
+
} finally {
|
|
177
|
+
process.chdir(prev);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function handleAudit(args: Record<string, unknown>, cwd: string): unknown {
|
|
182
|
+
const r = audit(args, cwd);
|
|
183
|
+
return {
|
|
184
|
+
cwd,
|
|
185
|
+
...r,
|
|
186
|
+
next:
|
|
187
|
+
"Static analysis decides only a few criteria. Run ultra11y_adjudicate for the judgment ones, and ultra11y_scan for the rendering ones " +
|
|
188
|
+
"(contrast, focus, zoom) — a criterion nobody tested is untested, not conformant.",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleReport(args: Record<string, unknown>, cwd: string): unknown {
|
|
193
|
+
const standard = standardOf(args);
|
|
194
|
+
const lang = langOf(args);
|
|
195
|
+
const r = audit(args, cwd);
|
|
196
|
+
const md = standard === "wcag" ? renderReport(r, lang) : renderPackReport(r, loadPack(standard), lang);
|
|
197
|
+
return { cwd, standard, lang, report: md, next: "Validate it with ultra11y_check before presenting it." };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function handlePrd(args: Record<string, unknown>, cwd: string): unknown {
|
|
201
|
+
const standard = standardOf(args);
|
|
202
|
+
const lang = langOf(args);
|
|
203
|
+
const r = audit(args, cwd);
|
|
204
|
+
const units = prdUnits(r, standard, lang);
|
|
205
|
+
const { nc, advisory } = partitionUnits(units);
|
|
206
|
+
return {
|
|
207
|
+
cwd,
|
|
208
|
+
standard,
|
|
209
|
+
non_conformities: nc,
|
|
210
|
+
advisory,
|
|
211
|
+
...(str(args.split) === "criterion" ? { grouped_by: "criterion" } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function handleCheck(args: Record<string, unknown>, cwd: string): unknown {
|
|
216
|
+
const text = reportText(args, "check");
|
|
217
|
+
const res = checkReport(text, standardOf(args), langOf(args));
|
|
218
|
+
// ok:false is a verdict, not a failure: the tool did its job and the report
|
|
219
|
+
// did not pass.
|
|
220
|
+
return { cwd, source: str(args.report_text) ? "inline" : "file", ...res };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleVerify(args: Record<string, unknown>, cwd: string): unknown {
|
|
224
|
+
const text = reportText(args, "verify");
|
|
225
|
+
const standard = standardOf(args);
|
|
226
|
+
const max = positive(args.max_verify, "max_verify");
|
|
227
|
+
const items = max === undefined ? buildWorklist(text, standard) : buildWorklist(text, standard, max);
|
|
228
|
+
return {
|
|
229
|
+
cwd,
|
|
230
|
+
standard,
|
|
231
|
+
count: items.length,
|
|
232
|
+
items,
|
|
233
|
+
worklist: formatWorklist(items, true, standard, langOf(args)),
|
|
234
|
+
next: "For each item, read the real markup with ultra11y_read and judge whether the evidence carries the claim.",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function handleAdjudicate(args: Record<string, unknown>, cwd: string): unknown {
|
|
239
|
+
const standard = standardOf(args);
|
|
240
|
+
const r = audit(args, cwd);
|
|
241
|
+
const items = buildAdjudicationWorklist(r, { cwd, standard });
|
|
242
|
+
return {
|
|
243
|
+
cwd,
|
|
244
|
+
standard,
|
|
245
|
+
count: items.length,
|
|
246
|
+
items,
|
|
247
|
+
worklist: formatAdjudication(items, langOf(args)),
|
|
248
|
+
next: "These are the criteria the engine cannot decide. Read each element's real context with ultra11y_read, then rule on it.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function handlePackCheck(args: Record<string, unknown>, cwd: string): unknown {
|
|
253
|
+
const pack = str(args.pack);
|
|
254
|
+
if (!pack) throw new ToolError("`pack` is required — an absolute path to the pack JSON.");
|
|
255
|
+
if (!isAbsolute(pack)) throw new ToolError("`pack` must be an absolute path.");
|
|
256
|
+
const res = runPackCheck(pack, str(args.guidance));
|
|
257
|
+
return { cwd, ...res };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function handleSampleCheck(cwd: string): unknown {
|
|
261
|
+
const file = join(cwd, ".ultra11yrc.json");
|
|
262
|
+
if (!existsSync(file)) {
|
|
263
|
+
throw new ToolError(`no .ultra11yrc.json at ${cwd} — a page sample must be declared before it can be linted.`);
|
|
264
|
+
}
|
|
265
|
+
let raw: unknown;
|
|
266
|
+
try {
|
|
267
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
268
|
+
} catch (e) {
|
|
269
|
+
throw new ToolError(`.ultra11yrc.json is not valid JSON: ${(e as Error).message}`);
|
|
270
|
+
}
|
|
271
|
+
return { cwd, config: raw, next: "Check the sample covers every page kind the methodology requires, not just the convenient ones." };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handleFix(args: Record<string, unknown>, cwd: string): unknown {
|
|
275
|
+
const prev = process.cwd();
|
|
276
|
+
try {
|
|
277
|
+
process.chdir(cwd);
|
|
278
|
+
const write = bool(args.write);
|
|
279
|
+
const res = runFix({
|
|
280
|
+
inputs: strArray(args.globs) ?? DEFAULT_GLOBS,
|
|
281
|
+
only: strArray(args.only),
|
|
282
|
+
changed: bool(args.changed),
|
|
283
|
+
write,
|
|
284
|
+
safe: true,
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
cwd,
|
|
288
|
+
applied: write,
|
|
289
|
+
summary: fixSummary(res, langOf(args), write),
|
|
290
|
+
...res,
|
|
291
|
+
...(write ? {} : { next: "This was a dry run. Pass write:true to apply it." }),
|
|
292
|
+
};
|
|
293
|
+
} finally {
|
|
294
|
+
process.chdir(prev);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function handleInit(args: Record<string, unknown>, cwd: string): unknown {
|
|
299
|
+
const failOnRaw = str(args.fail_on) ?? "error";
|
|
300
|
+
if (!["error", "warning", "notice"].includes(failOnRaw)) {
|
|
301
|
+
throw new ToolError(`\`fail_on\` must be one of: error, warning, notice (got "${failOnRaw}")`);
|
|
302
|
+
}
|
|
303
|
+
const failOn = failOnRaw as Parameters<typeof writeHook>[2];
|
|
304
|
+
const enginePath = resolve("scripts/ultra11y.mjs");
|
|
305
|
+
const written: string[] = [];
|
|
306
|
+
if (bool(args.hook)) written.push(writeHook(cwd, enginePath, failOn));
|
|
307
|
+
if (bool(args.ci)) written.push(join(cwd, ".github/workflows/a11y.yml"));
|
|
308
|
+
if (!written.length) {
|
|
309
|
+
throw new ToolError("nothing to install — pass hook:true and/or ci:true.");
|
|
310
|
+
}
|
|
311
|
+
return { cwd, written, ...(bool(args.ci) ? { ci_workflow: ciWorkflow(enginePath, failOn) } : {}) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function handleScan(args: Record<string, unknown>, cwd: string): unknown {
|
|
315
|
+
const target = str(args.target);
|
|
316
|
+
if (!target) throw new ToolError("`target` is required — a URL or a local HTML file to render.");
|
|
317
|
+
// Deliberately not implemented in-process: `scan` drives a headless browser
|
|
318
|
+
// and optionally pulls a Docker image, which is a subprocess lifecycle a
|
|
319
|
+
// long-lived server should not own. Say so precisely rather than fail
|
|
320
|
+
// obscurely, and name the command that does it.
|
|
321
|
+
throw new ToolError(
|
|
322
|
+
`ultra11y_scan is not available over MCP: it launches a headless browser (and may pull a Docker image), which this server does not manage. ` +
|
|
323
|
+
`Run it from the CLI instead — \`ultra11y scan ${target} --merge\` in ${cwd} — then re-run ultra11y_audit to see the merged result.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The report to operate on: inline text, or a file the caller named.
|
|
328
|
+
function reportText(args: Record<string, unknown>, tool: string): string {
|
|
329
|
+
const inline = str(args.report_text);
|
|
330
|
+
const file = str(args.report_file);
|
|
331
|
+
if (inline && file) throw new ToolError("pass `report_text` or `report_file`, not both.");
|
|
332
|
+
if (inline) return inline;
|
|
333
|
+
if (!file) throw new ToolError(`\`report_text\` is required — the report markdown for ultra11y_${tool} to work on.`);
|
|
334
|
+
if (!isAbsolute(file)) throw new ToolError("`report_file` must be an absolute path.");
|
|
335
|
+
if (!existsSync(file)) throw new ToolError(`report file not found: ${file}`);
|
|
336
|
+
return readFileSync(file, "utf8");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handleCriteria(args: Record<string, unknown>): unknown {
|
|
340
|
+
const lang = langOf(args);
|
|
341
|
+
const id = str(args.sc);
|
|
342
|
+
if (!id) {
|
|
343
|
+
return { standard: standardOf(args), criteria: allSC().map((c) => ({ sc: c.sc, title: c.title })) };
|
|
344
|
+
}
|
|
345
|
+
const sc = getSC(id);
|
|
346
|
+
if (!sc) throw new ToolError(`no such success criterion: ${id}. List them all by omitting \`sc\`.`);
|
|
347
|
+
return { standard: standardOf(args), sc: id, text: formatSC(sc, lang) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function handleRead(args: Record<string, unknown>, cwd: string): unknown {
|
|
351
|
+
const raw = str(args.path);
|
|
352
|
+
if (!raw) throw new ToolError("`path` is required — relative to the project root, or an absolute path inside it.");
|
|
353
|
+
const target = isAbsolute(raw) ? raw : join(cwd, raw);
|
|
354
|
+
|
|
355
|
+
// Containment on the REALPATH: a symlink inside the project normalises
|
|
356
|
+
// cleanly as a string and only escapes once the filesystem resolves it. This
|
|
357
|
+
// server can be reached over HTTP.
|
|
358
|
+
let real: string;
|
|
359
|
+
try {
|
|
360
|
+
real = realpathSync(target);
|
|
361
|
+
} catch {
|
|
362
|
+
throw new ToolError(`no such file: ${raw}`);
|
|
363
|
+
}
|
|
364
|
+
const root = realpathSync(cwd);
|
|
365
|
+
if (real !== root && !real.startsWith(root + sep)) {
|
|
366
|
+
throw new ToolError(`path is outside the project: ${raw}. Use your own file tool for anything else.`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const st = statSync(real);
|
|
370
|
+
if (!st.isFile()) throw new ToolError(`not a file: ${raw}`);
|
|
371
|
+
if (st.size > MAX_READ_BYTES) throw new ToolError(`file is too large to read (${st.size} bytes): ${raw}`);
|
|
372
|
+
|
|
373
|
+
const lines = readFileSync(real, "utf8").split("\n");
|
|
374
|
+
const total = lines.length;
|
|
375
|
+
const start = Math.max(1, Math.floor(num(args.start_line) ?? 1));
|
|
376
|
+
if (start > total) throw new ToolError(`start_line ${start} is past the end of the file (${total} lines).`);
|
|
377
|
+
const requestedEnd = Math.floor(num(args.end_line) ?? total);
|
|
378
|
+
const end = Math.min(total, Math.max(start, requestedEnd), start + MAX_READ_LINES - 1);
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
path: isAbsolute(raw) ? real : raw,
|
|
382
|
+
start_line: start,
|
|
383
|
+
end_line: end,
|
|
384
|
+
total_lines: total,
|
|
385
|
+
truncated: end < Math.min(total, requestedEnd),
|
|
386
|
+
content: lines.slice(start - 1, end).join("\n"),
|
|
387
|
+
};
|
|
388
|
+
}
|