@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
package/src/adopt/moduleGraph.js
CHANGED
|
@@ -20,12 +20,36 @@ const DEFAULT_IGNORE = [
|
|
|
20
20
|
|
|
21
21
|
export const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
...
|
|
28
|
-
...
|
|
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
|
+
];
|