@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.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. 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
+ }