@starklab/stark-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +10 -4
  2. package/src/adopt/adoptScanReport.js +124 -0
  3. package/src/adopt/catalog.js +26 -6
  4. package/src/adopt/foreignDiscoveryResolver.js +276 -0
  5. package/src/adopt/foreignPropSchemaResolver.js +134 -0
  6. package/src/adopt/foreignScanReport.js +210 -0
  7. package/src/adopt/foreignScoringResolver.js +192 -0
  8. package/src/adopt/foreignSystemConfig.js +356 -0
  9. package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
  10. package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
  11. package/src/adopt/installedPackageScoringResolver.js +153 -0
  12. package/src/adopt/installedSystemAutoDetector.js +51 -0
  13. package/src/adopt/installedSystemScan.js +101 -0
  14. package/src/adopt/jsxOpportunityHelpers.js +99 -0
  15. package/src/adopt/moduleGraph.js +39 -8
  16. package/src/adopt/opportunityResolver.js +255 -0
  17. package/src/adopt/opportunitySignaturesNative.js +47 -0
  18. package/src/adopt/usageRulesResolver.js +298 -0
  19. package/src/adopt/vecnaMaterializer.js +165 -0
  20. package/src/adopt/vecnaVerifier.js +127 -0
  21. package/src/cli.js +407 -1
  22. package/src/server.js +38 -14
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  31. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  32. package/src/adopt/dominionFixture.test.js +0 -165
  33. package/src/adopt/propApiResolver.test.js +0 -229
  34. package/src/adopt/referenceResolver.test.js +0 -213
  35. package/src/adopt/rnTailwindResolver.test.js +0 -263
  36. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  37. package/src/adopt/tailwindResolver.test.js +0 -178
  38. package/src/adopt/targetDiscovery.test.js +0 -227
  39. package/src/adopt/tokenAliasResolver.test.js +0 -319
  40. package/src/adopt/wrapperResolver.test.js +0 -324
  41. package/src/data.test.js +0 -231
@@ -20,12 +20,36 @@ const DEFAULT_IGNORE = [
20
20
 
21
21
  export const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
22
22
 
23
- export function resolveRelative(fromFile, specifier) {
24
- const base = path.resolve(path.dirname(fromFile), specifier);
23
+ /**
24
+ * RN's platform-split file convention — a bare `import Button from
25
+ * './Button'` resolves to one of these ahead of the plain `.tsx`/`.ts`/etc.
26
+ * sibling when `platform: 'native'` is threaded through, mirroring
27
+ * Metro/react-native's own module resolution priority (`.native.*` first,
28
+ * then the OS-specific suffix, then the platform-agnostic file last).
29
+ */
30
+ const NATIVE_SUFFIXES = ['.native', '.ios', '.android'];
31
+ const NATIVE_EXTENSIONS = NATIVE_SUFFIXES.flatMap((suffix) => EXTENSIONS.map((ext) => suffix + ext));
32
+
33
+ /**
34
+ * Tries `base` itself, then each known extension appended, then each
35
+ * extension under an `index` file inside `base` as a directory. Shared by
36
+ * resolveRelative() (relative-specifier hops) and, via export, by any
37
+ * resolver that has already turned a non-relative specifier into a
38
+ * filesystem base path of its own (e.g. foreignScoringResolver.js
39
+ * resolving a tsconfig `paths` alias) and just needs the same fallback.
40
+ * Pass `{ platform: 'native' }` to prefer a `.native.*`/`.ios.*`/`.android.*`
41
+ * sibling over the plain extension, for a target repo whose own source uses
42
+ * RN platform-split files (installedPackageDiscoveryResolver.js's `platform:
43
+ * 'native'` case) — the installed package's own .d.ts resolution is
44
+ * unaffected, since a package's types entry is a single file regardless of
45
+ * platform.
46
+ */
47
+ export function resolveCandidateFile(base, { platform = 'web' } = {}) {
48
+ const exts = platform === 'native' ? [...NATIVE_EXTENSIONS, ...EXTENSIONS] : EXTENSIONS;
25
49
  const candidates = [
26
50
  base,
27
- ...EXTENSIONS.map((ext) => base + ext),
28
- ...EXTENSIONS.map((ext) => path.join(base, 'index' + ext)),
51
+ ...exts.map((ext) => base + ext),
52
+ ...exts.map((ext) => path.join(base, 'index' + ext)),
29
53
  ];
30
54
  for (const candidate of candidates) {
31
55
  if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
@@ -33,6 +57,11 @@ export function resolveRelative(fromFile, specifier) {
33
57
  return null;
34
58
  }
35
59
 
60
+ export function resolveRelative(fromFile, specifier, opts = {}) {
61
+ const base = path.resolve(path.dirname(fromFile), specifier);
62
+ return resolveCandidateFile(base, opts);
63
+ }
64
+
36
65
  /**
37
66
  * Parses every source file under `root` once and extracts each file's import
38
67
  * bindings and re-export edges. This is the graph the reference resolver
@@ -145,7 +174,7 @@ export function buildModuleGraph(root, { ignore = [] } = {}) {
145
174
  * such binding)
146
175
  */
147
176
  export function resolveOrigin(moduleGraph, file, name, opts = {}) {
148
- const { visited = new Set(), depth = 0, viaExport = false } = opts;
177
+ const { visited = new Set(), depth = 0, viaExport = false, platform = 'web' } = opts;
149
178
  const MAX_DEPTH = 20; // safety valve only — see ADOPTION_APP_PLAN.md §4 "5 is governance, 20 is safety"
150
179
  const key = `${file}#${viaExport ? 'export' : 'local'}#${name}`;
151
180
  if (visited.has(key)) return { cycle: [...visited, key] };
@@ -170,13 +199,14 @@ export function resolveOrigin(moduleGraph, file, name, opts = {}) {
170
199
  return null;
171
200
  }
172
201
 
173
- const resolvedFile = resolveRelative(file, source);
202
+ const resolvedFile = resolveRelative(file, source, { platform });
174
203
  if (!resolvedFile || !moduleGraph.graph.has(resolvedFile)) return null;
175
204
 
176
205
  return resolveOrigin(moduleGraph, resolvedFile, imported, {
177
206
  visited: nextVisited,
178
207
  depth: depth + 1,
179
208
  viaExport: true,
209
+ platform,
180
210
  });
181
211
  }
182
212
 
@@ -196,7 +226,7 @@ export function resolveOrigin(moduleGraph, file, name, opts = {}) {
196
226
  * null — dead end (missing file, namespace import, unparseable)
197
227
  */
198
228
  export function resolveOriginDeep(moduleGraph, file, name, opts = {}) {
199
- const { visited = new Set(), depth = 0, viaExport = false } = opts;
229
+ const { visited = new Set(), depth = 0, viaExport = false, platform = 'web' } = opts;
200
230
  const MAX_DEPTH = 20; // safety valve only — see ADOPTION_APP_PLAN.md §4 "5 is governance, 20 is safety"
201
231
  const key = `${file}#${viaExport ? 'export' : 'local'}#${name}`;
202
232
  if (visited.has(key)) return { cycle: [...visited, key] };
@@ -221,12 +251,13 @@ export function resolveOriginDeep(moduleGraph, file, name, opts = {}) {
221
251
  return null;
222
252
  }
223
253
 
224
- const resolvedFile = resolveRelative(file, source);
254
+ const resolvedFile = resolveRelative(file, source, { platform });
225
255
  if (!resolvedFile || !moduleGraph.graph.has(resolvedFile)) return null;
226
256
 
227
257
  return resolveOriginDeep(moduleGraph, resolvedFile, imported, {
228
258
  visited: nextVisited,
229
259
  depth: depth + 1,
230
260
  viaExport: true,
261
+ platform,
231
262
  });
232
263
  }
@@ -0,0 +1,255 @@
1
+ import path from 'node:path';
2
+
3
+ import traverseModule from '@babel/traverse';
4
+
5
+ import { buildModuleGraph } from './moduleGraph.js';
6
+ import { resolveJsxLiteral } from './usageRulesResolver.js';
7
+ import {
8
+ getAttr,
9
+ domTagName as nativeTagName,
10
+ classNameMatches,
11
+ hasStaticClassNameContent,
12
+ styleTouchesKeys,
13
+ significantJsxChildren,
14
+ } from './jsxOpportunityHelpers.js';
15
+ import { OPPORTUNITY_SIGNATURES_NATIVE } from './opportunitySignaturesNative.js';
16
+
17
+ const traverse = traverseModule.default ?? traverseModule;
18
+
19
+ // ───────────────────────────── Button signature ─────────────────────────────
20
+
21
+ const BUTTON_STYLE_KEY_RE = /^(background|border|padding)/i;
22
+
23
+ /**
24
+ * A native <button> with onClick alone is common and often fine as-is (a
25
+ * bare form reset/submit, a test id target). Requiring visual styling too
26
+ * — the same "don't flag on one weak signal" discipline wrapperResolver.js
27
+ * uses for passthrough classification — narrows to the case that's actually
28
+ * worth flagging: someone re-implemented what Button already provides.
29
+ * "Styled" means style keys touching background/border/padding, OR any
30
+ * static class name at all (see hasStaticClassNameContent — a keyword
31
+ * regex like /btn|button/ was tried first and missed every real case in
32
+ * apps/portfolio's own hand-rolled buttons, none of which named their class
33
+ * that way).
34
+ */
35
+ function matchButton(elPath) {
36
+ const opening = elPath.node.openingElement;
37
+ if (nativeTagName(opening.name) !== 'button') return null;
38
+ if (!getAttr(opening, 'onClick')) return null;
39
+ const styled = styleTouchesKeys(opening, BUTTON_STYLE_KEY_RE) || hasStaticClassNameContent(opening);
40
+ if (!styled) return null;
41
+ return { confidence: 'high', detail: 'native <button> with onClick and visual styling (style/className)' };
42
+ }
43
+
44
+ // ───────────────────────────── TextInput signature ─────────────────────────────
45
+
46
+ const TEXT_INPUT_TYPES = new Set(['text', 'email', 'password', 'search', 'tel', 'url', 'number']);
47
+
48
+ function isLabelTag(node) {
49
+ return node.type === 'JSXElement' && nativeTagName(node.openingElement.name) === 'label';
50
+ }
51
+
52
+ function pairedWithLabel(elPath) {
53
+ const parentNode = elPath.parentPath?.node;
54
+ if (parentNode?.type === 'JSXElement' && isLabelTag(parentNode)) return true; // <label>Name <input/></label>
55
+ if (parentNode?.type === 'JSXElement' || parentNode?.type === 'JSXFragment') {
56
+ return (parentNode.children || []).some((c) => c !== elPath.node && isLabelTag(c));
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * The <label> pairing is what distinguishes a real form field from an
63
+ * inline filter/search box glued into custom logic — an <input> with no
64
+ * label anywhere nearby is left alone rather than guessed as a TextInput
65
+ * candidate. A dynamic `type` expression is never guessed at; the input is
66
+ * simply skipped, same "don't guess" discipline every other adopt/ resolver
67
+ * follows for a non-literal attribute value.
68
+ */
69
+ function matchTextInput(elPath) {
70
+ const opening = elPath.node.openingElement;
71
+ if (nativeTagName(opening.name) !== 'input') return null;
72
+
73
+ const typeAttr = getAttr(opening, 'type');
74
+ let type = 'text'; // HTML's own default when the attribute is omitted
75
+ if (typeAttr) {
76
+ const r = resolveJsxLiteral(typeAttr.value);
77
+ if (!r.resolved || typeof r.value !== 'string') return null;
78
+ type = r.value;
79
+ }
80
+ if (!TEXT_INPUT_TYPES.has(type)) return null;
81
+ if (!pairedWithLabel(elPath)) return null;
82
+ return { confidence: 'high', detail: `native <input type="${type}"> paired with a <label>` };
83
+ }
84
+
85
+ // ───────────────────────────── Toolbar signature ─────────────────────────────
86
+
87
+ const TOOLBAR_CLASSNAME_RE = /\bflex\b|\brow\b/i;
88
+
89
+ function isFlexRowContainer(opening) {
90
+ if (classNameMatches(opening, TOOLBAR_CLASSNAME_RE)) return true;
91
+ const attr = getAttr(opening, 'style');
92
+ if (!attr || attr.value?.type !== 'JSXExpressionContainer') return false;
93
+ const expr = attr.value.expression;
94
+ if (expr.type !== 'ObjectExpression') return false;
95
+ let display = null;
96
+ let direction = null;
97
+ for (const p of expr.properties) {
98
+ if (p.type !== 'ObjectProperty' || p.computed) continue;
99
+ const key = p.key.type === 'Identifier' ? p.key.name : p.key.type === 'StringLiteral' ? p.key.value : null;
100
+ if (key === 'display') { const r = resolveJsxLiteral(p.value); if (r.resolved) display = r.value; }
101
+ if (key === 'flexDirection') { const r = resolveJsxLiteral(p.value); if (r.resolved) direction = r.value; }
102
+ }
103
+ if (typeof display !== 'string' || !/flex/i.test(display)) return false;
104
+ return direction !== 'column' && direction !== 'column-reverse';
105
+ }
106
+
107
+ /**
108
+ * High confidence: an explicit `role="toolbar"` — a repo already telling
109
+ * assistive tech what this is, just not using the component that does the
110
+ * same thing with tokens. Medium confidence: no such declaration exists, so
111
+ * this falls back to a structural guess — a flex/row container whose ONLY
112
+ * significant children are native <button>s (never a mixed container that
113
+ * merely happens to hold two buttons among other content, which would fire
114
+ * constantly on things like a card footer or a data table row and isn't
115
+ * what this signature means to catch). Deliberately does not attempt to
116
+ * judge "top of a component tree" positioning — that needs whole-component
117
+ * structural analysis this per-element matcher doesn't have; a known,
118
+ * accepted source of over-eagerness on the medium tier, not a bug.
119
+ */
120
+ function matchToolbar(elPath) {
121
+ const opening = elPath.node.openingElement;
122
+ const tag = nativeTagName(opening.name);
123
+ if (!tag) return null;
124
+
125
+ const roleAttr = getAttr(opening, 'role');
126
+ if (roleAttr) {
127
+ const r = resolveJsxLiteral(roleAttr.value);
128
+ if (r.resolved && r.value === 'toolbar') {
129
+ return { confidence: 'high', detail: `<${tag} role="toolbar">` };
130
+ }
131
+ }
132
+
133
+ if (!isFlexRowContainer(opening)) return null;
134
+ const sig = significantJsxChildren(elPath.node.children);
135
+ if (sig.length < 2) return null;
136
+ const allButtons = sig.every((c) => c.type === 'JSXElement' && nativeTagName(c.openingElement.name) === 'button');
137
+ if (!allButtons) return null;
138
+ return { confidence: 'medium', detail: `<${tag}> flex/row container with ${sig.length} sibling <button> children` };
139
+ }
140
+
141
+ // ───────────────────────────── assembly ─────────────────────────────
142
+
143
+ /**
144
+ * Explicit and auditable, not inferred from layoutSchema/prop-mapping —
145
+ * those describe Stark's own API surface, not what generic hand-rolled HTML
146
+ * maps to it. Deliberately 3 starter components, not an attempt at the full
147
+ * catalog (ADOPTION_APP_PLAN.md R3 dimension 1's scoping plan) — expand only
148
+ * once a live run proves the false-positive rate low.
149
+ */
150
+ const OPPORTUNITY_SIGNATURES = [
151
+ { component: 'Button', match: matchButton },
152
+ { component: 'TextInput', match: matchTextInput },
153
+ { component: 'Toolbar', match: matchToolbar },
154
+ ];
155
+
156
+ /**
157
+ * Scans a repo for hand-rolled UI patterns that resemble a Stark catalog
158
+ * component, whether or not the repo has adopted Stark at all. This closes
159
+ * R3 dimension 1 (ADOPTION_APP_PLAN.md / project_usage_rules_adapter_r3):
160
+ * every other adopt/ resolver, checkComponentRules included, only has
161
+ * something to say about a repo that already imports @starklab/stk* — a
162
+ * 0%-adoption repo produces a scan with zero findings from any of them, not
163
+ * "here's what you're missing." This resolver is additive, not a
164
+ * replacement for those: it targets the population they can't see.
165
+ *
166
+ * Every existing resolver detects a *fact* with zero judgment calls; "this
167
+ * raw JSX could become a Button" is inherently a heuristic, so it runs
168
+ * under a different discipline than the rest of adopt/ — a small number of
169
+ * narrow, named, auditable structural signatures (OPPORTUNITY_SIGNATURES),
170
+ * never fuzzy/statistical scoring. Every finding carries `confidence`
171
+ * ('high'|'medium', a suggestion strength) separately from `severity`,
172
+ * which is always 'Info' — an opportunity is not a defect and must never
173
+ * read as a CI-gate failure, the same instinct behind vecnaVerifier.js's
174
+ * `vacuousByConstruction` labeling.
175
+ *
176
+ * Output is keyed "opportunities", deliberately the same word
177
+ * targetDiscovery.js's discoverTargets() already uses for a different,
178
+ * package-granularity idea (an out-of-scope workspace target that still
179
+ * renders UI). The two can appear side by side under `adopt --all-targets`:
180
+ * that one flags whole excluded packages worth bringing into scope; this
181
+ * one flags individual JSX patterns worth swapping for a catalog component,
182
+ * inside any package regardless of scope. Same word, two granularities —
183
+ * intentional, not a naming collision, since they never occupy the same key
184
+ * (targetDiscovery's lives at the top level; this resolver's is nested
185
+ * under "opportunities" inside each target's own adopt result).
186
+ *
187
+ * Web and native — 'web' uses OPPORTUNITY_SIGNATURES (DOM/JSX-shape
188
+ * assumptions: role="toolbar", style.display, native <input>/<button>);
189
+ * 'native' uses OPPORTUNITY_SIGNATURES_NATIVE (RN-shape assumptions:
190
+ * Pressable/TouchableOpacity + onPress + StyleSheet-object styling — see
191
+ * opportunitySignaturesNative.js). Any other platform value throws, same
192
+ * "never guess" discipline as every other resolver's unsupported-input case.
193
+ * JSX-only like every other resolver here: a repo whose markup lives in
194
+ * .astro/.vue/.svelte files produces zero opportunities, not a false "clean"
195
+ * signal — same unresolvedFiles/parseError reporting as usageRulesResolver.js,
196
+ * so a caller can see what wasn't actually scanned.
197
+ */
198
+ export function resolveOpportunities(root, { platform = 'web', ignore = [] } = {}) {
199
+ const signatures = platform === 'web'
200
+ ? OPPORTUNITY_SIGNATURES
201
+ : platform === 'native'
202
+ ? OPPORTUNITY_SIGNATURES_NATIVE
203
+ : null;
204
+ if (!signatures) {
205
+ throw new Error(`resolveOpportunities only supports platform "web" or "native" (got "${platform}").`);
206
+ }
207
+
208
+ const moduleGraph = buildModuleGraph(root, { ignore });
209
+ const opportunities = [];
210
+ const unresolvedFiles = [];
211
+ let filesWithOpportunities = 0;
212
+
213
+ for (const file of moduleGraph.files) {
214
+ const entry = moduleGraph.graph.get(file);
215
+ if (!entry) continue;
216
+ if (entry.parseError) {
217
+ unresolvedFiles.push(path.relative(root, file));
218
+ continue;
219
+ }
220
+
221
+ const relFile = path.relative(root, file);
222
+ const fileFindings = [];
223
+
224
+ traverse(entry.ast, {
225
+ JSXElement(elPath) {
226
+ for (const sig of signatures) {
227
+ const result = sig.match(elPath);
228
+ if (!result) continue;
229
+ fileFindings.push({
230
+ component: sig.component,
231
+ confidence: result.confidence,
232
+ severity: 'Info',
233
+ detail: result.detail,
234
+ file: relFile,
235
+ line: elPath.node.loc?.start.line ?? null,
236
+ });
237
+ }
238
+ },
239
+ });
240
+
241
+ if (fileFindings.length > 0) {
242
+ filesWithOpportunities += 1;
243
+ opportunities.push(...fileFindings);
244
+ }
245
+ }
246
+
247
+ return {
248
+ platform,
249
+ root,
250
+ scannedFiles: moduleGraph.files.length,
251
+ filesWithOpportunities,
252
+ unresolvedFiles,
253
+ opportunities,
254
+ };
255
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ getAttr,
3
+ componentTagName,
4
+ styleTouchesKeys,
5
+ styleReferencesStylesheetMember,
6
+ } from './jsxOpportunityHelpers.js';
7
+
8
+ // ───────────────────────────── Button signature (RN) ─────────────────────────────
9
+
10
+ const BUTTON_STYLE_KEY_RE = /^(background|border|padding)/i;
11
+ const BUTTON_TAGS = new Set(['Pressable', 'TouchableOpacity', 'TouchableHighlight', 'TouchableWithoutFeedback']);
12
+
13
+ /**
14
+ * RN counterpart of opportunityResolver.js's matchButton: a hand-rolled
15
+ * pressable primitive (Pressable/TouchableOpacity/TouchableHighlight/
16
+ * TouchableWithoutFeedback) with an onPress handler, styled via either an
17
+ * inline style={{...}} touching background/border/padding keys or a
18
+ * StyleSheet-object reference (styles.foo / [styles.foo, cond && styles.bar])
19
+ * — RN's equivalent of a static className, since RN has no CSS classes.
20
+ * Same "onPress alone isn't enough" discipline as the web signature: a bare
21
+ * pressable with no visual styling is often fine as-is (a no-op test target,
22
+ * a transparent hit-area wrapper), so styling is required too.
23
+ */
24
+ function matchButton(elPath) {
25
+ const opening = elPath.node.openingElement;
26
+ const tag = componentTagName(opening.name);
27
+ if (!tag || !BUTTON_TAGS.has(tag)) return null;
28
+ if (!getAttr(opening, 'onPress')) return null;
29
+ const styled = styleTouchesKeys(opening, BUTTON_STYLE_KEY_RE) || styleReferencesStylesheetMember(opening);
30
+ if (!styled) return null;
31
+ return { confidence: 'high', detail: `<${tag}> with onPress and visual styling (style/StyleSheet reference)` };
32
+ }
33
+
34
+ // ───────────────────────────── assembly ─────────────────────────────
35
+
36
+ /**
37
+ * RN twin of opportunityResolver.js's OPPORTUNITY_SIGNATURES — a single
38
+ * starter signature (Button), per ADOPTION_APP_PLAN.md §10 decision #25
39
+ * Phase 2's "expand only after a live run proves the false-positive rate
40
+ * low" precedent, the same discipline the web table's own R3 dimension-1
41
+ * scoping already established. Lives in its own file (not inline in
42
+ * opportunityResolver.js) to avoid a circular import — see
43
+ * jsxOpportunityHelpers.js's header comment.
44
+ */
45
+ export const OPPORTUNITY_SIGNATURES_NATIVE = [
46
+ { component: 'Button', match: matchButton },
47
+ ];