@lariocpt/flawless-react 0.0.0-stage → 0.3.1

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