@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.
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 +608 -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,1421 @@
1
+ import ts from "typescript";
2
+ import type {
3
+ ClickHandler,
4
+ ElementHints,
5
+ FieldInfo,
6
+ FormInfo,
7
+ HandlerBinding,
8
+ HandlerEffect,
9
+ InteractionInfo,
10
+ LabelSource,
11
+ NativeA11yElement,
12
+ PropsShape,
13
+ SubmitControl,
14
+ } from "../types.ts";
15
+ import { findDisclosures } from "./extractDisclosures.ts";
16
+ import {
17
+ hasDynamicLabel,
18
+ hasDynamicRole,
19
+ hasLiteralLabel,
20
+ literalRoleAttrs,
21
+ PRESS_FAMILY,
22
+ readHiddenFromA11y,
23
+ } from "./extractNativeA11y.ts";
24
+ import { domOnlyResolver, type HostTagResolver } from "./hostTag.ts";
25
+ import type { HostDialect } from "./nativeDialect.ts";
26
+ import { NATIVE_TAG_TO_ROLE, TEXTLESS_ELEMENTS } from "./nativeProps.ts";
27
+ import { posOf } from "./pos.ts";
28
+ import { hookCalleeName } from "./refs.ts";
29
+ import {
30
+ emptyEnv,
31
+ evaluate,
32
+ initialRenderExpression,
33
+ type RenderEnv,
34
+ reachability,
35
+ truthy,
36
+ } from "./renderPath.ts";
37
+
38
+ const EVENT_PATTERN =
39
+ /^on(Click|MouseDown|PointerDown|Submit|Change|Input|Focus|Blur|KeyDown|KeyPress)$/;
40
+
41
+ /**
42
+ * React Native events @testing-library/react-native can drive without inventing
43
+ * a payload.
44
+ *
45
+ * Deliberately excluded: onScroll, onEndReached, onLayout, onContentSizeChange.
46
+ * Each needs a synthetic nativeEvent carrying contentSize/layoutMeasurement, and
47
+ * a made-up viewport is a guess the resulting assertion then depends on.
48
+ *
49
+ * onChange IS included: React Native's TextInput onChange payload is a
50
+ * documented shape (`{ nativeEvent: { text } }`) — a specification, not a guess.
51
+ */
52
+ const NATIVE_EVENT_PATTERN =
53
+ /^on(Press|LongPress|PressIn|PressOut|ChangeText|Change|SubmitEditing|EndEditing|Focus|Blur|ValueChange|Refresh)$/;
54
+
55
+ const FORM_FIELD_TAGS = new Set(["input", "select", "textarea"]);
56
+
57
+ export function extractInteractions(
58
+ bodyNode: ts.Node,
59
+ sourceFile: ts.SourceFile,
60
+ propsShape: PropsShape,
61
+ unresolvedProps: string[] = [],
62
+ env: RenderEnv = emptyEnv(),
63
+ /** Component tag → the host element it actually renders, where resolvable. */
64
+ tagRootTags: Map<string, string> = new Map(),
65
+ /** Which tags are host elements. Defaults to the DOM rule, so web is unchanged. */
66
+ hostTag: HostTagResolver = domOnlyResolver,
67
+ /**
68
+ * The file's element vocabulary, decided by detectHostDialect from its whole
69
+ * import list. Passed rather than probed: no single tag name is present in
70
+ * every React Native file.
71
+ */
72
+ dialect: HostDialect = "dom",
73
+ /**
74
+ * State setter names, so a `setX(…)` in a handler is recorded as a setState
75
+ * effect rather than as an opaque call. Passed rather than re-derived:
76
+ * extractState already owns this and running it twice invites the two to
77
+ * disagree.
78
+ */
79
+ setterNames: Set<string> = new Set(),
80
+ ): InteractionInfo {
81
+ // Handler props are function-typed, which extractProps cannot synthesise and
82
+ // routes to unresolvedProps. Union both so the classifier sees them.
83
+ const propNames = new Set([...Object.keys(propsShape), ...unresolvedProps]);
84
+ const clicks: ClickHandler[] = [];
85
+ const forms: FormInfo[] = [];
86
+
87
+ // The dialect, taken from the caller.
88
+ //
89
+ // This was `hostTag("View")?.dialect === "native"`, which is only true when the
90
+ // file imports View UNDER THAT EXACT LOCAL NAME. `import { Pressable, Text }`,
91
+ // `import { View as Box }` and `import * as RN` all answered "dom", so the DOM
92
+ // EVENT_PATTERN was used, onPress matched nothing, and clicks came back EMPTY —
93
+ // producing a spec with a mount assertion, no interaction tests, and NO SKIP
94
+ // naming the gap. Green, touching nothing, silent about it: the exact failure
95
+ // this tool exists to refuse, on the commonest file shape in a React Native app.
96
+ //
97
+ // detectHostDialect already answers this correctly from the file's whole import
98
+ // list, and analyse/index.ts computes it one line before building the resolver.
99
+ // It just was not passed.
100
+ const native = dialect === "native";
101
+ const eventPattern = native ? NATIVE_EVENT_PATTERN : EVENT_PATTERN;
102
+
103
+ /**
104
+ * Elements a label harvest may look THROUGH on native.
105
+ *
106
+ * A container (View, Text, a layout wrapper) holds the copy one level down, so
107
+ * descending finds "Sign in" inside `<Pressable><Text>Sign in</Text></Pressable>`.
108
+ * A textless element (an icon, a spinner) genuinely renders no text, so it
109
+ * stays an interruption — which is what leaves an icon-only Pressable honestly
110
+ * unlabelled instead of borrowing a name it does not have.
111
+ *
112
+ * An UNKNOWN component tag is also an interruption. It may well render text,
113
+ * but we cannot see that from here, and claiming a name the render may not
114
+ * produce is the wrong way to be wrong.
115
+ */
116
+ /**
117
+ * The children of a conditional's taken branch, when it renders a transparent
118
+ * element.
119
+ *
120
+ * Parentheses are the whole reason this is a function. Prettier and biome both
121
+ * format a multi-line JSX ternary as `cond ? (\n <A/>\n) : (\n <B/>\n)`, so
122
+ * the branch is a ParenthesizedExpression and a bare `isJsxElement` check
123
+ * misses EVERY real submit button. The fixture was written without parentheses
124
+ * and passed; the dogfood app has them everywhere and did not.
125
+ */
126
+ const childrenOfBranch = (branch: ts.Expression): ts.NodeArray<ts.JsxChild> | undefined => {
127
+ const el = unwrapParens(branch);
128
+ if (!ts.isJsxElement(el)) return undefined;
129
+ return transparent(el) ? el.children : undefined;
130
+ };
131
+
132
+ /**
133
+ * Whether an element renders element children and ALL of them are textless.
134
+ *
135
+ * Not simply "has no label": a Pressable whose text sits behind an undecidable
136
+ * condition is unlabelled too, but adding an accessibilityLabel is not the
137
+ * whole story there. Icon-only is the case where the remediation is exact.
138
+ */
139
+ const isIconOnly = (el: ts.JsxElement): boolean => {
140
+ let sawElement = false;
141
+ let allTextless = true;
142
+ const walk = (children: ts.NodeArray<ts.JsxChild>): void => {
143
+ for (const c of children) {
144
+ if (ts.isJsxText(c) && c.text.trim()) allTextless = false;
145
+ else if (ts.isJsxExpression(c) && c.expression) allTextless = false;
146
+ else if (ts.isJsxElement(c) || ts.isJsxSelfClosingElement(c)) {
147
+ const name = readTagName(ts.isJsxElement(c) ? c.openingElement.tagName : c.tagName);
148
+ const resolved = hostTag(name);
149
+ sawElement = true;
150
+ if (!resolved || !TEXTLESS_ELEMENTS.has(resolved.name)) allTextless = false;
151
+ if (ts.isJsxElement(c)) walk(c.children);
152
+ } else if (ts.isJsxFragment(c)) walk(c.children);
153
+ }
154
+ };
155
+ walk(el.children);
156
+ return sawElement && allTextless;
157
+ };
158
+
159
+ /**
160
+ * Literal navigation targets reached from a handler expression.
161
+ *
162
+ * `onPress={() => router.push("/items/new")}` is the shape; the receiver must
163
+ * be an expo-router router (a local bound from useRouter()), and the argument
164
+ * must be a string literal. A template literal names a route the spec cannot
165
+ * predict — `/items/${'${item.id}'}` depends on data — so it is skipped rather
166
+ * than half-matched.
167
+ */
168
+ const NAV_METHODS = new Set(["push", "replace", "navigate"]);
169
+ /** `router.back()` — same receiver rule, no argument to read. */
170
+ const callsBack = (init: ts.JsxAttribute["initializer"]): boolean => {
171
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return false;
172
+ let found = false;
173
+ const walk = (n: ts.Node): void => {
174
+ if (
175
+ ts.isCallExpression(n) &&
176
+ ts.isPropertyAccessExpression(n.expression) &&
177
+ n.expression.name.text === "back" &&
178
+ routerNames.has(rootIdentifierOf(n.expression.expression))
179
+ ) {
180
+ found = true;
181
+ }
182
+ ts.forEachChild(n, walk);
183
+ };
184
+ walk(init.expression);
185
+ return found;
186
+ };
187
+ /**
188
+ * Locals bound from expo-router's useRouter(), so a `.push` on anything else
189
+ * is not read as navigation. `const router = useRouter()` is the only shape
190
+ * this recognises; anything less direct bails rather than guesses.
191
+ */
192
+ const routerNames = new Set<string>();
193
+ {
194
+ const findRouters = (n: ts.Node): void => {
195
+ if (
196
+ ts.isVariableDeclaration(n) &&
197
+ ts.isIdentifier(n.name) &&
198
+ n.initializer &&
199
+ ts.isCallExpression(n.initializer) &&
200
+ ts.isIdentifier(n.initializer.expression) &&
201
+ n.initializer.expression.text === "useRouter"
202
+ ) {
203
+ routerNames.add(n.name.text);
204
+ }
205
+ ts.forEachChild(n, findRouters);
206
+ };
207
+ findRouters(bodyNode);
208
+ }
209
+ const collectPushTargets = (init: ts.JsxAttribute["initializer"]): string[] => {
210
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return [];
211
+ const out: string[] = [];
212
+ const walk = (n: ts.Node): void => {
213
+ if (
214
+ ts.isCallExpression(n) &&
215
+ ts.isPropertyAccessExpression(n.expression) &&
216
+ NAV_METHODS.has(n.expression.name.text) &&
217
+ // The RECEIVER has to be a router, not merely something with a method of
218
+ // that name. String.prototype.replace and Array.prototype.push share the
219
+ // namespace, so `setRaw(raw.replace("$", ""))` was captured as a
220
+ // navigation to "$" and emitted `toHavePathname("$")` — a guaranteed
221
+ // failure on a screen that navigates nowhere.
222
+ routerNames.has(rootIdentifierOf(n.expression.expression))
223
+ ) {
224
+ const arg = n.arguments[0];
225
+ if (arg && ts.isStringLiteral(arg)) out.push(arg.text);
226
+ }
227
+ ts.forEachChild(n, walk);
228
+ };
229
+ walk(init.expression);
230
+ return [...new Set(out)];
231
+ };
232
+
233
+ const transparent = (el: ts.JsxElement): boolean => {
234
+ const resolved = hostTag(readTagName(el.openingElement.tagName));
235
+ if (resolved?.dialect !== "native") return false;
236
+ return !TEXTLESS_ELEMENTS.has(resolved.name);
237
+ };
238
+ const descend = native
239
+ ? (child: ts.JsxChild): ts.NodeArray<ts.JsxChild> | undefined => {
240
+ if (ts.isJsxElement(child)) return transparent(child) ? child.children : undefined;
241
+ // `{busy ? <Spinner/> : <Text>Sign in</Text>}` is the shape of every
242
+ // submit control in a real app. The condition reduces at the props and
243
+ // initial state the spec passes — `busy` starts false — so the label IS
244
+ // knowable, and treating the whole expression as opaque threw away the
245
+ // one name the button actually has. Same evaluator the visit loop uses
246
+ // to decide reachability, so the two cannot disagree about which branch
247
+ // renders.
248
+ if (!ts.isJsxExpression(child) || !child.expression) return undefined;
249
+ const inner = unwrapParens(child.expression);
250
+ // `{busy && <Text>Saving</Text>}` as well as the ternary: both are how a
251
+ // real screen writes a conditional label.
252
+ if (
253
+ ts.isBinaryExpression(inner) &&
254
+ inner.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
255
+ ) {
256
+ const gate = evaluate(inner.left, scope);
257
+ if (gate === undefined || !truthy(gate)) return undefined;
258
+ return childrenOfBranch(inner.right);
259
+ }
260
+ if (!ts.isConditionalExpression(inner)) return undefined;
261
+ const decided = evaluate(inner.condition, scope);
262
+ if (decided === undefined) return undefined;
263
+ return childrenOfBranch(truthy(decided) ? inner.whenTrue : inner.whenFalse);
264
+ }
265
+ : undefined;
266
+
267
+ // Scope the harvest to the JSX that actually mounts. Scanning the whole body
268
+ // collected a "Cancel" button and a #notes field from a disclosure's expanded
269
+ // branch, then drove them against the collapsed render — three failures in one
270
+ // file, each looking like a missing element rather than a phantom one.
271
+ const initial = initialRenderExpression(bodyNode, env);
272
+ const scope = initial?.scope ?? env;
273
+
274
+ // Elements that only exist after a trigger is clicked. Querying them straight
275
+ // after render finds nothing, and the failure reads as a wrong selector.
276
+ const disclosures = initial ? findDisclosures(initial.expr) : [];
277
+ const gateFor = (n: ts.Node): LabelSource | undefined =>
278
+ disclosures.find((d) => d.gated.has(n))?.trigger;
279
+
280
+ // How many elements of each host tag this render contains. A bare-tag locator
281
+ // is only a name for the element when it is the only one of its kind.
282
+ const tagCounts = countHostTags(initial ? initial.expr : bodyNode, tagRootTags);
283
+
284
+ const visit = (n: ts.Node): void => {
285
+ // "no" means provably not rendered at these props; "unknown" is kept, since
286
+ // dropping it would lose every .map row and render-prop target.
287
+ if (initial && n !== initial.expr && reachability(n, initial.expr, scope) === "no") return;
288
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
289
+ const tag = readTagName(n.tagName);
290
+ if (tag !== "<dynamic>") {
291
+ const enclosing = enclosingElement(n);
292
+ const hints = collectHints(
293
+ n.attributes,
294
+ scope,
295
+ native ? "native" : "dom",
296
+ hostTag(tag)?.name,
297
+ );
298
+ const label = extractLabel(enclosing, hints, propNames, descend);
299
+
300
+ if (tag === "form") {
301
+ const form = buildForm(
302
+ enclosing,
303
+ n.attributes,
304
+ propNames,
305
+ posOf(n, sourceFile),
306
+ sourceFile,
307
+ scope,
308
+ bodyNode,
309
+ );
310
+ // A form inside a closed dialog is not in the DOM until the trigger
311
+ // opens it — the same gate the click tests honour (RenameModal).
312
+ const gate = gateFor(n);
313
+ forms.push(gate ? { ...form, gatedBy: gate } : form);
314
+ }
315
+
316
+ for (const attr of n.attributes.properties) {
317
+ if (!ts.isJsxAttribute(attr)) continue;
318
+ if (!ts.isIdentifier(attr.name)) continue;
319
+ const event = attr.name.text;
320
+ if (!eventPattern.test(event)) continue;
321
+ if (tag === "form" && event === "onSubmit") continue;
322
+ const binding = classifyBinding(attr.initializer, propNames, bodyNode);
323
+ const gatedBy = gateFor(n);
324
+ clicks.push({
325
+ event,
326
+ tag,
327
+ label,
328
+ hints,
329
+ binding,
330
+ ...(gatedBy ? { gatedBy } : {}),
331
+ ...((tagCounts.get(tag) ?? 0) > 1 ? { ambiguousTag: true } : {}),
332
+ ...(native && enclosing && isIconOnly(enclosing) ? { iconOnly: true } : {}),
333
+ // With NO decidable initial render, the walk falls back to the whole
334
+ // body and harvests handlers from every branch — including ones the
335
+ // defaults provably never take. `initial &&` short-circuited, so
336
+ // those arrived unmarked and read as certainly-present, which is how
337
+ // a spec came to drive a button inside `if (this.state.error)`.
338
+ // Nothing here is known to render, so all of it is conditional.
339
+ ...(initial
340
+ ? reachability(n, initial.expr, scope) !== "yes"
341
+ ? { conditional: true }
342
+ : {}
343
+ : { conditional: true }),
344
+ ...(() => {
345
+ if (!native) return {};
346
+ const targets = collectPushTargets(attr.initializer);
347
+ return targets.length > 0 ? { pushTargets: targets } : {};
348
+ })(),
349
+ ...(native && callsBack(attr.initializer) ? { navBack: true } : {}),
350
+ // What the handler provably DOES, for deciding what may be asserted
351
+ // after dispatching it. The alternative was
352
+ // `expect(container.firstElementChild).not.toBeNull()`, which under a
353
+ // provider cannot fail and without one reddens a component that
354
+ // legitimately unmounts.
355
+ ...(() => {
356
+ const init = attr.initializer;
357
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return {};
358
+ const effects = handlerEffects(init.expression, routerNames, setterNames, bodyNode);
359
+ return effects.length > 0 ? { effects } : {};
360
+ })(),
361
+ pos: posOf(attr, sourceFile),
362
+ });
363
+ }
364
+ }
365
+ }
366
+ ts.forEachChild(n, visit);
367
+ };
368
+ // When no single render describes the component, fall back to the whole body
369
+ // rather than emitting nothing at all.
370
+ if (initial) visit(initial.expr);
371
+ else ts.forEachChild(bodyNode, visit);
372
+
373
+ // ——— The native a11y census ———
374
+ //
375
+ // A SECOND walk, over the whole source file, with no reachability pruning.
376
+ // Deliberately not a branch inside `visit`: that loop decides what a spec can
377
+ // DRIVE, where a phantom element costs a wrong click, so it prunes branches
378
+ // the initial render provably never takes. This census decides what a screen
379
+ // reader ENCOUNTERS, and the back button inside an error branch still needs
380
+ // its role. Walking the file rather than the body also reaches the same-file
381
+ // row components where half the dogfood app's pressables live; the task
382
+ // dedupes by position, since every component analysed out of one file
383
+ // carries the file's whole census.
384
+ let a11yElements: NativeA11yElement[] | undefined;
385
+ if (native) {
386
+ // An ancestor that owns the announcement: a press target (RNTL and the
387
+ // platform both resolve the pressable, not its glyph), a literal label
388
+ // (on anything, including an unresolved component — exempting MORE is the
389
+ // conservative direction for a warn rule), or a subtree already hidden.
390
+ //
391
+ // An element handed to an owner this file cannot see is exempt too. Two
392
+ // spellings of the same fact: the walk crosses a JsxAttribute (the island
393
+ // is an attribute value — `tabBarIcon: ({ size }) => <Ionicons … />` inside
394
+ // `options={…}`, or `ListEmptyComponent={<Icon/>}` — so the receiving
395
+ // component decides its context), or the element has no JSX ancestor at
396
+ // all (the root of a render-prop arrow). React Navigation's tab button
397
+ // announces the tab's title over exactly the first shape, so flagging it
398
+ // would be a guess about an invisible container. Same posture as an
399
+ // unknown component tag: unknowable, so stand down. A `.map()` in CHILDREN
400
+ // position crosses no attribute and stays flaggable — that JSX renders in
401
+ // place.
402
+ const exemptAncestor = (start: ts.JsxOpeningElement | ts.JsxSelfClosingElement): boolean => {
403
+ let sawJsxAncestor = false;
404
+ for (let p: ts.Node | undefined = start.parent; p; p = p.parent) {
405
+ if (ts.isJsxAttribute(p)) return true;
406
+ if (!ts.isJsxElement(p)) continue;
407
+ const open = p.openingElement;
408
+ if (open === start) continue;
409
+ sawJsxAncestor = true;
410
+ const resolved = hostTag(readTagName(open.tagName));
411
+ if (resolved?.dialect === "native" && PRESS_FAMILY.has(resolved.name)) return true;
412
+ if (hasLiteralLabel(open.attributes)) return true;
413
+ if (readHiddenFromA11y(open.attributes) !== undefined) return true;
414
+ }
415
+ return !sawJsxAncestor;
416
+ };
417
+ const childless = (el: ts.JsxElement): boolean =>
418
+ el.children.every((c) => ts.isJsxText(c) && !c.text.trim());
419
+ const els: NativeA11yElement[] = [];
420
+ const censusVisit = (n: ts.Node): void => {
421
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
422
+ const tag = readTagName(n.tagName);
423
+ const resolved = tag === "<dynamic>" ? undefined : hostTag(tag);
424
+ if (resolved?.dialect === "native") {
425
+ const enclosing = enclosingElement(n);
426
+ const hints = collectHints(n.attributes, scope, "native", resolved.name);
427
+ const label = extractLabel(enclosing, hints, propNames, descend);
428
+ const textless = enclosing
429
+ ? isIconOnly(enclosing) || childless(enclosing)
430
+ : ts.isJsxSelfClosingElement(n);
431
+ const roleLiterals = literalRoleAttrs(n.attributes);
432
+ const hidden = readHiddenFromA11y(n.attributes);
433
+ els.push({
434
+ element: resolved.name,
435
+ pos: posOf(n, sourceFile),
436
+ hints,
437
+ label,
438
+ ...(PRESS_FAMILY.has(resolved.name) ? { pressable: true as const } : {}),
439
+ ...(textless ? { textless: true as const } : {}),
440
+ ...(hasDynamicLabel(n.attributes) ? { dynamicLabel: true as const } : {}),
441
+ ...(hasDynamicRole(n.attributes) ? { dynamicRole: true as const } : {}),
442
+ ...(roleLiterals.length > 0 ? { roleLiterals } : {}),
443
+ ...(hidden ? { hiddenFromA11y: hidden } : {}),
444
+ ...(exemptAncestor(n) ? { exemptAncestor: true as const } : {}),
445
+ });
446
+ }
447
+ }
448
+ ts.forEachChild(n, censusVisit);
449
+ };
450
+ censusVisit(sourceFile);
451
+ a11yElements = els;
452
+ }
453
+
454
+ return { clicks, forms, ...(a11yElements ? { a11yElements } : {}) };
455
+ }
456
+
457
+ /**
458
+ * How many elements in this render could answer to a bare host-tag locator.
459
+ *
460
+ * A `<button>` counts towards `button`, and so does a component tag whose OWN
461
+ * root element is a button — read from that component's source, one hop away.
462
+ * Where the tag cannot be resolved (a third-party component, a dynamic tag) the
463
+ * old naming convention stands in; it can only over-count, and over-counting
464
+ * costs a test rather than a wrong click.
465
+ *
466
+ * VoteCard is why any of this exists: one mapped `<button>` carrying the
467
+ * handler and one `<Button>` rendering "Cast vote", which
468
+ * `page.locator("button").first()` resolved to instead.
469
+ *
470
+ * An element inside `.map()` is counted once — the source shows one, the DOM may
471
+ * show none or twenty. That is enough to answer the only question being asked:
472
+ * does this locator have competition.
473
+ */
474
+ function countHostTags(root: ts.Node, tagRootTags: Map<string, string>): Map<string, number> {
475
+ const counts = new Map<string, number>();
476
+ const bump = (tag: string): void => {
477
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
478
+ };
479
+ const visit = (n: ts.Node): void => {
480
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
481
+ const tag = readTagName(n.tagName);
482
+ bump(tag);
483
+ if (/^[A-Z]/.test(tag)) {
484
+ const resolved = tagRootTags.get(tag);
485
+ if (resolved) {
486
+ // Evidence: this tag's own root element, read from its source.
487
+ bump(resolved);
488
+ } else {
489
+ // Unresolvable — a third-party component, a dynamic tag. Fall back to
490
+ // the naming convention, which can only over-count and so can only
491
+ // turn a locator into a skip.
492
+ const lower = tag.toLowerCase();
493
+ for (const host of ["button", "input", "textarea", "select", "form"]) {
494
+ if (lower.endsWith(host)) bump(host);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ ts.forEachChild(n, visit);
500
+ };
501
+ visit(root);
502
+ return counts;
503
+ }
504
+
505
+ function enclosingElement(
506
+ n: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
507
+ ): ts.JsxElement | undefined {
508
+ if (ts.isJsxOpeningElement(n) && n.parent && ts.isJsxElement(n.parent)) {
509
+ return n.parent;
510
+ }
511
+ return undefined;
512
+ }
513
+
514
+ function readTagName(tag: ts.JsxTagNameExpression): string {
515
+ if (ts.isIdentifier(tag)) return tag.text;
516
+ if (ts.isPropertyAccessExpression(tag)) return readPropertyAccess(tag);
517
+ return "<dynamic>";
518
+ }
519
+
520
+ function readPropertyAccess(p: ts.PropertyAccessExpression): string {
521
+ const head = ts.isIdentifier(p.expression)
522
+ ? p.expression.text
523
+ : ts.isPropertyAccessExpression(p.expression)
524
+ ? readPropertyAccess(p.expression)
525
+ : "<dynamic>";
526
+ return `${head}.${p.name.text}`;
527
+ }
528
+
529
+ function collectHints(
530
+ attrs: ts.JsxAttributes,
531
+ env: RenderEnv = emptyEnv(),
532
+ dialect: "dom" | "native" = "dom",
533
+ /** Resolved host element name, for the one RN element that carries an implicit role. */
534
+ element: string | undefined = undefined,
535
+ ): ElementHints {
536
+ const hints: ElementHints = {};
537
+ for (const a of attrs.properties) {
538
+ if (!ts.isJsxAttribute(a)) continue;
539
+ const name = ts.isIdentifier(a.name) ? a.name.text : undefined;
540
+ if (!name) continue;
541
+ if (name === "disabled") {
542
+ const state = disabledAtInitialRender(a.initializer, env);
543
+ if (state) hints.disabled = state;
544
+ continue;
545
+ }
546
+ // React Native's disabled equivalent for a text field, with INVERTED polarity:
547
+ // editable={false} disables the field, editable={true}/bare leaves it working.
548
+ // disabledAtInitialRender reads the attribute's truthiness as if it were
549
+ // `disabled`, so its result is inverted here — a non-editable field is
550
+ // disabled "always", an editable one is not disabled at all, and an
551
+ // undecidable editable stays "conditional" exactly as `disabled` would be.
552
+ if (dialect === "native" && name === "editable") {
553
+ const editableState = disabledAtInitialRender(a.initializer, env);
554
+ if (editableState === undefined) hints.disabled = "always";
555
+ else if (editableState === "conditional") hints.disabled = "conditional";
556
+ // editableState === "always" → the field is editable → leave hints.disabled unset.
557
+ continue;
558
+ }
559
+ const value = readStringInitializer(a.initializer);
560
+ if (value === undefined) continue;
561
+ if (dialect === "native") {
562
+ // The React Native locator surface. `name` and `type` are deliberately
563
+ // never populated here — they have no React Native equivalent, and leaving
564
+ // them empty is what makes selectors.ts's DOM-only branches naturally
565
+ // inert on native data rather than emitting a query that cannot match.
566
+ switch (name) {
567
+ case "title":
568
+ // RN's <Button title="Save"/> renders no children; `title` IS the name.
569
+ hints.buttonTitle = value;
570
+ break;
571
+ case "testID":
572
+ hints.testid = value;
573
+ break;
574
+ case "accessibilityLabel":
575
+ case "aria-label":
576
+ hints.ariaLabel = value;
577
+ break;
578
+ case "accessibilityRole":
579
+ case "role":
580
+ hints.role = value;
581
+ break;
582
+ case "accessibilityHint":
583
+ hints.hint = value;
584
+ break;
585
+ case "placeholder":
586
+ hints.placeholder = value;
587
+ break;
588
+ case "value":
589
+ hints.displayValue = value;
590
+ break;
591
+ // `id`/`nativeID` are real facts about the source and DO work under
592
+ // react-native-web + Playwright, so they are recorded. The RNTL emitter
593
+ // simply must not read them, and a test pins that.
594
+ case "id":
595
+ case "nativeID":
596
+ hints.id = value;
597
+ break;
598
+ }
599
+ continue;
600
+ }
601
+ switch (name) {
602
+ case "data-testid":
603
+ hints.testid = value;
604
+ break;
605
+ case "aria-label":
606
+ hints.ariaLabel = value;
607
+ break;
608
+ case "role":
609
+ hints.role = value;
610
+ break;
611
+ case "id":
612
+ hints.id = value;
613
+ break;
614
+ case "name":
615
+ hints.name = value;
616
+ break;
617
+ case "type":
618
+ hints.type = value;
619
+ break;
620
+ case "placeholder":
621
+ hints.placeholder = value;
622
+ break;
623
+ }
624
+ }
625
+ // React Native's own <Button> is the ONE element whose source sets an
626
+ // accessibilityRole unconditionally, and it names itself with `title` rather
627
+ // than with children. Without this it fell to the bottom of the ladder and was
628
+ // skipped as "nothing identifies it: no testID, no accessibilityLabel, no
629
+ // accessibilityRole" — a remediation that is factually wrong, on an element
630
+ // that getByRole("button", { name }) addresses perfectly. NATIVE_TAG_TO_ROLE
631
+ // existed for this and had no production caller.
632
+ if (dialect === "native" && element !== undefined) {
633
+ const implicitRole = NATIVE_TAG_TO_ROLE[element];
634
+ if (implicitRole !== undefined && hints.role === undefined) hints.role = implicitRole;
635
+ }
636
+ return hints;
637
+ }
638
+
639
+ /**
640
+ * Whether `disabled={...}` holds at the props and initial state a spec renders.
641
+ *
642
+ * `<Button disabled={!selected || pending}>` with `useState(null)` is disabled
643
+ * the moment it mounts, which the same evaluator that picks the initial render
644
+ * branch can decide. Clicking it anyway cost a 30-second Playwright timeout per
645
+ * occurrence; asserting it is disabled is both faster and true. Anything that
646
+ * does not reduce stays "conditional" and is driven as before.
647
+ */
648
+ function disabledAtInitialRender(
649
+ init: ts.JsxAttribute["initializer"],
650
+ env: RenderEnv,
651
+ ): ElementHints["disabled"] {
652
+ // Bare `disabled` with no value is the HTML shorthand for disabled={true}.
653
+ if (!init) return "always";
654
+ if (init.kind === ts.SyntaxKind.StringLiteral) return "always";
655
+ if (!ts.isJsxExpression(init) || !init.expression) return undefined;
656
+ const value = evaluate(init.expression, env);
657
+ if (value === undefined) return "conditional";
658
+ return truthy(value) ? "always" : undefined;
659
+ }
660
+
661
+ function readStringInitializer(init: ts.JsxAttribute["initializer"]): string | undefined {
662
+ if (!init) return undefined;
663
+ if (ts.isStringLiteral(init)) return init.text;
664
+ if (ts.isJsxExpression(init) && init.expression && ts.isStringLiteral(init.expression)) {
665
+ return init.expression.text;
666
+ }
667
+ return undefined;
668
+ }
669
+
670
+ /** Below this a fragment identifies nothing — "(" or "—" would match half the page. */
671
+ const MIN_LABEL_FRAGMENT = 3;
672
+
673
+ function extractLabel(
674
+ parent: ts.JsxElement | undefined,
675
+ hints: ElementHints,
676
+ propNames: Set<string>,
677
+ /**
678
+ * On native, look through container children for the text.
679
+ *
680
+ * In the DOM the copy sits inside the handler element: `<button>Save</button>`.
681
+ * In React Native it is ALWAYS one level down, inside a `<Text>`:
682
+ *
683
+ * <Pressable onPress={...}><Text>Sign in</Text></Pressable>
684
+ *
685
+ * Reading direct children only, this loop sees a JsxElement, sets hasDynamic
686
+ * and returns { kind: "none" } — so on the dogfood app EVERY press handler was
687
+ * unlabelled, and every interaction spec would have been a skip.
688
+ *
689
+ * Descending is not a guess: RNTL's own getTextContent recurses the whole
690
+ * subtree and joins it, and fireEvent.press walks UP to the nearest press
691
+ * responder — so the descendant <Text> is a legitimate handle for the ancestor
692
+ * <Pressable>, which is exactly the canonical RNTL idiom.
693
+ */
694
+ descend: ((child: ts.JsxChild) => ts.NodeArray<ts.JsxChild> | undefined) | undefined = undefined,
695
+ ): LabelSource {
696
+ if (parent) {
697
+ // Collect every literal fragment, and note whether anything non-literal sits
698
+ // among them. Taking the first fragment and calling it the whole accessible
699
+ // name turned `Record payment ({fmt(due)} outstanding)` into the exact name
700
+ // "Record payment (", which no rendered element can equal.
701
+ // Contiguous literal RUNS, not one concatenation. Joining across a dynamic
702
+ // child produced "Record payment ( outstanding)" for
703
+ // `Record payment ({fmt(due)} outstanding)` — a string that appears nowhere,
704
+ // because the amount sits between the two halves.
705
+ const runs: string[] = [];
706
+ let run = "";
707
+ let hasDynamic = false;
708
+ let propLabel: Extract<LabelSource, { kind: "prop" }> | undefined;
709
+ // Literal text on either side of a prop interpolation: `+{step}` is the name
710
+ // "+3", not "3". Only trustworthy when exactly ONE prop is interpolated —
711
+ // with two, the second value would slip into the anchored tail unmatched.
712
+ let propCount = 0;
713
+ let before = "";
714
+ let after = "";
715
+
716
+ const endRun = (): void => {
717
+ const text = run.replace(/\s+/g, " ").trim();
718
+ if (text) runs.push(text);
719
+ run = "";
720
+ };
721
+
722
+ const walk = (children: ts.NodeArray<ts.JsxChild>): void => {
723
+ for (const child of children) {
724
+ if (ts.isJsxText(child)) {
725
+ run += child.text;
726
+ if (propCount === 0) before += child.text;
727
+ else after += child.text;
728
+ continue;
729
+ }
730
+ if (ts.isJsxExpression(child) && child.expression) {
731
+ if (ts.isStringLiteral(child.expression)) {
732
+ run += child.expression.text;
733
+ if (propCount === 0) before += child.expression.text;
734
+ else after += child.expression.text;
735
+ continue;
736
+ }
737
+ if (ts.isIdentifier(child.expression) && propNames.has(child.expression.text)) {
738
+ propLabel ??= { kind: "prop", propName: child.expression.text };
739
+ propCount++;
740
+ }
741
+ }
742
+ // On native, a container child is a level to look THROUGH, not a dynamic
743
+ // interruption — the text lives inside it. `descend` returns the children
744
+ // to keep walking, or undefined for anything opaque: an icon, a spinner,
745
+ // an unresolved component, or a conditional whose branch cannot be
746
+ // decided. So an icon-only Pressable still ends up honestly unlabelled.
747
+ const through = descend?.(child);
748
+ if (through) {
749
+ walk(through);
750
+ continue;
751
+ }
752
+ // An icon element, an interpolation, a call: text we cannot predict, so
753
+ // whatever ran up to here is a fragment of the real accessible name.
754
+ hasDynamic = true;
755
+ endRun();
756
+ }
757
+ };
758
+ walk(parent.children);
759
+ endRun();
760
+
761
+ if (!hasDynamic && runs.length === 1) return { kind: "text", value: runs[0] as string };
762
+ // The longest run is the most identifying thing the source offers.
763
+ const longest = runs.sort((a, b) => b.length - a.length)[0];
764
+ if (longest && longest.length >= MIN_LABEL_FRAGMENT) {
765
+ return { kind: "text-partial", value: longest };
766
+ }
767
+ if (propLabel) {
768
+ const b = before.replace(/\s+/g, " ").trim();
769
+ const a = after.replace(/\s+/g, " ").trim();
770
+ return propCount === 1 && (b || a)
771
+ ? {
772
+ kind: "prop",
773
+ propName: propLabel.propName,
774
+ ...(b ? { before: b } : {}),
775
+ ...(a ? { after: a } : {}),
776
+ }
777
+ : propLabel;
778
+ }
779
+ }
780
+ if (hints.ariaLabel) return { kind: "aria", value: hints.ariaLabel };
781
+ if (hints.testid) return { kind: "testid", value: hints.testid };
782
+ return { kind: "none" };
783
+ }
784
+
785
+ function classifyBinding(
786
+ init: ts.JsxAttribute["initializer"],
787
+ propNames: Set<string>,
788
+ /** The component body, to resolve a local handler to its declaration. */
789
+ bodyNode?: ts.Node,
790
+ ): HandlerBinding {
791
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return { kind: "inline" };
792
+ const expr = init.expression;
793
+ if (ts.isIdentifier(expr)) {
794
+ if (propNames.has(expr.text) && /^on[A-Z]/.test(expr.text)) {
795
+ return { kind: "prop", propName: expr.text };
796
+ }
797
+ // A named local is only opaque until its declaration is read:
798
+ // `onClick={handleConfirm}` where `const handleConfirm = () => { onConfirm(); }`
799
+ // IS a prop call — ConfirmModal's canary asserted the spy was NOT called
800
+ // while the handler called exactly that. Only the DIRECT shapes upgrade
801
+ // (see directPropCall); anything less obvious stays a canary.
802
+ if (bodyNode) {
803
+ const fn = resolveLocalHandler(bodyNode, expr.text);
804
+ const body = fn?.body;
805
+ const prop = body === undefined ? undefined : directPropCall(body, propNames);
806
+ if (prop) return { kind: "prop", propName: prop };
807
+ }
808
+ return { kind: "local", localName: expr.text };
809
+ }
810
+ if (ts.isPropertyAccessExpression(expr)) {
811
+ // props.onSave → prop. Anything else nested is treated as local.
812
+ if (ts.isIdentifier(expr.expression) && expr.expression.text === "props") {
813
+ const name = expr.name.text;
814
+ if (propNames.has(name) && /^on[A-Z]/.test(name)) {
815
+ return { kind: "prop", propName: name };
816
+ }
817
+ }
818
+ }
819
+ if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
820
+ const propCall = findPropCallInBody(expr.body, propNames);
821
+ if (propCall) return { kind: "prop", propName: propCall };
822
+ return { kind: "inline" };
823
+ }
824
+ return { kind: "inline" };
825
+ }
826
+
827
+ /** Find `const NAME = (…) => …` / `function NAME(…) {}` inside the body. */
828
+ function resolveLocalHandler(
829
+ body: ts.Node,
830
+ name: string,
831
+ ): ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | undefined {
832
+ let out: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | undefined;
833
+ const visit = (n: ts.Node): void => {
834
+ if (out) return;
835
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === name) {
836
+ const init = n.initializer;
837
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
838
+ out = init;
839
+ return;
840
+ }
841
+ }
842
+ if (ts.isFunctionDeclaration(n) && n.name?.text === name) {
843
+ out = n;
844
+ return;
845
+ }
846
+ ts.forEachChild(n, visit);
847
+ };
848
+ ts.forEachChild(body, visit);
849
+ return out;
850
+ }
851
+
852
+ /**
853
+ * The ONE prop a handler body calls DIRECTLY: `() => { prop(); cleanup(); }`
854
+ * or `() => prop()`. findPropCallInBody descends into callbacks, which suits
855
+ * the inline arrow written at the JSX site (its author sees what it does);
856
+ * across statements that generosity would claim calls hidden behind an
857
+ * `await` or a `.then`, so this is the strict variant for resolved locals.
858
+ * More than one distinct prop call and nothing is claimed — the emit would
859
+ * otherwise assert the siblings were NOT called while the handler calls them.
860
+ */
861
+ function directPropCall(body: ts.ConciseBody, propNames: Set<string>): string | undefined {
862
+ const calls: string[] = [];
863
+ const check = (expr: ts.Expression): void => {
864
+ if (!ts.isCallExpression(expr)) return;
865
+ const callee = expr.expression;
866
+ if (ts.isIdentifier(callee) && propNames.has(callee.text)) {
867
+ calls.push(callee.text);
868
+ } else if (
869
+ ts.isPropertyAccessExpression(callee) &&
870
+ ts.isIdentifier(callee.expression) &&
871
+ callee.expression.text === "props" &&
872
+ propNames.has(callee.name.text)
873
+ ) {
874
+ calls.push(callee.name.text);
875
+ }
876
+ };
877
+ if (ts.isBlock(body)) {
878
+ for (const st of body.statements) {
879
+ if (ts.isExpressionStatement(st)) check(st.expression);
880
+ }
881
+ } else {
882
+ check(body);
883
+ }
884
+ const unique = [...new Set(calls)];
885
+ return unique.length === 1 ? unique[0] : undefined;
886
+ }
887
+
888
+ function findPropCallInBody(body: ts.Node, propNames: Set<string>): string | undefined {
889
+ let found: string | undefined;
890
+ const visit = (n: ts.Node): void => {
891
+ if (found) return;
892
+ if (ts.isCallExpression(n)) {
893
+ const callee = n.expression;
894
+ if (ts.isIdentifier(callee) && propNames.has(callee.text) && /^on[A-Z]/.test(callee.text)) {
895
+ found = callee.text;
896
+ return;
897
+ }
898
+ if (
899
+ ts.isPropertyAccessExpression(callee) &&
900
+ ts.isIdentifier(callee.expression) &&
901
+ callee.expression.text === "props" &&
902
+ propNames.has(callee.name.text) &&
903
+ /^on[A-Z]/.test(callee.name.text)
904
+ ) {
905
+ found = callee.name.text;
906
+ return;
907
+ }
908
+ }
909
+ ts.forEachChild(n, visit);
910
+ };
911
+ visit(body);
912
+ return found;
913
+ }
914
+
915
+ function buildForm(
916
+ parent: ts.JsxElement | undefined,
917
+ attrs: ts.JsxAttributes,
918
+ propNames: Set<string>,
919
+ pos: FormInfo["pos"],
920
+ sourceFile: ts.SourceFile,
921
+ env: RenderEnv,
922
+ /** The component body, for resolving `action={x}` back to the action itself. */
923
+ componentBody?: ts.Node,
924
+ ): FormInfo {
925
+ let submit: HandlerBinding | undefined;
926
+ let actionLocal: string | undefined;
927
+ const hints: { testid?: string; ariaLabel?: string; id?: string } = {};
928
+ for (const a of attrs.properties) {
929
+ if (!ts.isJsxAttribute(a)) continue;
930
+ const name = ts.isIdentifier(a.name) ? a.name.text : undefined;
931
+ if (name === "onSubmit") submit = classifyBinding(a.initializer, propNames, componentBody);
932
+ if (name === "action") actionLocal = readIdentifierInitializer(a.initializer);
933
+ if (name === "data-testid") hints.testid = readStringInitializer(a.initializer);
934
+ if (name === "aria-label") hints.ariaLabel = readStringInitializer(a.initializer);
935
+ if (name === "id") hints.id = readStringInitializer(a.initializer);
936
+ }
937
+ const fields = parent ? collectFields(parent, sourceFile) : [];
938
+ const submitControl = parent ? findSubmitControl(parent, propNames, env) : undefined;
939
+ const actionBinding =
940
+ actionLocal === undefined ? undefined : actionSourceOf(actionLocal, componentBody);
941
+ return {
942
+ submit,
943
+ fields,
944
+ ...(submitControl ? { submitControl } : {}),
945
+ ...(actionBinding ? { actionBinding } : {}),
946
+ hints,
947
+ pos,
948
+ };
949
+ }
950
+
951
+ /**
952
+ * The button that submits this form, read out of the markup.
953
+ *
954
+ * Two rules, and the asymmetry between them is the point:
955
+ *
956
+ * - A host `<button>` inside a form submits it unless it says otherwise, because
957
+ * that is HTML's default `type`.
958
+ * - A component tag counts only when it declares `type="submit"`. shadcn and
959
+ * base-ui both render `type="button"` by default — the DOM dumps from the
960
+ * failing run show it — so on a `<Button>` the ABSENCE of a type attribute is
961
+ * not evidence of anything.
962
+ *
963
+ * An enabled control wins; a disabled one is still returned, flagged, so the
964
+ * spec can say "the only way to submit this form is disabled at these props"
965
+ * rather than "there is no submit control", which would be false.
966
+ */
967
+ function findSubmitControl(
968
+ parent: ts.JsxElement,
969
+ propNames: Set<string>,
970
+ env: RenderEnv,
971
+ ): FormInfo["submitControl"] {
972
+ const found: SubmitControl[] = [];
973
+ const visit = (n: ts.Node): void => {
974
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
975
+ const tag = readTagName(n.tagName);
976
+ const isHostButton = tag === "button";
977
+ if (isHostButton || /Button$/.test(tag)) {
978
+ const hints = collectHints(n.attributes, env);
979
+ const declared = hints.type;
980
+ const submits = isHostButton
981
+ ? declared === undefined || declared === "submit"
982
+ : declared === "submit";
983
+ if (submits) {
984
+ found.push({
985
+ label: extractLabel(enclosingElement(n), hints, propNames),
986
+ host: isHostButton,
987
+ ...(hints.disabled === "always" ? { disabled: true } : {}),
988
+ });
989
+ }
990
+ }
991
+ }
992
+ ts.forEachChild(n, visit);
993
+ };
994
+ ts.forEachChild(parent, visit);
995
+ return found.find((c) => !c.disabled) ?? found[0];
996
+ }
997
+
998
+ function collectFields(parent: ts.JsxElement, sourceFile: ts.SourceFile): FieldInfo[] {
999
+ const fields: FieldInfo[] = [];
1000
+ const labelsByHtmlFor = new Map<string, string>();
1001
+
1002
+ // First pass: harvest <label htmlFor="x"> text-content keyed by htmlFor.
1003
+ //
1004
+ // Uppercase `*Label` tags count too, but only when they carry htmlFor: that is
1005
+ // a React DOM prop, so its presence is evidence the component forwards to a
1006
+ // real <label>. shadcn's <Label htmlFor> is the common case, and matching the
1007
+ // lowercase tag alone left every one of those fields with no accessible label.
1008
+ const collectLabels = (n: ts.Node): void => {
1009
+ if (ts.isJsxElement(n)) {
1010
+ const opening = n.openingElement;
1011
+ if (ts.isIdentifier(opening.tagName) && isLabelTag(opening.tagName.text)) {
1012
+ let htmlFor: string | undefined;
1013
+ for (const a of opening.attributes.properties) {
1014
+ if (!ts.isJsxAttribute(a)) continue;
1015
+ if (ts.isIdentifier(a.name) && a.name.text === "htmlFor") {
1016
+ const v = readStringInitializer(a.initializer);
1017
+ if (v) htmlFor = v;
1018
+ }
1019
+ }
1020
+ if (htmlFor) {
1021
+ const text = jsxTextOf(n);
1022
+ if (text) labelsByHtmlFor.set(htmlFor, text);
1023
+ }
1024
+ }
1025
+ }
1026
+ ts.forEachChild(n, collectLabels);
1027
+ };
1028
+ collectLabels(parent);
1029
+
1030
+ const visit = (n: ts.Node): void => {
1031
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
1032
+ const tag = readTagName(n.tagName);
1033
+ if (FORM_FIELD_TAGS.has(tag)) {
1034
+ const hints = collectHints(n.attributes);
1035
+ // A placeholder is NOT an accessible label. Falling back to it here made
1036
+ // every placeholder-only field emit getByLabelText(placeholder), which
1037
+ // always throws — while emitFormTest's getByPlaceholderText branch, which
1038
+ // is already correct, could never be reached because `label` was set.
1039
+ const label = (hints.id && labelsByHtmlFor.get(hints.id)) ?? undefined;
1040
+ fields.push({
1041
+ tag: tag as FieldInfo["tag"],
1042
+ type: hints.type,
1043
+ name: hints.name,
1044
+ id: hints.id,
1045
+ placeholder: hints.placeholder,
1046
+ label,
1047
+ pos: posOf(n, sourceFile),
1048
+ });
1049
+ }
1050
+ }
1051
+ ts.forEachChild(n, visit);
1052
+ };
1053
+ ts.forEachChild(parent, visit);
1054
+ return disambiguateByName(fields);
1055
+ }
1056
+
1057
+ /**
1058
+ * Drop fields whose `name` does not pick out one control.
1059
+ *
1060
+ * `canPostInternal ? <select name="visibleTo"> : <input type="hidden"
1061
+ * name="visibleTo">` collects both branches — the condition is a prop nothing
1062
+ * resolves, so neither is provably rendered. The generated fill then called
1063
+ * selectOption on a name that at runtime was the hidden input, and waited for a
1064
+ * `<select>` that never appears.
1065
+ *
1066
+ * A group that agrees on tag and type is a different thing: sixteen checkboxes
1067
+ * called "trades" are one control, and `.first().check()` drives it correctly.
1068
+ * So the rule is disagreement, not multiplicity.
1069
+ */
1070
+ function disambiguateByName(fields: FieldInfo[]): FieldInfo[] {
1071
+ const byName = new Map<string, FieldInfo[]>();
1072
+ for (const f of fields) {
1073
+ if (!f.name) continue;
1074
+ const group = byName.get(f.name);
1075
+ if (group) group.push(f);
1076
+ else byName.set(f.name, [f]);
1077
+ }
1078
+ const seen = new Set<string>();
1079
+ const out: FieldInfo[] = [];
1080
+ for (const f of fields) {
1081
+ if (!f.name) {
1082
+ out.push(f);
1083
+ continue;
1084
+ }
1085
+ const group = byName.get(f.name) as FieldInfo[];
1086
+ const agrees = group.every((g) => g.tag === f.tag && g.type === f.type);
1087
+ if (!agrees || seen.has(f.name)) continue;
1088
+ seen.add(f.name);
1089
+ out.push(f);
1090
+ }
1091
+ return out;
1092
+ }
1093
+
1094
+ /** The host tag, or a wrapper whose name ends in "Label" (shadcn's <Label>, <FormLabel>). */
1095
+ function isLabelTag(tag: string): boolean {
1096
+ return tag === "label" || /Label$/.test(tag);
1097
+ }
1098
+
1099
+ function jsxTextOf(el: ts.JsxElement): string | undefined {
1100
+ for (const child of el.children) {
1101
+ if (ts.isJsxText(child)) {
1102
+ const t = child.text.trim();
1103
+ if (t) return t;
1104
+ } else if (
1105
+ ts.isJsxExpression(child) &&
1106
+ child.expression &&
1107
+ ts.isStringLiteral(child.expression)
1108
+ ) {
1109
+ return child.expression.text;
1110
+ }
1111
+ }
1112
+ return undefined;
1113
+ }
1114
+
1115
+ /** Strip the parentheses a formatter puts around a multi-line JSX branch. */
1116
+ function unwrapParens(expr: ts.Expression): ts.Expression {
1117
+ let cur = expr;
1118
+ while (ts.isParenthesizedExpression(cur)) cur = cur.expression;
1119
+ return cur;
1120
+ }
1121
+
1122
+ /** The root identifier of an expression, for deciding what a `.push` was called on. */
1123
+ function rootIdentifierOf(expr: ts.Expression): string {
1124
+ let cur: ts.Expression = expr;
1125
+ while (ts.isPropertyAccessExpression(cur) || ts.isCallExpression(cur)) {
1126
+ cur = ts.isCallExpression(cur) ? cur.expression : cur.expression;
1127
+ }
1128
+ return ts.isIdentifier(cur) ? cur.text : "";
1129
+ }
1130
+
1131
+ /**
1132
+ * True iff `call` runs whenever the handler runs.
1133
+ *
1134
+ * "Dominated" in the flow sense: the call sits in the handler body's own
1135
+ * top-level statement list, and no preceding sibling can leave the function
1136
+ * first. Not inside an if/switch/loop/try/catch/finally, and not inside a
1137
+ * nested function.
1138
+ *
1139
+ * A NEW predicate rather than a reuse of hasConditionalBetween, whose existing
1140
+ * consumers depend on today's meaning: that one walks ANCESTORS only, so the
1141
+ * dominant real shape —
1142
+ *
1143
+ * if (!parsed.success) { setErr(…); return; }
1144
+ * setBusy(true);
1145
+ *
1146
+ * — reads as unguarded for `setBusy`, because the `if` is a preceding SIBLING
1147
+ * and not an ancestor. Asserting on `setBusy` there is a claim about a path the
1148
+ * defaults may never reach.
1149
+ */
1150
+ export function reachedUnconditionally(call: ts.Node, body: ts.Node): boolean {
1151
+ // Walk up to the handler body, refusing any construct that makes the call
1152
+ // conditional on something.
1153
+ let node: ts.Node = call;
1154
+ let stmt: ts.Statement | undefined;
1155
+ for (;;) {
1156
+ // Stop AT the body. A concise arrow (`onClick={() => save()}`) has the call
1157
+ // itself as its body, so without this the arrow below disqualified the very
1158
+ // handler it is the body of — every inline one-expression handler came back
1159
+ // conditional.
1160
+ if (node === body) break;
1161
+ const parent: ts.Node | undefined = node.parent;
1162
+ if (!parent || parent === body) break;
1163
+ if (
1164
+ ts.isIfStatement(parent) ||
1165
+ ts.isSwitchStatement(parent) ||
1166
+ ts.isCaseClause(parent) ||
1167
+ ts.isDefaultClause(parent) ||
1168
+ ts.isForStatement(parent) ||
1169
+ ts.isForOfStatement(parent) ||
1170
+ ts.isForInStatement(parent) ||
1171
+ ts.isWhileStatement(parent) ||
1172
+ ts.isDoStatement(parent) ||
1173
+ ts.isTryStatement(parent) ||
1174
+ ts.isCatchClause(parent) ||
1175
+ ts.isConditionalExpression(parent)
1176
+ ) {
1177
+ return false;
1178
+ }
1179
+ // A short-circuit right-hand side runs only if the left decided so.
1180
+ if (
1181
+ ts.isBinaryExpression(parent) &&
1182
+ (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
1183
+ parent.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
1184
+ parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) &&
1185
+ parent.right === node
1186
+ ) {
1187
+ return false;
1188
+ }
1189
+ // A nested function is not the handler's own flow.
1190
+ if (
1191
+ ts.isArrowFunction(parent) ||
1192
+ ts.isFunctionExpression(parent) ||
1193
+ ts.isFunctionDeclaration(parent)
1194
+ ) {
1195
+ return false;
1196
+ }
1197
+ if (ts.isStatement(parent) && parent.parent === body) stmt = parent;
1198
+ node = parent;
1199
+ }
1200
+ if (!stmt) {
1201
+ // A concise arrow body (`onClick={() => save()}`) has no statement list at
1202
+ // all, so the call is trivially the whole handler.
1203
+ return true;
1204
+ }
1205
+ // Nothing before it in the same list may leave the function first.
1206
+ const list = ts.isBlock(body) ? body.statements : undefined;
1207
+ if (!list) return true;
1208
+ for (const s of list) {
1209
+ if (s === stmt) return true;
1210
+ if (exitsEarly(s)) return false;
1211
+ }
1212
+ return true;
1213
+ }
1214
+
1215
+ /** Whether a statement can return or throw out of the handler at any depth. */
1216
+ function exitsEarly(stmt: ts.Statement): boolean {
1217
+ let found = false;
1218
+ const walk = (n: ts.Node): void => {
1219
+ if (found) return;
1220
+ // A return inside a nested function returns from THAT function.
1221
+ if (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) return;
1222
+ if (ts.isReturnStatement(n) || ts.isThrowStatement(n)) {
1223
+ found = true;
1224
+ return;
1225
+ }
1226
+ ts.forEachChild(n, walk);
1227
+ };
1228
+ walk(stmt);
1229
+ return found;
1230
+ }
1231
+
1232
+ /**
1233
+ * What a handler expression provably does. See HandlerEffect.
1234
+ *
1235
+ * Syntactic only: a call whose callee root is an identifier, a router method on
1236
+ * a `useRouter()` receiver, or a `set*` call. It claims nothing about what those
1237
+ * do — the emitter decides whether it can observe them.
1238
+ */
1239
+ export function handlerEffects(
1240
+ expr: ts.Expression,
1241
+ routerNames: Set<string>,
1242
+ setterNames: Set<string>,
1243
+ /**
1244
+ * The component body, for resolving `onClick={submit}` to the local `submit`.
1245
+ *
1246
+ * ONE hop, and it is the shape that matters: on the dogfood app every handler
1247
+ * that reaches a server action is written this way, so without it the ladder
1248
+ * produced zero real claims over 174 specs and only deleted things. Two hops
1249
+ * would be a call graph; a miss costs a canary, which is honest.
1250
+ */
1251
+ componentBody?: ts.Node,
1252
+ ): HandlerEffect[] {
1253
+ const body = handlerBody(expr, componentBody);
1254
+ if (!body) return [];
1255
+ const out: HandlerEffect[] = [];
1256
+ const walk = (n: ts.Node): void => {
1257
+ if (ts.isCallExpression(n)) {
1258
+ const dominated = reachedUnconditionally(n, body);
1259
+ const callee = n.expression;
1260
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
1261
+ const method = callee.name.text;
1262
+ if (
1263
+ (method === "push" ||
1264
+ method === "replace" ||
1265
+ method === "navigate" ||
1266
+ method === "back") &&
1267
+ routerNames.has(rootIdentifierOf(callee.expression))
1268
+ ) {
1269
+ const arg = n.arguments[0];
1270
+ const target =
1271
+ arg && ts.isStringLiteral(arg)
1272
+ ? arg.text
1273
+ : arg && ts.isNoSubstitutionTemplateLiteral(arg)
1274
+ ? arg.text
1275
+ : undefined;
1276
+ out.push({ kind: "router", method, ...(target ? { target } : {}), dominated });
1277
+ }
1278
+ } else if (ts.isIdentifier(callee)) {
1279
+ if (setterNames.has(callee.text)) {
1280
+ out.push({ kind: "setState", setter: callee.text, dominated });
1281
+ } else {
1282
+ out.push({ kind: "call", root: callee.text, dominated });
1283
+ }
1284
+ }
1285
+ }
1286
+ ts.forEachChild(n, walk);
1287
+ };
1288
+ walk(body);
1289
+ return out;
1290
+ }
1291
+
1292
+ /**
1293
+ * The body whose statements the handler runs — inline, or the local function it
1294
+ * names.
1295
+ *
1296
+ * `onClick={submit}` is the ordinary way to write anything longer than one
1297
+ * expression, and treating it as opaque made every such handler unassertable.
1298
+ */
1299
+ function handlerBody(expr: ts.Expression, componentBody?: ts.Node): ts.Node | undefined {
1300
+ if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) return expr.body;
1301
+ if (ts.isIdentifier(expr) && componentBody) return localFunctionBody(expr.text, componentBody);
1302
+ return undefined;
1303
+ }
1304
+
1305
+ /** The body of a function declared locally under `scope` and bound to `name`. */
1306
+ function localFunctionBody(name: string, scope: ts.Node): ts.Node | undefined {
1307
+ let found: ts.Node | undefined;
1308
+ const visit = (n: ts.Node): void => {
1309
+ if (found) return;
1310
+ if (ts.isFunctionDeclaration(n) && n.name?.text === name && n.body) {
1311
+ found = n.body;
1312
+ return;
1313
+ }
1314
+ if (
1315
+ ts.isVariableDeclaration(n) &&
1316
+ ts.isIdentifier(n.name) &&
1317
+ n.name.text === name &&
1318
+ n.initializer &&
1319
+ (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer))
1320
+ ) {
1321
+ found = n.initializer.body;
1322
+ return;
1323
+ }
1324
+ ts.forEachChild(n, visit);
1325
+ };
1326
+ visit(scope);
1327
+ return found;
1328
+ }
1329
+
1330
+ /** A JSX attribute whose value is a bare identifier: `action={formAction}`. */
1331
+ function readIdentifierInitializer(init: ts.JsxAttribute["initializer"]): string | undefined {
1332
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return undefined;
1333
+ return ts.isIdentifier(init.expression) ? init.expression.text : undefined;
1334
+ }
1335
+
1336
+ /**
1337
+ * The server action behind a form's `action={local}`.
1338
+ *
1339
+ * Two shapes, both statically decidable and both present in real code:
1340
+ *
1341
+ * const [state, action] = useActionState(loginAction, undefined); -> loginAction
1342
+ * <form action={loginAction}> -> loginAction
1343
+ *
1344
+ * The FIRST is what matters: React calls the wrapped action on submit, with
1345
+ * nothing in between to guard it. Only `useActionState` is unwrapped — a local
1346
+ * function assigned to `action` may do anything before dispatching, and claiming
1347
+ * otherwise would be exactly the guess the click ladder refuses to make.
1348
+ */
1349
+ function actionSourceOf(local: string, scope?: ts.Node): string | undefined {
1350
+ if (!scope) return local;
1351
+ let found: string | undefined;
1352
+ const visit = (n: ts.Node): void => {
1353
+ if (found) return;
1354
+ if (
1355
+ ts.isVariableDeclaration(n) &&
1356
+ ts.isArrayBindingPattern(n.name) &&
1357
+ n.initializer &&
1358
+ ts.isCallExpression(n.initializer) &&
1359
+ hookCalleeName(n.initializer.expression) === "useActionState"
1360
+ ) {
1361
+ const el1 = n.name.elements[1];
1362
+ if (el1 && ts.isBindingElement(el1) && ts.isIdentifier(el1.name) && el1.name.text === local) {
1363
+ const arg0 = n.initializer.arguments[0];
1364
+ if (arg0) found = actionRootOf(arg0, scope);
1365
+ }
1366
+ if (found) return;
1367
+ }
1368
+ ts.forEachChild(n, visit);
1369
+ };
1370
+ visit(scope);
1371
+ // No useActionState binding: the attribute names the action directly, or names
1372
+ // a local this cannot follow. Returning the local is right for the first and
1373
+ // harmless for the second — the emitter only asserts on a name it has ALSO
1374
+ // confirmed is a mocked import.
1375
+ return found ?? local;
1376
+ }
1377
+
1378
+ /**
1379
+ * The action an expression handed to useActionState ultimately calls.
1380
+ *
1381
+ * Three shapes, all in the dogfood app:
1382
+ *
1383
+ * useActionState(loginAction, undefined) -> loginAction
1384
+ * useActionState(createAction.bind(null, id), {}) -> createAction
1385
+ * const a = createAction.bind(null, id); useActionState(a) -> createAction
1386
+ *
1387
+ * `.bind` is followed because a bound function calls the original — asserting the
1388
+ * original fired is exactly true. One hop through a local const, for the same
1389
+ * reason handlerEffects takes one: it is how people write it, and a miss costs a
1390
+ * weaker test rather than a wrong one.
1391
+ */
1392
+ function actionRootOf(expr: ts.Expression, scope: ts.Node, depth = 0): string | undefined {
1393
+ if (depth > 2) return undefined;
1394
+ if (
1395
+ ts.isCallExpression(expr) &&
1396
+ ts.isPropertyAccessExpression(expr.expression) &&
1397
+ expr.expression.name.text === "bind" &&
1398
+ ts.isIdentifier(expr.expression.expression)
1399
+ ) {
1400
+ return expr.expression.expression.text;
1401
+ }
1402
+ if (!ts.isIdentifier(expr)) return undefined;
1403
+ const name = expr.text;
1404
+ let via: string | undefined;
1405
+ const visit = (n: ts.Node): void => {
1406
+ if (via) return;
1407
+ if (
1408
+ ts.isVariableDeclaration(n) &&
1409
+ ts.isIdentifier(n.name) &&
1410
+ n.name.text === name &&
1411
+ n.initializer
1412
+ ) {
1413
+ via = actionRootOf(n.initializer, scope, depth + 1);
1414
+ return;
1415
+ }
1416
+ ts.forEachChild(n, visit);
1417
+ };
1418
+ visit(scope);
1419
+ // No local binding to follow: the name IS the action.
1420
+ return via ?? name;
1421
+ }