@starklab/stark-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +10 -4
  2. package/src/adopt/adoptScanReport.js +124 -0
  3. package/src/adopt/catalog.js +26 -6
  4. package/src/adopt/foreignDiscoveryResolver.js +276 -0
  5. package/src/adopt/foreignPropSchemaResolver.js +134 -0
  6. package/src/adopt/foreignScanReport.js +210 -0
  7. package/src/adopt/foreignScoringResolver.js +192 -0
  8. package/src/adopt/foreignSystemConfig.js +356 -0
  9. package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
  10. package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
  11. package/src/adopt/installedPackageScoringResolver.js +153 -0
  12. package/src/adopt/installedSystemAutoDetector.js +51 -0
  13. package/src/adopt/installedSystemScan.js +101 -0
  14. package/src/adopt/jsxOpportunityHelpers.js +99 -0
  15. package/src/adopt/moduleGraph.js +39 -8
  16. package/src/adopt/opportunityResolver.js +255 -0
  17. package/src/adopt/opportunitySignaturesNative.js +47 -0
  18. package/src/adopt/usageRulesResolver.js +298 -0
  19. package/src/adopt/vecnaMaterializer.js +165 -0
  20. package/src/adopt/vecnaVerifier.js +127 -0
  21. package/src/cli.js +407 -1
  22. package/src/server.js +38 -14
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  31. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  32. package/src/adopt/dominionFixture.test.js +0 -165
  33. package/src/adopt/propApiResolver.test.js +0 -229
  34. package/src/adopt/referenceResolver.test.js +0 -213
  35. package/src/adopt/rnTailwindResolver.test.js +0 -263
  36. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  37. package/src/adopt/tailwindResolver.test.js +0 -178
  38. package/src/adopt/targetDiscovery.test.js +0 -227
  39. package/src/adopt/tokenAliasResolver.test.js +0 -319
  40. package/src/adopt/wrapperResolver.test.js +0 -324
  41. package/src/data.test.js +0 -231
package/src/cli.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path';
3
+ import { readFileSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
3
5
 
4
6
  import {
5
7
  listComponents,
@@ -13,9 +15,20 @@ import { resolveWrappers } from './adopt/wrapperResolver.js';
13
15
  import { resolveTokenAliases } from './adopt/tokenAliasResolver.js';
14
16
  import { resolveTailwindTokens } from './adopt/tailwindResolver.js';
15
17
  import { resolvePropApi } from './adopt/propApiResolver.js';
18
+ import { resolveUsageRules } from './adopt/usageRulesResolver.js';
19
+ import { resolveOpportunities } from './adopt/opportunityResolver.js';
16
20
  import { resolveRnTokenAliases } from './adopt/rnTokenAliasResolver.js';
17
21
  import { resolveRnTailwindTokens } from './adopt/rnTailwindResolver.js';
18
22
  import { discoverTargets, workspacePackageMap } from './adopt/targetDiscovery.js';
23
+ import { verifyVecnaLayout } from './adopt/vecnaVerifier.js';
24
+ import { resolveForeignDiscovery } from './adopt/foreignDiscoveryResolver.js';
25
+ import { resolvePropSchema } from './adopt/foreignPropSchemaResolver.js';
26
+ import { scoreForeignAdoption } from './adopt/foreignScoringResolver.js';
27
+ import { FOREIGN_SYSTEMS, listForeignSystems, systemPackages } from './adopt/foreignSystemConfig.js';
28
+ import { detectForeignSystems } from './adopt/installedSystemAutoDetector.js';
29
+ import { scanInstalledSystem } from './adopt/installedSystemScan.js';
30
+ import { reportForeignScan } from './adopt/foreignScanReport.js';
31
+ import { reportAdoptScan } from './adopt/adoptScanReport.js';
19
32
 
20
33
  function print(value) {
21
34
  console.log(JSON.stringify(value, null, 2));
@@ -26,6 +39,43 @@ function fail(message) {
26
39
  process.exitCode = 1;
27
40
  }
28
41
 
42
+ /** How a foreign system is identified in scan-foreign's human-readable
43
+ * lists: its package name, or — for a system with no umbrella package, whose
44
+ * name would otherwise be one arbitrary sibling of forty — the count. */
45
+ function packageSummary(system) {
46
+ if (system.npmPackages && system.npmPackages.length > 1) {
47
+ return `${system.npmPackages.length} packages, e.g. ${system.npmPackages[0]}`;
48
+ }
49
+ return system.npmPackage ?? system.distribution;
50
+ }
51
+
52
+ // Reported to the tracker alongside every scan, so a coverage number can
53
+ // always be traced back to the code that produced it.
54
+ const SCANNER_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version;
55
+
56
+ /** The commit a reported scan describes. CI almost always already knows the
57
+ * SHA it checked out, so those variables are preferred over shelling out;
58
+ * `git rev-parse` covers a local run. A tree with neither reports nothing
59
+ * rather than inventing a placeholder — a coverage row keyed to a fake commit
60
+ * is worse than one that was never written. */
61
+ function resolveCommitSha(root) {
62
+ const fromEnv =
63
+ process.env.GITHUB_SHA ||
64
+ process.env.VERCEL_GIT_COMMIT_SHA ||
65
+ process.env.CI_COMMIT_SHA ||
66
+ process.env.BUILDKITE_COMMIT;
67
+ if (fromEnv) return fromEnv;
68
+ try {
69
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
70
+ cwd: root,
71
+ encoding: 'utf-8',
72
+ stdio: ['ignore', 'pipe', 'ignore'],
73
+ }).trim();
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
29
79
  function hasFlag(args, name) {
30
80
  return args.includes(`--${name}`);
31
81
  }
@@ -49,6 +99,20 @@ function runAdopt(root, platform, ignore, workspacePackages) {
49
99
  // prop-mapping/ is flat, with no rn/ subdirectory — there is no artifact
50
100
  // to validate an RN prop against, so metric 6 only runs on web.
51
101
  const propApi = platform === 'web' ? resolvePropApi(root, { platform, ignore }) : null;
102
+ // Runs the Vecna conformance gate's own component-usage rules
103
+ // (checkComponentRules — Toolbar count, Button primary-variant count,
104
+ // DropdownMenu empty-items) against this repo's real JSX, closing the R3
105
+ // gap: the scanner and the gate used to have zero references to each
106
+ // other in either direction (ADOPTION_APP_PLAN.md §10 decision #3). Web
107
+ // only — see usageRulesResolver.js's header for why.
108
+ const usageRules = platform === 'web' ? resolveUsageRules(root, { platform, ignore }) : null;
109
+ // Scans for hand-rolled UI patterns resembling a catalog component,
110
+ // regardless of whether this repo has adopted Stark at all — closes R3
111
+ // dimension 1: every other resolver above only has something to say once
112
+ // a @starklab/stk* import is already in scope. DOM/JSX-shape signatures
113
+ // (role="toolbar", native <input>/<button>) — web only, see
114
+ // opportunityResolver.js's header for why.
115
+ const opportunities = platform === 'web' ? resolveOpportunities(root, { platform, ignore }) : null;
52
116
  // The RN JS-object token indirection resolver is the inverse: it only
53
117
  // makes sense for the native platform (see rnTokenAliasResolver.js).
54
118
  const rnTokenAliases = platform === 'native' ? resolveRnTokenAliases(root, { platform, ignore }) : null;
@@ -65,6 +129,8 @@ function runAdopt(root, platform, ignore, workspacePackages) {
65
129
  tokenAliases,
66
130
  tailwind,
67
131
  propApi,
132
+ usageRules,
133
+ opportunities,
68
134
  rnTokenAliases,
69
135
  rnTailwind,
70
136
  };
@@ -79,7 +145,12 @@ Usage:
79
145
  stark-cli manifest
80
146
  stark-cli eject <Component> [--platform=web|native] [--out=<dir>] [--force]
81
147
  stark-cli adopt [path] [--platform=web|native] [--ignore=<glob>,<glob>,...] [--all-targets]
148
+ [--report=<url> --target-dir=<dir> [--commit=<sha>]]
82
149
  stark-cli targets [path]
150
+ stark-cli verify-vecna <path-to-layout.json> [--out=<dir>]
151
+ stark-cli scan-foreign <system-id-or-npm-package|auto> [path] [--platform=web|native] [--allow-network] [--ignore=<glob>,<glob>,...]
152
+ [--report=<url> --target-dir=<dir> [--commit=<sha>]]
153
+ stark-cli scan-foreign List the tested systems (pass one's id, or any raw npm package name)
83
154
 
84
155
  Options:
85
156
  --platform=<web|native> Target platform for "props", "eject", and "adopt" (default: web)
@@ -176,6 +247,28 @@ missing feature; population is a separate, later pass. Not run for
176
247
  --platform=native (prop-mapping/ has no RN equivalent) — metric 6 is a
177
248
  visible "n/a" there, never an implicit zero.
178
249
 
250
+ Also on the web platform, "adopt" scans for hand-rolled UI patterns that
251
+ resemble a catalog component, whether or not the repo has adopted Stark at
252
+ all — reported under "opportunities". Every other check above only has
253
+ something to say about JSX that already imports @starklab/stk-components;
254
+ a 0%-adoption repo produces zero findings from all of them, not "here's
255
+ what you're missing." This closes that gap with a small, fixed set of
256
+ named structural signatures, never fuzzy/statistical scoring: a native
257
+ <button> with both an onClick handler and visual styling (style touching
258
+ background/border/padding, or a className matching /btn|button/) suggests
259
+ Button; a native <input type="text"|"email"|...> paired with a <label>
260
+ suggests TextInput; an element with role="toolbar", or a flex/row container
261
+ whose only significant children are 2+ native <button>s, suggests Toolbar.
262
+ Each finding carries "confidence" ("high"|"medium", a suggestion strength)
263
+ separate from "severity", which is always "Info" — an opportunity is not a
264
+ defect and must never read as a CI-gate failure. Output is deliberately
265
+ keyed "opportunities", the same word "targets"/"adopt --all-targets" use
266
+ for a different, package-granularity idea (a whole excluded workspace
267
+ target that still renders UI) — the two can appear side by side under
268
+ "--all-targets" but never occupy the same key: that one is top-level, this
269
+ one is nested per-target under its own "opportunities". Not run for
270
+ --platform=native (the signatures are DOM/JSX-shape assumptions).
271
+
179
272
  On the native platform, "adopt" instead resolves the consumer's own JS-
180
273
  object token indirection — RN has no CSS custom properties, so the
181
274
  indirection is a local const or object property instead of var(), e.g.
@@ -253,10 +346,152 @@ without this, a shared internal UI package would score near-0% in every
253
346
  consuming target and 100% in the one that defines it, the same inverted-
254
347
  result problem catalog left-joining exists to prevent, recurring one level
255
348
  up at package granularity.
349
+
350
+ "adopt --report=<url>" additionally POSTs the scan to a stark.dominion
351
+ tracker (its /api/adoption-scan endpoint), the Stark-adoption counterpart
352
+ of "scan-foreign --report" and the way a repo-connected target's real
353
+ adoption score gets into the dashboard. It takes the same
354
+ "--target-dir=<dir>" (required), "--commit=<sha>", STARK_DOMINION_TOKEN /
355
+ "--token=<secret>" and never-fail-the-build behaviour documented under
356
+ "scan-foreign" below, and the same path scrubbing. What is sent is a
357
+ reduced projection: every per-site enumeration ("sites", "usages",
358
+ "properties", "checks") is dropped, since the tracker reads counts and the
359
+ three-number reports, never the enumeration behind them. Findings are kept.
360
+ "--report" is not supported together with "--all-targets" — a reported scan
361
+ belongs to exactly one tracker target, so run one "adopt <dir>
362
+ --report --target-dir=<dir>" per target instead of reporting a whole
363
+ discovery sweep under a single dir.
364
+
365
+ "verify-vecna <path-to-layout.json>" is the reverse direction of "adopt":
366
+ instead of scanning a consumer's real code for catalog usage, it checks one
367
+ of Vecna's own generated LayoutConfig JSON files against the same catalog.
368
+ The JSON is compiled into a real .jsx file with a real import statement
369
+ from '@starklab/stk-components' (see adopt/vecnaMaterializer.js), written
370
+ under --out (default ./.vecna-verify), then the real wrapperResolver/
371
+ tokenAliasResolver/propApiResolver run against it unmodified — not a
372
+ second, narrower reimplementation of the checks.
373
+
374
+ Of those three, only "propApi" can produce real findings here: it validates
375
+ literal call-site JSX props (invalid enum values, missing required props,
376
+ deprecated props, className/style escape hatches) against
377
+ prop-mapping/*.mapping.json, which is exactly what a materialized layout is.
378
+ "wrappers" and "tokenAliases" are included for parity with "adopt"'s own
379
+ resolver set, but always report empty by construction — Vecna's output
380
+ never contains a local wrapper component (wrappers' whole domain) or a CSS
381
+ custom-property alias chain (tokenAliases' whole domain). This is marked
382
+ explicitly per-resolver as "vacuousByConstruction" in the output rather than
383
+ silently reported as "0 findings," so it never reads as broader coverage
384
+ than it has.
385
+
386
+ A layout node whose type isn't in the catalog (the 5 hardcoded layout
387
+ primitives — logo/heading/text/divider/spacer — or any future node type
388
+ added without a matching prop-mapping layoutSchema) is skipped and listed
389
+ under "materialized.skippedNodes" with a reason, never guessed.
390
+
391
+ "scan-foreign <system-id-or-npm-package> [path]" is EXPERIMENTAL —
392
+ ADOPTION_APP_PLAN.md §10 decision #25, "Version B": it answers a different
393
+ question than "adopt": not "how much of @starklab/stk does this repo use,"
394
+ but "what does this repo's OWN (non-Stark) design system look like, and how
395
+ consistently does the repo itself use it." No opinionated conformance rules
396
+ and no CI-gate severity — Stark has no house opinion about a design system
397
+ it doesn't own; this reports facts, not defects. Two distribution modes,
398
+ dispatched by the first argument:
399
+
400
+ Phase 1 — "copy-paste" distribution (currently shadcn/ui only, <system> =
401
+ "shadcn"): the component source ships directly in the consumer's repo (e.g.
402
+ via "npx shadcn init"), so there's no node_modules boundary to cross.
403
+ Discovery detects the package (components.json / @radix-ui/* deps), enumerates
404
+ components by parsing every .tsx/.jsx directly under a components/ui
405
+ directory, and detects the token source (CSS custom properties matching
406
+ --background/--primary/--radius). Prop schema is extracted from each
407
+ component's own "NameProps" interface/type, parsed straight from its file.
408
+ Structural scoring covers catalog coverage (which discovered components are
409
+ actually imported elsewhere in the repo) plus hand-rolled-duplicate detection
410
+ for native <button> markup outside the catalog's own directory.
411
+
412
+ Phase 2 — "installed-package" distribution (any React/React Native design
413
+ system published to npm and already installed in the target repo — MUI,
414
+ Chakra, Carbon, or any other; pass a registered id like "mui" for a friendly
415
+ label, or any raw npm package name like "@mui/material" directly — the
416
+ registry is a convenience, never a gate): real component/prop types live in
417
+ node_modules/<package>'s own .d.ts, so discovery and prop-schema extraction
418
+ both use the TypeScript compiler API (checker.getExportsOfModule,
419
+ checker.getPropertiesOfType) against the package's resolved types entry
420
+ instead of parsing local .tsx source — this also resolves generic prop
421
+ aliases like ComponentProps<'div'> that a Babel-AST walk (Phase 1's approach)
422
+ cannot. Component enumeration covers the package's FULL exported catalog,
423
+ not just the components the target repo happens to import. Structural
424
+ scoring covers catalog coverage (bare-import usage, resolveOrigin-based) plus
425
+ hand-rolled-duplicate detection — reusing the same opportunity signatures
426
+ "adopt" uses, web (native <button>-shaped) or native (Pressable/
427
+ TouchableOpacity + onPress + StyleSheet-shaped) per --platform. Reads only
428
+ already-installed node_modules by default: no "npm install" is ever run
429
+ (that would execute install-time lifecycle scripts — real code execution
430
+ from scanning a repo). If the package isn't installed, pass --allow-network
431
+ to fetch its published package.json + .d.ts read-only from a CDN
432
+ (cdn.jsdelivr.net — static file GETs only, still never installed or
433
+ executed); without that flag, an uninstalled package reports
434
+ "unresolvedReason" rather than fetching anything. --platform (default
435
+ "web") selects RN vs DOM component-file conventions and duplicate-detection
436
+ signatures; --ignore works the same as "adopt"'s.
437
+
438
+ A system does not have to be one package. Some ship as many sibling
439
+ packages with no umbrella barrel — Atlaskit's ~100 @atlaskit/* packages are
440
+ the whole system, with no @atlaskit/core to name — so a registered entry may
441
+ declare several, and a raw argument may be a comma-separated list
442
+ ("@atlaskit/button,@atlaskit/textfield"). Each package is discovered and scored
443
+ separately and the results are aggregated: output then carries
444
+ "multiPackage": true, a "packages" array, and "totals" (coverage summed,
445
+ never averaged across packages), with repo-wide facts — "root",
446
+ "platform", "duplicates" — hoisted out of the per-package entries. A
447
+ one-package scan's output shape is unchanged.
448
+
449
+ Known gap for Phase 2 (not built): JS-theme-object token detection
450
+ (MUI/Chakra createTheme()/extendTheme() call-site parsing) — token source is
451
+ not reported for installed-package systems in this pass.
452
+
453
+ Two ways to skip typing a package name: "stark-cli scan-foreign" with no
454
+ system argument prints the tested-systems list (id, label, npm package,
455
+ platform) instead of running a scan — pick one's id, or fall through to any
456
+ raw npm package name for a system that isn't listed ("Other" is just
457
+ passing the package name directly, never a separate mode). "stark-cli
458
+ scan-foreign auto [path]" instead reads [path]'s (default ".") own
459
+ package.json dependency graph and matches it against that same tested list
460
+ — if it's a registered dependency already, there's no reason to make the
461
+ caller re-type what the repo itself already declares. Zero matches fails
462
+ with the tested list (nothing registered is installed — pass a system id or
463
+ raw package name explicitly); more than one match fails and lists what
464
+ matched (a repo can depend on more than one design system; "auto" only
465
+ resolves the unambiguous case) rather than guessing which one you meant.
466
+
467
+ "--report=<url>" additionally POSTs the scan to a stark.dominion tracker
468
+ (its /api/foreign-scan endpoint), which is how a foreign coverage number
469
+ gets from a consumer's CI into the dashboard — the scan has to run where
470
+ node_modules exists, so it runs there and pushes, rather than the tracker
471
+ pulling. "--target-dir=<dir>" names the tracker target the scan belongs to
472
+ and is required with it. The bearer secret is read from
473
+ STARK_DOMINION_TOKEN, or from "--token=<secret>" — prefer the environment
474
+ variable, since a flag is visible in the process list and in most CI logs.
475
+ The commit comes from GITHUB_SHA / VERCEL_GIT_COMMIT_SHA / CI_COMMIT_SHA /
476
+ BUILDKITE_COMMIT, then "git rev-parse HEAD", and can be set explicitly with
477
+ "--commit=<sha>".
478
+
479
+ Reporting never fails the build and never changes what is printed. An
480
+ unreachable tracker, a rotated token or a missing target is written to
481
+ stderr as a skipped-report line; stdout is the same JSON document either
482
+ way and the exit code is the scan's own. What gets sent is a reduced
483
+ projection of that document, not a copy of it: prop schemas are dropped
484
+ (the tracker does not read them, and re-scanning re-derives them — on a
485
+ real @mui/material scan this is 2.18 MB down to 10.6 KB), and every
486
+ absolute path is rewritten to "<root>"/"<home>" before the payload leaves
487
+ the machine. Paths are scrubbed only on the reported copy — stdout keeps
488
+ the real ones, which is what is actually useful when debugging a local
489
+ scan.
256
490
  `;
257
491
 
258
492
  const [, , command, ...rest] = process.argv;
259
493
 
494
+ async function main() {
260
495
  switch (command) {
261
496
  case 'list':
262
497
  print(listComponents());
@@ -321,7 +556,19 @@ switch (command) {
321
556
  const ignoreArg = flagValue(rest, 'ignore', '');
322
557
  const ignore = ignoreArg ? ignoreArg.split(',').map(s => s.trim()).filter(Boolean) : [];
323
558
 
559
+ const reportUrl = flagValue(rest, 'report', undefined);
560
+
324
561
  if (hasFlag(rest, 'all-targets')) {
562
+ if (reportUrl) {
563
+ // Refused rather than silently ignored. A reported scan is keyed to
564
+ // one tracker target (targets.dir + the per-target bearer secret), and
565
+ // an --all-targets sweep produces one result per discovered directory
566
+ // — there is no single dir the aggregate honestly belongs to. Filing
567
+ // it under whatever --target-dir happened to be passed would attribute
568
+ // 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.');
570
+ break;
571
+ }
325
572
  try {
326
573
  const discovery = discoverTargets(root);
327
574
  const workspacePackages = workspacePackageMap(discovery);
@@ -344,11 +591,37 @@ switch (command) {
344
591
  break;
345
592
  }
346
593
 
594
+ let adoptResult = null;
347
595
  try {
348
- print(runAdopt(root, platform, ignore));
596
+ adoptResult = runAdopt(root, platform, ignore);
597
+ print(adoptResult);
349
598
  } catch (err) {
599
+ adoptResult = null;
350
600
  fail(err.message);
351
601
  }
602
+
603
+ // Same placement and the same reasoning as scan-foreign's reporting block
604
+ // below: outside the scan's try/catch, so a fault in the reporting path
605
+ // can never reach fail() and turn a successful scan into a non-zero exit.
606
+ // Outcome goes to stderr only; stdout stays a clean JSON document whether
607
+ // or not a tracker is configured.
608
+ if (adoptResult && reportUrl) {
609
+ const token = flagValue(rest, 'token', process.env.STARK_DOMINION_TOKEN);
610
+ const outcome = await reportAdoptScan(adoptResult, {
611
+ url: reportUrl,
612
+ token,
613
+ root,
614
+ targetDir: flagValue(rest, 'target-dir', undefined),
615
+ commitSha: flagValue(rest, 'commit', resolveCommitSha(root)),
616
+ scannerVersion: SCANNER_VERSION,
617
+ }).catch((err) => ({ reported: false, reason: err.message }));
618
+
619
+ if (outcome.reported) {
620
+ console.error(`Reported to ${reportUrl} (adoption scan #${outcome.response?.id ?? '?'}).`);
621
+ } else {
622
+ console.error(`Scan complete; reporting was skipped: ${outcome.reason}`);
623
+ }
624
+ }
352
625
  break;
353
626
  }
354
627
 
@@ -364,6 +637,136 @@ switch (command) {
364
637
  break;
365
638
  }
366
639
 
640
+ case 'verify-vecna': {
641
+ const [maybePath] = rest;
642
+ if (!maybePath || maybePath.startsWith('--')) {
643
+ fail('Usage: stark-cli verify-vecna <path-to-layout.json> [--out=<dir>]');
644
+ break;
645
+ }
646
+ const layoutPath = path.resolve(process.cwd(), maybePath);
647
+ const outDir = flagValue(rest, 'out', undefined);
648
+ const slug = path.basename(layoutPath).replace(/\.layout\.json$|\.json$/, '');
649
+ try {
650
+ const layoutConfig = JSON.parse(readFileSync(layoutPath, 'utf-8'));
651
+ print(verifyVecnaLayout(layoutConfig, { outDir, slug }));
652
+ } catch (err) {
653
+ fail(err.message);
654
+ }
655
+ break;
656
+ }
657
+
658
+ case 'scan-foreign': {
659
+ const [systemArg, maybePath] = rest;
660
+ if (!systemArg) {
661
+ print({
662
+ hint: 'Pass a system id below, any raw npm package name for one not listed, or "auto" to detect from [path]\'s package.json.',
663
+ systems: listForeignSystems(),
664
+ });
665
+ break;
666
+ }
667
+ const target = maybePath && !maybePath.startsWith('--') ? maybePath : '.';
668
+ const root = path.resolve(process.cwd(), target);
669
+ const ignoreArg = flagValue(rest, 'ignore', '');
670
+ const ignore = ignoreArg ? ignoreArg.split(',').map(s => s.trim()).filter(Boolean) : [];
671
+ const platform = flagValue(rest, 'platform', 'web');
672
+ const allowNetwork = hasFlag(rest, 'allow-network');
673
+
674
+ // "auto" resolves to a real registered system id up front, then falls
675
+ // straight into the same dispatch below — it never becomes a third
676
+ // code path of its own. Ambiguous (>1) and no-match cases fail with
677
+ // the candidates/tested list rather than guessing, same "never guess
678
+ // among ambiguous candidates" discipline as the rest of adopt/.
679
+ let resolvedSystemArg = systemArg;
680
+ if (systemArg === 'auto') {
681
+ const detected = detectForeignSystems(root, { ignore });
682
+ if (detected.length === 0) {
683
+ fail(
684
+ `No tested system's dependency was found in ${root}'s package.json — pass a system id or raw npm package name explicitly. Tested systems:\n` +
685
+ listForeignSystems().map((s) => ` ${s.id} — ${s.label} (${packageSummary(s)})`).join('\n')
686
+ );
687
+ break;
688
+ }
689
+ if (detected.length > 1) {
690
+ fail(
691
+ `More than one tested system's dependency was found in ${root}'s package.json — pass one explicitly:\n` +
692
+ detected.map((d) => ` ${d.id} — ${d.label} (${packageSummary(d)})`).join('\n')
693
+ );
694
+ break;
695
+ }
696
+ resolvedSystemArg = detected[0].id;
697
+ console.error(`Auto-detected "${detected[0].label}" (${packageSummary(detected[0])}) from ${root}'s package.json dependencies.`);
698
+ }
699
+
700
+ // A registered system id (e.g. "shadcn", "mui") dispatches on its own
701
+ // "distribution" shape; anything else is treated as a raw npm package
702
+ // name, defaulting to the "installed-package" path — this is what makes
703
+ // "whatever design system" real rather than a curated list (see
704
+ // foreignSystemConfig.js's header comment).
705
+ const registered = FOREIGN_SYSTEMS[resolvedSystemArg];
706
+
707
+ const reportUrl = flagValue(rest, 'report', undefined);
708
+
709
+ let result = null;
710
+ try {
711
+ if (registered && registered.distribution === 'copy-paste') {
712
+ const discovery = resolveForeignDiscovery(root, resolvedSystemArg, { ignore });
713
+ const propSchema = resolvePropSchema(root, resolvedSystemArg, discovery.components);
714
+ const scoring = scoreForeignAdoption(root, resolvedSystemArg, discovery, { ignore });
715
+ result = { discovery, propSchema, scoring };
716
+ } else {
717
+ // A registered system contributes its declared package(s); an
718
+ // unregistered argument is a raw package name, or a comma-separated
719
+ // list of them — the same multi-package shape the registry can now
720
+ // express, kept available to systems nobody has registered, since
721
+ // this command's whole premise is "whatever design system," not a
722
+ // curated list. Commas are safe as the separator: npm names can't
723
+ // contain one.
724
+ const packages = registered
725
+ ? systemPackages(registered)
726
+ : resolvedSystemArg.split(',').map((s) => s.trim()).filter(Boolean);
727
+ result = await scanInstalledSystem(root, packages, {
728
+ platform,
729
+ ignore,
730
+ allowNetwork,
731
+ system: registered?.id ?? null,
732
+ label: registered?.label ?? null,
733
+ });
734
+ }
735
+ print(result);
736
+ } catch (err) {
737
+ fail(err.message);
738
+ }
739
+
740
+ // Reporting sits outside the scan's own try/catch on purpose. A scan that
741
+ // failed has already reported that failure and left `result` null, so
742
+ // nothing is sent; and a fault in the reporting path can never reach
743
+ // `fail()` and turn a successful scan into a non-zero exit. Its outcome
744
+ // goes to stderr only, so stdout stays a clean JSON document whether or
745
+ // not a tracker is configured. See foreignScanReport.js's header for why
746
+ // never breaking the consumer's CI is the governing constraint.
747
+ if (result && reportUrl) {
748
+ // The token is read from the environment by default: a secret passed as
749
+ // a flag is visible in the process list and in most CI logs.
750
+ const token = flagValue(rest, 'token', process.env.STARK_DOMINION_TOKEN);
751
+ const outcome = await reportForeignScan(result, {
752
+ url: reportUrl,
753
+ token,
754
+ root,
755
+ targetDir: flagValue(rest, 'target-dir', undefined),
756
+ commitSha: flagValue(rest, 'commit', resolveCommitSha(root)),
757
+ scannerVersion: SCANNER_VERSION,
758
+ label: registered?.label ?? null,
759
+ }).catch((err) => ({ reported: false, reason: err.message }));
760
+
761
+ if (outcome.reported) {
762
+ console.error(`Reported to ${reportUrl} (foreign scan #${outcome.response?.id ?? '?'}).`);
763
+ } else {
764
+ console.error(`Scan complete; reporting was skipped: ${outcome.reason}`);
765
+ }
766
+ }
767
+ break;
768
+ }
769
+
367
770
  case undefined:
368
771
  case '--help':
369
772
  case '-h':
@@ -374,3 +777,6 @@ switch (command) {
374
777
  default:
375
778
  fail(`Unknown command "${command}".\n\n${HELP}`);
376
779
  }
780
+ }
781
+
782
+ main();
package/src/server.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+
1
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
4
  import { z } from 'zod';
3
5
 
@@ -12,10 +14,10 @@ import {
12
14
  getGenerationProtocol,
13
15
  } from './data.js';
14
16
 
15
- const json = value => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
16
- const error = message => ({ content: [{ type: 'text', text: message }], isError: true });
17
+ const json = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
18
+ const error = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
17
19
 
18
- const wrap = fn => async args => {
20
+ const wrap = (fn) => async (args) => {
19
21
  try {
20
22
  return json(await fn(args));
21
23
  } catch (err) {
@@ -23,8 +25,16 @@ const wrap = fn => async args => {
23
25
  }
24
26
  };
25
27
 
28
+ // Read rather than written down: a hand-maintained literal here goes stale on
29
+ // the first release nobody remembers to update it in, and no client ever
30
+ // complains loudly enough to notice — it shipped as 0.1.0 alongside the
31
+ // package's own 0.2.0. cli.js already derives SCANNER_VERSION this way.
32
+ const VERSION = JSON.parse(
33
+ readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
34
+ ).version;
35
+
26
36
  export function createServer() {
27
- const server = new McpServer({ name: 'stark-design-system', version: '0.1.0' });
37
+ const server = new McpServer({ name: 'stark-design-system', version: VERSION });
28
38
 
29
39
  server.registerTool(
30
40
  'list_components',
@@ -46,7 +56,9 @@ export function createServer() {
46
56
  'Get the dos/donts, prop table, and Figma link for one Stark component. ' +
47
57
  'Read this before placing the component in a layout.',
48
58
  inputSchema: {
49
- component: z.string().describe('Component name, e.g. "Button" or "TextInput" (case-insensitive)'),
59
+ component: z
60
+ .string()
61
+ .describe('Component name, e.g. "Button" or "TextInput" (case-insensitive)'),
50
62
  },
51
63
  },
52
64
  wrap(({ component }) => getComponentUsage(component))
@@ -71,7 +83,9 @@ export function createServer() {
71
83
  ),
72
84
  },
73
85
  },
74
- wrap(({ component, platform, includeTokens }) => getComponentProps(component, platform, includeTokens))
86
+ wrap(({ component, platform, includeTokens }) =>
87
+ getComponentProps(component, platform, includeTokens)
88
+ )
75
89
  );
76
90
 
77
91
  server.registerTool(
@@ -93,7 +107,7 @@ export function createServer() {
93
107
  title: 'Get the full layout catalog',
94
108
  description:
95
109
  'Get every component that can be used as a standalone layout node, with its props ' +
96
- 'and slots. This is the same catalog Stark\'s own layout generator (Vecna) is restricted to.',
110
+ "and slots. This is the same catalog Stark's own layout generator (Vecna) is restricted to.",
97
111
  inputSchema: {
98
112
  platform: z.enum(['web', 'native']).default('web').describe('Target platform'),
99
113
  },
@@ -104,7 +118,7 @@ export function createServer() {
104
118
  server.registerTool(
105
119
  'get_layout_schema',
106
120
  {
107
- title: 'Get one component\'s layout schema',
121
+ title: "Get one component's layout schema",
108
122
  description: 'Get the props/slots schema for a single layout-capable component.',
109
123
  inputSchema: {
110
124
  component: z.string().describe('Component name, e.g. "Card"'),
@@ -119,18 +133,28 @@ export function createServer() {
119
133
  {
120
134
  title: 'Validate a generated layout',
121
135
  description:
122
- 'Run Stark\'s deterministic conformance checks (no LLM) against a LayoutConfig JSON: ' +
136
+ "Run Stark's deterministic conformance checks (no LLM) against a LayoutConfig JSON: " +
123
137
  'catalog membership, component do/dont rules, guardrails, and prop shape. Returns findings ' +
124
- 'ranked Critical/Warning/Info. Pass `canvasCases` (your renderer\'s supported node-type list) ' +
138
+ "ranked Critical/Warning/Info. Pass `canvasCases` (your renderer's supported node-type list) " +
125
139
  'if you have one — without it, the catalog<->renderer parity check is skipped.',
126
140
  inputSchema: {
127
- layout: z.record(z.string(), z.any()).describe('The LayoutConfig JSON, shaped { page: { sections: [...] } }'),
128
- intent: z.record(z.string(), z.any()).optional().describe('Intent/guardrails object the layout was generated from'),
141
+ layout: z
142
+ .record(z.string(), z.any())
143
+ .describe('The LayoutConfig JSON, shaped { page: { sections: [...] } }'),
144
+ intent: z
145
+ .record(z.string(), z.any())
146
+ .optional()
147
+ .describe('Intent/guardrails object the layout was generated from'),
129
148
  platform: z.enum(['web', 'native']).default('web'),
130
- canvasCases: z.array(z.string()).optional().describe('Node types your renderer actually supports'),
149
+ canvasCases: z
150
+ .array(z.string())
151
+ .optional()
152
+ .describe('Node types your renderer actually supports'),
131
153
  },
132
154
  },
133
- wrap(({ layout, intent, platform, canvasCases }) => validateLayout({ layout, intent, platform, canvasCases }))
155
+ wrap(({ layout, intent, platform, canvasCases }) =>
156
+ validateLayout({ layout, intent, platform, canvasCases })
157
+ )
134
158
  );
135
159
 
136
160
  server.registerTool(
@@ -1,21 +0,0 @@
1
- import React from 'react';
2
- import { Button } from '@starklab/stk-components';
3
- import { AppButton } from '../wrappers/AppButton';
4
- import { BrandButton } from '../wrappers/BrandButton';
5
- import { FeatureCard } from '../wrappers/FeatureCard';
6
-
7
- export function Home() {
8
- return (
9
- <div className="dark:bg-primary">
10
- <AppButton>Save</AppButton>
11
- <BrandButton>Buy now</BrandButton>
12
- <BrandButton>Learn more</BrandButton>
13
- <BrandButton>Get started</BrandButton>
14
- {/* Direct (non-wrapper) catalog call site — invalid enum value plus a
15
- className passthrough, exercising resolvePropApi's two checkable
16
- rules against the real button.mapping.json data. */}
17
- <Button variant="primry" className="cta-override">Go</Button>
18
- <FeatureCard content={<p>Feature</p>} />
19
- </div>
20
- );
21
- }
@@ -1,13 +0,0 @@
1
- import React from 'react';
2
- import { DropdownMenu } from '@starklab/stk-components';
3
-
4
- // Compound Foo.Bar usage — <DropdownMenu.SubTrigger/> is a JSXMemberExpression
5
- // and must attribute back to the DropdownMenu catalog entry, not go unmatched
6
- // as a component literally named "DropdownMenu.SubTrigger".
7
- export function Menu() {
8
- return (
9
- <DropdownMenu>
10
- <DropdownMenu.SubTrigger>Open</DropdownMenu.SubTrigger>
11
- </DropdownMenu>
12
- );
13
- }
@@ -1,11 +0,0 @@
1
- import React from 'react';
2
- import { BrandButton } from '../wrappers/BrandButton';
3
-
4
- export function Profile() {
5
- return (
6
- <div>
7
- <BrandButton>Edit</BrandButton>
8
- <BrandButton>Delete</BrandButton>
9
- </div>
10
- );
11
- }