@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +31 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
- package/src/adopt/catalog.js +88 -0
- package/src/adopt/dominionFixture.test.js +165 -0
- package/src/adopt/moduleGraph.js +232 -0
- package/src/adopt/parseSource.js +25 -0
- package/src/adopt/propApiResolver.js +278 -0
- package/src/adopt/propApiResolver.test.js +229 -0
- package/src/adopt/referenceResolver.js +151 -0
- package/src/adopt/referenceResolver.test.js +213 -0
- package/src/adopt/rnTailwindResolver.js +347 -0
- package/src/adopt/rnTailwindResolver.test.js +263 -0
- package/src/adopt/rnTokenAliasResolver.js +474 -0
- package/src/adopt/rnTokenAliasResolver.test.js +260 -0
- package/src/adopt/tailwindResolver.js +512 -0
- package/src/adopt/tailwindResolver.test.js +178 -0
- package/src/adopt/targetDiscovery.js +237 -0
- package/src/adopt/targetDiscovery.test.js +227 -0
- package/src/adopt/tokenAliasResolver.js +513 -0
- package/src/adopt/tokenAliasResolver.test.js +319 -0
- package/src/adopt/wrapperResolver.js +874 -0
- package/src/adopt/wrapperResolver.test.js +324 -0
- package/src/cli.js +376 -0
- package/src/data.js +267 -0
- package/src/data.test.js +231 -0
- package/src/index.js +8 -0
- package/src/server.js +149 -0
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import traverseModule from '@babel/traverse';
|
|
4
|
+
|
|
5
|
+
import { loadCatalog, packageNameForPlatform } from './catalog.js';
|
|
6
|
+
import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
|
|
7
|
+
|
|
8
|
+
const traverse = traverseModule.default ?? traverseModule;
|
|
9
|
+
|
|
10
|
+
export const KIND_CONFIDENCE = {
|
|
11
|
+
jsx: 'certain',
|
|
12
|
+
createElement: 'certain',
|
|
13
|
+
hoc: 'high',
|
|
14
|
+
indirect: 'needs-review',
|
|
15
|
+
reexport: 'not-a-usage',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isCreateElementCallee(node) {
|
|
19
|
+
if (!node) return false;
|
|
20
|
+
if (node.type === 'Identifier') return node.name === 'createElement';
|
|
21
|
+
if (node.type === 'MemberExpression' && !node.computed) {
|
|
22
|
+
return node.property.type === 'Identifier' && node.property.name === 'createElement';
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Classifies a single reference to a catalog component per
|
|
29
|
+
* ADOPTION_APP_PLAN.md §4 blind spot 2's five-kind table:
|
|
30
|
+
* jsx — rendered as a JSX element (`<Button/>`, `<Foo.Bar/>`)
|
|
31
|
+
* createElement — rendered via a non-JSX call (`React.createElement(Button, ...)`,
|
|
32
|
+
* or the component invoked directly as a function)
|
|
33
|
+
* hoc — passed as an argument to some other function (`styled(Button)`)
|
|
34
|
+
* indirect — referenced any other way (variable, object value, ternary
|
|
35
|
+
* branch, spread, member access) — needs human review, may
|
|
36
|
+
* or may not render
|
|
37
|
+
* reexport — forwarded via `export { Button }` — not a usage
|
|
38
|
+
*/
|
|
39
|
+
export function classifyReference(refPath) {
|
|
40
|
+
const parent = refPath.parentPath;
|
|
41
|
+
if (!parent) return 'indirect';
|
|
42
|
+
|
|
43
|
+
// The closing tag (`</Button>`) is a second reference to the same binding
|
|
44
|
+
// for the same element the opening tag already counted — skip it rather
|
|
45
|
+
// than double-counting or misclassifying it as indirect.
|
|
46
|
+
if (parent.isJSXClosingElement()) return null;
|
|
47
|
+
|
|
48
|
+
if (parent.isJSXOpeningElement() && parent.node.name === refPath.node) return 'jsx';
|
|
49
|
+
if (parent.isJSXMemberExpression() && parent.node.object === refPath.node) {
|
|
50
|
+
// Same closing-tag skip as above, one level up for `<Foo.Bar/>` compound
|
|
51
|
+
// tags: the identifier's direct parent is the JSXMemberExpression, whose
|
|
52
|
+
// own parent is the (opening or closing) element.
|
|
53
|
+
if (parent.parentPath?.isJSXClosingElement()) return null;
|
|
54
|
+
return 'jsx';
|
|
55
|
+
}
|
|
56
|
+
if (parent.isExportSpecifier() && parent.node.local === refPath.node) return 'reexport';
|
|
57
|
+
|
|
58
|
+
if (parent.isCallExpression()) {
|
|
59
|
+
if (parent.node.callee === refPath.node) return 'createElement';
|
|
60
|
+
if (isCreateElementCallee(parent.node.callee) && parent.node.arguments[0] === refPath.node) {
|
|
61
|
+
return 'createElement';
|
|
62
|
+
}
|
|
63
|
+
return 'hoc';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return 'indirect';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Scans every source file under `root`, resolves each local binding back to
|
|
71
|
+
* a catalog component (directly imported, or transitively re-exported
|
|
72
|
+
* through the consumer's own barrels), classifies every reference site by
|
|
73
|
+
* kind, and left-joins the result onto the full catalog so components with
|
|
74
|
+
* zero usages appear as 0 rather than being silently absent from the report
|
|
75
|
+
* (§4 blind spots 1 & 2).
|
|
76
|
+
*/
|
|
77
|
+
export function resolveReferences(root, { platform = 'web', ignore = [] } = {}) {
|
|
78
|
+
const pkgName = packageNameForPlatform(platform);
|
|
79
|
+
const catalog = loadCatalog(platform);
|
|
80
|
+
const byName = new Map(catalog.components.map((c) => [c.name, c]));
|
|
81
|
+
|
|
82
|
+
const moduleGraph = buildModuleGraph(root, { ignore });
|
|
83
|
+
|
|
84
|
+
const results = new Map(
|
|
85
|
+
catalog.components.map((c) => [
|
|
86
|
+
c.name,
|
|
87
|
+
{
|
|
88
|
+
name: c.name,
|
|
89
|
+
slug: c.slug,
|
|
90
|
+
counts: { jsx: 0, createElement: 0, hoc: 0, indirect: 0, reexport: 0 },
|
|
91
|
+
sites: [],
|
|
92
|
+
},
|
|
93
|
+
])
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const unresolvedFiles = [];
|
|
97
|
+
|
|
98
|
+
for (const file of moduleGraph.files) {
|
|
99
|
+
const entry = moduleGraph.graph.get(file);
|
|
100
|
+
if (!entry) continue;
|
|
101
|
+
if (entry.parseError) {
|
|
102
|
+
unresolvedFiles.push(path.relative(root, file));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const localToComponent = new Map();
|
|
107
|
+
for (const localName of entry.imports.keys()) {
|
|
108
|
+
const origin = resolveOrigin(moduleGraph, file, localName);
|
|
109
|
+
if (origin?.pkg === pkgName && byName.has(origin.name)) {
|
|
110
|
+
localToComponent.set(localName, origin.name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (localToComponent.size === 0) continue;
|
|
114
|
+
|
|
115
|
+
traverse(entry.ast, {
|
|
116
|
+
Program(programPath) {
|
|
117
|
+
for (const [localName, componentName] of localToComponent) {
|
|
118
|
+
const binding = programPath.scope.getBinding(localName);
|
|
119
|
+
if (!binding) continue;
|
|
120
|
+
const result = results.get(componentName);
|
|
121
|
+
for (const refPath of binding.referencePaths) {
|
|
122
|
+
const kind = classifyReference(refPath);
|
|
123
|
+
if (!kind) continue; // closing-tag identifier — not a distinct usage
|
|
124
|
+
result.counts[kind] += 1;
|
|
125
|
+
result.sites.push({
|
|
126
|
+
file: path.relative(root, file),
|
|
127
|
+
line: refPath.node.loc?.start.line ?? null,
|
|
128
|
+
kind,
|
|
129
|
+
confidence: KIND_CONFIDENCE[kind],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const components = [...results.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
138
|
+
const zeroUsage = components.filter(
|
|
139
|
+
(c) => c.counts.jsx + c.counts.createElement + c.counts.hoc + c.counts.indirect === 0
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
platform,
|
|
144
|
+
package: pkgName,
|
|
145
|
+
root,
|
|
146
|
+
scannedFiles: moduleGraph.files.length,
|
|
147
|
+
unresolvedFiles,
|
|
148
|
+
components,
|
|
149
|
+
zeroUsage: zeroUsage.map((c) => c.name),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
});
|