@lariocpt/flawless-react 0.0.0-stage → 0.3.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/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +608 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The imported functions this component calls ON MOUNT, unconditionally.
|
|
5
|
+
*
|
|
6
|
+
* The loaded-state test mocks one function to return a row and asserts the row
|
|
7
|
+
* appears. That is only a fair test if mounting the component actually CALLS
|
|
8
|
+
* that function — and for eleven of the dogfood app's screens it did not, so the
|
|
9
|
+
* test failed for a reason that was neither the app's nor the tool's:
|
|
10
|
+
*
|
|
11
|
+
* reportJob a mutation, called from a submit handler
|
|
12
|
+
* listBuildingInvitations inside `if (isTrustee) { … }`
|
|
13
|
+
* listTicketUpdatesMobile behind a guard on the loaded parent record
|
|
14
|
+
*
|
|
15
|
+
* An earlier note in this repo blamed "an external store the harness does not
|
|
16
|
+
* reach". That was a guess, and it was wrong. The cause is visible in the AST:
|
|
17
|
+
* the emitter was choosing any imported function whose return type contains a
|
|
18
|
+
* collection, with no evidence that mounting reaches it.
|
|
19
|
+
*
|
|
20
|
+
* "Mount path" means: reachable from a `useEffect` / `useLayoutEffect` /
|
|
21
|
+
* `useFocusEffect` callback, or evaluated directly in the component body — and
|
|
22
|
+
* NOT inside a branch, a catch, or a JSX event handler.
|
|
23
|
+
*
|
|
24
|
+
* Pass the COMPONENT's own declaration, not its source file. Two exported
|
|
25
|
+
* components in one file share every file-level fact, so a file-scoped walk gave
|
|
26
|
+
* a component that imports nothing and calls nothing a skip reading "loaded state
|
|
27
|
+
* via listItems — what it returns is never rendered as a list", naming a function
|
|
28
|
+
* that appears nowhere in its subtree. `componentNode` below is how to get it.
|
|
29
|
+
*
|
|
30
|
+
* Reachability is a fixpoint over LOCAL functions, not a fixed number of hops,
|
|
31
|
+
* because the real screens are two deep and a one-hop rule found none of them:
|
|
32
|
+
*
|
|
33
|
+
* const init = useCallback(async () => { … listAshaMessages(…) … }, [id]);
|
|
34
|
+
* useFocusEffect(useCallback(() => { init(); }, [init]));
|
|
35
|
+
*
|
|
36
|
+
* It never leaves the file. A call into an imported helper is not followed, so a
|
|
37
|
+
* loader hidden behind one is missed — and a miss costs a skip, while a wrong
|
|
38
|
+
* answer costs a red test on a healthy component. It errs toward the skip.
|
|
39
|
+
*/
|
|
40
|
+
export function mountPathCalls(root: ts.Node): Set<string> {
|
|
41
|
+
// Every unconditional call in the file, with the local name whose body holds
|
|
42
|
+
// it. Collected once; the fixpoint below only re-reads this list.
|
|
43
|
+
const sites: { callee: string; owner: string | undefined; underEffect: boolean }[] = [];
|
|
44
|
+
const walk = (n: ts.Node): void => {
|
|
45
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && !isConditional(n)) {
|
|
46
|
+
sites.push({
|
|
47
|
+
callee: n.expression.text,
|
|
48
|
+
owner: owningLocalName(n),
|
|
49
|
+
underEffect: underEffectHook(n) || inComponentBody(n),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
ts.forEachChild(n, walk);
|
|
53
|
+
};
|
|
54
|
+
walk(root);
|
|
55
|
+
|
|
56
|
+
// Seed with what an effect hook evaluates directly, then keep adding the
|
|
57
|
+
// bodies of local functions those calls name, until nothing new appears.
|
|
58
|
+
// Bounded by the number of distinct local names, so it always terminates.
|
|
59
|
+
const reached = new Set<string>();
|
|
60
|
+
for (const s of sites) {
|
|
61
|
+
if (s.underEffect) reached.add(s.callee);
|
|
62
|
+
}
|
|
63
|
+
for (;;) {
|
|
64
|
+
const before = reached.size;
|
|
65
|
+
for (const s of sites) {
|
|
66
|
+
if (s.owner !== undefined && reached.has(s.owner)) reached.add(s.callee);
|
|
67
|
+
}
|
|
68
|
+
if (reached.size === before) break;
|
|
69
|
+
}
|
|
70
|
+
return reached;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The local name whose body contains this call — `init` for a call inside
|
|
75
|
+
* `const init = useCallback(async () => { … })`, or inside `function init()`.
|
|
76
|
+
*
|
|
77
|
+
* The useCallback wrapper is transparent on purpose: it is how every one of the
|
|
78
|
+
* dogfood app's loaders is written, and treating it as an opaque function put
|
|
79
|
+
* every loader out of reach.
|
|
80
|
+
*/
|
|
81
|
+
function owningLocalName(n: ts.Node): string | undefined {
|
|
82
|
+
const fn = enclosingFunction(n);
|
|
83
|
+
if (!fn) return undefined;
|
|
84
|
+
let up: ts.Node | undefined = fn.parent;
|
|
85
|
+
// Through useCallback/useMemo and any other single-argument wrapper call.
|
|
86
|
+
while (up && ts.isCallExpression(up)) up = up.parent;
|
|
87
|
+
if (up && ts.isVariableDeclaration(up) && ts.isIdentifier(up.name)) return up.name.text;
|
|
88
|
+
if (ts.isFunctionDeclaration(fn) && fn.name) return fn.name.text;
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isEffectHook(e: ts.Expression): boolean {
|
|
93
|
+
const name = ts.isIdentifier(e)
|
|
94
|
+
? e.text
|
|
95
|
+
: ts.isPropertyAccessExpression(e)
|
|
96
|
+
? e.name.text
|
|
97
|
+
: undefined;
|
|
98
|
+
return name === "useEffect" || name === "useLayoutEffect" || name === "useFocusEffect";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether this call runs on an unconditional mount.
|
|
103
|
+
*
|
|
104
|
+
* Walks to the source file rather than stopping at the first function, because
|
|
105
|
+
* the enclosing chain is the whole question: a call inside a `useCallback`
|
|
106
|
+
* inside a `useFocusEffect` is on the mount path, and the same call inside an
|
|
107
|
+
* `onPress={…}` is not.
|
|
108
|
+
*/
|
|
109
|
+
function isConditional(call: ts.CallExpression): boolean {
|
|
110
|
+
let node: ts.Node = call;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const parent: ts.Node | undefined = node.parent;
|
|
113
|
+
if (!parent) return false;
|
|
114
|
+
// A branch anywhere above disqualifies it — the mock returns a row, the
|
|
115
|
+
// guard is false, and nothing renders. That is `if (isTrustee)`, exactly.
|
|
116
|
+
if (ts.isIfStatement(parent) && parent.expression !== node) return true;
|
|
117
|
+
if (ts.isConditionalExpression(parent) && parent.condition !== node) return true;
|
|
118
|
+
if (ts.isCatchClause(parent)) return true;
|
|
119
|
+
if (
|
|
120
|
+
ts.isBinaryExpression(parent) &&
|
|
121
|
+
(parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
122
|
+
parent.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
|
|
123
|
+
parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) &&
|
|
124
|
+
parent.right === node
|
|
125
|
+
) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
// A JSX attribute value is an event handler, not a mount.
|
|
129
|
+
if (ts.isJsxAttribute(parent) || ts.isJsxExpression(parent)) return true;
|
|
130
|
+
node = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function underEffectHook(call: ts.CallExpression): boolean {
|
|
135
|
+
let node: ts.Node | undefined = call.parent;
|
|
136
|
+
while (node) {
|
|
137
|
+
if (ts.isCallExpression(node) && isEffectHook(node.expression)) return true;
|
|
138
|
+
node = node.parent;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The call sits directly in a top-level function's body.
|
|
145
|
+
*
|
|
146
|
+
* "Top-level" meaning the nearest enclosing function is not itself inside
|
|
147
|
+
* another function — i.e. it is the component, not a callback within it. A bare
|
|
148
|
+
* `hasEnclosingFunction` check would be false for every call in a component,
|
|
149
|
+
* since the component IS a function, making the clause unreachable.
|
|
150
|
+
*/
|
|
151
|
+
function inComponentBody(n: ts.Node): boolean {
|
|
152
|
+
const nearest = enclosingFunction(n);
|
|
153
|
+
if (!nearest) return true;
|
|
154
|
+
return enclosingFunction(nearest) === undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function enclosingFunction(n: ts.Node): ts.Node | undefined {
|
|
158
|
+
let node: ts.Node | undefined = n.parent;
|
|
159
|
+
while (node) {
|
|
160
|
+
if (
|
|
161
|
+
ts.isArrowFunction(node) ||
|
|
162
|
+
ts.isFunctionExpression(node) ||
|
|
163
|
+
ts.isFunctionDeclaration(node) ||
|
|
164
|
+
ts.isMethodDeclaration(node)
|
|
165
|
+
) {
|
|
166
|
+
return node;
|
|
167
|
+
}
|
|
168
|
+
node = node.parent;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The imported functions whose returned collection is RENDERED as a list.
|
|
175
|
+
*
|
|
176
|
+
* The mount-path filter answers "is this function called?". This answers the
|
|
177
|
+
* other half — "does what it returns reach the screen?" — and without it the
|
|
178
|
+
* loaded-state test picked `getMeWithMemberships` on five screens. That call is
|
|
179
|
+
* on the mount path, its return type does contain a collection (`memberships`),
|
|
180
|
+
* and every one of the five uses it to decide a permission flag:
|
|
181
|
+
*
|
|
182
|
+
* const isTrustee = me.memberships.some((m) => TRUSTEE_ROLES.includes(m.role));
|
|
183
|
+
*
|
|
184
|
+
* A mock returning a row changes a boolean. Nothing renders, and the test failed
|
|
185
|
+
* looking for text no screen was ever going to show.
|
|
186
|
+
*
|
|
187
|
+
* The chain, entirely within the file: a call's result is destructured to a
|
|
188
|
+
* local, the local is passed to a `useState` setter, and that setter's state
|
|
189
|
+
* variable is mapped in JSX or handed to a list as `data`. Anything else is not
|
|
190
|
+
* claimed.
|
|
191
|
+
*
|
|
192
|
+
* Returns the ROW FIELDS each qualifying function's list renders as text, not
|
|
193
|
+
* just the function names, because the witness has to come from the row that
|
|
194
|
+
* maps THIS collection. Taken file-wide it picked `label` for a screen whose
|
|
195
|
+
* invoice row renders `invoice_number` and `due_date` — the field is rendered
|
|
196
|
+
* somewhere in the file, by a different element, over different data.
|
|
197
|
+
*/
|
|
198
|
+
export function collectionRenderingCalls(root: ts.Node): Map<string, Set<string>> {
|
|
199
|
+
const stateOfSetter = new Map<string, string>();
|
|
200
|
+
/** state variable → the fields its row renders as text. */
|
|
201
|
+
const renderedLists = new Map<string, Set<string>>();
|
|
202
|
+
const boundFrom = new Map<string, string>();
|
|
203
|
+
|
|
204
|
+
const walk = (n: ts.Node): void => {
|
|
205
|
+
if (ts.isVariableDeclaration(n)) {
|
|
206
|
+
// const [items, setItems] = useState(…)
|
|
207
|
+
if (
|
|
208
|
+
n.name &&
|
|
209
|
+
ts.isArrayBindingPattern(n.name) &&
|
|
210
|
+
n.initializer &&
|
|
211
|
+
ts.isCallExpression(n.initializer) &&
|
|
212
|
+
calleeName(n.initializer.expression) === "useState"
|
|
213
|
+
) {
|
|
214
|
+
const [a, b] = n.name.elements;
|
|
215
|
+
if (
|
|
216
|
+
a &&
|
|
217
|
+
ts.isBindingElement(a) &&
|
|
218
|
+
ts.isIdentifier(a.name) &&
|
|
219
|
+
b &&
|
|
220
|
+
ts.isBindingElement(b) &&
|
|
221
|
+
ts.isIdentifier(b.name)
|
|
222
|
+
) {
|
|
223
|
+
stateOfSetter.set(b.name.text, a.name.text);
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
recordBindings(n, boundFrom);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// items.map(…) — only inside JSX, so a `.map` used to compute a value is
|
|
230
|
+
// not mistaken for a render.
|
|
231
|
+
if (
|
|
232
|
+
ts.isCallExpression(n) &&
|
|
233
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
234
|
+
n.expression.name.text === "map" &&
|
|
235
|
+
ts.isIdentifier(n.expression.expression) &&
|
|
236
|
+
insideJsx(n)
|
|
237
|
+
) {
|
|
238
|
+
renderedLists.set(n.expression.expression.text, rowTextFields(n));
|
|
239
|
+
}
|
|
240
|
+
// <FlatList data={items} /> and its SectionList peer.
|
|
241
|
+
if (
|
|
242
|
+
ts.isJsxAttribute(n) &&
|
|
243
|
+
ts.isIdentifier(n.name) &&
|
|
244
|
+
(n.name.text === "data" || n.name.text === "sections") &&
|
|
245
|
+
n.initializer &&
|
|
246
|
+
ts.isJsxExpression(n.initializer) &&
|
|
247
|
+
n.initializer.expression &&
|
|
248
|
+
ts.isIdentifier(n.initializer.expression)
|
|
249
|
+
) {
|
|
250
|
+
// A FlatList renders through renderItem, whose text fields this does not
|
|
251
|
+
// read — an empty set, so the caller falls back to its own evidence
|
|
252
|
+
// rather than being handed a wrong answer.
|
|
253
|
+
const name = n.initializer.expression.text;
|
|
254
|
+
if (!renderedLists.has(name)) renderedLists.set(name, new Set<string>());
|
|
255
|
+
}
|
|
256
|
+
ts.forEachChild(n, walk);
|
|
257
|
+
};
|
|
258
|
+
walk(root);
|
|
259
|
+
|
|
260
|
+
const out = new Map<string, Set<string>>();
|
|
261
|
+
const walkSetters = (n: ts.Node): void => {
|
|
262
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
|
263
|
+
const state = stateOfSetter.get(n.expression.text);
|
|
264
|
+
const arg = n.arguments[0];
|
|
265
|
+
const fields = state === undefined ? undefined : renderedLists.get(state);
|
|
266
|
+
if (fields !== undefined && arg && ts.isIdentifier(arg)) {
|
|
267
|
+
const origin = boundFrom.get(arg.text);
|
|
268
|
+
if (origin !== undefined) out.set(origin, fields);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
ts.forEachChild(n, walkSetters);
|
|
272
|
+
};
|
|
273
|
+
walkSetters(root);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The row's own text fields: `inv` members that a `.map((inv) => …)` callback
|
|
279
|
+
* renders as JSX CHILDREN.
|
|
280
|
+
*
|
|
281
|
+
* Children only, and only where the access is the whole child expression or a
|
|
282
|
+
* span of a template literal that is. `key={inv.id}` and
|
|
283
|
+
* `onPress={() => router.push(`…${inv.id}`)}` are attribute values — a witness
|
|
284
|
+
* taken from either would be a string the screen never displays.
|
|
285
|
+
*/
|
|
286
|
+
function rowTextFields(map: ts.CallExpression): Set<string> {
|
|
287
|
+
const out = new Set<string>();
|
|
288
|
+
const cb = map.arguments[0];
|
|
289
|
+
if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return out;
|
|
290
|
+
const p = cb.parameters[0];
|
|
291
|
+
if (!p || !ts.isIdentifier(p.name)) return out;
|
|
292
|
+
const row = p.name.text;
|
|
293
|
+
|
|
294
|
+
const record = (e: ts.Expression): void => {
|
|
295
|
+
if (
|
|
296
|
+
ts.isPropertyAccessExpression(e) &&
|
|
297
|
+
ts.isIdentifier(e.expression) &&
|
|
298
|
+
e.expression.text === row
|
|
299
|
+
) {
|
|
300
|
+
out.add(e.name.text);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const walk = (n: ts.Node): void => {
|
|
304
|
+
if (
|
|
305
|
+
ts.isJsxExpression(n) &&
|
|
306
|
+
n.expression &&
|
|
307
|
+
n.parent &&
|
|
308
|
+
(ts.isJsxElement(n.parent) || ts.isJsxFragment(n.parent))
|
|
309
|
+
) {
|
|
310
|
+
const e = n.expression;
|
|
311
|
+
record(e);
|
|
312
|
+
if (ts.isTemplateExpression(e)) {
|
|
313
|
+
for (const span of e.templateSpans) record(span.expression);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
ts.forEachChild(n, walk);
|
|
317
|
+
};
|
|
318
|
+
walk(cb.body);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Locals bound from a call's result, mapped to the function that produced it.
|
|
324
|
+
*
|
|
325
|
+
* Two shapes, both of which the dogfood app uses:
|
|
326
|
+
* const { items } = await listBuildings(id);
|
|
327
|
+
* const [{ poll: p }, { items: cs }] = await Promise.all([getPoll(…), listPollComments(…)]);
|
|
328
|
+
*
|
|
329
|
+
* The Promise.all form is positional — binding element i comes from array
|
|
330
|
+
* element i — which is why it is matched by index rather than by name.
|
|
331
|
+
*/
|
|
332
|
+
function recordBindings(decl: ts.VariableDeclaration, out: Map<string, string>): void {
|
|
333
|
+
const init = decl.initializer;
|
|
334
|
+
if (!init) return;
|
|
335
|
+
const inner = ts.isAwaitExpression(init) ? init.expression : init;
|
|
336
|
+
if (!ts.isCallExpression(inner)) return;
|
|
337
|
+
|
|
338
|
+
if (calleeName(inner.expression) === "Promise.all") {
|
|
339
|
+
const arr = inner.arguments[0];
|
|
340
|
+
if (!arr || !ts.isArrayLiteralExpression(arr)) return;
|
|
341
|
+
if (!ts.isArrayBindingPattern(decl.name)) return;
|
|
342
|
+
decl.name.elements.forEach((el, i) => {
|
|
343
|
+
const producer = arr.elements[i];
|
|
344
|
+
if (!producer || !ts.isCallExpression(producer)) return;
|
|
345
|
+
const fn = calleeName(producer.expression);
|
|
346
|
+
if (!fn || !ts.isBindingElement(el)) return;
|
|
347
|
+
for (const name of bindingNames(el.name)) out.set(name, fn);
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const fn = calleeName(inner.expression);
|
|
352
|
+
if (!fn) return;
|
|
353
|
+
for (const name of bindingNames(decl.name)) out.set(name, fn);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function bindingNames(n: ts.BindingName): string[] {
|
|
357
|
+
if (ts.isIdentifier(n)) return [n.text];
|
|
358
|
+
const out: string[] = [];
|
|
359
|
+
for (const el of n.elements) {
|
|
360
|
+
if (ts.isBindingElement(el)) out.push(...bindingNames(el.name));
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function calleeName(e: ts.Expression): string | undefined {
|
|
366
|
+
if (ts.isIdentifier(e)) return e.text;
|
|
367
|
+
if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression)) {
|
|
368
|
+
return `${e.expression.text}.${e.name.text}`;
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function insideJsx(n: ts.Node): boolean {
|
|
374
|
+
let node: ts.Node | undefined = n.parent;
|
|
375
|
+
while (node) {
|
|
376
|
+
if (ts.isJsxExpression(node)) return true;
|
|
377
|
+
node = node.parent;
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The declaration of `name` in `sf`, for scoping the two walks above.
|
|
384
|
+
*
|
|
385
|
+
* Both classifiers answer questions ABOUT A COMPONENT, and every fact they read
|
|
386
|
+
* — which calls are on the mount path, which collections reach JSX — belongs to
|
|
387
|
+
* one component's subtree. Handed a whole file they answer for the file, and a
|
|
388
|
+
* second exported component in it inherits the first one's evidence.
|
|
389
|
+
*
|
|
390
|
+
* Falls back to the file when the name cannot be located, which is what the
|
|
391
|
+
* emitter did for every component before this existed: worse than scoped, and
|
|
392
|
+
* still better than claiming nothing.
|
|
393
|
+
*/
|
|
394
|
+
export function componentNode(sf: ts.SourceFile, name: string): ts.Node {
|
|
395
|
+
let found: ts.Node | undefined;
|
|
396
|
+
const visit = (n: ts.Node): void => {
|
|
397
|
+
if (found) return;
|
|
398
|
+
if (ts.isFunctionDeclaration(n) && n.name?.text === name) {
|
|
399
|
+
found = n;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (
|
|
403
|
+
ts.isVariableDeclaration(n) &&
|
|
404
|
+
ts.isIdentifier(n.name) &&
|
|
405
|
+
n.name.text === name &&
|
|
406
|
+
n.initializer
|
|
407
|
+
) {
|
|
408
|
+
found = n.initializer;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// A class component, whose subtree is the class body. Without this arm a
|
|
412
|
+
// class fell through to `sf` — the whole source file — which is the exact
|
|
413
|
+
// failure the doc comment above exists to prevent, and it arrived the moment
|
|
414
|
+
// parseFile learned to see classes at all.
|
|
415
|
+
if (ts.isClassDeclaration(n) && n.name?.text === name) {
|
|
416
|
+
found = n;
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
ts.forEachChild(n, visit);
|
|
420
|
+
};
|
|
421
|
+
visit(sf);
|
|
422
|
+
return found ?? sf;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Every identifier named anywhere in `root`.
|
|
427
|
+
*
|
|
428
|
+
* `moduleImports` is a FILE-level fact — parseFile collects the file's imports,
|
|
429
|
+
* not each component's — so a component that references none of them still
|
|
430
|
+
* arrives at the mock emitter carrying all of them. Without this, a component
|
|
431
|
+
* that imports nothing and calls nothing was handed a skip reading "loaded state
|
|
432
|
+
* via listItems", naming its sibling's dependency.
|
|
433
|
+
*/
|
|
434
|
+
export function referencedNames(root: ts.Node): Set<string> {
|
|
435
|
+
const out = new Set<string>();
|
|
436
|
+
const walk = (n: ts.Node): void => {
|
|
437
|
+
if (ts.isIdentifier(n)) out.add(n.text);
|
|
438
|
+
ts.forEachChild(n, walk);
|
|
439
|
+
};
|
|
440
|
+
walk(root);
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { FieldInfo } from "../types.ts";
|
|
2
|
+
import { nativeElementFor } from "./nativeProps.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Facts about React Native's content model that a generated harness has to respect.
|
|
6
|
+
*
|
|
7
|
+
* The peer of htmlContentModel.ts, and much shorter — which is the finding, not
|
|
8
|
+
* an omission. React Native has no `<tbody>`-must-be-inside-`<table>` rules: no
|
|
9
|
+
* required-ancestor chains exist, so this file declares none rather than
|
|
10
|
+
* inventing a table to look symmetrical.
|
|
11
|
+
*
|
|
12
|
+
* What it does encode is the one hard runtime invariant React Native does have,
|
|
13
|
+
* plus the fill model, both of which change what a spec may assert.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a raw string may be rendered directly inside this element.
|
|
18
|
+
*
|
|
19
|
+
* React Native throws
|
|
20
|
+
*
|
|
21
|
+
* Invariant Violation: Text strings must be rendered within a <Text> component
|
|
22
|
+
*
|
|
23
|
+
* — a HARD runtime invariant, which makes this the direct analogue of
|
|
24
|
+
* REQUIRED_ANCESTORS: a fact about the framework, not an inference about the app.
|
|
25
|
+
* It is what stops extractStaticText harvesting copy from `<View>hello</View>`,
|
|
26
|
+
* which no query could ever find because the component never rendered at all.
|
|
27
|
+
*
|
|
28
|
+
* Unknown elements (every component tag) answer false: a component may well
|
|
29
|
+
* render its children inside a <Text> of its own, but we cannot see that from
|
|
30
|
+
* here, and claiming text the render may not produce is the wrong way to be wrong.
|
|
31
|
+
*/
|
|
32
|
+
export function stringsAllowedIn(element: string | undefined): boolean {
|
|
33
|
+
return element === "Text";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How (or whether) a native field can be driven — the peer of `fillKind`.
|
|
38
|
+
*
|
|
39
|
+
* There is no "select": React Native has no `<select>`, and a Picker is a
|
|
40
|
+
* third-party component whose driving is library-specific.
|
|
41
|
+
*
|
|
42
|
+
* "frozen" is the interesting one and it is recorded rather than dropped, so the
|
|
43
|
+
* emitter can say WHY it skipped instead of pretending the field is absent. A
|
|
44
|
+
* `<TextInput value={x}/>` with no `onChangeText` cannot be typed into: RN issues
|
|
45
|
+
* no controlled-input warning (unlike the DOM), so a fill-and-assert spec against
|
|
46
|
+
* it fails for the SPEC's fault, not the app's.
|
|
47
|
+
*/
|
|
48
|
+
export type NativeFillKind = "text" | "toggle" | "skip";
|
|
49
|
+
|
|
50
|
+
export function nativeFillKind(field: Pick<FieldInfo, "tag" | "control">): NativeFillKind {
|
|
51
|
+
if (field.tag === "Switch") return "toggle";
|
|
52
|
+
if (field.tag !== "TextInput") return "skip";
|
|
53
|
+
return field.control === "frozen" ? "skip" : "text";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Providers a component's own HOOK calls demand, outermost first.
|
|
58
|
+
*
|
|
59
|
+
* Keyed on hooks rather than on tags, because that is where the throw actually
|
|
60
|
+
* is. `useFocusEffect` reaches `useNavigation()`, which throws
|
|
61
|
+
*
|
|
62
|
+
* Couldn't find a navigation object. Is your component inside NavigationContainer?
|
|
63
|
+
*
|
|
64
|
+
* so a screen using it cannot be mounted by a bare render at all — it needs
|
|
65
|
+
* `renderRouter` from expo-router/testing-library.
|
|
66
|
+
*
|
|
67
|
+
* `SafeAreaView` is deliberately ABSENT from this list, and that absence is
|
|
68
|
+
* checked by a test. react-native-safe-area-context v5's SafeAreaView renders a
|
|
69
|
+
* native view and reads no context; only `useSafeAreaInsets` and
|
|
70
|
+
* `useSafeAreaFrame` throw. Listing it would encode a required-ancestor fact that
|
|
71
|
+
* is simply false — which is the failure mode this whole file exists to avoid.
|
|
72
|
+
*/
|
|
73
|
+
export interface RequiredProvider {
|
|
74
|
+
hook: string;
|
|
75
|
+
provider: string;
|
|
76
|
+
from: string;
|
|
77
|
+
note: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const HOOK_PROVIDERS: Record<string, RequiredProvider> = {
|
|
81
|
+
useFocusEffect: {
|
|
82
|
+
hook: "useFocusEffect",
|
|
83
|
+
provider: "renderRouter",
|
|
84
|
+
from: "expo-router/testing-library",
|
|
85
|
+
note:
|
|
86
|
+
"calls useFocusEffect, which reaches useNavigation() and throws \"Couldn't find a " +
|
|
87
|
+
'navigation object" outside a navigator — mount it with renderRouter() instead of render()',
|
|
88
|
+
},
|
|
89
|
+
useNavigation: {
|
|
90
|
+
hook: "useNavigation",
|
|
91
|
+
provider: "renderRouter",
|
|
92
|
+
from: "expo-router/testing-library",
|
|
93
|
+
note: "calls useNavigation(), which throws outside a navigator — mount it with renderRouter()",
|
|
94
|
+
},
|
|
95
|
+
useLocalSearchParams: {
|
|
96
|
+
hook: "useLocalSearchParams",
|
|
97
|
+
provider: "renderRouter",
|
|
98
|
+
from: "expo-router/testing-library",
|
|
99
|
+
note:
|
|
100
|
+
"reads useLocalSearchParams(), which is empty outside a router — mount it with " +
|
|
101
|
+
"renderRouter() at a URL that supplies the params",
|
|
102
|
+
},
|
|
103
|
+
useSafeAreaInsets: {
|
|
104
|
+
hook: "useSafeAreaInsets",
|
|
105
|
+
provider: "SafeAreaProvider",
|
|
106
|
+
from: "react-native-safe-area-context",
|
|
107
|
+
note:
|
|
108
|
+
'calls useSafeAreaInsets(), which throws "No safe area value available" without a ' +
|
|
109
|
+
"SafeAreaProvider carrying initialMetrics",
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function requiredNativeProviders(hooks: readonly string[]): RequiredProvider[] {
|
|
114
|
+
const out: RequiredProvider[] = [];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
for (const h of hooks) {
|
|
117
|
+
const p = HOOK_PROVIDERS[h];
|
|
118
|
+
if (p && !seen.has(p.provider + p.hook)) {
|
|
119
|
+
seen.add(p.provider + p.hook);
|
|
120
|
+
out.push(p);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** True when the element accepts children, per the closed table. */
|
|
127
|
+
export function nativeAcceptsChildren(specifier: string, exportedName: string): boolean {
|
|
128
|
+
return nativeElementFor(specifier, exportedName)?.acceptsChildren ?? true;
|
|
129
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ComponentTree } from "../types.ts";
|
|
2
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which host-element vocabulary a FILE's JSX is written in.
|
|
6
|
+
*
|
|
7
|
+
* - "dom" — lowercase intrinsics (`<div>`, `<button>`). Every file the tool
|
|
8
|
+
* has ever analysed, and the answer whenever nothing says otherwise.
|
|
9
|
+
* - "native" — React Native elements, which are IMPORTED capitalised tags
|
|
10
|
+
* (`<View>` from "react-native").
|
|
11
|
+
*
|
|
12
|
+
* Decided per FILE from that file's own imports, not per run from a flag. Three
|
|
13
|
+
* reasons, in order of how much they cost to get wrong:
|
|
14
|
+
*
|
|
15
|
+
* 1. The evidence is already here. `import { View } from "react-native"` sits in
|
|
16
|
+
* `parsed.imports` before any extractor runs, so a flag would ask the operator
|
|
17
|
+
* to assert something the tool can read.
|
|
18
|
+
* 2. A flag cannot express a monorepo. `apps/web` and `apps/mobile` under one
|
|
19
|
+
* sweep have different answers, and a single global value has to be wrong for
|
|
20
|
+
* one of them.
|
|
21
|
+
* 3. "An Expo app also targets web" argues AGAINST a run-level axis, not for it.
|
|
22
|
+
* Under Expo web the source is byte-identical — still `<View>`, still imported
|
|
23
|
+
* from "react-native". What changes is the runtime (react-native-web maps View
|
|
24
|
+
* to a div), not the AST. There is nothing here for a platform flag to select.
|
|
25
|
+
*
|
|
26
|
+
* The invariant that makes this safe: **a file with host elements to classify
|
|
27
|
+
* always has the import that proves the dialect**, because a React Native host
|
|
28
|
+
* element is only ever reachable through an import — there are no ambient
|
|
29
|
+
* capitalised intrinsics. A file with no native import either renders lowercase
|
|
30
|
+
* intrinsics (dialect "dom", correct) or renders only component tags (dialect
|
|
31
|
+
* irrelevant, because nothing downstream branches).
|
|
32
|
+
*/
|
|
33
|
+
export type HostDialect = "dom" | "native";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Specifiers whose presence proves the file is React Native source.
|
|
37
|
+
*
|
|
38
|
+
* Broad on purpose: this decides the DIALECT, not the element set. `expo-secure-store`
|
|
39
|
+
* exports no element at all, but a file importing it is unambiguously React
|
|
40
|
+
* Native, and that is the only question being asked here. Which tags are host
|
|
41
|
+
* elements is `nativeProps.ts`'s much narrower, closed table.
|
|
42
|
+
*
|
|
43
|
+
* `react-dom` is deliberately absent — a file may import both under Expo web.
|
|
44
|
+
* The native import is the positive evidence; nothing is evidence against it.
|
|
45
|
+
*/
|
|
46
|
+
const NATIVE_DIALECT_SPECIFIER =
|
|
47
|
+
/^(react-native|react-native-[\w.-]+|@react-native(-community)?\/|expo|expo-[\w.-]+|@expo\/|@react-navigation\/|nativewind)$|^(react-native|@react-native|expo|@expo|@react-navigation|nativewind)\//;
|
|
48
|
+
|
|
49
|
+
export function detectHostDialect(imports: Map<string, ImportBinding>): HostDialect {
|
|
50
|
+
for (const binding of imports.values()) {
|
|
51
|
+
if (binding.isTypeOnly) continue;
|
|
52
|
+
if (NATIVE_DIALECT_SPECIFIER.test(binding.specifier)) return "native";
|
|
53
|
+
}
|
|
54
|
+
return "dom";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the component graph under `tree` renders ANY React Native source —
|
|
59
|
+
* the root's own file, or any component below it.
|
|
60
|
+
*
|
|
61
|
+
* This is the question a DOM plane has to answer. A spec imports the component,
|
|
62
|
+
* so the whole graph below it loads: one `<View>` anywhere in it drags
|
|
63
|
+
* `react-native` into the module graph, and vitest dies at COLLECT — its source
|
|
64
|
+
* is Flow and Metro-targeted, so vite cannot parse it, let alone mount it
|
|
65
|
+
* (`SyntaxError: Unexpected token 'typeof'`). Not a failing test: no test.
|
|
66
|
+
*
|
|
67
|
+
* The root's own dialect is what bites in practice — in a bare-RN app nearly
|
|
68
|
+
* every source file imports react-native directly, which is how ospfranco/sol
|
|
69
|
+
* measured — but the WALK is what makes the answer correct rather than lucky:
|
|
70
|
+
* a DOM-shaped shell that imports a native child loads react-native too, and
|
|
71
|
+
* emitting a spec for the shell hands back exactly the crash this avoids. The
|
|
72
|
+
* other direction is harmless to skip, since nothing under a native page can
|
|
73
|
+
* mount either.
|
|
74
|
+
*/
|
|
75
|
+
export function rendersNativeSource(tree: ComponentTree): boolean {
|
|
76
|
+
if (tree.entry.hostDialect === "native") return true;
|
|
77
|
+
return tree.children.some(rendersNativeSource);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Exported for the test that pins which specifiers count. */
|
|
81
|
+
export function isNativeSpecifier(specifier: string): boolean {
|
|
82
|
+
return NATIVE_DIALECT_SPECIFIER.test(specifier);
|
|
83
|
+
}
|