@starklab/stark-mcp 0.2.1 → 0.3.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 +42 -4
- package/package.json +12 -1
- package/src/adopt/a11yPass.js +397 -0
- package/src/adopt/adoptGate.js +471 -0
- package/src/adopt/adoptScanReport.js +9 -0
- package/src/adopt/componentPropApi.js +200 -0
- package/src/adopt/findingSnippet.js +386 -0
- package/src/adopt/foreignDiscoveryResolver.js +20 -3
- package/src/adopt/foreignScoringResolver.js +163 -22
- package/src/adopt/moduleGraph.js +44 -2
- package/src/adopt/prCheckReport.js +274 -0
- package/src/adopt/propApiResolver.js +2 -2
- package/src/adopt/referenceResolver.js +2 -2
- package/src/adopt/scanRollup.js +192 -0
- package/src/adopt/tailwindResolver.js +6 -1
- package/src/adopt/targetDiscovery.js +31 -3
- package/src/adopt/tokenAliasResolver.js +45 -2
- package/src/adopt/usageRulesResolver.js +299 -14
- package/src/adopt/vecnaMaterializer.js +45 -5
- package/src/adopt/vecnaVerifier.js +20 -9
- package/src/adopt/wrapperResolver.js +3 -3
- package/src/cli.js +343 -8
- package/src/data.js +105 -11
- package/src/server.js +31 -0
- package/src/whisperer.d.ts +106 -0
- package/src/whisperer.js +814 -0
package/README.md
CHANGED
|
@@ -31,14 +31,15 @@ Add to your MCP client config (e.g. `.mcp.json`):
|
|
|
31
31
|
|
|
32
32
|
| Tool | Wraps | Returns |
|
|
33
33
|
|---|---|---|
|
|
34
|
-
| `list_components` | `prop-mapping/components/*.mapping.json` + `usage/components
|
|
34
|
+
| `list_components` | `catalog.json` exports ∪ `prop-mapping/components/*.mapping.json` + `usage/components/{,rn/}*.usage.json` | name, slug, status, description, platforms |
|
|
35
35
|
| `get_component_usage` | `usage/loader.js` (`loadUsage`) | dos/donts, props, figmaUrl, status |
|
|
36
36
|
| `get_component_props` | `prop-mapping/components/{c}.mapping.json` (+ `tokens/components/{c}.json` when `includeTokens` is set) | React↔Figma `propMap` for a platform, optionally the component's token JSON |
|
|
37
|
-
| `get_manifest` | all of the above, aggregated | usage + props (per platform) + tokens for every component in one call |
|
|
37
|
+
| `get_manifest` | all of the above, aggregated | usage (web) + nativeUsage (React Native) + props (per platform) + tokens for every component in one call |
|
|
38
38
|
| `get_layout_catalog` | `conformance/catalog.js` (`buildCatalogFromDir`) | every layout-capable component's props/slots |
|
|
39
39
|
| `get_layout_schema` | same, filtered to one component | one component's layoutSchema |
|
|
40
40
|
| `validate_layout` | `conformance/index.js` (`runConformance`) | deterministic findings (`Critical`/`Warning`/`Info`), no LLM involved |
|
|
41
41
|
| `get_generation_protocol` | `generation-protocol.json` | the enforced step checklist Vecna runs |
|
|
42
|
+
| `whisperer_context` | `manifest.json` + `catalog.json` + `component-props.json` + `json-meta` + `json-dark`, through `src/whisperer.js` | a system prompt (prose, not JSON): the rules to answer under and every component and token to answer from, with the ones the question names expanded in full |
|
|
42
43
|
|
|
43
44
|
## CLI
|
|
44
45
|
|
|
@@ -58,13 +59,50 @@ The package name and the `stark-mcp` bin match, so plain `npx
|
|
|
58
59
|
is what selects the second (`stark-cli`) bin instead. Once installed locally
|
|
59
60
|
or globally, just run `stark-cli <command>`. Run `stark-cli --help` for the
|
|
60
61
|
full command list. Output is always JSON on stdout; errors go to stderr with
|
|
61
|
-
a non-zero exit code.
|
|
62
|
+
a non-zero exit code — except `whisper`, whose output is prose.
|
|
63
|
+
|
|
64
|
+
## Whisperer
|
|
65
|
+
|
|
66
|
+
`whisperer_context` is the brain behind Whisperer, the customization
|
|
67
|
+
concierge in Stark Dominion, published so that an editor agent — Cursor,
|
|
68
|
+
Claude Code, anything holding this server — answers a design-system question
|
|
69
|
+
from the same facts Dominion's chat panel does, through the same code path
|
|
70
|
+
(`src/whisperer.js`; Dominion imports it rather than keeping a copy). Give it
|
|
71
|
+
the question and, for a follow-up, the prior turns newest first; put what
|
|
72
|
+
comes back in the system prompt and answer from it alone. It carries every
|
|
73
|
+
component with its props per platform, every semantic token and scale with
|
|
74
|
+
its light and dark values, the token it aliases and its React Native export
|
|
75
|
+
name, and the full detail — usage, props, examples, component tokens — of the
|
|
76
|
+
components the question names. The colour-shade primitives are deliberately
|
|
77
|
+
withheld from the list, because a component token must alias a semantic
|
|
78
|
+
token, never a shade; a question that names one outright still gets it
|
|
79
|
+
resolved.
|
|
80
|
+
|
|
81
|
+
From a terminal:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx --package=@starklab/stark-mcp stark-cli whisper "Does Card exist on React Native?"
|
|
85
|
+
npx --package=@starklab/stark-mcp stark-cli whisper "and the sizes?" --context="What variants does Button have?"
|
|
86
|
+
npx --package=@starklab/stark-mcp stark-cli whisper "What is --stk-radius-md?" --json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`--json` gives the parts apart — `systemPrompt`, `grounding`, and the
|
|
90
|
+
`components`, `named` and `tokens` the grounding expanded — for a caller
|
|
91
|
+
that fills its own slots. The rules the prompt carries are the design
|
|
92
|
+
system's; a surface adds its own (Dominion adds how its panel renders and
|
|
93
|
+
that it cannot see the questioner's code).
|
|
62
94
|
|
|
63
95
|
## Public manifest
|
|
64
96
|
|
|
65
97
|
`npm run generate-manifest` writes the full catalog (usage + props + tokens
|
|
66
98
|
for every component) to `manifest.json` inside the installed
|
|
67
|
-
`@starklab/stk` package.
|
|
99
|
+
`@starklab/stk` package. The list of components is `catalog.json`'s exports
|
|
100
|
+
(every `component`, `primitive` and `subpart`, on either platform) unioned
|
|
101
|
+
with the mapping files, so a component that ships without a mapping file
|
|
102
|
+
(`MoveMenu`, `StatusPage`, every React Native component) is still in it. Per
|
|
103
|
+
component, `usage` is the web usage file and `nativeUsage` the React Native
|
|
104
|
+
one from `usage/components/rn/` — its own rules, prop table and code example —
|
|
105
|
+
or `null` where the platform does not apply. Run it before publishing a new version of
|
|
68
106
|
`stk` so the manifest ships with it and stays fetchable as a static file —
|
|
69
107
|
e.g. via a CDN mirror of the npm package — without needing an MCP session at
|
|
70
108
|
all. It's not checked into git (same as `stk`'s `build/` output); regenerate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starklab/stark-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server exposing the Stark design system catalog, usage rules, prop mappings, and layout conformance checks to any MCP-compatible coding agent — without needing the stark-workspace monorepo checked out.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,9 +21,20 @@
|
|
|
21
21
|
"start": "node src/index.js",
|
|
22
22
|
"generate-manifest": "node scripts/generate-manifest.js",
|
|
23
23
|
"generate-foreign-systems": "node scripts/generate-foreign-systems.js",
|
|
24
|
+
"canary:foreign": "node scripts/foreign-canary.js",
|
|
24
25
|
"validate:pack": "node ../../scripts/validate-pack.mjs",
|
|
25
26
|
"prepublishOnly": "npm run validate:pack"
|
|
26
27
|
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"axe-core": ">=4",
|
|
30
|
+
"playwright": ">=1.40",
|
|
31
|
+
"playwright-core": ">=1.40"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"axe-core": { "optional": true },
|
|
35
|
+
"playwright": { "optional": true },
|
|
36
|
+
"playwright-core": { "optional": true }
|
|
37
|
+
},
|
|
27
38
|
"dependencies": {
|
|
28
39
|
"@starklab/stk": "^1.2.0",
|
|
29
40
|
"@babel/parser": "^7.29.7",
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accessibility half of `stark-cli adopt`, and the only part of the
|
|
3
|
+
* command that needs a browser.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists at all: accessibility rules are statements about a *rendered*
|
|
6
|
+
* page — a contrast ratio is a fact about two resolved colors, a missing
|
|
7
|
+
* accessible name is a fact about the element the browser actually built. A
|
|
8
|
+
* static scan of source has neither, which is why lib/adoptionScan.ts's
|
|
9
|
+
* `scoreFrom` drops the a11y term rather than inventing it. The missing
|
|
10
|
+
* ingredient is a running product, not a connector: it happens that Dominion's
|
|
11
|
+
* own crawler is the only place one existed, and that is a fact about where
|
|
12
|
+
* the browser was, not about repos.
|
|
13
|
+
*
|
|
14
|
+
* So this runs the browser where a running product already exists and no
|
|
15
|
+
* credential problem does — inside the consumer's own CI, against the app
|
|
16
|
+
* their pipeline just booted, with whatever logged-in session their own tests
|
|
17
|
+
* already set up. Dominion never sees the URLs; it sees the numbers.
|
|
18
|
+
*
|
|
19
|
+
* Three properties are inherited wholesale from adoptScanReport.js, because
|
|
20
|
+
* this runs in the same place under the same rules:
|
|
21
|
+
*
|
|
22
|
+
* 1. **It must never break the consumer's CI.** Every failure path returns
|
|
23
|
+
* `{ ran: false, reason }`. Nothing throws, nothing sets process.exitCode,
|
|
24
|
+
* and a missing browser is an ordinary outcome rather than an error — the
|
|
25
|
+
* adoption scan itself already succeeded and has numbers to report.
|
|
26
|
+
* 2. **It must not leak anything the consumer did not name.** The only URLs
|
|
27
|
+
* visited are the ones passed on the command line. There is no crawl here
|
|
28
|
+
* and deliberately so: following links inside a CI network is how a
|
|
29
|
+
* scanner ends up on an internal host nobody meant to hand it.
|
|
30
|
+
* 3. **The penalty scale is shared, not chosen.** AXE_SCORE_MAP below is the
|
|
31
|
+
* same map packages/stk/scripts/a11y/axe-runner.js and dominion's
|
|
32
|
+
* lib/url-crawl.ts use. An a11y number measured here has to be comparable
|
|
33
|
+
* with one measured by the crawler or the label "includes accessibility"
|
|
34
|
+
* would mean two different things.
|
|
35
|
+
*
|
|
36
|
+
* One rule is this module's own, because only this module runs against a page
|
|
37
|
+
* the consumer's CI just booted rather than a public site: **a page axe could
|
|
38
|
+
* read but the browser never filled is not a measurement.** See SETTLE_CAP_MS
|
|
39
|
+
* and isBlankRender below — between them they are the difference between
|
|
40
|
+
* scoring the product and scoring the empty div it mounts into.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* ds-governance.md's scoring matrix, byte-identical to the two other places it
|
|
45
|
+
* appears. Only `critical` and `serious` are scored: `moderate` and `minor`
|
|
46
|
+
* are advisory in axe's own vocabulary, and letting them move a number would
|
|
47
|
+
* make the score depend on axe's taxonomy drift rather than on the product.
|
|
48
|
+
*/
|
|
49
|
+
const AXE_SCORE_MAP = { critical: -20, serious: -10 };
|
|
50
|
+
|
|
51
|
+
/** Same ceiling as lib/url-crawl.ts's SITE_PAGE_CAP, for the same reason. */
|
|
52
|
+
const MAX_PAGES = 25;
|
|
53
|
+
|
|
54
|
+
/** Per page. A CI job is not a place to hang on a slow route. */
|
|
55
|
+
const DEFAULT_PAGE_TIMEOUT_MS = 20000;
|
|
56
|
+
|
|
57
|
+
/** A snippet is a hover-sized excerpt — dominion bounds these again on ingest. */
|
|
58
|
+
const MAX_HTML_LENGTH = 400;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How long a page may keep changing after `load` before axe reads it, and how
|
|
62
|
+
* much stillness counts as finished.
|
|
63
|
+
*
|
|
64
|
+
* `load` fires once the document and its subresources are in. On a
|
|
65
|
+
* server-rendered page that is the moment the markup is final; on a
|
|
66
|
+
* client-rendered one it is roughly the moment the first script starts
|
|
67
|
+
* running. This pass is pointed at a page of the consumer's own product,
|
|
68
|
+
* booted by their own pipeline, so the second case is the common one and axe
|
|
69
|
+
* was reading the shell before the product existed — no violations found,
|
|
70
|
+
* score 100, and the "includes accessibility" label on a measurement of
|
|
71
|
+
* nothing.
|
|
72
|
+
*
|
|
73
|
+
* Waiting on the network is the usual reflex and the wrong one here.
|
|
74
|
+
* `networkidle` wants 500ms with no connections at all, and the app under
|
|
75
|
+
* test in CI is very often a dev server holding an open hot-reload socket, so
|
|
76
|
+
* that wait would expire on every page and spend its whole budget doing it.
|
|
77
|
+
* What axe needs is a DOM that has stopped moving *and has something in it*,
|
|
78
|
+
* so that is what this waits for directly. Both halves are load-bearing, and
|
|
79
|
+
* the first one alone is wrong in a way that looks right: at `load` a
|
|
80
|
+
* client-rendered page is perfectly still — it has not started yet — so a
|
|
81
|
+
* plain quiet-period check passes immediately and measures the shell it was
|
|
82
|
+
* written to avoid. Measured against a page that renders after 800ms, the
|
|
83
|
+
* quiet-only version returned "nothing rendered" and the pass reported no
|
|
84
|
+
* measurement at all.
|
|
85
|
+
*
|
|
86
|
+
* The cost lands where it should: a page already finished at `load` leaves
|
|
87
|
+
* after SETTLE_QUIET_MS, a page still building leaves SETTLE_QUIET_MS after
|
|
88
|
+
* its last change, and only a page that never renders anything pays the full
|
|
89
|
+
* cap — which is exactly the page whose measurement is about to be thrown
|
|
90
|
+
* away anyway.
|
|
91
|
+
*/
|
|
92
|
+
const SETTLE_QUIET_MS = 500;
|
|
93
|
+
const SETTLE_CAP_MS = 5000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Loads playwright without making it a dependency of this package.
|
|
97
|
+
*
|
|
98
|
+
* `playwright` is tried before `playwright-core` because only the former
|
|
99
|
+
* brings a browser with it; a consumer who installed the core package alone is
|
|
100
|
+
* expected to point at their own binary. Neither is declared as anything but
|
|
101
|
+
* an optional peer: most people running `stark-cli adopt` never pass an a11y
|
|
102
|
+
* URL, and a browser download is not a reasonable cost to put on them.
|
|
103
|
+
*/
|
|
104
|
+
async function loadChromium() {
|
|
105
|
+
for (const specifier of ['playwright', 'playwright-core']) {
|
|
106
|
+
try {
|
|
107
|
+
const mod = await import(specifier);
|
|
108
|
+
if (mod?.chromium) return { chromium: mod.chromium, specifier };
|
|
109
|
+
} catch {
|
|
110
|
+
// Not installed, or installed broken. Try the next one; the caller gets
|
|
111
|
+
// one actionable message below rather than a stack per attempt.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function loadAxe() {
|
|
118
|
+
try {
|
|
119
|
+
const mod = await import('axe-core');
|
|
120
|
+
// axe-core ships CJS; the default interop is where `source` lands.
|
|
121
|
+
const axe = mod?.default ?? mod;
|
|
122
|
+
return axe?.source ? axe : null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The element's own markup, bounded. Unlike a source finding there is no file
|
|
130
|
+
* and no line to point at, so the markup and the selector are the whole of the
|
|
131
|
+
* evidence — the same trade dominion's URL findings make, and the reason those
|
|
132
|
+
* show "what to write" rather than a diff to apply.
|
|
133
|
+
*/
|
|
134
|
+
function snippetFor(url, node, violation) {
|
|
135
|
+
const html = String(node?.html ?? '').slice(0, MAX_HTML_LENGTH);
|
|
136
|
+
if (!html) return null;
|
|
137
|
+
return {
|
|
138
|
+
file: url,
|
|
139
|
+
before: [html],
|
|
140
|
+
after: null,
|
|
141
|
+
highlightIndex: 0,
|
|
142
|
+
beforeAnnotation: String(violation?.help ?? violation?.id ?? '').slice(0, 200) || null,
|
|
143
|
+
afterAnnotation: null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function selectorOf(node) {
|
|
148
|
+
const target = Array.isArray(node?.target) ? node.target : [];
|
|
149
|
+
// axe nests a frame path as an array; flattened here so one string is one
|
|
150
|
+
// element wherever it lives.
|
|
151
|
+
return target.flat().filter((t) => typeof t === 'string').join(' >>> ');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Wait for the page to stop building itself, and to have built something.
|
|
156
|
+
*
|
|
157
|
+
* Best-effort by contract: a page that loaded gets measured whether or not
|
|
158
|
+
* this settles, so every failure here is swallowed rather than costing the
|
|
159
|
+
* consumer a measurement. The cap is what makes it safe to point at a page
|
|
160
|
+
* that never comes to rest — a ticking clock, a carousel, a poller, or one
|
|
161
|
+
* that renders nothing at all — and the quiet period is what keeps it cheap
|
|
162
|
+
* against a page that was finished before it arrived.
|
|
163
|
+
*
|
|
164
|
+
* The readiness test is isBlankRender's, inverted, and it has to be: this
|
|
165
|
+
* decides when to stop waiting for content, that decides whether content ever
|
|
166
|
+
* came, and two different answers to "is there anything here" would let a
|
|
167
|
+
* page fail one and pass the other.
|
|
168
|
+
*/
|
|
169
|
+
async function settleDom(page, { quietMs = SETTLE_QUIET_MS, capMs = SETTLE_CAP_MS } = {}) {
|
|
170
|
+
try {
|
|
171
|
+
await page.evaluate(
|
|
172
|
+
({ quiet, cap }) =>
|
|
173
|
+
new Promise((resolve) => {
|
|
174
|
+
let idle;
|
|
175
|
+
const observer = new MutationObserver(restart);
|
|
176
|
+
|
|
177
|
+
const rendered = () => {
|
|
178
|
+
const body = document.body;
|
|
179
|
+
if (!body) return false;
|
|
180
|
+
if (body.innerText.trim().length > 0) return true;
|
|
181
|
+
return body.querySelectorAll('img, svg, canvas, video, iframe').length > 0;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function stop() {
|
|
185
|
+
clearTimeout(idle);
|
|
186
|
+
clearTimeout(ceiling);
|
|
187
|
+
observer.disconnect();
|
|
188
|
+
resolve();
|
|
189
|
+
}
|
|
190
|
+
// Quiet is only the end of the wait once there is something to have
|
|
191
|
+
// gone quiet about. Until then this re-arms rather than finishing,
|
|
192
|
+
// which also keeps a page that renders with no DOM change of its
|
|
193
|
+
// own — an <img> that simply took its time — from being read early.
|
|
194
|
+
function settled() {
|
|
195
|
+
if (rendered()) stop();
|
|
196
|
+
else restart();
|
|
197
|
+
}
|
|
198
|
+
function restart() {
|
|
199
|
+
clearTimeout(idle);
|
|
200
|
+
idle = setTimeout(settled, quiet);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
observer.observe(document.documentElement, {
|
|
204
|
+
childList: true,
|
|
205
|
+
subtree: true,
|
|
206
|
+
attributes: true,
|
|
207
|
+
characterData: true,
|
|
208
|
+
});
|
|
209
|
+
// Declared here rather than above so it can be const: nothing can
|
|
210
|
+
// call stop() before this line runs, since this is the first timer
|
|
211
|
+
// armed and the observer only ever re-arms `idle`.
|
|
212
|
+
const ceiling = setTimeout(stop, cap);
|
|
213
|
+
restart();
|
|
214
|
+
}),
|
|
215
|
+
{ quiet: quietMs, cap: capMs },
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
// The page went away, or navigated under us. Either way the next step
|
|
219
|
+
// reads whatever is actually there, and says so if it is nothing.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What the browser ended up showing, reduced to the two facts below. */
|
|
224
|
+
async function inspectRender(page) {
|
|
225
|
+
return page.evaluate(() => ({
|
|
226
|
+
text: (document.body ? document.body.innerText : '').trim().slice(0, 200),
|
|
227
|
+
visuals: document.body
|
|
228
|
+
? document.body.querySelectorAll('img, svg, canvas, video, iframe').length
|
|
229
|
+
: 0,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether the browser rendered nothing a person could have read or seen.
|
|
235
|
+
*
|
|
236
|
+
* axe is honest about an empty page: it reports no violations, because there
|
|
237
|
+
* is nothing there to violate anything. Scored, that reads as a flawless
|
|
238
|
+
* page — the same "reward a broken route" mistake the load-failure path
|
|
239
|
+
* refuses to make, arriving through a different door, and the one failure
|
|
240
|
+
* this pass could otherwise report as a success. A client-rendered app whose
|
|
241
|
+
* bundle 404s in the consumer's CI lands here every time.
|
|
242
|
+
*
|
|
243
|
+
* Deliberately blunt: any text at all, or any image, icon, canvas, video or
|
|
244
|
+
* frame, counts as rendered. A real page clears that bar without trying, and
|
|
245
|
+
* the alternative — deciding how much content is enough — would start
|
|
246
|
+
* throwing away real measurements of pages that are merely sparse.
|
|
247
|
+
*/
|
|
248
|
+
export function isBlankRender({ text = '', visuals = 0 } = {}) {
|
|
249
|
+
return String(text).trim().length === 0 && visuals === 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const BLANK_RENDER_REASON =
|
|
253
|
+
'The page loaded but rendered nothing — no text and no images. An empty page has no violations, which would score as a perfect one, so it is reported as not measured instead.';
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Runs axe against each URL and folds the results into one a11y score.
|
|
257
|
+
*
|
|
258
|
+
* Never throws. `ran: false` means the pass could not run at all and the caller
|
|
259
|
+
* must report a scan *without* accessibility rather than one with a zero in it
|
|
260
|
+
* — a page that failed to load and a page with no violations are not the same
|
|
261
|
+
* measurement, and only one of them may carry the "includes accessibility"
|
|
262
|
+
* label.
|
|
263
|
+
*/
|
|
264
|
+
export async function runA11yPass(urls, {
|
|
265
|
+
timeoutMs = DEFAULT_PAGE_TIMEOUT_MS,
|
|
266
|
+
maxPages = MAX_PAGES,
|
|
267
|
+
executablePath = process.env.STARK_A11Y_BROWSER_PATH || undefined,
|
|
268
|
+
} = {}) {
|
|
269
|
+
const wanted = [...new Set((urls ?? []).filter(Boolean))];
|
|
270
|
+
if (wanted.length === 0) return { ran: false, reason: 'No --a11y-url was given.' };
|
|
271
|
+
|
|
272
|
+
const loaded = await loadChromium();
|
|
273
|
+
if (!loaded) {
|
|
274
|
+
return {
|
|
275
|
+
ran: false,
|
|
276
|
+
reason:
|
|
277
|
+
'Accessibility pass skipped: playwright is not installed. Add it where this scan runs — "npm i -D playwright && npx playwright install chromium" — or drop --a11y-url to score without accessibility.',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const axe = await loadAxe();
|
|
281
|
+
if (!axe) {
|
|
282
|
+
return {
|
|
283
|
+
ran: false,
|
|
284
|
+
reason: 'Accessibility pass skipped: axe-core is not installed. Add it where this scan runs: "npm i -D axe-core".',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const requested = wanted.length;
|
|
289
|
+
const pageUrls = wanted.slice(0, maxPages);
|
|
290
|
+
|
|
291
|
+
let browser;
|
|
292
|
+
try {
|
|
293
|
+
browser = await loaded.chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return {
|
|
296
|
+
ran: false,
|
|
297
|
+
reason: `Accessibility pass skipped: could not launch a browser (${err.message}). If you installed playwright-core only, set STARK_A11Y_BROWSER_PATH or pass --a11y-browser-path.`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const pages = [];
|
|
302
|
+
const findings = [];
|
|
303
|
+
const failed = [];
|
|
304
|
+
try {
|
|
305
|
+
const context = await browser.newContext();
|
|
306
|
+
for (const url of pageUrls) {
|
|
307
|
+
const page = await context.newPage();
|
|
308
|
+
try {
|
|
309
|
+
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
|
|
310
|
+
await settleDom(page);
|
|
311
|
+
if (isBlankRender(await inspectRender(page))) {
|
|
312
|
+
failed.push({ url, reason: BLANK_RENDER_REASON });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
await page.addScriptTag({ content: axe.source });
|
|
316
|
+
const results = await page.evaluate(() => window.axe.run(document.documentElement));
|
|
317
|
+
|
|
318
|
+
// One entry per failing node, not per rule: the score is a count of
|
|
319
|
+
// occurrences, and so is the queue a person works through.
|
|
320
|
+
const nodes = (results?.violations ?? [])
|
|
321
|
+
.filter((v) => v.impact === 'critical' || v.impact === 'serious')
|
|
322
|
+
.flatMap((v) => (v.nodes ?? []).map((node) => ({ violation: v, node })));
|
|
323
|
+
|
|
324
|
+
let critical = 0;
|
|
325
|
+
let serious = 0;
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
for (const { violation, node } of nodes) {
|
|
328
|
+
const selector = selectorOf(node);
|
|
329
|
+
// Two axe checks can report the same element under the same rule
|
|
330
|
+
// through different frame paths; the pair is the occurrence.
|
|
331
|
+
const key = `${violation.id}:${selector}`;
|
|
332
|
+
if (seen.has(key)) continue;
|
|
333
|
+
seen.add(key);
|
|
334
|
+
|
|
335
|
+
if (violation.impact === 'critical') critical += 1;
|
|
336
|
+
else serious += 1;
|
|
337
|
+
|
|
338
|
+
findings.push({
|
|
339
|
+
rule: violation.id,
|
|
340
|
+
// The page's own URL stands in for a file path — the same
|
|
341
|
+
// substitution lib/url-crawl.ts makes, and what the finding's
|
|
342
|
+
// header shows.
|
|
343
|
+
file: url,
|
|
344
|
+
selector,
|
|
345
|
+
severity: violation.impact === 'critical' ? 'critical' : 'warning',
|
|
346
|
+
snippet: snippetFor(url, node, violation),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const penalty = critical * AXE_SCORE_MAP.critical + serious * AXE_SCORE_MAP.serious;
|
|
351
|
+
pages.push({ url, score: Math.max(0, 100 + penalty), critical, serious });
|
|
352
|
+
} catch (err) {
|
|
353
|
+
// A page that did not load was not measured. It is recorded as such
|
|
354
|
+
// rather than scored 100 (which would reward a broken route) or 0
|
|
355
|
+
// (which would punish the product for a CI networking problem).
|
|
356
|
+
failed.push({ url, reason: err.message });
|
|
357
|
+
} finally {
|
|
358
|
+
await page.close().catch(() => {});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return { ran: false, reason: `Accessibility pass failed: ${err.message}` };
|
|
363
|
+
} finally {
|
|
364
|
+
await browser.close().catch(() => {});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (pages.length === 0) {
|
|
368
|
+
return {
|
|
369
|
+
ran: false,
|
|
370
|
+
// "measured", not "loaded": a page can now fail here by loading
|
|
371
|
+
// perfectly and showing nothing, and the reason below says which.
|
|
372
|
+
reason: `Accessibility pass skipped: none of the ${requested} page(s) could be measured.${failed[0] ? ` First reason: ${failed[0].reason}` : ''}`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Averaged across pages, not summed: a11y is a rate, and lib/url-crawl.ts's
|
|
377
|
+
// aggregateSite averages it for the same reason component and token counts
|
|
378
|
+
// are summed and it is not — twenty clean pages do not pay for one broken
|
|
379
|
+
// one, and a site is not more accessible for having more pages.
|
|
380
|
+
const score = Math.round(pages.reduce((s, p) => s + p.score, 0) / pages.length);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
ran: true,
|
|
384
|
+
score,
|
|
385
|
+
pages,
|
|
386
|
+
requested,
|
|
387
|
+
scanned: pages.length,
|
|
388
|
+
skipped: failed,
|
|
389
|
+
counts: {
|
|
390
|
+
critical: pages.reduce((s, p) => s + p.critical, 0),
|
|
391
|
+
serious: pages.reduce((s, p) => s + p.serious, 0),
|
|
392
|
+
},
|
|
393
|
+
findings,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export { AXE_SCORE_MAP };
|