@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.
- package/package.json +10 -4
- package/src/adopt/adoptScanReport.js +124 -0
- package/src/adopt/catalog.js +26 -6
- package/src/adopt/foreignDiscoveryResolver.js +276 -0
- package/src/adopt/foreignPropSchemaResolver.js +134 -0
- package/src/adopt/foreignScanReport.js +210 -0
- package/src/adopt/foreignScoringResolver.js +192 -0
- package/src/adopt/foreignSystemConfig.js +356 -0
- package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
- package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
- package/src/adopt/installedPackageScoringResolver.js +153 -0
- package/src/adopt/installedSystemAutoDetector.js +51 -0
- package/src/adopt/installedSystemScan.js +101 -0
- package/src/adopt/jsxOpportunityHelpers.js +99 -0
- package/src/adopt/moduleGraph.js +39 -8
- package/src/adopt/opportunityResolver.js +255 -0
- package/src/adopt/opportunitySignaturesNative.js +47 -0
- package/src/adopt/usageRulesResolver.js +298 -0
- package/src/adopt/vecnaMaterializer.js +165 -0
- package/src/adopt/vecnaVerifier.js +127 -0
- package/src/cli.js +407 -1
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
- package/src/adopt/dominionFixture.test.js +0 -165
- package/src/adopt/propApiResolver.test.js +0 -229
- package/src/adopt/referenceResolver.test.js +0 -213
- package/src/adopt/rnTailwindResolver.test.js +0 -263
- package/src/adopt/rnTokenAliasResolver.test.js +0 -260
- package/src/adopt/tailwindResolver.test.js +0 -178
- package/src/adopt/targetDiscovery.test.js +0 -227
- package/src/adopt/tokenAliasResolver.test.js +0 -319
- package/src/adopt/wrapperResolver.test.js +0 -324
- 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
|
-
});
|