@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,857 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { DataDepInfo, MockField, MockShape } from "../types.ts";
|
|
3
|
+
import { isGuardExit } from "./extractGuards.ts";
|
|
4
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
5
|
+
import { posOf } from "./pos.ts";
|
|
6
|
+
import { bodyStatements, collectRefs } from "./refs.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Infers what an async server component awaits and the shape each awaited
|
|
10
|
+
* value has to be mocked with. The caller decides whether the component is an
|
|
11
|
+
* async server component; this extractor never checks.
|
|
12
|
+
*
|
|
13
|
+
* Purely syntactic (no ts.Program, no checker), so every rule below carries an
|
|
14
|
+
* explicit give-up condition. A wrong shape produces a generated spec that
|
|
15
|
+
* fails against correct code — strictly worse than admitting defeat, which is
|
|
16
|
+
* what `uncertain: true` is for.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Callee roots that are never a module boundary worth mocking. `Promise` earns
|
|
21
|
+
* its place for a specific reason: `await Promise.all(members.map(...))` is
|
|
22
|
+
* derived from a dep that was already recorded one line above, so recording it
|
|
23
|
+
* again would ask the caller to mock a value that crosses no module edge.
|
|
24
|
+
*/
|
|
25
|
+
const SKIP_CALLEE_ROOTS = new Set([
|
|
26
|
+
"Promise",
|
|
27
|
+
"JSON",
|
|
28
|
+
"Number",
|
|
29
|
+
"String",
|
|
30
|
+
"Boolean",
|
|
31
|
+
"Array",
|
|
32
|
+
"Object",
|
|
33
|
+
"Math",
|
|
34
|
+
"Date",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Members that prove a value is an array without naming a data field. */
|
|
38
|
+
const ARRAY_METHODS = new Set([
|
|
39
|
+
"map",
|
|
40
|
+
"filter",
|
|
41
|
+
"forEach",
|
|
42
|
+
"find",
|
|
43
|
+
"slice",
|
|
44
|
+
"some",
|
|
45
|
+
"every",
|
|
46
|
+
// Unambiguously array-only (unlike includes/indexOf/at, which strings share):
|
|
47
|
+
// `poll.options.reduce(…)` classified options as an object, mocked it {},
|
|
48
|
+
// and the page crashed on `.reduce is not a function` into the skip escape.
|
|
49
|
+
"reduce",
|
|
50
|
+
"flatMap",
|
|
51
|
+
"findIndex",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/** String methods that prove the receiver is a string rather than an object. */
|
|
55
|
+
const STRING_METHODS = new Set([
|
|
56
|
+
"toLowerCase",
|
|
57
|
+
"toUpperCase",
|
|
58
|
+
"trim",
|
|
59
|
+
"split",
|
|
60
|
+
"startsWith",
|
|
61
|
+
"endsWith",
|
|
62
|
+
"includes",
|
|
63
|
+
"replace",
|
|
64
|
+
"replaceAll",
|
|
65
|
+
"padStart",
|
|
66
|
+
"padEnd",
|
|
67
|
+
"charAt",
|
|
68
|
+
"substring",
|
|
69
|
+
"toLocaleDateString",
|
|
70
|
+
"toLocaleTimeString",
|
|
71
|
+
"toISOString",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
type FieldKind =
|
|
75
|
+
| "string"
|
|
76
|
+
| "number"
|
|
77
|
+
| "date-string"
|
|
78
|
+
| "array"
|
|
79
|
+
| "object"
|
|
80
|
+
| "set"
|
|
81
|
+
| "map"
|
|
82
|
+
| "date"
|
|
83
|
+
| "unknown";
|
|
84
|
+
|
|
85
|
+
interface FieldNote {
|
|
86
|
+
kinds: Set<FieldKind>;
|
|
87
|
+
rendersAsText: boolean;
|
|
88
|
+
/** Fields read off this one, when it turned out to hold an object. */
|
|
89
|
+
nested?: FieldNotes;
|
|
90
|
+
/** A literal the component compares this field against — see MockField. */
|
|
91
|
+
equals?: string | number | boolean;
|
|
92
|
+
/** A prop path the component compares this field against — see MockField. */
|
|
93
|
+
equalsProp?: string[];
|
|
94
|
+
/** Another dep's field the component compares this one against — see MockField. */
|
|
95
|
+
equalsDep?: { var: string; field: string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type FieldNotes = Map<string, FieldNote>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What comparedLiteral needs beyond the comparison itself: the component body
|
|
102
|
+
* (to tell whether the comparison sits in a top-level guard condition) and the
|
|
103
|
+
* file's imports (to tell whether that guard's branch really aborts).
|
|
104
|
+
*/
|
|
105
|
+
interface CompareCtx {
|
|
106
|
+
bodyNode: ts.Node;
|
|
107
|
+
imports: Map<string, ImportBinding>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function extractDataDeps(
|
|
111
|
+
bodyNode: ts.Node,
|
|
112
|
+
sourceFile: ts.SourceFile,
|
|
113
|
+
imports: Map<string, ImportBinding>,
|
|
114
|
+
): DataDepInfo[] {
|
|
115
|
+
const out: DataDepInfo[] = [];
|
|
116
|
+
// Top-level statements only. A call nested in a branch or a closure runs
|
|
117
|
+
// conditionally, so a mock for it would have to fire on paths that never
|
|
118
|
+
// reach it — and we cannot tell which paths those are without a checker. A
|
|
119
|
+
// top-level TRY body is the one exception: it always runs, and `let ticket;
|
|
120
|
+
// try { ticket = await getTicketDetail(…) } catch …` is how a page fetches
|
|
121
|
+
// when the data layer throws domain errors — the dep is as real as a const.
|
|
122
|
+
for (const stmt of bodyStatements(bodyNode)) {
|
|
123
|
+
if (ts.isTryStatement(stmt)) {
|
|
124
|
+
for (const inner of stmt.tryBlock.statements) {
|
|
125
|
+
const dep = buildTryAssignDep(inner, bodyNode, sourceFile, imports);
|
|
126
|
+
if (dep && !out.some((d) => d.varName === dep.varName)) out.push(dep);
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
131
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
132
|
+
const parallel = buildPromiseAllDeps(decl, bodyNode, sourceFile, imports);
|
|
133
|
+
if (parallel) {
|
|
134
|
+
out.push(...parallel);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const dep = buildDep(decl, bodyNode, sourceFile, imports);
|
|
138
|
+
if (dep) out.push(dep);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `ticket = await getTicketDetail(…)` inside a top-level try, assigning a
|
|
146
|
+
* top-level `let` declared WITHOUT an initializer (one with an initializer is
|
|
147
|
+
* already a dep, and would be recorded twice). Everything downstream — field
|
|
148
|
+
* notes, guard polarity, equals capture — then reads the variable the same
|
|
149
|
+
* way it reads a const-awaited one.
|
|
150
|
+
*/
|
|
151
|
+
function buildTryAssignDep(
|
|
152
|
+
stmt: ts.Statement,
|
|
153
|
+
bodyNode: ts.Node,
|
|
154
|
+
sourceFile: ts.SourceFile,
|
|
155
|
+
imports: Map<string, ImportBinding>,
|
|
156
|
+
): DataDepInfo | undefined {
|
|
157
|
+
if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) return undefined;
|
|
158
|
+
const assign = stmt.expression;
|
|
159
|
+
if (assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return undefined;
|
|
160
|
+
if (!ts.isIdentifier(assign.left)) return undefined;
|
|
161
|
+
const varName = assign.left.text;
|
|
162
|
+
const declaredBare = bodyStatements(bodyNode).some(
|
|
163
|
+
(s) =>
|
|
164
|
+
ts.isVariableStatement(s) &&
|
|
165
|
+
s.declarationList.declarations.some(
|
|
166
|
+
(d) => ts.isIdentifier(d.name) && d.name.text === varName && !d.initializer,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
if (!declaredBare) return undefined;
|
|
170
|
+
|
|
171
|
+
const init = assign.right;
|
|
172
|
+
const awaited = ts.isAwaitExpression(init);
|
|
173
|
+
const source = awaited ? init.expression : init;
|
|
174
|
+
if (!ts.isCallExpression(source)) return undefined;
|
|
175
|
+
const callee = dottedName(source.expression);
|
|
176
|
+
if (!callee || SKIP_CALLEE_ROOTS.has(callee.root)) return undefined;
|
|
177
|
+
const specifier = imports.get(callee.root)?.specifier;
|
|
178
|
+
const inferred = inferShape(varName, bodyNode, sourceFile, imports);
|
|
179
|
+
return {
|
|
180
|
+
varName,
|
|
181
|
+
callee: callee.text,
|
|
182
|
+
calleeRoot: callee.root,
|
|
183
|
+
...(specifier !== undefined ? { specifier } : {}),
|
|
184
|
+
awaited,
|
|
185
|
+
pos: posOf(stmt, sourceFile),
|
|
186
|
+
shape: inferred.shape,
|
|
187
|
+
uncertain: inferred.uncertain,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `const [a, b] = await Promise.all([f(), g()])` — one dependency per element.
|
|
193
|
+
*
|
|
194
|
+
* buildDep bails on this twice over: "Promise" is a skipped callee root, and the
|
|
195
|
+
* binding is an array pattern rather than an identifier. So both f and g got a
|
|
196
|
+
* bare vi.fn() returning undefined, and the component crashed on `a.length` with
|
|
197
|
+
* an error that named neither call.
|
|
198
|
+
*
|
|
199
|
+
* Position i of the array literal pairs with binding i. Anything that breaks
|
|
200
|
+
* that correspondence — a spread, an omitted slot, a rest element, a non-call
|
|
201
|
+
* element — returns undefined so the caller falls back to the single-dep path.
|
|
202
|
+
*/
|
|
203
|
+
function buildPromiseAllDeps(
|
|
204
|
+
decl: ts.VariableDeclaration,
|
|
205
|
+
bodyNode: ts.Node,
|
|
206
|
+
sourceFile: ts.SourceFile,
|
|
207
|
+
imports: Map<string, ImportBinding>,
|
|
208
|
+
): DataDepInfo[] | undefined {
|
|
209
|
+
if (!ts.isArrayBindingPattern(decl.name)) return undefined;
|
|
210
|
+
const init = decl.initializer;
|
|
211
|
+
if (!init || !ts.isAwaitExpression(init)) return undefined;
|
|
212
|
+
const call = init.expression;
|
|
213
|
+
if (!ts.isCallExpression(call)) return undefined;
|
|
214
|
+
const callee = dottedName(call.expression);
|
|
215
|
+
if (callee?.text !== "Promise.all") return undefined;
|
|
216
|
+
|
|
217
|
+
const arg = call.arguments[0];
|
|
218
|
+
if (!arg || !ts.isArrayLiteralExpression(arg)) return undefined;
|
|
219
|
+
const elements = decl.name.elements;
|
|
220
|
+
if (elements.length !== arg.elements.length) return undefined;
|
|
221
|
+
|
|
222
|
+
const out: DataDepInfo[] = [];
|
|
223
|
+
for (const [i, binding] of elements.entries()) {
|
|
224
|
+
if (!ts.isBindingElement(binding) || binding.dotDotDotToken) return undefined;
|
|
225
|
+
if (!ts.isIdentifier(binding.name)) return undefined;
|
|
226
|
+
const element = arg.elements[i];
|
|
227
|
+
if (!element || !ts.isCallExpression(element)) return undefined;
|
|
228
|
+
const elementCallee = dottedName(element.expression);
|
|
229
|
+
if (!elementCallee || SKIP_CALLEE_ROOTS.has(elementCallee.root)) return undefined;
|
|
230
|
+
|
|
231
|
+
const varName = binding.name.text;
|
|
232
|
+
const inferred = inferShape(varName, bodyNode, sourceFile, imports);
|
|
233
|
+
const specifier = imports.get(elementCallee.root)?.specifier;
|
|
234
|
+
out.push({
|
|
235
|
+
varName,
|
|
236
|
+
callee: elementCallee.text,
|
|
237
|
+
calleeRoot: elementCallee.root,
|
|
238
|
+
...(specifier !== undefined ? { specifier } : {}),
|
|
239
|
+
awaited: true,
|
|
240
|
+
pos: posOf(decl, sourceFile),
|
|
241
|
+
shape: inferred.shape,
|
|
242
|
+
uncertain: inferred.uncertain,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildDep(
|
|
249
|
+
decl: ts.VariableDeclaration,
|
|
250
|
+
bodyNode: ts.Node,
|
|
251
|
+
sourceFile: ts.SourceFile,
|
|
252
|
+
imports: Map<string, ImportBinding>,
|
|
253
|
+
): DataDepInfo | undefined {
|
|
254
|
+
const init = decl.initializer;
|
|
255
|
+
if (!init) return undefined;
|
|
256
|
+
|
|
257
|
+
const awaited = ts.isAwaitExpression(init);
|
|
258
|
+
const source = awaited ? init.expression : init;
|
|
259
|
+
// `await x` counts even when x is not a call (a promise-valued prop such as
|
|
260
|
+
// Next.js `params`); a *non*-awaited bare identifier is only an alias and
|
|
261
|
+
// crosses no boundary, so it is not a dependency.
|
|
262
|
+
const calleeExpr = ts.isCallExpression(source) ? source.expression : awaited ? source : undefined;
|
|
263
|
+
if (!calleeExpr) return undefined;
|
|
264
|
+
|
|
265
|
+
// Computed callees (`handlers[kind]()`, `(cond ? a : b)()`) have no stable
|
|
266
|
+
// name for a mock to attach to.
|
|
267
|
+
const callee = dottedName(calleeExpr);
|
|
268
|
+
if (!callee) return undefined;
|
|
269
|
+
if (SKIP_CALLEE_ROOTS.has(callee.root)) return undefined;
|
|
270
|
+
|
|
271
|
+
// Absent for a helper declared in this same file — still worth recording, as
|
|
272
|
+
// the caller may stub it another way.
|
|
273
|
+
const specifier = imports.get(callee.root)?.specifier;
|
|
274
|
+
const common = {
|
|
275
|
+
callee: callee.text,
|
|
276
|
+
calleeRoot: callee.root,
|
|
277
|
+
...(specifier !== undefined ? { specifier } : {}),
|
|
278
|
+
awaited,
|
|
279
|
+
pos: posOf(decl, sourceFile),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
if (ts.isObjectBindingPattern(decl.name)) {
|
|
283
|
+
// The destructured keys *are* the shape. Their own value shapes are left
|
|
284
|
+
// unknown: each key is a separate binding, and chasing them would attribute
|
|
285
|
+
// a shadowed reference to the wrong value.
|
|
286
|
+
return {
|
|
287
|
+
varName: undefined,
|
|
288
|
+
...common,
|
|
289
|
+
shape: { kind: "object", fields: bindingPatternFields(decl.name) },
|
|
290
|
+
uncertain: false,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// An array binding pattern (`const [a, b] = await ...`) names positions, not
|
|
294
|
+
// keys, and we have nothing to say about element count or order.
|
|
295
|
+
if (!ts.isIdentifier(decl.name)) return undefined;
|
|
296
|
+
|
|
297
|
+
const varName = decl.name.text;
|
|
298
|
+
const inferred = inferShape(varName, bodyNode, sourceFile, imports);
|
|
299
|
+
return { varName, ...common, shape: inferred.shape, uncertain: inferred.uncertain };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function bindingPatternFields(pattern: ts.ObjectBindingPattern): MockField[] {
|
|
303
|
+
const fields: MockField[] = [];
|
|
304
|
+
for (const el of pattern.elements) {
|
|
305
|
+
// `{ ...rest }` names no key; the keys listed beside it are still real, so
|
|
306
|
+
// the element is skipped rather than the whole dependency dropped.
|
|
307
|
+
if (el.dotDotDotToken) continue;
|
|
308
|
+
const key = el.propertyName ?? el.name;
|
|
309
|
+
const name = ts.isIdentifier(key)
|
|
310
|
+
? key.text
|
|
311
|
+
: ts.isStringLiteral(key)
|
|
312
|
+
? key.text
|
|
313
|
+
: ts.isNumericLiteral(key)
|
|
314
|
+
? key.text
|
|
315
|
+
: undefined;
|
|
316
|
+
if (name === undefined) continue;
|
|
317
|
+
fields.push({ name, shape: { kind: "unknown" }, rendersAsText: false });
|
|
318
|
+
}
|
|
319
|
+
return fields;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Shape inference
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
function inferShape(
|
|
327
|
+
varName: string,
|
|
328
|
+
bodyNode: ts.Node,
|
|
329
|
+
sourceFile: ts.SourceFile,
|
|
330
|
+
imports: Map<string, ImportBinding>,
|
|
331
|
+
): { shape: MockShape; uncertain: boolean } {
|
|
332
|
+
const aliases = propAliases(bodyNode);
|
|
333
|
+
const ctx: CompareCtx = { bodyNode, imports };
|
|
334
|
+
let arrayEvidence = false;
|
|
335
|
+
let booleanTest = false;
|
|
336
|
+
let scalar = false;
|
|
337
|
+
const objectFields: FieldNotes = new Map();
|
|
338
|
+
const elementFields: FieldNotes = new Map();
|
|
339
|
+
|
|
340
|
+
for (const ref of collectRefs(bodyNode, sourceFile, new Set([varName]), undefined)) {
|
|
341
|
+
const id: ts.Node = ref.node;
|
|
342
|
+
const parent = id.parent;
|
|
343
|
+
if (!parent) continue;
|
|
344
|
+
|
|
345
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === id) {
|
|
346
|
+
if (!ts.isIdentifier(parent.name)) continue;
|
|
347
|
+
const member = parent.name.text;
|
|
348
|
+
if (member === "length") {
|
|
349
|
+
arrayEvidence = true;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (ARRAY_METHODS.has(member)) {
|
|
353
|
+
arrayEvidence = true;
|
|
354
|
+
collectElementFields(parent, sourceFile, elementFields, aliases, ctx);
|
|
355
|
+
if (member === "find") {
|
|
356
|
+
collectFindResultFields(parent, bodyNode, sourceFile, elementFields, aliases, ctx);
|
|
357
|
+
}
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
noteField(
|
|
361
|
+
objectFields,
|
|
362
|
+
member,
|
|
363
|
+
fieldKind(parent),
|
|
364
|
+
isJsxTextChild(parent),
|
|
365
|
+
nestedOf(parent),
|
|
366
|
+
comparedLiteral(parent, aliases, ctx),
|
|
367
|
+
);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (ts.isElementAccessExpression(parent) && parent.expression === id) {
|
|
372
|
+
// Only a numeric index proves an array. `lookup[key]` is a record, and
|
|
373
|
+
// mocking a record as an array is the kind of guess that breaks specs.
|
|
374
|
+
if (ts.isNumericLiteral(parent.argumentExpression)) arrayEvidence = true;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (isBooleanTest(id)) {
|
|
379
|
+
booleanTest = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (isScalarRender(id)) {
|
|
383
|
+
scalar = true;
|
|
384
|
+
}
|
|
385
|
+
// Anything else — a JSX attribute value, a call argument, a return — hands
|
|
386
|
+
// the value to code we cannot see, so it is no evidence at all.
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const fields = toFields(objectFields);
|
|
390
|
+
|
|
391
|
+
if (arrayEvidence && fields.length === 0) {
|
|
392
|
+
const element = toFields(elementFields);
|
|
393
|
+
return {
|
|
394
|
+
shape:
|
|
395
|
+
element.length > 0
|
|
396
|
+
? { kind: "array", element: { kind: "object", fields: element } }
|
|
397
|
+
: { kind: "array" },
|
|
398
|
+
uncertain: false,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (arrayEvidence) {
|
|
402
|
+
// An envelope: `data.length` sitting next to `data.total` cannot both be
|
|
403
|
+
// right. The object wins because it is the shape that survives a member
|
|
404
|
+
// access, and the caller is told the inference is shaky.
|
|
405
|
+
return { shape: { kind: "object", fields }, uncertain: true };
|
|
406
|
+
}
|
|
407
|
+
if (fields.length > 0) return { shape: { kind: "object", fields }, uncertain: false };
|
|
408
|
+
// A truthy object is only safe when nothing renders the value: React throws
|
|
409
|
+
// on an object child, whereas a string passes a truthiness test just as well.
|
|
410
|
+
if (booleanTest && !scalar) return { shape: { kind: "truthy-object" }, uncertain: false };
|
|
411
|
+
if (scalar) return { shape: { kind: "string" }, uncertain: false };
|
|
412
|
+
return { shape: { kind: "unknown" }, uncertain: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Fields of the callback element in `xs.map((item) => …)`. */
|
|
416
|
+
function collectElementFields(
|
|
417
|
+
access: ts.PropertyAccessExpression,
|
|
418
|
+
sourceFile: ts.SourceFile,
|
|
419
|
+
notes: FieldNotes,
|
|
420
|
+
aliases: Map<string, string[]>,
|
|
421
|
+
ctx: CompareCtx,
|
|
422
|
+
): void {
|
|
423
|
+
const call = access.parent;
|
|
424
|
+
if (!call || !ts.isCallExpression(call) || call.expression !== access) return;
|
|
425
|
+
const cb = call.arguments[0];
|
|
426
|
+
// Only an inline arrow with a single named parameter describes the element.
|
|
427
|
+
// A callback passed by name, or one that destructures, lives somewhere this
|
|
428
|
+
// walk cannot follow.
|
|
429
|
+
if (!cb || !ts.isArrowFunction(cb) || cb.parameters.length !== 1) return;
|
|
430
|
+
const param = cb.parameters[0];
|
|
431
|
+
if (!param || !ts.isIdentifier(param.name)) return;
|
|
432
|
+
|
|
433
|
+
noteAccessesOf(param.name.text, cb.body, sourceFile, notes, aliases, ctx);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Fields read off the RESULT of `const mine = xs.find(…)` count as element
|
|
438
|
+
* fields of `xs`: the result IS one element, so `mine.status !== "active"` in
|
|
439
|
+
* a guard, or `mine.label` in the JSX, describes the row the fixture has to
|
|
440
|
+
* hold just as surely as the same read inside the callback would.
|
|
441
|
+
*
|
|
442
|
+
* Only the exact shape `const <identifier> = xs.find(…)` as a top-level
|
|
443
|
+
* statement qualifies — a chained `.find(…).x`, an optional `?.find`, or a
|
|
444
|
+
* result bound inside a branch runs under conditions this walk cannot
|
|
445
|
+
* reconstruct, and the note would describe a row that may never be looked at.
|
|
446
|
+
*/
|
|
447
|
+
function collectFindResultFields(
|
|
448
|
+
access: ts.PropertyAccessExpression,
|
|
449
|
+
bodyNode: ts.Node,
|
|
450
|
+
sourceFile: ts.SourceFile,
|
|
451
|
+
notes: FieldNotes,
|
|
452
|
+
aliases: Map<string, string[]>,
|
|
453
|
+
ctx: CompareCtx,
|
|
454
|
+
): void {
|
|
455
|
+
const call = access.parent;
|
|
456
|
+
if (!call || !ts.isCallExpression(call) || call.expression !== access) return;
|
|
457
|
+
const decl = call.parent;
|
|
458
|
+
if (!decl || !ts.isVariableDeclaration(decl) || decl.initializer !== call) return;
|
|
459
|
+
if (!ts.isIdentifier(decl.name)) return;
|
|
460
|
+
const stmt = decl.parent?.parent;
|
|
461
|
+
if (!stmt || !ts.isVariableStatement(stmt)) return;
|
|
462
|
+
if (!bodyStatements(bodyNode).includes(stmt)) return;
|
|
463
|
+
|
|
464
|
+
noteAccessesOf(decl.name.text, bodyNode, sourceFile, notes, aliases, ctx);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Every `name.field` read under `scope`, noted the way element fields are. */
|
|
468
|
+
function noteAccessesOf(
|
|
469
|
+
name: string,
|
|
470
|
+
scope: ts.Node,
|
|
471
|
+
sourceFile: ts.SourceFile,
|
|
472
|
+
notes: FieldNotes,
|
|
473
|
+
aliases: Map<string, string[]>,
|
|
474
|
+
ctx: CompareCtx,
|
|
475
|
+
): void {
|
|
476
|
+
for (const ref of collectRefs(scope, sourceFile, new Set([name]), undefined)) {
|
|
477
|
+
const parent = ref.node.parent;
|
|
478
|
+
if (!parent || !ts.isPropertyAccessExpression(parent)) continue;
|
|
479
|
+
if (parent.expression !== ref.node || !ts.isIdentifier(parent.name)) continue;
|
|
480
|
+
const member = parent.name.text;
|
|
481
|
+
// Builtins name no data field, so mocking them would invent a key.
|
|
482
|
+
if (member === "length" || ARRAY_METHODS.has(member)) continue;
|
|
483
|
+
noteField(
|
|
484
|
+
notes,
|
|
485
|
+
member,
|
|
486
|
+
fieldKind(parent),
|
|
487
|
+
isJsxTextChild(parent),
|
|
488
|
+
nestedOf(parent),
|
|
489
|
+
comparedLiteral(parent, aliases, ctx),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Value kind of a single `x.k` access. One level of nesting is the hard limit:
|
|
496
|
+
* anything deeper falls back to unknown, which mocks as `{}` and therefore
|
|
497
|
+
* survives the access instead of inventing a sub-object that specs assert on.
|
|
498
|
+
*/
|
|
499
|
+
function fieldKind(access: ts.PropertyAccessExpression): FieldKind {
|
|
500
|
+
// Casts and parens are transparent: `(meeting.agenda_items as Item[]).map`
|
|
501
|
+
// is array evidence exactly as the bare access would be — reading the cast
|
|
502
|
+
// as the parent classified the field a string, mocked it one, and the page
|
|
503
|
+
// crashed on `.map is not a function` behind the skip escape.
|
|
504
|
+
let node: ts.Expression = access;
|
|
505
|
+
let parent = access.parent;
|
|
506
|
+
while (
|
|
507
|
+
parent &&
|
|
508
|
+
(ts.isAsExpression(parent) ||
|
|
509
|
+
ts.isParenthesizedExpression(parent) ||
|
|
510
|
+
ts.isNonNullExpression(parent) ||
|
|
511
|
+
ts.isSatisfiesExpression(parent))
|
|
512
|
+
) {
|
|
513
|
+
node = parent as ts.Expression;
|
|
514
|
+
parent = parent.parent;
|
|
515
|
+
}
|
|
516
|
+
if (!parent) return "string";
|
|
517
|
+
|
|
518
|
+
if (ts.isNewExpression(parent) && isNamedCallee(parent.expression, "Date")) {
|
|
519
|
+
if ((parent.arguments ?? []).some((a) => a === node)) return "date-string";
|
|
520
|
+
}
|
|
521
|
+
if (ts.isCallExpression(parent) && isNamedCallee(parent.expression, "Number")) {
|
|
522
|
+
if (parent.arguments.some((a) => a === node)) return "number";
|
|
523
|
+
}
|
|
524
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
|
525
|
+
if (!ts.isIdentifier(parent.name)) return "unknown";
|
|
526
|
+
const inner = parent.name.text;
|
|
527
|
+
if (inner === "toFixed") return "number";
|
|
528
|
+
if (inner === "length" || ARRAY_METHODS.has(inner)) return "array";
|
|
529
|
+
// A string method proves the field is a string, which the default already
|
|
530
|
+
// covers — but saying so explicitly keeps it out of the "object" branch.
|
|
531
|
+
if (STRING_METHODS.has(inner)) return "string";
|
|
532
|
+
// `session.user.email` — a further property read means this field holds an
|
|
533
|
+
// object. Reporting "unknown" mocked it as the string "flawless-user", and
|
|
534
|
+
// then `.email` was undefined and `.toLowerCase()` threw.
|
|
535
|
+
return "object";
|
|
536
|
+
}
|
|
537
|
+
return "string";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Locals destructured from another local, as a path: `const { id } = await
|
|
542
|
+
* params` gives `id → ["params", "id"]`.
|
|
543
|
+
*
|
|
544
|
+
* Top-level only, and only from a plain identifier — that is the shape a Next
|
|
545
|
+
* page uses to read its route params, and anything deeper is a value this
|
|
546
|
+
* cannot follow. The root is looked up in the props fixture at emit time, so a
|
|
547
|
+
* root that is not a prop simply resolves to nothing.
|
|
548
|
+
*/
|
|
549
|
+
function propAliases(bodyNode: ts.Node): Map<string, string[]> {
|
|
550
|
+
const out = new Map<string, string[]>();
|
|
551
|
+
for (const stmt of bodyStatements(bodyNode)) {
|
|
552
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
553
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
554
|
+
if (!decl.initializer || !ts.isObjectBindingPattern(decl.name)) continue;
|
|
555
|
+
const init = ts.isAwaitExpression(decl.initializer)
|
|
556
|
+
? decl.initializer.expression
|
|
557
|
+
: decl.initializer;
|
|
558
|
+
if (!ts.isIdentifier(init)) continue;
|
|
559
|
+
for (const el of decl.name.elements) {
|
|
560
|
+
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
|
|
561
|
+
const from =
|
|
562
|
+
el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
|
|
563
|
+
out.set(el.name.text, [init.text, from]);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
interface ComparedNote {
|
|
571
|
+
equals?: string | number | boolean;
|
|
572
|
+
equalsProp?: string[];
|
|
573
|
+
equalsDep?: { var: string; field: string };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The value this access is compared against: `m.status === "active"`.
|
|
578
|
+
*
|
|
579
|
+
* Strict comparisons only, and polarity depends on where the comparison sits.
|
|
580
|
+
* In render position, `===` names the value the fixture should hold and `!==`
|
|
581
|
+
* only states what it must NOT be, which does not pick one out. Inside a
|
|
582
|
+
* top-level `if (cond) notFound()` guard the polarity inverts: there `===`
|
|
583
|
+
* names the value that would abort the render — capturing it re-creates the
|
|
584
|
+
* green-test-over-an-empty-render failure — while `!==` uniquely names the one
|
|
585
|
+
* value that passes (`invoice.building_id !== id` passes only when the field
|
|
586
|
+
* IS the route param).
|
|
587
|
+
*/
|
|
588
|
+
function comparedLiteral(
|
|
589
|
+
access: ts.PropertyAccessExpression,
|
|
590
|
+
aliases: Map<string, string[]>,
|
|
591
|
+
ctx: CompareCtx,
|
|
592
|
+
): ComparedNote {
|
|
593
|
+
const parent = access.parent;
|
|
594
|
+
if (!parent || !ts.isBinaryExpression(parent)) return {};
|
|
595
|
+
const op = parent.operatorToken.kind;
|
|
596
|
+
const strictEquals = op === ts.SyntaxKind.EqualsEqualsEqualsToken;
|
|
597
|
+
if (!strictEquals && op !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return {};
|
|
598
|
+
if (strictEquals === inGuardExitCondition(parent, ctx)) return {};
|
|
599
|
+
|
|
600
|
+
const other = parent.left === access ? parent.right : parent.left;
|
|
601
|
+
if (ts.isStringLiteral(other)) return { equals: other.text };
|
|
602
|
+
if (ts.isNumericLiteral(other)) return { equals: Number(other.text) };
|
|
603
|
+
if (other.kind === ts.SyntaxKind.TrueKeyword) return { equals: true };
|
|
604
|
+
if (other.kind === ts.SyntaxKind.FalseKeyword) return { equals: false };
|
|
605
|
+
if (ts.isIdentifier(other)) {
|
|
606
|
+
const path = aliases.get(other.text);
|
|
607
|
+
if (path) return { equalsProp: path };
|
|
608
|
+
}
|
|
609
|
+
// `m.user_id === user.id` — the other side is a field of another local.
|
|
610
|
+
// Recorded by name; the emitter resolves it against that dep's own fixture,
|
|
611
|
+
// and drops the note when the name turns out not to be a dep it can read.
|
|
612
|
+
if (
|
|
613
|
+
ts.isPropertyAccessExpression(other) &&
|
|
614
|
+
ts.isIdentifier(other.expression) &&
|
|
615
|
+
ts.isIdentifier(other.name)
|
|
616
|
+
) {
|
|
617
|
+
return { equalsDep: { var: other.expression.text, field: other.name.text } };
|
|
618
|
+
}
|
|
619
|
+
return {};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Whether this comparison sits — possibly under parens and `&&`/`||`, in
|
|
624
|
+
* positive position — in the condition of a top-level guard whose branch
|
|
625
|
+
* aborts the render. Anything else on the way up (a `!`, a ternary, a
|
|
626
|
+
* callback boundary) makes the comparison's polarity something this cannot
|
|
627
|
+
* read, so it is not guard context; a comparison inside a `find()` callback
|
|
628
|
+
* keeps its render-position polarity even when the call feeds a guard.
|
|
629
|
+
*/
|
|
630
|
+
function inGuardExitCondition(comparison: ts.BinaryExpression, ctx: CompareCtx): boolean {
|
|
631
|
+
let cur: ts.Node = comparison;
|
|
632
|
+
let parent = cur.parent;
|
|
633
|
+
while (parent) {
|
|
634
|
+
if (ts.isParenthesizedExpression(parent)) {
|
|
635
|
+
cur = parent;
|
|
636
|
+
parent = parent.parent;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (
|
|
640
|
+
ts.isBinaryExpression(parent) &&
|
|
641
|
+
(parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
642
|
+
parent.operatorToken.kind === ts.SyntaxKind.BarBarToken)
|
|
643
|
+
) {
|
|
644
|
+
cur = parent;
|
|
645
|
+
parent = parent.parent;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
if (!parent || !ts.isIfStatement(parent) || parent.expression !== cur) return false;
|
|
651
|
+
if (!bodyStatements(ctx.bodyNode).includes(parent)) return false;
|
|
652
|
+
return isGuardExit(parent, ctx.imports);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** `<receiver>.method(...)` — the access is the callee of a call expression. */
|
|
656
|
+
function isCalledAsMethod(access: ts.PropertyAccessExpression): boolean {
|
|
657
|
+
const parent = access.parent;
|
|
658
|
+
return ts.isCallExpression(parent) && parent.expression === access;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Method names that prove the RECEIVER is a Set, a Map, or a Date. */
|
|
662
|
+
const COLLECTION_METHODS: Record<string, "set" | "map" | "date"> = {
|
|
663
|
+
has: "set",
|
|
664
|
+
add: "set",
|
|
665
|
+
get: "map",
|
|
666
|
+
set: "map",
|
|
667
|
+
getTime: "date",
|
|
668
|
+
getFullYear: "date",
|
|
669
|
+
getMonth: "date",
|
|
670
|
+
getDate: "date",
|
|
671
|
+
getHours: "date",
|
|
672
|
+
toISOString: "date",
|
|
673
|
+
toLocaleDateString: "date",
|
|
674
|
+
toLocaleTimeString: "date",
|
|
675
|
+
toDateString: "date",
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
function collectionKindOf(method: string): "set" | "map" | "date" | undefined {
|
|
679
|
+
return COLLECTION_METHODS[method];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function noteField(
|
|
683
|
+
notes: FieldNotes,
|
|
684
|
+
name: string,
|
|
685
|
+
kind: FieldKind,
|
|
686
|
+
rendersAsText: boolean,
|
|
687
|
+
nestedAccess?: ts.PropertyAccessExpression,
|
|
688
|
+
compared?: ComparedNote,
|
|
689
|
+
): void {
|
|
690
|
+
const note = notes.get(name) ?? { kinds: new Set<FieldKind>(), rendersAsText: false };
|
|
691
|
+
note.kinds.add(kind);
|
|
692
|
+
note.rendersAsText = note.rendersAsText || rendersAsText;
|
|
693
|
+
// First comparison wins. Two different literals for one field is the
|
|
694
|
+
// component testing for two states, and picking the second over the first is
|
|
695
|
+
// arbitrary — so it keeps the first and stays deterministic.
|
|
696
|
+
if (compared?.equals !== undefined && note.equals === undefined) note.equals = compared.equals;
|
|
697
|
+
if (compared?.equalsProp !== undefined && note.equalsProp === undefined) {
|
|
698
|
+
note.equalsProp = compared.equalsProp;
|
|
699
|
+
}
|
|
700
|
+
if (compared?.equalsDep !== undefined && note.equalsDep === undefined) {
|
|
701
|
+
note.equalsDep = compared.equalsDep;
|
|
702
|
+
}
|
|
703
|
+
// One level deeper, and only where the source actually read it. `session.user`
|
|
704
|
+
// followed by `.email` gives user: { email: … } rather than a bare {}, which
|
|
705
|
+
// would still be undefined at the read the component performs.
|
|
706
|
+
if (kind === "object" && nestedAccess && ts.isIdentifier(nestedAccess.name)) {
|
|
707
|
+
const method = nestedAccess.name.text;
|
|
708
|
+
const coll = isCalledAsMethod(nestedAccess) ? collectionKindOf(method) : undefined;
|
|
709
|
+
if (coll) {
|
|
710
|
+
// `<field>.has(id)` — the call says the field IS a Set (or a Map), not
|
|
711
|
+
// an object with a property named has. The nested stand-in that used to
|
|
712
|
+
// be built here, { has: "flawless-has" }, fails on exactly the call that
|
|
713
|
+
// exposed the field. The collection kind replaces the weaker "object".
|
|
714
|
+
note.kinds.clear();
|
|
715
|
+
note.kinds.add(coll);
|
|
716
|
+
notes.set(name, note);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
note.nested ??= new Map();
|
|
720
|
+
noteField(
|
|
721
|
+
note.nested,
|
|
722
|
+
nestedAccess.name.text,
|
|
723
|
+
fieldKind(nestedAccess),
|
|
724
|
+
isJsxTextChild(nestedAccess),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
notes.set(name, note);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function toFields(notes: FieldNotes): MockField[] {
|
|
731
|
+
const out: MockField[] = [];
|
|
732
|
+
for (const [name, note] of notes) {
|
|
733
|
+
const kind = settleKind(note.kinds);
|
|
734
|
+
const shape: MockShape =
|
|
735
|
+
kind === "object" && note.nested && note.nested.size > 0
|
|
736
|
+
? { kind: "object", fields: toFields(note.nested) }
|
|
737
|
+
: shapeOfFieldKind(kind);
|
|
738
|
+
out.push({
|
|
739
|
+
name,
|
|
740
|
+
shape,
|
|
741
|
+
rendersAsText: note.rendersAsText,
|
|
742
|
+
...(note.equals !== undefined ? { equals: note.equals } : {}),
|
|
743
|
+
...(note.equalsProp !== undefined ? { equalsProp: note.equalsProp } : {}),
|
|
744
|
+
...(note.equalsDep !== undefined ? { equalsDep: note.equalsDep } : {}),
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* "string" is the fallback every access without stronger evidence lands on, so
|
|
752
|
+
* a more specific kind outranks it. Two *different* specific kinds contradict
|
|
753
|
+
* each other, and guessing between them is exactly what breaks a real spec.
|
|
754
|
+
*/
|
|
755
|
+
function settleKind(kinds: Set<FieldKind>): FieldKind {
|
|
756
|
+
const specific = [...kinds].filter((k) => k !== "string");
|
|
757
|
+
if (specific.length === 0) return "string";
|
|
758
|
+
if (specific.length === 1) return specific[0] as FieldKind;
|
|
759
|
+
// Collection kinds (Set/Map/Date) come from CALLS — direct evidence about
|
|
760
|
+
// the value. The plain property reads that also leave "object" on the same
|
|
761
|
+
// field are weaker, so set/map/date win against object+string. Any other
|
|
762
|
+
// specific kind alongside one still contradicts, exactly as before.
|
|
763
|
+
const coll = specific.find((k) => k === "set" || k === "map" || k === "date");
|
|
764
|
+
if (coll && specific.every((k) => k === coll || k === "object")) return coll;
|
|
765
|
+
return "unknown";
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** The `x.y.z` access hanging off `x.y`, when there is one. */
|
|
769
|
+
function nestedOf(access: ts.PropertyAccessExpression): ts.PropertyAccessExpression | undefined {
|
|
770
|
+
const parent = access.parent;
|
|
771
|
+
return parent && ts.isPropertyAccessExpression(parent) && parent.expression === access
|
|
772
|
+
? parent
|
|
773
|
+
: undefined;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function shapeOfFieldKind(kind: FieldKind): MockShape {
|
|
777
|
+
switch (kind) {
|
|
778
|
+
case "number":
|
|
779
|
+
return { kind: "number" };
|
|
780
|
+
case "date-string":
|
|
781
|
+
return { kind: "date-string" };
|
|
782
|
+
case "array":
|
|
783
|
+
return { kind: "array" };
|
|
784
|
+
case "set":
|
|
785
|
+
return { kind: "set" };
|
|
786
|
+
case "map":
|
|
787
|
+
return { kind: "map" };
|
|
788
|
+
case "date":
|
|
789
|
+
return { kind: "date" };
|
|
790
|
+
case "unknown":
|
|
791
|
+
return { kind: "unknown" };
|
|
792
|
+
case "object":
|
|
793
|
+
// An object with no observed fields: {} survives a property read, which is
|
|
794
|
+
// the most a bare `x.y` with no further use can honestly support.
|
|
795
|
+
return { kind: "truthy-object" };
|
|
796
|
+
default:
|
|
797
|
+
return { kind: "string" };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// Reference position tests
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
function isBooleanTest(node: ts.Node): boolean {
|
|
806
|
+
const parent = node.parent;
|
|
807
|
+
if (!parent) return false;
|
|
808
|
+
if (
|
|
809
|
+
ts.isPrefixUnaryExpression(parent) &&
|
|
810
|
+
parent.operator === ts.SyntaxKind.ExclamationToken &&
|
|
811
|
+
parent.operand === node
|
|
812
|
+
) {
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
if (ts.isConditionalExpression(parent) && parent.condition === node) return true;
|
|
816
|
+
if (ts.isIfStatement(parent) && parent.expression === node) return true;
|
|
817
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
818
|
+
const op = parent.operatorToken.kind;
|
|
819
|
+
return (
|
|
820
|
+
op === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
821
|
+
op === ts.SyntaxKind.BarBarToken ||
|
|
822
|
+
op === ts.SyntaxKind.QuestionQuestionToken
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function isScalarRender(node: ts.Node): boolean {
|
|
829
|
+
if (isJsxTextChild(node)) return true;
|
|
830
|
+
const parent = node.parent;
|
|
831
|
+
return parent !== undefined && ts.isTemplateSpan(parent) && parent.expression === node;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** `<p>{x}</p>` — the node is the whole expression React renders as text. */
|
|
835
|
+
function isJsxTextChild(node: ts.Node): boolean {
|
|
836
|
+
const parent = node.parent;
|
|
837
|
+
if (!parent || !ts.isJsxExpression(parent) || parent.expression !== node) return false;
|
|
838
|
+
return (
|
|
839
|
+
parent.parent !== undefined &&
|
|
840
|
+
(ts.isJsxElement(parent.parent) || ts.isJsxFragment(parent.parent))
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function isNamedCallee(expr: ts.Expression, name: string): boolean {
|
|
845
|
+
return ts.isIdentifier(expr) && expr.text === name;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** `db.query.foo.findMany` → { text, root: "db" }; anything computed → undefined. */
|
|
849
|
+
function dottedName(expr: ts.Expression): { text: string; root: string } | undefined {
|
|
850
|
+
if (ts.isIdentifier(expr)) return { text: expr.text, root: expr.text };
|
|
851
|
+
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) {
|
|
852
|
+
const head = dottedName(expr.expression);
|
|
853
|
+
if (!head) return undefined;
|
|
854
|
+
return { text: `${head.text}.${expr.name.text}`, root: head.root };
|
|
855
|
+
}
|
|
856
|
+
return undefined;
|
|
857
|
+
}
|