@starklab/stark-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/package.json +31 -0
  4. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
  5. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
  6. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
  7. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
  8. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
  9. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
  10. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
  11. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
  12. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
  13. package/src/adopt/catalog.js +88 -0
  14. package/src/adopt/dominionFixture.test.js +165 -0
  15. package/src/adopt/moduleGraph.js +232 -0
  16. package/src/adopt/parseSource.js +25 -0
  17. package/src/adopt/propApiResolver.js +278 -0
  18. package/src/adopt/propApiResolver.test.js +229 -0
  19. package/src/adopt/referenceResolver.js +151 -0
  20. package/src/adopt/referenceResolver.test.js +213 -0
  21. package/src/adopt/rnTailwindResolver.js +347 -0
  22. package/src/adopt/rnTailwindResolver.test.js +263 -0
  23. package/src/adopt/rnTokenAliasResolver.js +474 -0
  24. package/src/adopt/rnTokenAliasResolver.test.js +260 -0
  25. package/src/adopt/tailwindResolver.js +512 -0
  26. package/src/adopt/tailwindResolver.test.js +178 -0
  27. package/src/adopt/targetDiscovery.js +237 -0
  28. package/src/adopt/targetDiscovery.test.js +227 -0
  29. package/src/adopt/tokenAliasResolver.js +513 -0
  30. package/src/adopt/tokenAliasResolver.test.js +319 -0
  31. package/src/adopt/wrapperResolver.js +874 -0
  32. package/src/adopt/wrapperResolver.test.js +324 -0
  33. package/src/cli.js +376 -0
  34. package/src/data.js +267 -0
  35. package/src/data.test.js +231 -0
  36. package/src/index.js +8 -0
  37. package/src/server.js +149 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alessandro Giordano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @starklab/stark-mcp
2
+
3
+ MCP server that exposes the Stark design system — component catalog, usage
4
+ rules, prop mappings, layout schemas, and deterministic layout conformance
5
+ checks — to any MCP-compatible coding agent, without needing the
6
+ `stark-workspace` monorepo checked out.
7
+
8
+ This is a companion to (not a replacement for) the Figma MCP bridge used
9
+ elsewhere in this workspace: the Figma bridge talks to the *live Figma file*
10
+ (variables, nodes, component properties); this server talks to the
11
+ *published code/token contract* (the JSON already checked into
12
+ `@starklab/stk`). A client can have both connected, but generating
13
+ or validating UI only needs this one.
14
+
15
+ ## Usage
16
+
17
+ Add to your MCP client config (e.g. `.mcp.json`):
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "stark": {
23
+ "command": "npx",
24
+ "args": ["-y", "@starklab/stark-mcp"]
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ ## Tools
31
+
32
+ | Tool | Wraps | Returns |
33
+ |---|---|---|
34
+ | `list_components` | `prop-mapping/components/*.mapping.json` + `usage/components/*.usage.json` | name, slug, status, description, platforms |
35
+ | `get_component_usage` | `usage/loader.js` (`loadUsage`) | dos/donts, props, figmaUrl, status |
36
+ | `get_component_props` | `prop-mapping/components/{c}.mapping.json` (+ `tokens/components/{c}.json` when `includeTokens` is set) | React↔Figma `propMap` for a platform, optionally the component's token JSON |
37
+ | `get_manifest` | all of the above, aggregated | usage + props (per platform) + tokens for every component in one call |
38
+ | `get_layout_catalog` | `conformance/catalog.js` (`buildCatalogFromDir`) | every layout-capable component's props/slots |
39
+ | `get_layout_schema` | same, filtered to one component | one component's layoutSchema |
40
+ | `validate_layout` | `conformance/index.js` (`runConformance`) | deterministic findings (`Critical`/`Warning`/`Info`), no LLM involved |
41
+ | `get_generation_protocol` | `generation-protocol.json` | the enforced step checklist Vecna runs |
42
+
43
+ ## CLI
44
+
45
+ The same catalog is also available from a terminal, without an MCP client:
46
+
47
+ ```bash
48
+ npx --package=@starklab/stark-mcp stark-cli list
49
+ npx --package=@starklab/stark-mcp stark-cli usage Button
50
+ npx --package=@starklab/stark-mcp stark-cli props Button --platform=web --tokens
51
+ npx --package=@starklab/stark-mcp stark-cli manifest
52
+ npx --package=@starklab/stark-mcp stark-cli eject Button --out=./src/stark-eject/Button
53
+ npx --package=@starklab/stark-mcp stark-cli eject Button --platform=native
54
+ ```
55
+
56
+ The package name and the `stark-mcp` bin match, so plain `npx
57
+ @starklab/stark-mcp` runs the MCP server by default — `--package=`
58
+ is what selects the second (`stark-cli`) bin instead. Once installed locally
59
+ or globally, just run `stark-cli <command>`. Run `stark-cli --help` for the
60
+ full command list. Output is always JSON on stdout; errors go to stderr with
61
+ a non-zero exit code.
62
+
63
+ ## Public manifest
64
+
65
+ `npm run generate-manifest` writes the full catalog (usage + props + tokens
66
+ for every component) to `manifest.json` inside the installed
67
+ `@starklab/stk` package. Run it before publishing a new version of
68
+ `stk` so the manifest ships with it and stays fetchable as a static file —
69
+ e.g. via a CDN mirror of the npm package — without needing an MCP session at
70
+ all. It's not checked into git (same as `stk`'s `build/` output); regenerate
71
+ it from source on demand.
72
+
73
+ ## Eject a component
74
+
75
+ `stark-cli eject <Component>` copies one component's source out of your
76
+ installed component package into your own project — an escape hatch for the
77
+ rare case where a component's token/prop API genuinely can't express what you
78
+ need. The copy is yours to edit freely; it stops receiving upstream fixes for
79
+ that component.
80
+
81
+ `--platform=web` (default) copies `.jsx` + `.css` from
82
+ `@starklab/stk-components`. `--platform=native` copies the `.jsx`
83
+ (RN components have no separate stylesheet file — styles are inline
84
+ `StyleSheet.create()`) from `@starklab/stk-react-native`. Either
85
+ way, the source package must be installed in the project you run it from
86
+ (resolved from your current directory, not from `stark-mcp`'s own
87
+ dependencies) — and the target component must actually support the platform
88
+ you ask for.
89
+
90
+ Defaults to `./stark-eject/<Component>`; pass `--out=<dir>` to change it, and
91
+ `--force` to overwrite a non-empty target.
92
+
93
+ ## Known limitation
94
+
95
+ `validate_layout` accepts an optional `canvasCases` argument — the list of
96
+ node types your renderer actually supports. Stark's own catalog↔renderer
97
+ parity check (`checkCatalogRendererParity`) is skipped when it's omitted,
98
+ since an external caller's renderer isn't `LayoutCanvas.jsx`. Pass your own
99
+ supported-type list if you have an equivalent check to run.
100
+
101
+ ## Prerequisite
102
+
103
+ Requires `@starklab/stk` to ship `prop-mapping/`, `conformance/`,
104
+ and `generation-protocol.json` in its published `files` — added in this same
105
+ change (previously only `build/`, `tokens/`, `usage/`, and `layout-rules/`
106
+ were published, so this server would have had no data to read once actually
107
+ installed from the npm registry rather than resolved via the workspace
108
+ symlink).
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@starklab/stark-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing the Stark design system catalog, usage rules, prop mappings, and layout conformance checks to any MCP-compatible coding agent — without needing the stark-workspace monorepo checked out.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "stark-mcp": "src/index.js",
9
+ "stark-cli": "src/cli.js"
10
+ },
11
+ "main": "./src/server.js",
12
+ "files": [
13
+ "src/"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "start": "node src/index.js",
20
+ "generate-manifest": "node scripts/generate-manifest.js"
21
+ },
22
+ "dependencies": {
23
+ "@starklab/stk": "^1.1.0",
24
+ "@babel/parser": "^7.29.7",
25
+ "@babel/traverse": "^7.29.7",
26
+ "@modelcontextprotocol/sdk": "^1.29.0",
27
+ "fast-glob": "^3.3.3",
28
+ "postcss": "^8.5.25",
29
+ "zod": "^4.0.0"
30
+ }
31
+ }
@@ -0,0 +1,21 @@
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
+ }
@@ -0,0 +1,13 @@
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
+ }
@@ -0,0 +1,11 @@
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
+ }
@@ -0,0 +1,34 @@
1
+ :root {
2
+ /* nested aliases — app-base -> app-mid -> app-top -> app-deep, terminal
3
+ depth 4 (deeper than tokenAliasResolver's WARN_DEPTH of 3), exercising
4
+ both transitive resolution and the deep-alias-chain finding */
5
+ --app-base: var(--stk-surface-brand-1-strong);
6
+ --app-mid: var(--app-base);
7
+ --app-top: var(--app-mid);
8
+ --app-deep: var(--app-top);
9
+
10
+ /* raw fallback — resolves conformant, still flagged info-level since the
11
+ fallback silently becomes load-bearing the day
12
+ --stk-surface-brand-1-strong is renamed */
13
+ --app-cta: var(--stk-surface-brand-1-strong, #1956dd);
14
+
15
+ /* primitive alias — layer violation: a consumer property pointing straight
16
+ at a base/primitive token instead of a semantic one */
17
+ --app-gap: var(--stk-spacing-md);
18
+
19
+ /* scoped-override target — conformant at :root */
20
+ --app-accent: var(--stk-surface-brand-1-strong);
21
+ }
22
+
23
+ /* scoped override — same property, different selector, resolving to a raw
24
+ hex under .theme-promo: :root and .theme-promo disagree, so the property
25
+ overall is partial-conformance rather than collapsed to one scope */
26
+ .theme-promo {
27
+ --app-accent: #1956dd;
28
+ }
29
+
30
+ /* Tailwind v4 theme entry — Home.jsx's className="dark:bg-primary" resolves
31
+ through this back to a Stark token */
32
+ @theme {
33
+ --color-primary: var(--stk-surface-brand-1-strong);
34
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { Button } from '@starklab/stk-components';
3
+
4
+ // Transparent wrapper — spreads props through unmodified, no hardcoded or
5
+ // style-only attributes.
6
+ export function AppButton(props) {
7
+ return <Button {...props} />;
8
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { Button } from '@starklab/stk-components';
3
+
4
+ // Divergent wrapper — a hardcoded className makes this a styling override,
5
+ // not a faithful passthrough. Used from several call sites (see
6
+ // pages/Home.jsx and pages/Profile.jsx) to exercise high fanout.
7
+ export function BrandButton(props) {
8
+ return <Button className="brand-button" {...props} />;
9
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { Card } from '@starklab/stk-components';
3
+
4
+ // First hop of a two-level wrapper chain (see FeatureCard.jsx) — transparent
5
+ // passthrough. Only consumed internally by FeatureCard, so its own external
6
+ // fanout should be zero (the chain edge itself is excluded from fanout).
7
+ export function CardBase(props) {
8
+ return <Card {...props} />;
9
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { CardBase } from './CardBase';
3
+
4
+ // Second hop of the chain — resolves through CardBase down to Card.
5
+ export function FeatureCard(props) {
6
+ return <CardBase {...props} />;
7
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { Card } from '@starklab/stk-components';
3
+
4
+ // An "as"-polymorphic call site. ADOPTION_APP_PLAN.md §4 "Polymorphism" calls
5
+ // for a declared `polymorphicProp` field in prop-mapping/schema.json as
6
+ // future work — no dedicated `as`-detection exists in wrapperResolver.js yet,
7
+ // so a hardcoded `as` attribute is classified through the ordinary
8
+ // hardcodedProps -> "constraining" path, same as any other literal prop.
9
+ // This fixture documents that current (pre-declaration) behavior.
10
+ export function SectionCard(props) {
11
+ return <Card as="section" {...props} />;
12
+ }
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { stkRoot } from '../data.js';
5
+
6
+ // catalog.json calls the RN platform "rn"; every other surface in this repo
7
+ // (getComponentProps, ejectComponent, prop-mapping "platforms" arrays) calls
8
+ // it "native". The adopt module speaks the latter to stay consistent with the
9
+ // rest of stark-mcp's public API and translates internally.
10
+ const CATALOG_KEY = { web: 'web', native: 'rn' };
11
+
12
+ const PACKAGE_NAME = {
13
+ web: '@starklab/stk-components',
14
+ native: '@starklab/stk-react-native',
15
+ };
16
+
17
+ /**
18
+ * Reads a flat re-export barrel (`export { A, B } from './x';` lines only)
19
+ * and returns the exported symbol names. Mirrors
20
+ * packages/stk/scripts/reconcile-catalog.js's readBarrel — existence is
21
+ * derived from the barrel and nowhere else, so this resolver's denominator
22
+ * can never drift from what reconcile-catalog.js already enforces in CI.
23
+ */
24
+ function readBarrel(file) {
25
+ const src = readFileSync(file, 'utf-8');
26
+ const names = [];
27
+ for (const line of src.split('\n')) {
28
+ const bare = line.trim();
29
+ if (!bare || bare.startsWith('//') || bare.startsWith('*') || bare.startsWith('/*')) continue;
30
+ const m = bare.match(/^export\s*\{([^}]+)\}\s*from\s*['"][^'"]+['"];?$/);
31
+ if (!m) continue;
32
+ for (const part of m[1].split(',')) {
33
+ const name = part.trim().split(/\s+as\s+/).pop().trim();
34
+ if (name) names.push(name);
35
+ }
36
+ }
37
+ return names;
38
+ }
39
+
40
+ const toSlug = (s) =>
41
+ String(s)
42
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
43
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
44
+ .toLowerCase();
45
+
46
+ /**
47
+ * The catalog denominator for one platform: every barrel export whose
48
+ * catalog.json kind is "component" (the only kind that's countable — see
49
+ * catalog.json's own $comment and ADOPTION_APP_PLAN.md §2). Never derive
50
+ * this list from a scan; the reference resolver left-joins scan counts onto
51
+ * this list so zero-usage components show up as 0, not as absent (§4 blind
52
+ * spot 1).
53
+ */
54
+ export function loadCatalog(platform = 'web') {
55
+ const catalogKey = CATALOG_KEY[platform];
56
+ if (!catalogKey) {
57
+ throw new Error(`Unsupported platform "${platform}". Available: ${Object.keys(CATALOG_KEY).join(', ')}.`);
58
+ }
59
+
60
+ const root = stkRoot();
61
+ const manifest = JSON.parse(readFileSync(path.join(root, 'catalog.json'), 'utf-8'));
62
+ const cfg = manifest.platforms[catalogKey];
63
+ if (!cfg) {
64
+ throw new Error(`catalog.json has no "${catalogKey}" platform entry.`);
65
+ }
66
+
67
+ const barrelPath = path.resolve(root, cfg.barrel);
68
+ const exported = readBarrel(barrelPath);
69
+ const classified = cfg.exports ?? {};
70
+
71
+ const components = exported
72
+ .filter((name) => classified[name]?.kind === 'component')
73
+ .map((name) => ({ name, slug: toSlug(name) }));
74
+
75
+ return {
76
+ platform,
77
+ package: PACKAGE_NAME[platform],
78
+ components,
79
+ };
80
+ }
81
+
82
+ export function packageNameForPlatform(platform) {
83
+ const name = PACKAGE_NAME[platform];
84
+ if (!name) {
85
+ throw new Error(`Unsupported platform "${platform}". Available: ${Object.keys(PACKAGE_NAME).join(', ')}.`);
86
+ }
87
+ return name;
88
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { resolveReferences } from './referenceResolver.js';
6
+ import { resolveWrappers } from './wrapperResolver.js';
7
+ import { resolveTokenAliases } from './tokenAliasResolver.js';
8
+ import { resolveTailwindTokens } from './tailwindResolver.js';
9
+ import { resolvePropApi } from './propApiResolver.js';
10
+
11
+ // This fixture app (__fixtures__/dominion-fixture-app/) is the shared
12
+ // consumer app ADOPTION_APP_PLAN.md's Phase 1 roadmap calls for — one app
13
+ // exercising every element the plan names (a theme file, nested aliases, a
14
+ // scoped override, a raw fallback, a primitive alias, a Tailwind variant, a
15
+ // transparent wrapper, a divergent high-fanout wrapper, a two-level wrapper
16
+ // chain, an "as"-polymorphic call site, a compound Foo.Bar usage, a direct
17
+ // invalid-enum-value call site, and a className passthrough) run through all
18
+ // five web resolvers together — treated as their test suite.
19
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'dominion-fixture-app');
20
+
21
+ describe('dominion fixture app — resolveReferences', () => {
22
+ it('attributes direct and compound-tag usages to the right catalog components', () => {
23
+ const result = resolveReferences(root, { platform: 'web' });
24
+ const byName = new Map(result.components.map((c) => [c.name, c]));
25
+
26
+ expect(byName.get('Button').counts.jsx).toBe(3); // AppButton.jsx, BrandButton.jsx, Home.jsx
27
+ expect(byName.get('Card').counts.jsx).toBe(2); // CardBase.jsx, SectionCard.jsx
28
+ // outer <DropdownMenu> opening tag + <DropdownMenu.SubTrigger>'s object ref
29
+ expect(byName.get('DropdownMenu').counts.jsx).toBe(2);
30
+ });
31
+ });
32
+
33
+ describe('dominion fixture app — resolveWrappers', () => {
34
+ it('classifies faithfulness, chain depth, and fanout for every wrapper', () => {
35
+ const result = resolveWrappers(root, { platform: 'web' });
36
+ const byExportedAs = new Map(result.wrappers.map((w) => [w.exportedAs, w]));
37
+
38
+ const appButton = byExportedAs.get('AppButton');
39
+ expect(appButton.component).toBe('Button');
40
+ expect(appButton.chain[0].faithfulness).toBe('transparent');
41
+ expect(appButton.depth).toBe(1);
42
+
43
+ const brandButton = byExportedAs.get('BrandButton');
44
+ expect(brandButton.component).toBe('Button');
45
+ expect(brandButton.chain[0].faithfulness).toBe('divergent');
46
+ expect(brandButton.fanout.counts.jsx).toBe(5); // 3x Home.jsx + 2x Profile.jsx
47
+
48
+ const cardBase = byExportedAs.get('CardBase');
49
+ expect(cardBase.component).toBe('Card');
50
+ expect(cardBase.chain[0].faithfulness).toBe('transparent');
51
+ // Only consumer is FeatureCard's own chain edge — excluded from fanout.
52
+ expect(cardBase.fanout.counts.jsx).toBe(0);
53
+
54
+ const featureCard = byExportedAs.get('FeatureCard');
55
+ expect(featureCard.component).toBe('Card');
56
+ expect(featureCard.depth).toBe(2);
57
+ expect(featureCard.warnDepth).toBe(false);
58
+ expect(featureCard.fanout.counts.jsx).toBe(1); // Home.jsx
59
+
60
+ const sectionCard = byExportedAs.get('SectionCard');
61
+ expect(sectionCard.component).toBe('Card');
62
+ // No dedicated `as`-polymorphism detection yet (ADOPTION_APP_PLAN.md §4)
63
+ // — a hardcoded `as` attribute takes the generic hardcodedProps path.
64
+ expect(sectionCard.chain[0].faithfulness).toBe('constraining');
65
+
66
+ const byComponent = new Map(result.byComponent.map((c) => [c.name, c]));
67
+ expect(byComponent.get('Card').viaWrappers.map((w) => w.exportedAs).sort()).toEqual([
68
+ 'CardBase',
69
+ 'FeatureCard',
70
+ 'SectionCard',
71
+ ]);
72
+ expect(byComponent.get('Button').viaWrappers.map((w) => w.exportedAs).sort()).toEqual([
73
+ 'AppButton',
74
+ 'BrandButton',
75
+ ]);
76
+ });
77
+ });
78
+
79
+ describe('dominion fixture app — resolveTokenAliases', () => {
80
+ it('resolves nested aliases, flags deep chains, layer violations, raw fallbacks, and partial conformance', () => {
81
+ const result = resolveTokenAliases(root, { platform: 'web' });
82
+ const byName = new Map(result.properties.map((p) => [p.name, p]));
83
+
84
+ expect(byName.get('--app-base').state).toBe('conformant');
85
+ expect(byName.get('--app-mid').state).toBe('conformant');
86
+ expect(byName.get('--app-top').state).toBe('conformant');
87
+ expect(byName.get('--app-deep').state).toBe('conformant');
88
+
89
+ const deepChain = result.findings.find((f) => f.rule === 'deep-alias-chain' && f.property === '--app-deep');
90
+ expect(deepChain).toBeDefined();
91
+ expect(deepChain.depth).toBe(4);
92
+ // Shallower hops in the same chain stay under WARN_DEPTH (3).
93
+ expect(result.findings.some((f) => f.rule === 'deep-alias-chain' && f.property === '--app-top')).toBe(false);
94
+
95
+ const rawFallback = result.findings.find((f) => f.rule === 'raw-fallback' && f.property === '--app-cta');
96
+ expect(rawFallback).toBeDefined();
97
+
98
+ const layerViolation = result.findings.find((f) => f.rule === 'layer-violation' && f.property === '--app-gap');
99
+ expect(layerViolation).toBeDefined();
100
+ expect(layerViolation.severity).toBe('critical');
101
+
102
+ expect(byName.get('--app-accent').state).toBe('partial-conformance');
103
+ expect(
104
+ result.findings.some(
105
+ (f) => f.rule === 'drift-behind-alias' && f.property === '--app-accent' && f.selector === '.theme-promo'
106
+ )
107
+ ).toBe(true);
108
+ expect(result.findings.some((f) => f.rule === 'partial-conformance' && f.property === '--app-accent')).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('dominion fixture app — resolveTailwindTokens', () => {
113
+ it('resolves a v4 @theme entry through a variant-prefixed utility class back to a Stark token', () => {
114
+ const result = resolveTailwindTokens(root, { platform: 'web' });
115
+
116
+ expect(result.detected).toBe(true);
117
+ expect(result.source).toBe('v4');
118
+
119
+ const colorPrimary = result.properties.find((p) => p.name === '--color-primary');
120
+ expect(colorPrimary.state).toBe('conformant');
121
+
122
+ const usage = result.usages.find((u) => u.className === 'dark:bg-primary');
123
+ expect(usage).toBeDefined();
124
+ expect(usage.classification).toBe('aliased');
125
+ expect(result.report.aliased).toBeGreaterThanOrEqual(1);
126
+ });
127
+ });
128
+
129
+ describe('dominion fixture app — resolvePropApi', () => {
130
+ it('flags an invalid enum value and every className/style passthrough against real button/card mapping data', () => {
131
+ const result = resolvePropApi(root, { platform: 'web' });
132
+
133
+ // Only one literal enum value appears anywhere in the fixture app
134
+ // (Home.jsx's `variant="primry"`) — everything else is either a spread
135
+ // passthrough (AppButton.jsx, CardBase.jsx), an untracked attribute
136
+ // (`as` on SectionCard.jsx's Card, absent from both propMap and ignore),
137
+ // or has no attributes at all (Menu.jsx's DropdownMenu tags).
138
+ expect(result.report).toEqual({ total: 1, valid: 0, validPct: 0, invalid: 1, invalidPct: 100, unresolved: 0, unresolvedPct: 0 });
139
+
140
+ const enumFinding = result.findings.find((f) => f.rule === 'invalid-enum-value');
141
+ expect(enumFinding).toMatchObject({
142
+ component: 'Button',
143
+ prop: 'variant',
144
+ value: 'primry',
145
+ allowed: ['primary', 'ghost', 'outline', 'danger'],
146
+ severity: 'critical',
147
+ file: 'src/pages/Home.jsx',
148
+ });
149
+
150
+ // className shows up on two real Button call sites — BrandButton.jsx's
151
+ // hardcoded wrapper prop and Home.jsx's own direct passthrough.
152
+ const styleFindings = result.findings.filter((f) => f.rule === 'style-escape-hatch');
153
+ expect(styleFindings.map((f) => f.file).sort()).toEqual([
154
+ 'src/pages/Home.jsx',
155
+ 'src/wrappers/BrandButton.jsx',
156
+ ]);
157
+ expect(styleFindings.every((f) => f.component === 'Button' && f.prop === 'className' && f.severity === 'warning')).toBe(true);
158
+
159
+ // SectionCard.jsx's hardcoded `as="section"` is neither in Card's
160
+ // propMap nor its ignore list — absence of evidence, never a violation.
161
+ expect(result.findings.some((f) => f.file === 'src/wrappers/SectionCard.jsx')).toBe(false);
162
+
163
+ expect(result.findings).toHaveLength(3);
164
+ });
165
+ });