@starklab/stark-mcp 0.2.0 → 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 +69 -14
- package/src/whisperer.d.ts +106 -0
- package/src/whisperer.js +814 -0
package/src/cli.js
CHANGED
|
@@ -9,18 +9,22 @@ import {
|
|
|
9
9
|
getComponentProps,
|
|
10
10
|
buildManifest,
|
|
11
11
|
ejectComponent,
|
|
12
|
+
whispererContext,
|
|
12
13
|
} from './data.js';
|
|
13
14
|
import { resolveReferences } from './adopt/referenceResolver.js';
|
|
14
15
|
import { resolveWrappers } from './adopt/wrapperResolver.js';
|
|
15
16
|
import { resolveTokenAliases } from './adopt/tokenAliasResolver.js';
|
|
16
17
|
import { resolveTailwindTokens } from './adopt/tailwindResolver.js';
|
|
17
18
|
import { resolvePropApi } from './adopt/propApiResolver.js';
|
|
19
|
+
import { buildScanRollup } from './adopt/scanRollup.js';
|
|
20
|
+
import { attachFindingSnippets } from './adopt/findingSnippet.js';
|
|
18
21
|
import { resolveUsageRules } from './adopt/usageRulesResolver.js';
|
|
19
22
|
import { resolveOpportunities } from './adopt/opportunityResolver.js';
|
|
20
23
|
import { resolveRnTokenAliases } from './adopt/rnTokenAliasResolver.js';
|
|
21
24
|
import { resolveRnTailwindTokens } from './adopt/rnTailwindResolver.js';
|
|
22
25
|
import { discoverTargets, workspacePackageMap } from './adopt/targetDiscovery.js';
|
|
23
26
|
import { verifyVecnaLayout } from './adopt/vecnaVerifier.js';
|
|
27
|
+
import { gateAdoptResult, GATE_SEVERITIES } from './adopt/adoptGate.js';
|
|
24
28
|
import { resolveForeignDiscovery } from './adopt/foreignDiscoveryResolver.js';
|
|
25
29
|
import { resolvePropSchema } from './adopt/foreignPropSchemaResolver.js';
|
|
26
30
|
import { scoreForeignAdoption } from './adopt/foreignScoringResolver.js';
|
|
@@ -29,6 +33,8 @@ import { detectForeignSystems } from './adopt/installedSystemAutoDetector.js';
|
|
|
29
33
|
import { scanInstalledSystem } from './adopt/installedSystemScan.js';
|
|
30
34
|
import { reportForeignScan } from './adopt/foreignScanReport.js';
|
|
31
35
|
import { reportAdoptScan } from './adopt/adoptScanReport.js';
|
|
36
|
+
import { reportPrCheck } from './adopt/prCheckReport.js';
|
|
37
|
+
import { runA11yPass } from './adopt/a11yPass.js';
|
|
32
38
|
|
|
33
39
|
function print(value) {
|
|
34
40
|
console.log(JSON.stringify(value, null, 2));
|
|
@@ -86,10 +92,77 @@ function flagValue(args, name, fallback) {
|
|
|
86
92
|
return arg ? arg.slice(prefix.length) : fallback;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
/** Every occurrence of a repeatable flag, each also split on commas, so
|
|
96
|
+
* "--a11y-url=a --a11y-url=b" and "--a11y-url=a,b" mean the same thing. A
|
|
97
|
+
* list of pages is the one input here a consumer plausibly writes both ways,
|
|
98
|
+
* and silently keeping only the first would score a subset of what they
|
|
99
|
+
* asked for while labelling it as the whole. */
|
|
100
|
+
function flagValues(args, name) {
|
|
101
|
+
const prefix = `--${name}=`;
|
|
102
|
+
return args
|
|
103
|
+
.filter(a => a.startsWith(prefix))
|
|
104
|
+
.flatMap(a => a.slice(prefix.length).split(','))
|
|
105
|
+
.map(s => s.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Gate flags, shared by "adopt --gate" and the standalone "gate" command so
|
|
110
|
+
* a verdict means the same thing however it was produced. Throws on an
|
|
111
|
+
* unusable value rather than silently falling back to the default — a CI
|
|
112
|
+
* that asked for --fail-on=warn and got critical would be told it passed
|
|
113
|
+
* under a threshold it never set. */
|
|
114
|
+
function gateOptions(args) {
|
|
115
|
+
const failOn = flagValue(args, 'fail-on', 'critical');
|
|
116
|
+
if (!GATE_SEVERITIES.includes(failOn)) {
|
|
117
|
+
throw new Error(`--fail-on must be one of: ${GATE_SEVERITIES.join(', ')} (got "${failOn}").`);
|
|
118
|
+
}
|
|
119
|
+
const raw = flagValue(args, 'min-components', undefined);
|
|
120
|
+
let minComponentsUsed = null;
|
|
121
|
+
if (raw !== undefined) {
|
|
122
|
+
minComponentsUsed = Number(raw);
|
|
123
|
+
if (!Number.isInteger(minComponentsUsed) || minComponentsUsed < 0) {
|
|
124
|
+
throw new Error(`--min-components must be a non-negative integer (got "${raw}").`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { failOn, minComponentsUsed };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A gate that cannot run must never report PASS — that is the one failure
|
|
131
|
+
* mode a gate cannot have. So a crash (or a bad flag on "adopt --gate",
|
|
132
|
+
* where the scan itself still has output worth printing) becomes a FAIL
|
|
133
|
+
* verdict carrying the error, instead of propagating out and taking the
|
|
134
|
+
* scan's stdout with it. This is the opposite of the reporting block below,
|
|
135
|
+
* which is allowed to fail silently precisely because it is not a judgment. */
|
|
136
|
+
function runGate(adoptResult, args) {
|
|
137
|
+
try {
|
|
138
|
+
return gateAdoptResult(adoptResult, gateOptions(args));
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { verdict: 'FAIL', error: `The gate could not run: ${err.message}`, summary: null, checks: [] };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** stdout stays a single JSON document; the human-readable verdict and the
|
|
145
|
+
* exit code go to stderr and process.exitCode, same split as --report's. */
|
|
146
|
+
function announceGate(gate) {
|
|
147
|
+
if (gate.verdict === 'FAIL') {
|
|
148
|
+
const failed = (gate.checks ?? []).filter(c => c.status === 'fail');
|
|
149
|
+
const detail = gate.error
|
|
150
|
+
? gate.error
|
|
151
|
+
: failed.map(c => `${c.id} (${c.evidenceTotal} finding${c.evidenceTotal === 1 ? '' : 's'})`).join(', ');
|
|
152
|
+
console.error(`Gate: FAIL — ${detail}`);
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const s = gate.summary;
|
|
157
|
+
console.error(
|
|
158
|
+
`Gate: PASS — ${s.passed}/${s.checks} checks passed, ${s.notApplicable} not applicable, ${s.noEvidence} with no evidence to judge.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
89
162
|
// Shared by the single-target `adopt` path and the `--all-targets` loop —
|
|
90
163
|
// `workspacePackages` is only non-empty in the latter, where it enables
|
|
91
164
|
// wrapperResolver.js's cross-workspace resolution (ADOPTION_APP_PLAN.md §3e).
|
|
92
|
-
function runAdopt(root, platform, ignore, workspacePackages) {
|
|
165
|
+
function runAdopt(root, platform, ignore, workspacePackages, { snippets = true } = {}) {
|
|
93
166
|
const references = resolveReferences(root, { platform, ignore });
|
|
94
167
|
const wrappers = resolveWrappers(root, { platform, ignore, workspacePackages });
|
|
95
168
|
// CSS custom properties and Tailwind utility classes have no RN
|
|
@@ -118,7 +191,7 @@ function runAdopt(root, platform, ignore, workspacePackages) {
|
|
|
118
191
|
const rnTokenAliases = platform === 'native' ? resolveRnTokenAliases(root, { platform, ignore }) : null;
|
|
119
192
|
// NativeWind is the RN analog of web Tailwind — same platform split.
|
|
120
193
|
const rnTailwind = platform === 'native' ? resolveRnTailwindTokens(root, { platform, ignore }) : null;
|
|
121
|
-
|
|
194
|
+
const result = {
|
|
122
195
|
...references,
|
|
123
196
|
wrappers: wrappers.wrappers,
|
|
124
197
|
byComponent: wrappers.byComponent,
|
|
@@ -134,6 +207,18 @@ function runAdopt(root, platform, ignore, workspacePackages) {
|
|
|
134
207
|
rnTokenAliases,
|
|
135
208
|
rnTailwind,
|
|
136
209
|
};
|
|
210
|
+
// Folded here, not in adoptScanReport.js, because its inputs are exactly the
|
|
211
|
+
// per-site enumerations `stripAdoptDetail`'s DROPPED_KEYS removes on the way
|
|
212
|
+
// out — computing it any later would mean computing it from data that is
|
|
213
|
+
// already gone (scanRollup.js). It is derived, so it also belongs in the
|
|
214
|
+
// printed document: `stark-cli gate <adopt.json>` and a human reading stdout
|
|
215
|
+
// both see the same rollup Dominion stores.
|
|
216
|
+
const reported = { ...result, rollup: buildScanRollup(result) };
|
|
217
|
+
// Last, and after the rollup: this is the only step that reads the
|
|
218
|
+
// consumer's source *text* rather than its structure, so it is the only one
|
|
219
|
+
// that can be turned off (--no-snippets) without changing any other number
|
|
220
|
+
// in the document. See findingSnippet.js for the bounds it works under.
|
|
221
|
+
return snippets ? attachFindingSnippets(reported, { root }) : reported;
|
|
137
222
|
}
|
|
138
223
|
|
|
139
224
|
const HELP = `stark-cli — query the Stark design system catalog from the terminal
|
|
@@ -144,13 +229,18 @@ Usage:
|
|
|
144
229
|
stark-cli props <Component> [--platform=web|native] [--tokens]
|
|
145
230
|
stark-cli manifest
|
|
146
231
|
stark-cli eject <Component> [--platform=web|native] [--out=<dir>] [--force]
|
|
147
|
-
stark-cli adopt [path] [--platform=web|native] [--ignore=<glob>,<glob>,...] [--all-targets]
|
|
232
|
+
stark-cli adopt [path] [--platform=web|native] [--ignore=<glob>,<glob>,...] [--all-targets] [--no-snippets]
|
|
233
|
+
[--a11y-url=<url>[,<url>...] [--a11y-browser-path=<bin>]]
|
|
148
234
|
[--report=<url> --target-dir=<dir> [--commit=<sha>]]
|
|
235
|
+
[--pr-check=<url> --target-dir=<dir> [--commit=<sha>]]
|
|
236
|
+
[--gate [--fail-on=critical|warning|info] [--min-components=<n>]]
|
|
149
237
|
stark-cli targets [path]
|
|
238
|
+
stark-cli gate <path-to-adopt.json> [--fail-on=critical|warning|info] [--min-components=<n>]
|
|
150
239
|
stark-cli verify-vecna <path-to-layout.json> [--out=<dir>]
|
|
151
240
|
stark-cli scan-foreign <system-id-or-npm-package|auto> [path] [--platform=web|native] [--allow-network] [--ignore=<glob>,<glob>,...]
|
|
152
241
|
[--report=<url> --target-dir=<dir> [--commit=<sha>]]
|
|
153
242
|
stark-cli scan-foreign List the tested systems (pass one's id, or any raw npm package name)
|
|
243
|
+
stark-cli whisper "<question>" [--context=<prior turn>]... [--json]
|
|
154
244
|
|
|
155
245
|
Options:
|
|
156
246
|
--platform=<web|native> Target platform for "props", "eject", and "adopt" (default: web)
|
|
@@ -160,6 +250,17 @@ Options:
|
|
|
160
250
|
--ignore=<globs> Extra comma-separated glob patterns to exclude from "adopt"'s scan
|
|
161
251
|
--all-targets Discover every workspace target under [path] and run "adopt" on
|
|
162
252
|
each in-scope one, instead of treating [path] as a single target
|
|
253
|
+
--gate Judge an "adopt" scan and print a PASS/FAIL verdict with evidence
|
|
254
|
+
--fail-on=<severity> Lowest severity that fails the gate (default: critical)
|
|
255
|
+
--min-components=<n> Opt-in CI floor: fail the gate below n referenced catalog components
|
|
256
|
+
--a11y-url=<url> A page of your running app to render and check with axe. Repeatable,
|
|
257
|
+
and comma-separated lists are accepted. Opt-in; without it an "adopt"
|
|
258
|
+
score measures components and tokens only
|
|
259
|
+
--a11y-browser-path=<bin> Chromium executable for the a11y pass (or STARK_A11Y_BROWSER_PATH),
|
|
260
|
+
needed only where "playwright-core" is installed without a browser
|
|
261
|
+
--context=<text> A prior turn of the conversation, for "whisper"; repeatable, newest first
|
|
262
|
+
--json Print "whisper"'s output as JSON ({ systemPrompt, grounding, components,
|
|
263
|
+
named, tokens }) instead of the ready-to-use system prompt text
|
|
163
264
|
|
|
164
265
|
"eject --platform=native" copies from @starklab/stk-react-native
|
|
165
266
|
instead of @starklab/stk-components.
|
|
@@ -357,11 +458,60 @@ adoption score gets into the dashboard. It takes the same
|
|
|
357
458
|
reduced projection: every per-site enumeration ("sites", "usages",
|
|
358
459
|
"properties", "checks") is dropped, since the tracker reads counts and the
|
|
359
460
|
three-number reports, never the enumeration behind them. Findings are kept.
|
|
461
|
+
|
|
462
|
+
Each finding that carries a file and a line also carries a "snippet": a few
|
|
463
|
+
lines of the real source around it ("before"), and, only where the token
|
|
464
|
+
build proves a single replacement, the same lines with the fix applied
|
|
465
|
+
("after"). This is the one part of a scan that ships the customer's own
|
|
466
|
+
code, so it is bounded (2 lines of context, 200 characters per line, 200
|
|
467
|
+
snippets per scan) and "--no-snippets" turns it off entirely, leaving every
|
|
468
|
+
other number in the document unchanged.
|
|
360
469
|
"--report" is not supported together with "--all-targets" — a reported scan
|
|
361
470
|
belongs to exactly one tracker target, so run one "adopt <dir>
|
|
362
471
|
--report --target-dir=<dir>" per target instead of reporting a whole
|
|
363
472
|
discovery sweep under a single dir.
|
|
364
473
|
|
|
474
|
+
"adopt --pr-check=<url>" is the pull-request half of the same wire, and
|
|
475
|
+
posts to a different endpoint (/api/pr-check) for a different purpose.
|
|
476
|
+
"--report" accumulates history: every scan is stored, and a falling score is
|
|
477
|
+
information rather than a failure. "--pr-check" judges one commit: the
|
|
478
|
+
tracker diffs this scan's findings against the ones already open on the
|
|
479
|
+
target, and posts a real GitHub Check Run on "--commit" — red when the pull
|
|
480
|
+
request introduces a critical finding, neutral when it introduces a
|
|
481
|
+
non-critical one, green when it introduces none. Both flags take the same
|
|
482
|
+
"--target-dir=<dir>" (required), "--commit=<sha>", STARK_DOMINION_TOKEN /
|
|
483
|
+
"--token=<secret>" and never-fail-the-build behaviour, both may be passed in
|
|
484
|
+
the same run, and neither is supported with "--all-targets" for the same
|
|
485
|
+
reason.
|
|
486
|
+
|
|
487
|
+
What is sent is only what the check can act on: a "{rule, location,
|
|
488
|
+
severity}" triple per finding, where "location" is the target-relative
|
|
489
|
+
"file:line". A finding the tracker cannot place in a file does not travel,
|
|
490
|
+
and the count of those is printed to stderr rather than swallowed — a
|
|
491
|
+
resolver that reports a property but no file, or a page URL instead of a
|
|
492
|
+
path (every accessibility finding is one), has nothing to annotate. Nothing
|
|
493
|
+
else about the scan is sent: no snippets, no counts, no source.
|
|
494
|
+
|
|
495
|
+
"adopt --a11y-url=<url>" adds the one measurement a source scan can never
|
|
496
|
+
make. Accessibility rules are statements about a rendered page — a contrast
|
|
497
|
+
ratio is a fact about two resolved colors, a missing accessible name a fact
|
|
498
|
+
about the element the browser built — so they need a running product, not a
|
|
499
|
+
particular kind of connection. Where your CI already boots your app (with
|
|
500
|
+
whatever session your own end-to-end tests set up), point this at the pages
|
|
501
|
+
you care about and the scan renders each one in headless Chromium, runs
|
|
502
|
+
axe-core against it, and folds the result in: the score becomes 40%
|
|
503
|
+
components / 40% tokens / 20% accessibility instead of 50/50, and every
|
|
504
|
+
critical or serious violation arrives as a finding. Without the flag,
|
|
505
|
+
nothing is rendered and the score stays 50/50 — a scan that did not look is
|
|
506
|
+
never reported as a scan that found nothing.
|
|
507
|
+
|
|
508
|
+
playwright and axe-core are optional peer dependencies, installed only where
|
|
509
|
+
you use this: "npm i -D playwright axe-core && npx playwright install
|
|
510
|
+
chromium". If either is missing, or a page fails to load, the pass is
|
|
511
|
+
skipped with a message on stderr and the scan reports its other numbers as
|
|
512
|
+
usual — this must never be the thing that breaks your build. Only the URLs
|
|
513
|
+
you name are visited; nothing is crawled, and at most 25 pages are rendered.
|
|
514
|
+
|
|
365
515
|
"verify-vecna <path-to-layout.json>" is the reverse direction of "adopt":
|
|
366
516
|
instead of scanning a consumer's real code for catalog usage, it checks one
|
|
367
517
|
of Vecna's own generated LayoutConfig JSON files against the same catalog.
|
|
@@ -487,6 +637,30 @@ absolute path is rewritten to "<root>"/"<home>" before the payload leaves
|
|
|
487
637
|
the machine. Paths are scrubbed only on the reported copy — stdout keeps
|
|
488
638
|
the real ones, which is what is actually useful when debugging a local
|
|
489
639
|
scan.
|
|
640
|
+
|
|
641
|
+
"adopt --gate" judges the scan it just ran and prints a PASS/FAIL verdict
|
|
642
|
+
under "gate", exiting non-zero on FAIL — the piece that makes a customer CI
|
|
643
|
+
run a gate rather than a report. "stark-cli gate <adopt.json>" re-judges an
|
|
644
|
+
already-saved scan with the same code, so a verdict can be reproduced (or
|
|
645
|
+
re-run at a different threshold) without re-scanning.
|
|
646
|
+
|
|
647
|
+
The verdict is assembled from findings the resolvers already produced — the
|
|
648
|
+
gate never re-implements a check — and each of its eight checks carries the
|
|
649
|
+
evidence for its own status: the components and call sites the reference
|
|
650
|
+
check found, the offending file/line/rule for anything that failed. A check
|
|
651
|
+
whose resolver did not run on this platform is "not-applicable"; one whose
|
|
652
|
+
resolver ran with nothing in its domain (no CSS files, no catalog JSX, no
|
|
653
|
+
Tailwind config) is "no-evidence". Neither can fail the gate, and neither is
|
|
654
|
+
folded into "pass" — a PASS over an empty denominator is a weaker claim than
|
|
655
|
+
one over a full denominator, and the two have to stay distinguishable.
|
|
656
|
+
|
|
657
|
+
Low adoption is never a failure: a repo using three catalog components
|
|
658
|
+
correctly passes. That is a score, which the tracker computes from the same
|
|
659
|
+
scan, not a conformance verdict. "--min-components=<n>" is the opt-in
|
|
660
|
+
exception, for a CI that wants an explicit floor; its finding is marked
|
|
661
|
+
"policy": true to keep it separable from what the resolvers found.
|
|
662
|
+
"--fail-on=<severity>" lowers the bar to warning (or info, which will fail
|
|
663
|
+
on findings whose own text says no action is needed).
|
|
490
664
|
`;
|
|
491
665
|
|
|
492
666
|
const [, , command, ...rest] = process.argv;
|
|
@@ -557,16 +731,36 @@ switch (command) {
|
|
|
557
731
|
const ignore = ignoreArg ? ignoreArg.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
558
732
|
|
|
559
733
|
const reportUrl = flagValue(rest, 'report', undefined);
|
|
734
|
+
const prCheckUrl = flagValue(rest, 'pr-check', undefined);
|
|
735
|
+
// Opt-out, not opt-in: a finding without its code is a coordinate someone
|
|
736
|
+
// has to go and look up by hand, which is the state this flag exists to
|
|
737
|
+
// let a customer choose deliberately rather than get by default.
|
|
738
|
+
const snippets = !hasFlag(rest, 'no-snippets');
|
|
739
|
+
|
|
740
|
+
// The pages this run may render. Empty is the normal case and means the
|
|
741
|
+
// scan measures components and tokens only — accessibility is opt-in
|
|
742
|
+
// because it needs a running product, which a source scan never has and a
|
|
743
|
+
// CI pipeline sometimes does.
|
|
744
|
+
const a11yUrls = flagValues(rest, 'a11y-url');
|
|
560
745
|
|
|
561
746
|
if (hasFlag(rest, 'all-targets')) {
|
|
562
|
-
if (
|
|
747
|
+
if (a11yUrls.length > 0) {
|
|
748
|
+
// Refused for the same reason --report is: a rendered page belongs to
|
|
749
|
+
// one target's score, and a sweep produces one result per discovered
|
|
750
|
+
// directory. Folding one a11y number into all of them would put a
|
|
751
|
+
// measurement on targets that were never rendered.
|
|
752
|
+
fail('--a11y-url cannot be combined with --all-targets. Run "stark-cli adopt <dir> --a11y-url=<url>" once per target.');
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (reportUrl || prCheckUrl) {
|
|
563
756
|
// Refused rather than silently ignored. A reported scan is keyed to
|
|
564
757
|
// one tracker target (targets.dir + the per-target bearer secret), and
|
|
565
758
|
// an --all-targets sweep produces one result per discovered directory
|
|
566
759
|
// — there is no single dir the aggregate honestly belongs to. Filing
|
|
567
760
|
// it under whatever --target-dir happened to be passed would attribute
|
|
568
761
|
// every target's numbers to one of them.
|
|
569
|
-
|
|
762
|
+
const which = reportUrl ? '--report' : '--pr-check';
|
|
763
|
+
fail(`${which} cannot be combined with --all-targets. Run "stark-cli adopt <dir> ${which}=<url> --target-dir=<dir>" once per target.`);
|
|
570
764
|
break;
|
|
571
765
|
}
|
|
572
766
|
try {
|
|
@@ -575,7 +769,7 @@ switch (command) {
|
|
|
575
769
|
const targets = {};
|
|
576
770
|
for (const t of discovery.inScope) {
|
|
577
771
|
const targetRoot = path.join(discovery.root, t.dir);
|
|
578
|
-
targets[t.dir] = runAdopt(targetRoot, platform, ignore, workspacePackages);
|
|
772
|
+
targets[t.dir] = runAdopt(targetRoot, platform, ignore, workspacePackages, { snippets });
|
|
579
773
|
}
|
|
580
774
|
print({
|
|
581
775
|
root: discovery.root,
|
|
@@ -593,13 +787,58 @@ switch (command) {
|
|
|
593
787
|
|
|
594
788
|
let adoptResult = null;
|
|
595
789
|
try {
|
|
596
|
-
adoptResult = runAdopt(root, platform, ignore);
|
|
597
|
-
print(adoptResult);
|
|
790
|
+
adoptResult = runAdopt(root, platform, ignore, undefined, { snippets });
|
|
598
791
|
} catch (err) {
|
|
599
792
|
adoptResult = null;
|
|
600
793
|
fail(err.message);
|
|
601
794
|
}
|
|
602
795
|
|
|
796
|
+
// Between the scan and the print, and outside the scan's try/catch on
|
|
797
|
+
// purpose: runA11yPass never throws (adopt/a11yPass.js), and its result
|
|
798
|
+
// has to be *inside* the printed document rather than beside it, because
|
|
799
|
+
// `a11y` is what decides whether the score this scan produces may say it
|
|
800
|
+
// measured accessibility at all. A pass that could not run is recorded as
|
|
801
|
+
// such — `{ran: false, reason}` — rather than omitted, so a consumer
|
|
802
|
+
// reading the artifact can tell "no browser here" from "never asked for".
|
|
803
|
+
if (adoptResult && a11yUrls.length > 0) {
|
|
804
|
+
const a11y = await runA11yPass(a11yUrls, {
|
|
805
|
+
executablePath: flagValue(rest, 'a11y-browser-path', undefined),
|
|
806
|
+
});
|
|
807
|
+
adoptResult = { ...adoptResult, a11y };
|
|
808
|
+
// stderr, like every other outcome line here: stdout stays a clean JSON
|
|
809
|
+
// document.
|
|
810
|
+
if (a11y.ran) {
|
|
811
|
+
// "N of M" whenever some pages were dropped, and then the reason for
|
|
812
|
+
// each one. A partial pass used to print only the count it managed to
|
|
813
|
+
// measure, which reads as a complete run to anyone who does not
|
|
814
|
+
// remember how many URLs they passed — and the pass has a way to drop
|
|
815
|
+
// a page that looks entirely healthy from the outside: a page that
|
|
816
|
+
// loads perfectly and renders nothing is reported as not measured
|
|
817
|
+
// rather than scored 100 (adopt/a11yPass.js). Silence there is the
|
|
818
|
+
// worst of both, so the reasons are already computed and simply were
|
|
819
|
+
// never printed.
|
|
820
|
+
const skipped = Array.isArray(a11y.skipped) ? a11y.skipped : [];
|
|
821
|
+
const over = skipped.length > 0 ? `${a11y.scanned} of ${a11y.requested}` : `${a11y.scanned}`;
|
|
822
|
+
console.error(`Accessibility: ${a11y.score}/100 over ${over} page(s) (${a11y.counts.critical} critical, ${a11y.counts.serious} serious).`);
|
|
823
|
+
for (const s of skipped) {
|
|
824
|
+
console.error(` Not measured: ${s.url} — ${s.reason}`);
|
|
825
|
+
}
|
|
826
|
+
} else {
|
|
827
|
+
console.error(a11y.reason);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (adoptResult) {
|
|
832
|
+
try {
|
|
833
|
+
const gate = hasFlag(rest, 'gate') ? runGate(adoptResult, rest) : null;
|
|
834
|
+
print(gate ? { ...adoptResult, gate } : adoptResult);
|
|
835
|
+
if (gate) announceGate(gate);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
adoptResult = null;
|
|
838
|
+
fail(err.message);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
603
842
|
// Same placement and the same reasoning as scan-foreign's reporting block
|
|
604
843
|
// below: outside the scan's try/catch, so a fault in the reporting path
|
|
605
844
|
// can never reach fail() and turn a successful scan into a non-zero exit.
|
|
@@ -622,6 +861,48 @@ switch (command) {
|
|
|
622
861
|
console.error(`Scan complete; reporting was skipped: ${outcome.reason}`);
|
|
623
862
|
}
|
|
624
863
|
}
|
|
864
|
+
|
|
865
|
+
// Same placement and the same reasoning as the block above: outside the
|
|
866
|
+
// scan's try/catch, stderr only, and a fault here can never turn a
|
|
867
|
+
// successful scan into a non-zero exit. The pull request going red is the
|
|
868
|
+
// route's decision, made from what we send; it is never this command's.
|
|
869
|
+
if (adoptResult && prCheckUrl) {
|
|
870
|
+
const token = flagValue(rest, 'token', process.env.STARK_DOMINION_TOKEN);
|
|
871
|
+
const outcome = await reportPrCheck(adoptResult, {
|
|
872
|
+
url: prCheckUrl,
|
|
873
|
+
token,
|
|
874
|
+
// Same `root` --report passes, and required for the same reason it is
|
|
875
|
+
// there: it is what turns an absolute `file` into the spelling the
|
|
876
|
+
// stored scan holds, so the check can tell a new finding from one it
|
|
877
|
+
// has already seen (adopt/prCheckReport.js).
|
|
878
|
+
root,
|
|
879
|
+
targetDir: flagValue(rest, 'target-dir', undefined),
|
|
880
|
+
commitSha: flagValue(rest, 'commit', resolveCommitSha(root)),
|
|
881
|
+
}).catch((err) => ({ reported: false, reason: err.message }));
|
|
882
|
+
|
|
883
|
+
if (outcome.reported) {
|
|
884
|
+
const check = outcome.response?.checkRun;
|
|
885
|
+
const where = check?.posted
|
|
886
|
+
? `Check Run posted (${check.conclusion})`
|
|
887
|
+
: outcome.response?.skipped
|
|
888
|
+
? 'the target has PR checks turned off'
|
|
889
|
+
: 'diff computed, no Check Run posted';
|
|
890
|
+
console.error(`PR check sent to ${prCheckUrl}: ${outcome.sent} finding(s), ${where}.`);
|
|
891
|
+
} else {
|
|
892
|
+
console.error(`Scan complete; the PR check was skipped: ${outcome.reason}`);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Printed whether or not the send succeeded, and never as a failure: a
|
|
896
|
+
// dropped finding is a gap in what the check can say, and a gap nobody
|
|
897
|
+
// is told about is how this whole wire stayed missing.
|
|
898
|
+
const s = outcome.skipped;
|
|
899
|
+
if (s && (s.noRule || s.noFile || s.notInFile)) {
|
|
900
|
+
console.error(
|
|
901
|
+
` Not sent (nothing to annotate): ${s.noFile} without a file, ` +
|
|
902
|
+
`${s.notInFile} not in a source file, ${s.noRule} without a rule name.`,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
625
906
|
break;
|
|
626
907
|
}
|
|
627
908
|
|
|
@@ -637,6 +918,35 @@ switch (command) {
|
|
|
637
918
|
break;
|
|
638
919
|
}
|
|
639
920
|
|
|
921
|
+
case 'gate': {
|
|
922
|
+
const [maybePath] = rest;
|
|
923
|
+
if (!maybePath || maybePath.startsWith('--')) {
|
|
924
|
+
fail('Usage: stark-cli gate <path-to-adopt.json> [--fail-on=<severity>] [--min-components=<n>]');
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
const scanPath = path.resolve(process.cwd(), maybePath);
|
|
928
|
+
let adoptResult;
|
|
929
|
+
try {
|
|
930
|
+
adoptResult = JSON.parse(readFileSync(scanPath, 'utf-8'));
|
|
931
|
+
} catch (err) {
|
|
932
|
+
fail(err.message);
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
// Unlike "adopt --gate", a bad threshold here is the whole command rather
|
|
936
|
+
// than one flag on a scan that still has output worth printing — so it
|
|
937
|
+
// fails outright instead of degrading to a FAIL verdict.
|
|
938
|
+
try {
|
|
939
|
+
gateOptions(rest);
|
|
940
|
+
} catch (err) {
|
|
941
|
+
fail(err.message);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
const gate = runGate(adoptResult, rest);
|
|
945
|
+
print(gate);
|
|
946
|
+
announceGate(gate);
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
|
|
640
950
|
case 'verify-vecna': {
|
|
641
951
|
const [maybePath] = rest;
|
|
642
952
|
if (!maybePath || maybePath.startsWith('--')) {
|
|
@@ -767,6 +1077,31 @@ switch (command) {
|
|
|
767
1077
|
break;
|
|
768
1078
|
}
|
|
769
1079
|
|
|
1080
|
+
// The grounding a model answers a design-system question from — the same
|
|
1081
|
+
// brain behind Whisperer in Stark Dominion, for an editor agent or a shell
|
|
1082
|
+
// pipeline. Prints the system prompt as text, ready to paste; --json gives
|
|
1083
|
+
// the parts apart, for a caller that puts them in its own slots.
|
|
1084
|
+
case 'whisper': {
|
|
1085
|
+
const question = rest.find(a => !a.startsWith('--'));
|
|
1086
|
+
if (!question) {
|
|
1087
|
+
fail('Usage: stark-cli whisper "<question>" [--context=<prior turn>]... [--json]');
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
const context = flagValues(rest, 'context');
|
|
1091
|
+
try {
|
|
1092
|
+
const result = whispererContext(question, context);
|
|
1093
|
+
if (hasFlag(rest, 'json')) {
|
|
1094
|
+
const { text, ...parts } = result;
|
|
1095
|
+
print(parts);
|
|
1096
|
+
} else {
|
|
1097
|
+
console.log(result.text);
|
|
1098
|
+
}
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
fail(err.message);
|
|
1101
|
+
}
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
770
1105
|
case undefined:
|
|
771
1106
|
case '--help':
|
|
772
1107
|
case '-h':
|
package/src/data.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { loadUsage } from '@starklab/stk/usage/loader.js';
|
|
6
6
|
import { buildCatalogFromDir } from '@starklab/stk/conformance/catalog.js';
|
|
7
7
|
import { runConformance, hasBlocking } from '@starklab/stk/conformance/index.js';
|
|
8
|
+
import { createWhisperer, WHISPERER_SYSTEM_PROMPT } from './whisperer.js';
|
|
8
9
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
|
|
@@ -53,17 +54,54 @@ function mappingFiles(dir) {
|
|
|
53
54
|
return readdirSync(dir).filter(f => f.endsWith('.mapping.json'));
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// catalog.json is the existence authority: one entry per barrel export, per
|
|
58
|
+
// platform, with the kind of thing it is. The mapping files are a Figma-parity
|
|
59
|
+
// contract, and a component can ship without one (MoveMenu, StatusPage — and,
|
|
60
|
+
// by design, every React Native component), so a list built from the mapping
|
|
61
|
+
// directory alone told external agents those components did not exist.
|
|
62
|
+
const CATALOG_PLATFORMS = { web: 'web', rn: 'native' };
|
|
63
|
+
|
|
64
|
+
// Kinds a caller can reach for as a component. Hooks, providers and constants
|
|
65
|
+
// are exports, not components, and stay out of the list.
|
|
66
|
+
const LISTED_KINDS = new Set(['component', 'primitive', 'subpart']);
|
|
67
|
+
|
|
68
|
+
function catalogComponents(root = stkRoot()) {
|
|
69
|
+
const catalog = readJson(path.join(root, 'catalog.json'));
|
|
70
|
+
const byName = new Map();
|
|
71
|
+
for (const [catalogPlatform, platform] of Object.entries(CATALOG_PLATFORMS)) {
|
|
72
|
+
const exports = catalog.platforms?.[catalogPlatform]?.exports ?? {};
|
|
73
|
+
for (const [name, entry] of Object.entries(exports)) {
|
|
74
|
+
if (!LISTED_KINDS.has(entry?.kind)) continue;
|
|
75
|
+
if (!byName.has(name)) byName.set(name, new Set());
|
|
76
|
+
byName.get(name).add(platform);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return byName;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The union of the catalog and the mapping files, and the union of the
|
|
83
|
+
// platforms each names: a mapping file may say `web` for a component the RN
|
|
84
|
+
// barrel also exports (DropdownMenu), and the catalog cannot know a platform
|
|
85
|
+
// the mapping declares for a component it does not classify.
|
|
56
86
|
export function listComponents() {
|
|
57
87
|
const dir = mappingDir();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
const platformsByName = catalogComponents();
|
|
89
|
+
for (const file of mappingFiles(dir)) {
|
|
90
|
+
const mapping = readJson(path.join(dir, file));
|
|
91
|
+
if (!platformsByName.has(mapping.component)) platformsByName.set(mapping.component, new Set());
|
|
92
|
+
for (const platform of mapping.platforms ?? ['web']) platformsByName.get(mapping.component).add(platform);
|
|
93
|
+
}
|
|
94
|
+
return [...platformsByName.entries()]
|
|
95
|
+
.map(([name, platformSet]) => {
|
|
96
|
+
const slug = toSlug(name);
|
|
97
|
+
const platforms = ['web', 'native'].filter(p => platformSet.has(p));
|
|
98
|
+
// The web file is the canonical prose; a component that only exists on
|
|
99
|
+
// React Native describes itself from its RN file instead.
|
|
100
|
+
const usage = loadUsage(slug) ?? loadUsage(slug, { platform: 'native' });
|
|
63
101
|
return {
|
|
64
|
-
name
|
|
102
|
+
name,
|
|
65
103
|
slug,
|
|
66
|
-
platforms
|
|
104
|
+
platforms,
|
|
67
105
|
status: usage?.status ?? 'unknown',
|
|
68
106
|
description: usage?.description ?? null,
|
|
69
107
|
figmaUrl: usage?.figmaUrl ?? null,
|
|
@@ -132,10 +170,17 @@ export function getComponentProps(component, platform = 'web', includeTokens = f
|
|
|
132
170
|
return result;
|
|
133
171
|
}
|
|
134
172
|
|
|
135
|
-
// Aggregates the full catalog — usage, props (per supported
|
|
136
|
-
// tokens — into one artifact, so an external caller doesn't
|
|
137
|
-
// trips (list_components, then get_component_usage/
|
|
138
|
-
// component) just to see the whole system's shape.
|
|
173
|
+
// Aggregates the full catalog — usage (per platform), props (per supported
|
|
174
|
+
// platform), and tokens — into one artifact, so an external caller doesn't
|
|
175
|
+
// need N round trips (list_components, then get_component_usage/
|
|
176
|
+
// get_component_props per component) just to see the whole system's shape.
|
|
177
|
+
//
|
|
178
|
+
// `usage` is the web file and `nativeUsage` the React Native one
|
|
179
|
+
// (usage/components/rn/): the RN files are their own set, with a prop table
|
|
180
|
+
// (name, type, default, description), a code example and their own dos/donts,
|
|
181
|
+
// because the RN port's props differ from web's for most components. Kept as
|
|
182
|
+
// two fields rather than nesting `usage` per platform so that a reader of an
|
|
183
|
+
// earlier manifest finds `usage` where it always was.
|
|
139
184
|
export function buildManifest() {
|
|
140
185
|
const components = listComponents().map(({ name, slug, status, description, figmaUrl, platforms }) => {
|
|
141
186
|
let usage = null;
|
|
@@ -144,6 +189,7 @@ export function buildManifest() {
|
|
|
144
189
|
} catch {
|
|
145
190
|
// no usage/*.usage.json for this component
|
|
146
191
|
}
|
|
192
|
+
const nativeUsage = platforms.includes('native') ? loadUsage(slug, { platform: 'native' }) : null;
|
|
147
193
|
|
|
148
194
|
const props = {};
|
|
149
195
|
for (const platform of ['web', 'native']) {
|
|
@@ -163,6 +209,7 @@ export function buildManifest() {
|
|
|
163
209
|
figmaUrl,
|
|
164
210
|
platforms,
|
|
165
211
|
usage,
|
|
212
|
+
nativeUsage,
|
|
166
213
|
props,
|
|
167
214
|
tokens: getComponentTokens(name),
|
|
168
215
|
};
|
|
@@ -265,3 +312,50 @@ export function ejectComponent(component, { outDir, cwd = process.cwd(), force =
|
|
|
265
312
|
|
|
266
313
|
return { component: name, platform, from: sourceDir, to: target, files };
|
|
267
314
|
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Whisperer — the grounding an answer about the design system is built on.
|
|
318
|
+
// The logic lives in whisperer.js, which takes the generated files as data so
|
|
319
|
+
// that Dominion's chat panel and this server share one brain; this is the
|
|
320
|
+
// side that loads those files from the installed @starklab/stk.
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
let whispererInstance = null;
|
|
325
|
+
|
|
326
|
+
// Built once per process: the token list and the catalog index are cached
|
|
327
|
+
// inside the instance, and the five files are ~1 MB of JSON to re-read.
|
|
328
|
+
export function loadWhisperer() {
|
|
329
|
+
if (!whispererInstance) {
|
|
330
|
+
whispererInstance = createWhisperer({
|
|
331
|
+
manifest: require('@starklab/stk/manifest.json'),
|
|
332
|
+
catalog: require('@starklab/stk/catalog.json'),
|
|
333
|
+
componentProps: require('@starklab/stk/component-props.json'),
|
|
334
|
+
tokenMeta: require('@starklab/stk/json-meta'),
|
|
335
|
+
tokenDark: require('@starklab/stk/json-dark'),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return whispererInstance;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Everything a model needs to answer one question about the design system:
|
|
342
|
+
// the rules it answers under and the facts it may answer from. `context` is
|
|
343
|
+
// the prior turns of the conversation, newest first, so a follow-up that
|
|
344
|
+
// names no component keeps the detail of the one it is about. The caller
|
|
345
|
+
// puts `systemPrompt` in its system slot and `grounding` under a
|
|
346
|
+
// "# The design system" heading — or takes `text`, which is both already
|
|
347
|
+
// joined that way.
|
|
348
|
+
export function whispererContext(question, context = []) {
|
|
349
|
+
if (typeof question !== 'string' || !question.trim()) {
|
|
350
|
+
throw new Error('whisperer_context needs a question: the text of what is being asked about the design system.');
|
|
351
|
+
}
|
|
352
|
+
const { text, components, named, tokens } = loadWhisperer().buildGrounding(question, context);
|
|
353
|
+
return {
|
|
354
|
+
systemPrompt: WHISPERER_SYSTEM_PROMPT,
|
|
355
|
+
grounding: text,
|
|
356
|
+
text: `${WHISPERER_SYSTEM_PROMPT}\n\n# The design system\n\n${text}`,
|
|
357
|
+
components,
|
|
358
|
+
named,
|
|
359
|
+
tokens,
|
|
360
|
+
};
|
|
361
|
+
}
|