@lariocpt/flawless-react 0.0.0-stage → 0.3.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 (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. 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
+ }