@starklab/stark-mcp 0.1.0 → 0.2.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 (40) 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/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  31. package/src/adopt/dominionFixture.test.js +0 -165
  32. package/src/adopt/propApiResolver.test.js +0 -229
  33. package/src/adopt/referenceResolver.test.js +0 -213
  34. package/src/adopt/rnTailwindResolver.test.js +0 -263
  35. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  36. package/src/adopt/tailwindResolver.test.js +0 -178
  37. package/src/adopt/targetDiscovery.test.js +0 -227
  38. package/src/adopt/tokenAliasResolver.test.js +0 -319
  39. package/src/adopt/wrapperResolver.test.js +0 -324
  40. package/src/data.test.js +0 -231
@@ -1,165 +0,0 @@
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
- });
@@ -1,229 +0,0 @@
1
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- import { describe, it, expect, afterEach } from 'vitest';
6
- import traverseModule from '@babel/traverse';
7
-
8
- import { evaluateCallSite, resolvePropApi } from './propApiResolver.js';
9
- import { parseSource } from './parseSource.js';
10
-
11
- const traverse = traverseModule.default ?? traverseModule;
12
-
13
- // Parses a single-element JSX snippet and returns its opening element's
14
- // attribute nodes — the same shape resolvePropApi's own traversal passes
15
- // into evaluateCallSite, so these unit tests exercise the exact input shape
16
- // without needing a real mapping file or a scanned consumer app on disk.
17
- function attrsFor(jsx) {
18
- const ast = parseSource(`const El = () => (${jsx});`, 'snippet.jsx');
19
- let attributes = null;
20
- traverse(ast, {
21
- JSXOpeningElement(p) {
22
- if (attributes === null) attributes = p.node.attributes;
23
- },
24
- });
25
- return attributes;
26
- }
27
-
28
- const ENUM_PROP_MAP = [
29
- { react: 'variant', type: 'enum', values: { primary: 'primary', ghost: 'ghost' } },
30
- ];
31
-
32
- let tmpDirs = [];
33
-
34
- afterEach(() => {
35
- for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true });
36
- tmpDirs = [];
37
- });
38
-
39
- function fixture(files) {
40
- const root = mkdtempSync(path.join(os.tmpdir(), 'stark-adopt-propapi-'));
41
- tmpDirs.push(root);
42
- for (const [rel, content] of Object.entries(files)) {
43
- const full = path.join(root, rel);
44
- mkdirSync(path.dirname(full), { recursive: true });
45
- writeFileSync(full, content);
46
- }
47
- return root;
48
- }
49
-
50
- describe('evaluateCallSite — enum values', () => {
51
- it('classifies a literal value inside the declared enum as valid', () => {
52
- const { checks, findings } = evaluateCallSite({
53
- attributes: attrsFor('<Button variant="primary" />'),
54
- propMap: ENUM_PROP_MAP,
55
- component: 'Button',
56
- });
57
- expect(checks).toEqual([{ component: 'Button', prop: 'variant', rule: 'enum-value', classification: 'valid', value: 'primary', file: undefined, line: undefined }]);
58
- expect(findings).toEqual([]);
59
- });
60
-
61
- it('classifies a literal value outside the declared enum as invalid, with an invalid-enum-value finding', () => {
62
- const { checks, findings } = evaluateCallSite({
63
- attributes: attrsFor('<Button variant="primry" />'),
64
- propMap: ENUM_PROP_MAP,
65
- component: 'Button',
66
- });
67
- expect(checks[0].classification).toBe('invalid');
68
- expect(findings).toEqual([
69
- { rule: 'invalid-enum-value', severity: 'critical', component: 'Button', prop: 'variant', value: 'primry', allowed: ['primary', 'ghost'], file: undefined, line: undefined },
70
- ]);
71
- });
72
-
73
- it('classifies a dynamic expression as unresolved, never valid or invalid', () => {
74
- const { checks, findings } = evaluateCallSite({
75
- attributes: attrsFor('<Button variant={someVar} />'),
76
- propMap: ENUM_PROP_MAP,
77
- component: 'Button',
78
- });
79
- expect(checks[0].classification).toBe('unresolved');
80
- expect(findings).toEqual([]);
81
- });
82
-
83
- it('also treats a template literal or ternary as unresolved', () => {
84
- for (const jsx of ['<Button variant={`primary`} />', '<Button variant={cond ? "primary" : "ghost"} />']) {
85
- const { checks } = evaluateCallSite({ attributes: attrsFor(jsx), propMap: ENUM_PROP_MAP, component: 'Button' });
86
- expect(checks[0].classification).toBe('unresolved');
87
- }
88
- });
89
- });
90
-
91
- describe('evaluateCallSite — required props', () => {
92
- const REQUIRED_PROP_MAP = [{ react: 'label', type: 'text', required: true }];
93
-
94
- it('flags a missing required prop as invalid, with a required-prop-missing finding', () => {
95
- const { checks, findings } = evaluateCallSite({
96
- attributes: attrsFor('<Field />'),
97
- propMap: REQUIRED_PROP_MAP,
98
- component: 'Field',
99
- });
100
- expect(checks).toEqual([{ component: 'Field', prop: 'label', rule: 'required-prop', classification: 'invalid', file: undefined, line: undefined }]);
101
- expect(findings).toEqual([{ rule: 'required-prop-missing', severity: 'critical', component: 'Field', prop: 'label', file: undefined, line: undefined }]);
102
- });
103
-
104
- it('classifies a present required prop as valid', () => {
105
- const { checks, findings } = evaluateCallSite({
106
- attributes: attrsFor('<Field label="Name" />'),
107
- propMap: REQUIRED_PROP_MAP,
108
- component: 'Field',
109
- });
110
- expect(checks).toEqual([{ component: 'Field', prop: 'label', rule: 'required-prop', classification: 'valid', file: undefined, line: undefined }]);
111
- expect(findings).toEqual([]);
112
- });
113
-
114
- it('leaves a missing required prop unresolved (not flagged) when the element spreads props', () => {
115
- const { checks, findings } = evaluateCallSite({
116
- attributes: attrsFor('<Field {...rest} />'),
117
- propMap: REQUIRED_PROP_MAP,
118
- component: 'Field',
119
- });
120
- expect(checks).toEqual([{ component: 'Field', prop: 'label', rule: 'required-prop', classification: 'unresolved', file: undefined, line: undefined }]);
121
- expect(findings).toEqual([]);
122
- });
123
- });
124
-
125
- describe('evaluateCallSite — deprecated props', () => {
126
- it('flags a deprecated prop only when actually passed, with the reason carried through', () => {
127
- const propMap = [{ react: 'legacy', type: 'text', deprecated: 'use "modern" instead' }];
128
-
129
- const passed = evaluateCallSite({ attributes: attrsFor('<Widget legacy="x" />'), propMap, component: 'Widget' });
130
- expect(passed.findings).toEqual([
131
- { rule: 'deprecated-prop-passed', severity: 'warning', component: 'Widget', prop: 'legacy', reason: 'use "modern" instead', file: undefined, line: undefined },
132
- ]);
133
- // Presence-based, not value-based — doesn't feed the valid/invalid/unresolved ledger.
134
- expect(passed.checks).toEqual([]);
135
-
136
- const absent = evaluateCallSite({ attributes: attrsFor('<Widget />'), propMap, component: 'Widget' });
137
- expect(absent.findings).toEqual([]);
138
- });
139
-
140
- it('flags a plain deprecated: true prop with no reason field', () => {
141
- const propMap = [{ react: 'legacy', type: 'text', deprecated: true }];
142
- const { findings } = evaluateCallSite({ attributes: attrsFor('<Widget legacy="x" />'), propMap, component: 'Widget' });
143
- expect(findings[0].reason).toBeUndefined();
144
- });
145
- });
146
-
147
- describe('evaluateCallSite — className/style escape hatch', () => {
148
- it('flags className and style as style-escape-hatch findings, outside the ledger', () => {
149
- const { checks, findings } = evaluateCallSite({
150
- attributes: attrsFor('<Card className="foo" style={{ color: "red" }} />'),
151
- propMap: [],
152
- ignore: ['className', 'style'],
153
- component: 'Card',
154
- });
155
- expect(checks).toEqual([]);
156
- expect(findings).toEqual([
157
- { rule: 'style-escape-hatch', severity: 'warning', component: 'Card', prop: 'className', file: undefined, line: undefined },
158
- { rule: 'style-escape-hatch', severity: 'warning', component: 'Card', prop: 'style', file: undefined, line: undefined },
159
- ]);
160
- });
161
- });
162
-
163
- describe('evaluateCallSite — untracked props', () => {
164
- it('never flags a prop in the mapping ignore list, or one absent from both propMap and ignore', () => {
165
- const { checks, findings } = evaluateCallSite({
166
- attributes: attrsFor('<Button aria-label="Close" onClick={fn} data-testid="x" />'),
167
- propMap: ENUM_PROP_MAP,
168
- ignore: ['aria-label', 'onClick', 'data-testid'],
169
- component: 'Button',
170
- });
171
- expect(checks).toEqual([]);
172
- expect(findings).toEqual([]);
173
- });
174
- });
175
-
176
- describe('resolvePropApi — integration against the real Button mapping', () => {
177
- it('throws for platform !== "web" — prop-mapping/ has no RN equivalent', () => {
178
- const root = fixture({ 'src/App.jsx': `export const App = () => null;` });
179
- expect(() => resolvePropApi(root, { platform: 'native' })).toThrow(/only supports platform "web"/);
180
- });
181
-
182
- it('reports a valid check for a correct enum value at a real Button call site', () => {
183
- const root = fixture({
184
- 'src/App.jsx': `
185
- import { Button } from '@starklab/stk-components';
186
- export const App = () => <Button variant="primary">Go</Button>;
187
- `,
188
- });
189
- const result = resolvePropApi(root, { platform: 'web' });
190
- expect(result.report).toEqual({ total: 1, valid: 1, validPct: 100, invalid: 0, invalidPct: 0, unresolved: 0, unresolvedPct: 0 });
191
- expect(result.findings).toEqual([]);
192
- });
193
-
194
- it('flags an invalid Button variant and a className passthrough at a real call site', () => {
195
- const root = fixture({
196
- 'src/App.jsx': `
197
- import { Button } from '@starklab/stk-components';
198
- export const App = () => <Button variant="primry" className="hero-cta">Go</Button>;
199
- `,
200
- });
201
- const result = resolvePropApi(root, { platform: 'web' });
202
- expect(result.report.invalid).toBe(1);
203
- expect(result.report.valid).toBe(0);
204
-
205
- const enumFinding = result.findings.find((f) => f.rule === 'invalid-enum-value');
206
- expect(enumFinding).toMatchObject({ component: 'Button', prop: 'variant', value: 'primry', severity: 'critical' });
207
-
208
- const styleFinding = result.findings.find((f) => f.rule === 'style-escape-hatch');
209
- expect(styleFinding).toMatchObject({ component: 'Button', prop: 'className', severity: 'warning' });
210
- });
211
-
212
- it('leaves a dynamic Button variant unresolved rather than scoring it', () => {
213
- const root = fixture({
214
- 'src/App.jsx': `
215
- import { Button } from '@starklab/stk-components';
216
- export const App = ({ v }) => <Button variant={v}>Go</Button>;
217
- `,
218
- });
219
- const result = resolvePropApi(root, { platform: 'web' });
220
- expect(result.report).toEqual({ total: 1, valid: 0, validPct: 0, invalid: 0, invalidPct: 0, unresolved: 1, unresolvedPct: 100 });
221
- });
222
-
223
- it('reports an all-zero ledger when the consumer uses no catalog components', () => {
224
- const root = fixture({ 'src/App.jsx': `export const App = () => <div />;` });
225
- const result = resolvePropApi(root, { platform: 'web' });
226
- expect(result.report).toEqual({ total: 0, valid: 0, validPct: 0, invalid: 0, invalidPct: 0, unresolved: 0, unresolvedPct: 0 });
227
- expect(result.findings).toEqual([]);
228
- });
229
- });
@@ -1,213 +0,0 @@
1
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- import { describe, it, expect, afterEach } from 'vitest';
6
-
7
- import { resolveReferences } from './referenceResolver.js';
8
-
9
- let tmpDirs = [];
10
-
11
- afterEach(() => {
12
- for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true });
13
- tmpDirs = [];
14
- });
15
-
16
- function fixture(files) {
17
- const root = mkdtempSync(path.join(os.tmpdir(), 'stark-adopt-'));
18
- tmpDirs.push(root);
19
- for (const [rel, content] of Object.entries(files)) {
20
- const full = path.join(root, rel);
21
- mkdirSync(path.dirname(full), { recursive: true });
22
- writeFileSync(full, content);
23
- }
24
- return root;
25
- }
26
-
27
- function component(result, name) {
28
- const found = result.components.find((c) => c.name === name);
29
- if (!found) throw new Error(`No component named "${name}" in result`);
30
- return found;
31
- }
32
-
33
- describe('resolveReferences — reference kind classification', () => {
34
- it('classifies a directly-imported JSX element as jsx, without double-counting the closing tag', () => {
35
- const root = fixture({
36
- 'src/App.jsx': `
37
- import { Button } from '@starklab/stk-components';
38
- export const App = () => <Button variant="primary">Click</Button>;
39
- `,
40
- });
41
- const result = resolveReferences(root, { platform: 'web' });
42
- const button = component(result, 'Button');
43
- expect(button.counts).toEqual({ jsx: 1, createElement: 0, hoc: 0, indirect: 0, reexport: 0 });
44
- expect(button.sites).toHaveLength(1);
45
- expect(button.sites[0].confidence).toBe('certain');
46
- });
47
-
48
- it('classifies a compound JSX member expression (<Foo.Bar/>) as jsx, once per element', () => {
49
- const root = fixture({
50
- 'src/App.jsx': `
51
- import { Accordion } from '@starklab/stk-components';
52
- export const App = () => <Accordion.Item>content</Accordion.Item>;
53
- `,
54
- });
55
- const result = resolveReferences(root, { platform: 'web' });
56
- const accordion = component(result, 'Accordion');
57
- expect(accordion.counts.jsx).toBe(1);
58
- });
59
-
60
- it('classifies React.createElement(Component, ...) and a bare invocation as createElement', () => {
61
- const root = fixture({
62
- 'src/App.js': `
63
- import React from 'react';
64
- import { Button } from '@starklab/stk-components';
65
- const a = React.createElement(Button, { variant: 'primary' });
66
- const b = Button({ variant: 'ghost' });
67
- `,
68
- });
69
- const result = resolveReferences(root, { platform: 'web' });
70
- const button = component(result, 'Button');
71
- expect(button.counts.createElement).toBe(2);
72
- expect(button.counts.jsx).toBe(0);
73
- });
74
-
75
- it('classifies a component passed as an argument to another function as hoc', () => {
76
- const root = fixture({
77
- 'src/App.js': `
78
- import styled from 'styled-components';
79
- import { Button } from '@starklab/stk-components';
80
- const StyledButton = styled(Button)\`color: red;\`;
81
- `,
82
- });
83
- const result = resolveReferences(root, { platform: 'web' });
84
- const button = component(result, 'Button');
85
- expect(button.counts.hoc).toBe(1);
86
- expect(button.sites[0].confidence).toBe('high');
87
- });
88
-
89
- it('classifies a variable/ternary reference as indirect', () => {
90
- const root = fixture({
91
- 'src/App.js': `
92
- import { Button } from '@starklab/stk-components';
93
- const chosen = true ? Button : null;
94
- const obj = { Comp: Button };
95
- `,
96
- });
97
- const result = resolveReferences(root, { platform: 'web' });
98
- const button = component(result, 'Button');
99
- expect(button.counts.indirect).toBe(2);
100
- expect(button.sites.every((s) => s.confidence === 'needs-review')).toBe(true);
101
- });
102
-
103
- it('classifies a forwarding export as reexport and excludes it from usage totals', () => {
104
- const root = fixture({
105
- 'src/App.js': `
106
- import { Button } from '@starklab/stk-components';
107
- export { Button };
108
- `,
109
- });
110
- const result = resolveReferences(root, { platform: 'web' });
111
- const button = component(result, 'Button');
112
- expect(button.counts.reexport).toBe(1);
113
- expect(button.counts.jsx + button.counts.createElement + button.counts.hoc + button.counts.indirect).toBe(0);
114
- // A reexport-only component still isn't "usage", so it shows up as zero-usage.
115
- expect(result.zeroUsage).toContain('Button');
116
- });
117
- });
118
-
119
- describe('resolveReferences — left-join over the catalog (blind spot 1)', () => {
120
- it('reports every catalog component, including ones with no references at all', () => {
121
- const root = fixture({
122
- 'src/App.jsx': `
123
- import { Button } from '@starklab/stk-components';
124
- export const App = () => <Button>Click</Button>;
125
- `,
126
- });
127
- const result = resolveReferences(root, { platform: 'web' });
128
- // Total catalog size, not just what was referenced.
129
- expect(result.components.length).toBeGreaterThan(50);
130
- const accordion = component(result, 'Accordion');
131
- expect(accordion.counts).toEqual({ jsx: 0, createElement: 0, hoc: 0, indirect: 0, reexport: 0 });
132
- expect(result.zeroUsage).toContain('Accordion');
133
- expect(result.zeroUsage).not.toContain('Button');
134
- });
135
- });
136
-
137
- describe('resolveReferences — transitive barrel resolution (blind spot 2)', () => {
138
- it('resolves a component re-exported through two hops of the consumer’s own barrels', () => {
139
- const root = fixture({
140
- 'src/ui/index.js': `export { Button } from '../components/ButtonLocal.js';`,
141
- 'src/components/ButtonLocal.js': `export { Button } from '@starklab/stk-components';`,
142
- 'src/App.jsx': `
143
- import { Button } from './ui/index.js';
144
- export const App = () => <Button>Click</Button>;
145
- `,
146
- });
147
- const result = resolveReferences(root, { platform: 'web' });
148
- const button = component(result, 'Button');
149
- expect(button.counts.jsx).toBe(1);
150
- });
151
-
152
- it('does not resolve or crash on a circular local re-export chain', () => {
153
- const root = fixture({
154
- 'src/a.js': `export { Button } from './b.js';`,
155
- 'src/b.js': `export { Button } from './a.js';`,
156
- 'src/App.jsx': `
157
- import { Button } from './a.js';
158
- export const App = () => <Button>Click</Button>;
159
- `,
160
- });
161
- expect(() => resolveReferences(root, { platform: 'web' })).not.toThrow();
162
- const result = resolveReferences(root, { platform: 'web' });
163
- const button = component(result, 'Button');
164
- // Never actually terminates at the real package, so it isn't counted.
165
- expect(button.counts).toEqual({ jsx: 0, createElement: 0, hoc: 0, indirect: 0, reexport: 0 });
166
- });
167
- });
168
-
169
- describe('resolveReferences — platform separation', () => {
170
- it('never counts a stk-react-native import while scanning the web platform', () => {
171
- const root = fixture({
172
- 'src/App.jsx': `
173
- import { Button } from '@starklab/stk-react-native';
174
- export const App = () => <Button>Click</Button>;
175
- `,
176
- });
177
- const result = resolveReferences(root, { platform: 'web' });
178
- const button = component(result, 'Button');
179
- expect(button.counts).toEqual({ jsx: 0, createElement: 0, hoc: 0, indirect: 0, reexport: 0 });
180
- });
181
- });
182
-
183
- describe('resolveReferences — resilience', () => {
184
- it('records an unparseable file without failing the whole scan', () => {
185
- const root = fixture({
186
- 'src/broken.js': `this is not { valid javascript at all +++`,
187
- 'src/App.jsx': `
188
- import { Button } from '@starklab/stk-components';
189
- export const App = () => <Button>Click</Button>;
190
- `,
191
- });
192
- const result = resolveReferences(root, { platform: 'web' });
193
- const button = component(result, 'Button');
194
- expect(button.counts.jsx).toBe(1);
195
- });
196
-
197
- it('excludes files matched by the ignore option from the scan', () => {
198
- const root = fixture({
199
- 'src/App.jsx': `
200
- import { Button } from '@starklab/stk-components';
201
- export const App = () => <Button>Click</Button>;
202
- `,
203
- 'src/generated/Skip.jsx': `
204
- import { BadgeStatus } from '@starklab/stk-components';
205
- export const Skip = () => <BadgeStatus>Skip</BadgeStatus>;
206
- `,
207
- });
208
- const result = resolveReferences(root, { platform: 'web', ignore: ['**/generated/**'] });
209
- expect(result.scannedFiles).toBe(1);
210
- const button = component(result, 'Button');
211
- expect(button.counts.jsx).toBe(1);
212
- });
213
- });