@lariocpt/flawless-react 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +608 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,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
|
+
}
|