@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/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
- return {
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 (reportUrl) {
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
- fail('--report cannot be combined with --all-targets. Run "stark-cli adopt <dir> --report=<url> --target-dir=<dir>" once per target.');
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
- return mappingFiles(dir)
59
- .map(file => {
60
- const mapping = readJson(path.join(dir, file));
61
- const slug = toSlug(mapping.component);
62
- const usage = loadUsage(slug);
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: mapping.component,
102
+ name,
65
103
  slug,
66
- platforms: mapping.platforms ?? ['web'],
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 platform), and
136
- // tokens — into one artifact, so an external caller doesn't need N round
137
- // trips (list_components, then get_component_usage/get_component_props per
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
+ }