@binclusive/a11y 0.1.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/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- package/src/workspace-resolve.ts +405 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local, stdio MCP server that exposes the a11y-checker to any MCP client
|
|
3
|
+
* (Cursor, Copilot, Claude, Windsurf) running on the developer's machine.
|
|
4
|
+
*
|
|
5
|
+
* It reads the developer's LOCAL files and runs the checker we already built —
|
|
6
|
+
* no auth, no network. This is the inverse of `@b8e/mcp`, which is a remote,
|
|
7
|
+
* OAuth-gated Worker/Durable-Object server for account management; the only
|
|
8
|
+
* thing shared is the `@modelcontextprotocol/sdk` shape (`server.tool(...)` +
|
|
9
|
+
* `{ content: [{ type: "text", text }] }`).
|
|
10
|
+
*
|
|
11
|
+
* Every tool is a THIN wrapper over the package's existing functions — no new
|
|
12
|
+
* a11y logic lives here. The handlers are factored out (`checkA11y`,
|
|
13
|
+
* `getA11yRules`, `learnA11yRule`) so tests can call them directly without a
|
|
14
|
+
* live transport; `registerTools` only adapts their return value into the MCP
|
|
15
|
+
* `content` envelope.
|
|
16
|
+
*
|
|
17
|
+
* Install snippet for a client's `.mcp.json` (stdio):
|
|
18
|
+
*
|
|
19
|
+
* {
|
|
20
|
+
* "mcpServers": {
|
|
21
|
+
* "binclusive-a11y": {
|
|
22
|
+
* "command": "npx",
|
|
23
|
+
* "args": ["-y", "@binclusive/a11y", "mcp"]
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { relative, resolve } from "node:path";
|
|
30
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
31
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { collectTsx } from "./collect";
|
|
34
|
+
import { scanUrl } from "./collect-dom";
|
|
35
|
+
import { learn } from "./commands";
|
|
36
|
+
import { scan } from "./core";
|
|
37
|
+
import {
|
|
38
|
+
type BaselineRuleInfo,
|
|
39
|
+
baselineRules,
|
|
40
|
+
type AxeImpact,
|
|
41
|
+
evidenceBestPractice,
|
|
42
|
+
type Evidence,
|
|
43
|
+
evidenceHelpUrl,
|
|
44
|
+
evidenceImpact,
|
|
45
|
+
type EnrichedFinding,
|
|
46
|
+
enrichAll,
|
|
47
|
+
resolveDisplay,
|
|
48
|
+
} from "./evidence";
|
|
49
|
+
import type { Coverage } from "./resolve-components";
|
|
50
|
+
import { collectUnityFindings } from "./unity-findings";
|
|
51
|
+
|
|
52
|
+
/** A single finding flattened to the shape the MCP tool returns. */
|
|
53
|
+
export interface CheckFinding {
|
|
54
|
+
readonly file: string;
|
|
55
|
+
readonly line: number;
|
|
56
|
+
readonly ruleId: string;
|
|
57
|
+
readonly wcag: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Which evidence source matched: `baseline` (axe's published per-rule catalog —
|
|
60
|
+
* coverage) or `none`. This flat shape is the deliberate FLAT VIEW of the
|
|
61
|
+
* {@link Evidence} union for external API consumers — the per-source accessors
|
|
62
|
+
* below project it. Frequency is no longer carried (ADR 0041 §G — the audit
|
|
63
|
+
* corpus left the engine; frequency is platform-derived).
|
|
64
|
+
*/
|
|
65
|
+
readonly source: Evidence["source"];
|
|
66
|
+
/** Impact: axe runtime impact, else the baseline catalog default; null when unknown. */
|
|
67
|
+
readonly impact: AxeImpact | null;
|
|
68
|
+
/**
|
|
69
|
+
* True for a baseline match on an axe best-practice rule with no WCAG SC — an
|
|
70
|
+
* axe recommendation, not a WCAG conformance failure.
|
|
71
|
+
*/
|
|
72
|
+
readonly bestPractice: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The rule-accurate fix. For source-pass findings (`jsx-a11y` / `enforce`)
|
|
75
|
+
* this is the SC-keyed baseline fix. For `provenance === "axe"` findings it is
|
|
76
|
+
* axe's OWN per-rule guidance (axe help), NOT the SC-generic baseline fix —
|
|
77
|
+
* which would contradict the rule. Both come from the single
|
|
78
|
+
* {@link resolveDisplay} contract the CLI uses, so the two can't disagree.
|
|
79
|
+
* `helpUrl` carries the canonical Deque fix page either way.
|
|
80
|
+
*/
|
|
81
|
+
readonly fix: string | null;
|
|
82
|
+
/** axe's Deque-University help URL, when the source knows it. */
|
|
83
|
+
readonly helpUrl: string | null;
|
|
84
|
+
readonly message: string;
|
|
85
|
+
readonly enforcement: EnrichedFinding["enforcement"];
|
|
86
|
+
/** Which pass produced it: structural `jsx-a11y`, or the call-site `enforce` check. */
|
|
87
|
+
readonly provenance: EnrichedFinding["provenance"];
|
|
88
|
+
/** The axe CSS selector for the offending node. Present only on rendered-DOM/axe findings. */
|
|
89
|
+
readonly selector?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Flatten an enriched finding to the MCP `CheckFinding` shape. */
|
|
93
|
+
function toCheckFinding(f: EnrichedFinding, file: string): CheckFinding {
|
|
94
|
+
return {
|
|
95
|
+
file,
|
|
96
|
+
line: f.line,
|
|
97
|
+
ruleId: f.ruleId,
|
|
98
|
+
wcag: f.wcag,
|
|
99
|
+
source: f.corpus.source,
|
|
100
|
+
impact: evidenceImpact(f),
|
|
101
|
+
bestPractice: evidenceBestPractice(f.corpus),
|
|
102
|
+
fix: resolveDisplay(f).fix,
|
|
103
|
+
helpUrl: evidenceHelpUrl(f),
|
|
104
|
+
message: f.message,
|
|
105
|
+
enforcement: f.enforcement,
|
|
106
|
+
provenance: f.provenance,
|
|
107
|
+
...(f.selector !== undefined ? { selector: f.selector } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The `check_a11y` result: findings plus the component-coverage summary. */
|
|
112
|
+
export interface CheckA11yResult {
|
|
113
|
+
readonly root: string;
|
|
114
|
+
readonly filesScanned: number;
|
|
115
|
+
readonly findings: readonly CheckFinding[];
|
|
116
|
+
/** The full coverage tally, incl. the opaque sub-buckets (trusted/icons/declare). */
|
|
117
|
+
readonly coverage: Coverage;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `check_a11y`: collect `.tsx` under `dir`, run `scan` + `enrichAll`, and
|
|
122
|
+
* return the baseline-enriched findings plus the coverage summary. Mirrors the
|
|
123
|
+
* `check` CLI command's collection + scan + enrich path exactly — no new logic.
|
|
124
|
+
*/
|
|
125
|
+
export async function checkA11y(dir: string): Promise<CheckA11yResult> {
|
|
126
|
+
const root = resolve(dir);
|
|
127
|
+
const files = await collectTsx(root);
|
|
128
|
+
const result = await scan(files);
|
|
129
|
+
const enriched = enrichAll(result.findings);
|
|
130
|
+
|
|
131
|
+
const findings: CheckFinding[] = enriched.map((f) => toCheckFinding(f, relative(root, f.file)));
|
|
132
|
+
|
|
133
|
+
return { root, filesScanned: files.length, coverage: result.coverage, findings };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The `check_url` result: the audited URL plus its rendered-DOM findings. */
|
|
137
|
+
export interface CheckUrlResult {
|
|
138
|
+
readonly url: string;
|
|
139
|
+
readonly findings: readonly CheckFinding[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `check_url`: render a live URL in a real browser, run axe-core against the
|
|
144
|
+
* DOM (`scanUrl`), and run those findings through `enrichAll` — the same
|
|
145
|
+
* enrichment pass `check_a11y` uses. The only difference is the source: a
|
|
146
|
+
* rendered page instead of `.tsx` files, so `file` is the URL (kept as-is, NOT
|
|
147
|
+
* relativized) and each finding carries the axe `selector`. No new a11y logic.
|
|
148
|
+
*/
|
|
149
|
+
export async function checkUrl(url: string): Promise<CheckUrlResult> {
|
|
150
|
+
const result = await scanUrl(url);
|
|
151
|
+
const enriched = enrichAll(result.findings);
|
|
152
|
+
|
|
153
|
+
// `file` stays the URL (NOT relativized) for the rendered-DOM path.
|
|
154
|
+
const findings: CheckFinding[] = enriched.map((f) => toCheckFinding(f, f.file));
|
|
155
|
+
|
|
156
|
+
return { url: result.url, findings };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The `check_unity` result: the scanned project root plus its enriched findings. */
|
|
160
|
+
export interface CheckUnityResult {
|
|
161
|
+
readonly root: string;
|
|
162
|
+
readonly findings: readonly CheckFinding[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `check_unity`: run the Unity finding-emission aggregator over a project dir
|
|
167
|
+
* (`collectUnityFindings` — `scanUnity` → the Unity rules → one canonical
|
|
168
|
+
* `Finding[]`), then run those findings through `enrichAll` — the SAME baseline enrichment
|
|
169
|
+
* pass `check_a11y`/`check_url` use. This is the Unity analog of `check_a11y`: the only
|
|
170
|
+
* difference is the source (serialized `.prefab`/`.unity` assets instead of `.tsx`),
|
|
171
|
+
* so the returned `findings` carry `provenance: "unity"` and `file` is relativized to
|
|
172
|
+
* the project root, exactly as `check_a11y` relativizes the `.tsx` paths. No Unity
|
|
173
|
+
* logic lives here — it is a thin wrapper over the shared aggregator + enrichment, the
|
|
174
|
+
* same reuse discipline as every other tool (epic #87 / #92).
|
|
175
|
+
*/
|
|
176
|
+
export async function checkUnity(dir: string): Promise<CheckUnityResult> {
|
|
177
|
+
const root = resolve(dir);
|
|
178
|
+
const raw = await collectUnityFindings(root);
|
|
179
|
+
const enriched = enrichAll(raw);
|
|
180
|
+
|
|
181
|
+
const findings: CheckFinding[] = enriched.map((f) => toCheckFinding(f, relative(root, f.file)));
|
|
182
|
+
|
|
183
|
+
return { root, findings };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** How many patterns `get_a11y_rules` returns when no filter is given. */
|
|
187
|
+
const TOP_RULES = 15;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The `get_a11y_rules` result. The corpus left the engine (ADR 0041 §G), so the
|
|
191
|
+
* answer is now purely the coverage catalog: axe's published per-rule entries
|
|
192
|
+
* (impact + standard fix + helpUrl, NO org count, NO frequency tier) for the
|
|
193
|
+
* requested component/SC/ruleId, so the tool can answer for any axe/WCAG rule.
|
|
194
|
+
*/
|
|
195
|
+
export interface GetA11yRulesResult {
|
|
196
|
+
readonly matchedOn: "component" | "sc" | "ruleId" | "top";
|
|
197
|
+
readonly count: number;
|
|
198
|
+
readonly baseline: readonly BaselineRuleInfo[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `get_a11y_rules`: surface the axe baseline catalog, filtered by a component
|
|
203
|
+
* substring (matched against the axe ruleId), a WCAG SC, or an axe ruleId if
|
|
204
|
+
* given, else the top N rules. Lets an agent ask "what are the a11y rules for a
|
|
205
|
+
* button?" BEFORE writing it. Pure read over the baseline catalog — no disk, no
|
|
206
|
+
* scan, no corpus.
|
|
207
|
+
*/
|
|
208
|
+
export function getA11yRules(filter: {
|
|
209
|
+
component?: string;
|
|
210
|
+
sc?: string;
|
|
211
|
+
ruleId?: string;
|
|
212
|
+
}): GetA11yRulesResult {
|
|
213
|
+
if (filter.ruleId !== undefined && filter.ruleId.trim() !== "") {
|
|
214
|
+
const baseline = baselineRules({ ruleId: filter.ruleId });
|
|
215
|
+
return { matchedOn: "ruleId", count: baseline.length, baseline };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (filter.component !== undefined && filter.component.trim() !== "") {
|
|
219
|
+
// A component-name query maps to a ruleId substring so it still yields axe's
|
|
220
|
+
// standard rule (e.g. "image" → image-alt).
|
|
221
|
+
const needle = filter.component.trim().toLowerCase();
|
|
222
|
+
const baseline = baselineRules({ ruleId: needle });
|
|
223
|
+
return { matchedOn: "component", count: baseline.length, baseline };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (filter.sc !== undefined && filter.sc.trim() !== "") {
|
|
227
|
+
const baseline = baselineRules({ sc: filter.sc.trim() });
|
|
228
|
+
return { matchedOn: "sc", count: baseline.length, baseline };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const baseline = baselineRules().slice(0, TOP_RULES);
|
|
232
|
+
return { matchedOn: "top", count: baseline.length, baseline };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The `learn_a11y_rule` result mirrors the `learn` CLI command's report. */
|
|
236
|
+
export interface LearnA11yRuleResult {
|
|
237
|
+
readonly added: boolean;
|
|
238
|
+
readonly id: string;
|
|
239
|
+
readonly contractPath: string;
|
|
240
|
+
readonly blockPaths: readonly string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* `learn_a11y_rule`: append a team rule to `binclusive.json` `learned[]` and
|
|
245
|
+
* regenerate the AGENTS.md / CLAUDE.md managed block — the SAME code path as
|
|
246
|
+
* the `learn` CLI command (`learn` from `commands.ts`). `dir` defaults to the
|
|
247
|
+
* client's cwd, matching how the CLI resolves an omitted positional.
|
|
248
|
+
*/
|
|
249
|
+
export async function learnA11yRule(input: {
|
|
250
|
+
rule: string;
|
|
251
|
+
wcag?: readonly string[];
|
|
252
|
+
fix?: string;
|
|
253
|
+
source?: string;
|
|
254
|
+
dir?: string;
|
|
255
|
+
}): Promise<LearnA11yRuleResult> {
|
|
256
|
+
const dir = input.dir ?? process.cwd();
|
|
257
|
+
return learn(dir, {
|
|
258
|
+
rule: input.rule,
|
|
259
|
+
wcag: input.wcag ?? [],
|
|
260
|
+
fix: input.fix ?? null,
|
|
261
|
+
source: input.source ?? "mcp",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Wrap any JSON-serializable result in the MCP text-content envelope. */
|
|
266
|
+
function jsonContent(value: unknown): { content: [{ type: "text"; text: string }] } {
|
|
267
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Register the a11y tools on an existing server. Split out for testing. */
|
|
271
|
+
export function registerTools(server: McpServer): void {
|
|
272
|
+
server.tool(
|
|
273
|
+
"check_a11y",
|
|
274
|
+
"Scan the .tsx files under a directory for accessibility violations and return each finding (file, line, jsx-a11y ruleId, WCAG SC, impact, and the representative fix) plus the component-coverage summary.",
|
|
275
|
+
{ dir: z.string().describe("Directory to scan recursively for .tsx files.") },
|
|
276
|
+
async ({ dir }) => jsonContent(await checkA11y(dir)),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
server.tool(
|
|
280
|
+
"check_url",
|
|
281
|
+
"Render a live URL in a real browser and run axe-core against the rendered DOM, returning each finding (the URL as file, axe ruleId, WCAG SC, impact, the representative fix, and the CSS selector of the offending node). Unlike check_a11y, this needs no source: it works on non-React, server-rendered, or otherwise source-less pages, while returning the same baseline findings.",
|
|
282
|
+
{ url: z.string().describe("The page URL to render and audit.") },
|
|
283
|
+
async ({ url }) => jsonContent(await checkUrl(url)),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
server.tool(
|
|
287
|
+
"check_unity",
|
|
288
|
+
"Scan a Unity project directory for accessibility violations in its serialized .prefab/.unity assets and return each finding (file, ruleId, WCAG SC, impact, and the representative fix). Mirrors check_a11y for the Unity ecosystem: missing accessible labels, color-only interactive state, and project-level gaps (no screen-reader support, no input rebinding), against the same axe baseline catalog.",
|
|
289
|
+
{ dir: z.string().describe("Unity project directory to scan recursively for .prefab/.unity assets.") },
|
|
290
|
+
async ({ dir }) => jsonContent(await checkUnity(dir)),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
server.tool(
|
|
294
|
+
"get_a11y_rules",
|
|
295
|
+
"Return the accessibility rules relevant to a component, WCAG SC, or axe ruleId so you can apply them BEFORE writing the code. The result is axe-core's published per-rule baseline catalog (ruleId, WCAG SC, impact, standard fix, helpUrl) for the requested component, SC, or ruleId (e.g. \"color-contrast\" / SC 1.4.3). With no filter, returns the top rules.",
|
|
296
|
+
{
|
|
297
|
+
component: z
|
|
298
|
+
.string()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe('Component substring to match, e.g. "button", "link", "form".'),
|
|
301
|
+
sc: z.string().optional().describe('Exact WCAG success criterion, e.g. "2.4.4".'),
|
|
302
|
+
ruleId: z
|
|
303
|
+
.string()
|
|
304
|
+
.optional()
|
|
305
|
+
.describe('axe rule id substring, e.g. "color-contrast", "image-alt".'),
|
|
306
|
+
},
|
|
307
|
+
async ({ component, sc, ruleId }) => jsonContent(getA11yRules({ component, sc, ruleId })),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
server.tool(
|
|
311
|
+
"learn_a11y_rule",
|
|
312
|
+
"Teach the project a new accessibility rule: append it to binclusive.json's learned[] and regenerate the AGENTS.md / CLAUDE.md managed block. Requires `a11y-checker init` to have run first in the target directory.",
|
|
313
|
+
{
|
|
314
|
+
rule: z.string().describe("The rule text, e.g. 'Label icon-only buttons with aria-label'."),
|
|
315
|
+
wcag: z.array(z.string()).optional().describe("WCAG success criteria this rule covers."),
|
|
316
|
+
fix: z.string().optional().describe("The representative fix for this rule."),
|
|
317
|
+
source: z.string().optional().describe("Where the rule came from (review, audit, ...)."),
|
|
318
|
+
dir: z
|
|
319
|
+
.string()
|
|
320
|
+
.optional()
|
|
321
|
+
.describe("Project root holding binclusive.json. Defaults to the server's cwd."),
|
|
322
|
+
},
|
|
323
|
+
async ({ rule, wcag, fix, source, dir }) =>
|
|
324
|
+
jsonContent(await learnA11yRule({ rule, wcag, fix, source, dir })),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Build the configured server (no transport attached). */
|
|
329
|
+
export function buildServer(): McpServer {
|
|
330
|
+
const server = new McpServer(
|
|
331
|
+
{ name: "binclusive-a11y", version: "0.1.0" },
|
|
332
|
+
{
|
|
333
|
+
capabilities: { tools: {} },
|
|
334
|
+
instructions: [
|
|
335
|
+
"Binclusive accessibility checker, running locally over your own files.",
|
|
336
|
+
"It wraps eslint-plugin-jsx-a11y with axe-core's published per-rule baseline catalog.",
|
|
337
|
+
"check_a11y scans a directory and reports WCAG findings with severities and fixes.",
|
|
338
|
+
"check_url renders a live URL in a real browser and runs axe-core, reporting the same findings for source-less or server-rendered pages.",
|
|
339
|
+
"check_unity scans a Unity project's .prefab/.unity assets and reports the same findings for the Unity ecosystem.",
|
|
340
|
+
"get_a11y_rules returns the rules for a component or WCAG SC so you can apply them before writing code.",
|
|
341
|
+
"learn_a11y_rule records a team rule into binclusive.json and the AGENTS.md/CLAUDE.md block.",
|
|
342
|
+
].join(" "),
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
registerTools(server);
|
|
346
|
+
return server;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Start the stdio server. Entry point for `a11y-checker mcp` and the bin. */
|
|
350
|
+
export async function startStdioServer(): Promise<void> {
|
|
351
|
+
const server = buildServer();
|
|
352
|
+
const transport = new StdioServerTransport();
|
|
353
|
+
await server.connect(transport);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Run when invoked directly (the `a11y-checker-mcp` bin). The `cli.ts` `mcp`
|
|
357
|
+
// subcommand calls `startStdioServer` instead of spawning this file.
|
|
358
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
359
|
+
startStdioServer().catch((err: unknown) => {
|
|
360
|
+
console.error(err instanceof Error ? err.stack : String(err));
|
|
361
|
+
process.exitCode = 1;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-specifier scoping — the shared rules for deciding what a "component
|
|
3
|
+
* import" is OURS vs an external library, used by both stack detection
|
|
4
|
+
* (`detect-stack.ts`) and the `init --suggest` host-guesser (`suggest.ts`).
|
|
5
|
+
*
|
|
6
|
+
* A design system is the PUBLISHED package that supplies a repo's UI components.
|
|
7
|
+
* Two things are NOT a design system and must be excluded everywhere we reason
|
|
8
|
+
* about "which library is this":
|
|
9
|
+
*
|
|
10
|
+
* - own code — relative imports + the conventional source aliases (`@/`, `~`,
|
|
11
|
+
* `#`) + any tsconfig `paths` alias mapping into own source.
|
|
12
|
+
* These are the team's one-off components.
|
|
13
|
+
* - framework primitives — `next`/`react`/… whose component-like exports
|
|
14
|
+
* (`next/link`, `next/image`) are platform plumbing, not UI.
|
|
15
|
+
*
|
|
16
|
+
* Keeping these rules in one module means detection and suggestion agree byte
|
|
17
|
+
* for byte on what counts as an external library — change the rule once, both
|
|
18
|
+
* follow.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reduce a module specifier to its package name so per-component sub-paths
|
|
23
|
+
* collapse onto one library: `@mui/material/Button` -> `@mui/material`,
|
|
24
|
+
* `@radix-ui/react-label` -> `@radix-ui/react-label` (scoped pkg kept whole),
|
|
25
|
+
* `next/link` -> `next`. Relative imports (`./`, `../`) are the repo's OWN
|
|
26
|
+
* components — they are not a design system and are excluded by the caller.
|
|
27
|
+
*/
|
|
28
|
+
export function packageNameOf(specifier: string): string {
|
|
29
|
+
const parts = specifier.split("/");
|
|
30
|
+
if (specifier.startsWith("@")) {
|
|
31
|
+
// Scoped: keep "@scope/name", drop deeper sub-paths.
|
|
32
|
+
return parts.slice(0, 2).join("/");
|
|
33
|
+
}
|
|
34
|
+
return parts[0] ?? specifier;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Known multi-package design-system FAMILIES, keyed by the scope (or bare
|
|
39
|
+
* package) a {@link packageNameOf} result starts with, mapped to the canonical
|
|
40
|
+
* human-facing family name.
|
|
41
|
+
*
|
|
42
|
+
* Radix (and MUI, Headless UI, …) ship one npm package PER component
|
|
43
|
+
* (`@radix-ui/react-checkbox`, `@radix-ui/react-dialog`, …). {@link packageNameOf}
|
|
44
|
+
* keeps a scoped package whole, so the dominant-component ranking in
|
|
45
|
+
* `detectDesignSystem` returns whichever sub-package won — alphabetically the
|
|
46
|
+
* first, e.g. `@radix-ui/react-checkbox`. That sub-package name is a leaky
|
|
47
|
+
* implementation detail to surface as "the design system"; the team thinks of it
|
|
48
|
+
* as "Radix". This map collapses the sub-package to the family it belongs to.
|
|
49
|
+
*
|
|
50
|
+
* Each entry is `[matcher, canonicalLabel]`. The matcher is matched against the
|
|
51
|
+
* `packageNameOf`-reduced specifier: a scope prefix (`@radix-ui`) collapses every
|
|
52
|
+
* package under it; a bare exact name (`antd`) collapses just that package. This
|
|
53
|
+
* is a deliberate, hand-maintained lookup — NOT a regex — so every family we
|
|
54
|
+
* collapse is an explicit, reviewable decision.
|
|
55
|
+
*
|
|
56
|
+
* Label choices follow each library's own brand: `Radix`, `MUI`, `Chakra UI`,
|
|
57
|
+
* `Headless UI`, `Mantine`, `Ant Design`, `Fluent UI`. (MUI's pre-v5
|
|
58
|
+
* `@material-ui/*` packages collapse to the same `MUI` label, since they are the
|
|
59
|
+
* same library's older namespace.)
|
|
60
|
+
*/
|
|
61
|
+
const DESIGN_SYSTEM_FAMILIES: ReadonlyArray<readonly [matcher: string, label: string]> = [
|
|
62
|
+
["@radix-ui", "Radix"],
|
|
63
|
+
["@mui", "MUI"],
|
|
64
|
+
["@material-ui", "MUI"],
|
|
65
|
+
["@chakra-ui", "Chakra UI"],
|
|
66
|
+
["@headlessui", "Headless UI"],
|
|
67
|
+
["@mantine", "Mantine"],
|
|
68
|
+
["@ant-design", "Ant Design"],
|
|
69
|
+
["antd", "Ant Design"],
|
|
70
|
+
["@fluentui", "Fluent UI"],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Collapse a detected design-system PACKAGE (already reduced by
|
|
75
|
+
* {@link packageNameOf}) to its canonical family name when it belongs to a known
|
|
76
|
+
* multi-package family ({@link DESIGN_SYSTEM_FAMILIES}); otherwise return it
|
|
77
|
+
* unchanged. So `@radix-ui/react-checkbox` -> `Radix`, but a single-package
|
|
78
|
+
* design system like `bootstrap` or a workspace package like `@acme/ui` passes
|
|
79
|
+
* through verbatim.
|
|
80
|
+
*
|
|
81
|
+
* This is the HUMAN-FACING label only — the reported/written
|
|
82
|
+
* `Stack.designSystem`. It does NOT drive trusted-library resolution (that keys
|
|
83
|
+
* off the raw module specifier in `registry.ts`, never off this label), so
|
|
84
|
+
* collapsing the label here cannot regress the `trusted` bucket. The one
|
|
85
|
+
* label-sensitive consumer, `suggest.ts`'s design-system-first sort, runs the
|
|
86
|
+
* resolution's package through THIS SAME function before comparing — so the sort
|
|
87
|
+
* still matches when the detected label is a collapsed family name.
|
|
88
|
+
*/
|
|
89
|
+
export function familyLabel(pkg: string): string {
|
|
90
|
+
for (const [matcher, label] of DESIGN_SYSTEM_FAMILIES) {
|
|
91
|
+
if (pkg === matcher || pkg.startsWith(`${matcher}/`)) return label;
|
|
92
|
+
}
|
|
93
|
+
return pkg;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A module specifier that resolves to the repo's OWN code, not an installed
|
|
98
|
+
* package: relative (`./`, `../`), the conventional source aliases (`~/...`,
|
|
99
|
+
* `@/...`, `#...`), and — when an `ownAlias` matcher is supplied — any
|
|
100
|
+
* `compilerOptions.paths` alias whose target maps INTO the repo's own source
|
|
101
|
+
* (Saleor `@dashboard/* -> src/*`, Cal.com `@coss/ui/* -> packages/.../src/*`).
|
|
102
|
+
* These are the team's own components and must never be mistaken for a design
|
|
103
|
+
* system — only published packages count.
|
|
104
|
+
*/
|
|
105
|
+
export function isOwnModule(specifier: string, ownAlias?: (s: string) => boolean): boolean {
|
|
106
|
+
if (specifier.startsWith(".")) return true;
|
|
107
|
+
if (specifier.startsWith("~") || specifier.startsWith("#")) return true;
|
|
108
|
+
// `@/...` is the common src alias; a real scoped package is `@scope/name`.
|
|
109
|
+
if (specifier.startsWith("@/")) return true;
|
|
110
|
+
// A project alias that maps into own source is own code, even when it LOOKS
|
|
111
|
+
// like a scoped package (`@dashboard/...`, `@coss/ui/...`).
|
|
112
|
+
if (ownAlias?.(specifier) === true) return true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Framework view/meta-framework PACKAGES whose component-like exports
|
|
118
|
+
* (`next/link`, `next/image`, `react`) are platform primitives, not a design
|
|
119
|
+
* system. A repo's "design system" is the package that supplies its UI
|
|
120
|
+
* COMPONENTS — never the framework it runs on. Excluding these is what stops
|
|
121
|
+
* `next` from winning the ranking just because every page imports `next/link`.
|
|
122
|
+
* Keyed by package name (after {@link packageNameOf}).
|
|
123
|
+
*/
|
|
124
|
+
const FRAMEWORK_PRIMITIVES: ReadonlySet<string> = new Set([
|
|
125
|
+
"next",
|
|
126
|
+
"react",
|
|
127
|
+
"react-dom",
|
|
128
|
+
"gatsby",
|
|
129
|
+
"@remix-run/react",
|
|
130
|
+
"@remix-run/node",
|
|
131
|
+
"astro",
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
/** Whether `pkg` (already reduced by {@link packageNameOf}) is a framework primitive. */
|
|
135
|
+
export function isFrameworkPrimitive(pkg: string): boolean {
|
|
136
|
+
return FRAMEWORK_PRIMITIVES.has(pkg);
|
|
137
|
+
}
|