@lariocpt/flawless-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
package/src/types.ts ADDED
@@ -0,0 +1,1419 @@
1
+ /** Bad input from the caller — the CLI maps it to exit 2, internal errors to 1.
2
+ * Lives here (not core.ts) so leaf modules like nextjs/detect.ts can throw it
3
+ * without importing core and creating a cycle. */
4
+ export class UsageError extends Error {}
5
+
6
+ export type PropPrimitive = string | number | boolean | null;
7
+ export type PropValue = PropPrimitive | PropValue[] | { [k: string]: PropValue };
8
+ export type PropsShape = Record<string, PropValue>;
9
+
10
+ /**
11
+ * Stands in for a real Date inside a PropValue (JSON-safe). A Date-typed prop
12
+ * gets this because a string answers none of getTime/toISOString/
13
+ * toLocaleTimeString; `defaultsJson` rewrites every occurrence back into a
14
+ * live `new Date(...)` at the places a PropsShape becomes spec source.
15
+ */
16
+ export const DATE_PROP_SENTINEL = "__flawless_date__";
17
+ export const DATE_PROP_ISO = "2024-01-01T00:00:00.000Z";
18
+
19
+ /** 1-based position of a node in a source file (file is absolute). */
20
+ export interface SourcePosition {
21
+ file: string;
22
+ line: number;
23
+ column: number;
24
+ }
25
+
26
+ /**
27
+ * Something a handler provably does, for deciding what a spec may assert AFTER
28
+ * dispatching it.
29
+ *
30
+ * The emitters used to fall back to `expect(container.firstElementChild)
31
+ * .not.toBeNull()` — "the click did not take the component down". Under a
32
+ * provider that is answered by the PROVIDER's node, so it cannot fail (30 of 30
33
+ * spec files carrying it on the dogfood app); without one it goes red on a
34
+ * component that legitimately unmounts. Vacuous or wrong, never useful.
35
+ *
36
+ * These are the crossings a spec can actually observe: a call into a module it
37
+ * mocks, a router navigation, a state setter. Anything else is honestly nothing,
38
+ * and the emitter says so rather than inventing a claim.
39
+ */
40
+ export type HandlerEffect =
41
+ | { kind: "call"; root: string; dominated: boolean }
42
+ | {
43
+ kind: "router";
44
+ method: "push" | "replace" | "navigate" | "back";
45
+ target?: string;
46
+ dominated: boolean;
47
+ }
48
+ | { kind: "setState"; setter: string; dominated: boolean };
49
+
50
+ export type HandlerBinding =
51
+ | { kind: "prop"; propName: string }
52
+ | { kind: "local"; localName: string }
53
+ | { kind: "inline" };
54
+
55
+ export type LabelSource =
56
+ | { kind: "text"; value: string }
57
+ /**
58
+ * A literal fragment of the element's copy, where the rest is an
59
+ * interpolation or a sibling element — `<Button>Record payment ({due})</Button>`
60
+ * or `<MenuItem><Sun /> Light</MenuItem>`. Only this fragment is knowable, so
61
+ * consumers must match it as a SUBSTRING of the accessible name. Asserting it
62
+ * as the whole name is a test that can never pass.
63
+ */
64
+ | { kind: "text-partial"; value: string }
65
+ /**
66
+ * The element's whole accessible name is one prop interpolated into static
67
+ * copy: `<button>+{step}</button>`. `before`/`after` are the trimmed literal
68
+ * text on either side of that interpolation, so the matcher can keep it — a
69
+ * name query of just the prop value never matches "+3", and the failure reads
70
+ * like an interaction gap rather than a dropped prefix.
71
+ */
72
+ | { kind: "prop"; propName: string; before?: string; after?: string }
73
+ /**
74
+ * A literal `data-testid`. Produced by both label ladders and consumed by
75
+ * every selector emitter (`getByTestId`), so it belongs in the union even
76
+ * though the analysers rank it differently: extractDisclosures tries it
77
+ * before `aria-label`, extractInteractions after.
78
+ */
79
+ | { kind: "testid"; value: string }
80
+ | { kind: "aria"; value: string }
81
+ /**
82
+ * The trigger's whole subtree is the `{children}` expression — the app's
83
+ * consumer IS the trigger (ConfirmModal's `<AlertDialogTrigger asChild>
84
+ * {children}</AlertDialogTrigger>`). Nothing inside names it, but a unit
85
+ * spec passes CHILD_TEXT there, so clicking the child text opens it.
86
+ */
87
+ | { kind: "children" }
88
+ | { kind: "none" };
89
+
90
+ export interface ElementHints {
91
+ testid?: string;
92
+ ariaLabel?: string;
93
+ role?: string;
94
+ id?: string;
95
+ /** Never populated on native — React Native elements have no `name`. */
96
+ name?: string;
97
+ /** Never populated on native — there is no input `type`. */
98
+ type?: string;
99
+ placeholder?: string;
100
+ /** `accessibilityHint`. Native only; RNTL queries it with getByHintText. */
101
+ hint?: string;
102
+ /**
103
+ * `title` on React Native's own `<Button>`, which renders no children — so the
104
+ * prop IS the accessible name, and the usual descendant-text rung finds nothing.
105
+ */
106
+ buttonTitle?: string;
107
+ /**
108
+ * A literal `value` on a TextInput. Native only; RNTL queries it with
109
+ * getByDisplayValue, which has no DOM-side equivalent in this ladder.
110
+ */
111
+ displayValue?: string;
112
+ /**
113
+ * Whether the element is disabled at the initial render, decided by evaluating
114
+ * the `disabled` attribute — "conditional" means the expression did not reduce.
115
+ *
116
+ * Playwright waits for a target to become enabled and fails on the timeout, so
117
+ * clicking a statically-disabled control costs the full 30s and then reports
118
+ * nothing useful. Knowing it is disabled turns that into the assertion the
119
+ * source actually supports.
120
+ */
121
+ disabled?: "always" | "conditional";
122
+ }
123
+
124
+ /**
125
+ * The control that submits a form, located structurally rather than by guessing
126
+ * at its wording.
127
+ *
128
+ * The generator used to look for a button whose accessible name matched
129
+ * /submit|save|send|create|.../ — a verb list. Against copy like "Update
130
+ * password" or "Move to review" it matched nothing and the spec hung until the
131
+ * suite timed out: 18 of the dogfood app's 40 browser failures, and about nine
132
+ * minutes of every run. What a form's submit control IS, however, is written
133
+ * down in the markup.
134
+ */
135
+ export interface SubmitControl {
136
+ /** Its accessible name, when the source spells it out. */
137
+ label: LabelSource;
138
+ /** A host <button type=submit> vs a component tag that declares type="submit". */
139
+ host: boolean;
140
+ /**
141
+ * Disabled at the props the spec passes — `disabled={pending || picked.length
142
+ * === 0}` with `picked` starting empty. Reported rather than hidden: "no
143
+ * submit control in the markup" would send someone hunting for a button that
144
+ * is plainly there.
145
+ */
146
+ disabled?: boolean;
147
+ }
148
+
149
+ export interface ClickHandler {
150
+ event: string;
151
+ tag: string;
152
+ label: LabelSource;
153
+ hints: ElementHints;
154
+ binding: HandlerBinding;
155
+ /**
156
+ * The element only mounts once this trigger is clicked (a menu item, a dialog
157
+ * body). A spec must open it first and then query asynchronously.
158
+ */
159
+ gatedBy?: LabelSource;
160
+ /**
161
+ * More than one element of this tag renders here, so "the <button>" does not
162
+ * name this one.
163
+ *
164
+ * The last-resort locator is the bare host tag. `page.locator("button")
165
+ * .first()` claims the first button is the one carrying this handler — true
166
+ * only when there is a single button. In VoteCard it resolved to the disabled
167
+ * "Cast vote" control while the handler belonged to a vote option, and the
168
+ * click waited for it to become enabled until the test timed out.
169
+ */
170
+ ambiguousTag?: boolean;
171
+ /**
172
+ * What this handler provably does — see HandlerEffect.
173
+ *
174
+ * `dominated` on each entry means the effect is reached with no branch and no
175
+ * earlier return in front of it, so a spec that dispatches the event can
176
+ * assert it happened. An effect that is NOT dominated is real but conditional,
177
+ * and asserting it would be a claim about a path the defaults may not take.
178
+ */
179
+ effects?: HandlerEffect[];
180
+ /**
181
+ * The element renders element children and every one of them is textless — an
182
+ * icon, a spinner — so nothing under it can name it.
183
+ *
184
+ * Native only, and it is the same absence twice: an icon-only control is
185
+ * unaddressable to a spec AND unannounced to a screen reader, so one
186
+ * accessibilityLabel retires both. Recorded here so the skip's wording and the
187
+ * a11y finding can agree without either re-deriving it.
188
+ */
189
+ iconOnly?: boolean;
190
+ /**
191
+ * Literal route paths this handler navigates to — `router.push("/items/new")`.
192
+ *
193
+ * The React Native analogue of a link's href, and statically knowable in
194
+ * exactly the cases that matter. Only LITERALS: a template like
195
+ * `/items/${'${item.id}'}` names a route the spec cannot predict, so it is
196
+ * omitted rather than guessed at.
197
+ *
198
+ * Two consumers: the emitted spec asserts the resulting pathname, and the
199
+ * renderRouter context must carry a stub for each target — without one the push
200
+ * lands on expo-router's injected +not-found and the assertion fails for a
201
+ * reason that has nothing to do with the component.
202
+ */
203
+ pushTargets?: string[];
204
+ /**
205
+ * The handler navigates BACK — `router.back()`.
206
+ *
207
+ * It needs a precondition the harness does not supply by default: renderRouter
208
+ * mounts a single screen at one URL with no history, so back() fails with
209
+ * "The action 'GO_BACK' was not handled by any navigator". Recorded so the
210
+ * emitted test can push an entry first, rather than failing for a reason that
211
+ * is neither the app's nor the tool's.
212
+ */
213
+ navBack?: boolean;
214
+ /**
215
+ * The element is reached only CONDITIONALLY at the props and state the spec
216
+ * passes — typically one row of a `.map()` over data the component fetches.
217
+ *
218
+ * `reachability` returns "unknown" for those and the harvest keeps them, which
219
+ * is right: dropping every mapped row would lose most of a list screen. But a
220
+ * spec cannot DRIVE what does not render, and on a screen whose collection the
221
+ * fixture leaves empty the row is genuinely absent — so the emitter turns these
222
+ * into a skip that says so rather than a test that fails.
223
+ */
224
+ conditional?: boolean;
225
+ pos?: SourcePosition;
226
+ }
227
+
228
+ export interface FieldInfo {
229
+ /** DOM tag on web; the module-side React Native export name on native. */
230
+ tag: "input" | "select" | "textarea" | "TextInput" | "Switch";
231
+ type?: string;
232
+ /** Never set on native — React Native inputs have no `name`. */
233
+ name?: string;
234
+ id?: string;
235
+ placeholder?: string;
236
+ /**
237
+ * Never set on native, and the negative result matters.
238
+ *
239
+ * React Native's idiom is a preceding sibling `<Text>Email</Text>`, but RNTL's
240
+ * getByLabelText matches `accessibilityLabel`/`aria-label`/`aria-labelledby`
241
+ * and NOTHING else — there is no htmlFor and no positional association. So
242
+ * treating a nearby `<Text>` as a label would emit a query that ALWAYS throws,
243
+ * which is worse than a check that cannot fail: it is a check that cannot pass.
244
+ */
245
+ label?: string;
246
+ /**
247
+ * Native only. How the field is bound, which decides whether it can be typed
248
+ * into at all:
249
+ *
250
+ * - "controlled" — `value={x}` with a matching `onChangeText={setX}`.
251
+ * - "uncontrolled" — `defaultValue`, or neither.
252
+ * - "frozen" — `value` with NO onChangeText. React Native issues no
253
+ * controlled-input warning (unlike the DOM), so the input is simply inert and
254
+ * `fireEvent.changeText` is a silent no-op that still passes. Recorded rather
255
+ * than dropped so the emitter can say why it skipped.
256
+ */
257
+ control?: "controlled" | "frozen" | "uncontrolled";
258
+ /** Native only: the useState variable `value` is bound to. */
259
+ stateVar?: string;
260
+ pos?: SourcePosition;
261
+ }
262
+
263
+ export interface FormInfo {
264
+ submit: HandlerBinding | undefined;
265
+ fields: FieldInfo[];
266
+ /** The button that submits it, when the markup identifies one. */
267
+ submitControl?: SubmitControl;
268
+ hints: { testid?: string; ariaLabel?: string; id?: string };
269
+ /**
270
+ * The disclosure whose trigger must be clicked before this form exists at
271
+ * all — a form inside a closed Radix dialog is not in the DOM until the
272
+ * trigger opens it (RenameModal). Same gate the click tests honour.
273
+ */
274
+ gatedBy?: LabelSource;
275
+ /**
276
+ * The server action this form's `action={…}` dispatches to, as the module
277
+ * names it.
278
+ *
279
+ * `<form action={action}>` where `const [state, action] = useActionState(
280
+ * loginAction, undefined)` — React invokes `loginAction` on submit, with no
281
+ * guard in between, so a spec that submits can assert it fired. That makes it
282
+ * the one post-dispatch claim on the DOM plane that this app's own shapes
283
+ * actually support: every CLICK handler here puts its action behind a guard
284
+ * AND inside a startTransition callback, so the click ladder correctly
285
+ * declines on all of them.
286
+ *
287
+ * Recorded as the MODULE-side name, because that is what the spec imports and
288
+ * what vi.mock replaced.
289
+ */
290
+ actionBinding?: string;
291
+ pos?: SourcePosition;
292
+ }
293
+
294
+ /**
295
+ * One native host element as the a11y census saw it — the raw material of the
296
+ * native a11y task's rules.
297
+ *
298
+ * Harvested at analysis time because the parse is gone by task time, by a walk
299
+ * of the WHOLE source file that is deliberately NOT reachability-pruned: the
300
+ * clicks harvest decides what a spec can DRIVE, where a phantom element costs a
301
+ * wrong click, but this census decides what a screen reader ENCOUNTERS, and the
302
+ * back button inside an error branch still needs its role. Elements belonging
303
+ * to sibling components in the same file are included — half the dogfood app's
304
+ * pressables live in same-file row components — and the task dedupes by
305
+ * position, since every component analysed out of a file carries the file's
306
+ * whole census.
307
+ */
308
+ export interface NativeA11yElement {
309
+ /** Module-side export name of the resolved native host element. */
310
+ element: string;
311
+ /** The element's own start position; `file` is absolute. */
312
+ pos: SourcePosition;
313
+ hints: ElementHints;
314
+ label: LabelSource;
315
+ /** Pressable / TouchableOpacity / TouchableHighlight / TouchableWithoutFeedback. */
316
+ pressable?: true;
317
+ /**
318
+ * Provably renders no text: every element child is textless (the iconOnly
319
+ * proof) or there are no children at all. A pressable with dynamic children
320
+ * is NOT textless — `{building.name}` announces at runtime — which is what
321
+ * keeps the unnamed-control rule at zero false positives.
322
+ */
323
+ textless?: true;
324
+ /** accessibilityLabel / aria-label is present but not a string literal. */
325
+ dynamicLabel?: true;
326
+ /** accessibilityRole / role is present but not a string literal. */
327
+ dynamicRole?: true;
328
+ /**
329
+ * Literal role attributes as written on the element. Kept per attribute
330
+ * because `accessibilityRole` and `role` have DIFFERENT valid-value unions
331
+ * ("header" vs "heading"), so each literal must be validated against its own.
332
+ */
333
+ roleLiterals?: Array<{ attr: "accessibilityRole" | "role"; value: string }>;
334
+ /**
335
+ * aria-hidden / accessibilityElementsHidden / importantForAccessibility=
336
+ * "no-hide-descendants" on the element itself. "unknown" means the attribute
337
+ * is present but not a literal, and the rules treat that as hidden — a miss
338
+ * costs coverage, a guess costs a false finding.
339
+ */
340
+ hiddenFromA11y?: "yes" | "unknown";
341
+ /**
342
+ * The announcement is owned elsewhere: some JSX ancestor is a press-family
343
+ * element, carries a literal accessibility label, or is itself hidden from
344
+ * the a11y tree — or the element is the ROOT of its JSX island (a render
345
+ * prop like `tabBarIcon: () => <Ionicons/>`), handed to an owner this file
346
+ * cannot see. The icon rule stands down on all of these.
347
+ */
348
+ exemptAncestor?: true;
349
+ }
350
+
351
+ export interface InteractionInfo {
352
+ clicks: ClickHandler[];
353
+ forms: FormInfo[];
354
+ /** Present only for native-dialect files. See NativeA11yElement. */
355
+ a11yElements?: NativeA11yElement[];
356
+ }
357
+
358
+ /** How a prop is used inside the component body. */
359
+ export type PropUsageKind =
360
+ | "jsx-attr"
361
+ | "passed-to-child"
362
+ | "jsx-child"
363
+ | "hook-dep"
364
+ | "hook-arg"
365
+ | "call"
366
+ | "member-access"
367
+ | "conditional"
368
+ | "other";
369
+
370
+ export interface PropUsage {
371
+ propName: string;
372
+ kind: PropUsageKind;
373
+ pos: SourcePosition;
374
+ /** JSX tag for jsx-attr / passed-to-child / jsx-child. */
375
+ tag?: string;
376
+ /** Attribute name for jsx-attr / passed-to-child. */
377
+ attr?: string;
378
+ /** Hook name for hook-dep / hook-arg. */
379
+ hook?: string;
380
+ /** Property accessed off the prop, when the reference is `prop.member`. */
381
+ member?: string;
382
+ /**
383
+ * Full member path for a member-access, e.g. ["body","length"] for
384
+ * `drop.body.length`. `member` is its head. The {} floor survives one hop
385
+ * and dies on the second, so the chain is what tells synthesiseDefaults
386
+ * the head needs a value richer than {}.
387
+ */
388
+ chain?: string[];
389
+ }
390
+
391
+ export type StateKind = "useState" | "useReducer" | "useRef" | "useActionState";
392
+
393
+ export interface SetterCallSite {
394
+ pos: SourcePosition;
395
+ context: "handler" | "effect" | "callback" | "render" | "unknown";
396
+ /** Nearest useful name: "handleAdd", "useEffect", "onClick". */
397
+ enclosingName?: string;
398
+ /** An if/ternary/&&/|| sits between the component body and the call. */
399
+ guarded: boolean;
400
+ argKind: "literal" | "updater" | "expression" | "none";
401
+ }
402
+
403
+ export interface StateJsxFlow {
404
+ tag: string;
405
+ via: "child" | "attr" | "condition";
406
+ attr?: string;
407
+ pos: SourcePosition;
408
+ }
409
+
410
+ export interface StateInfo {
411
+ kind: StateKind;
412
+ varName: string;
413
+ /** setX / dispatch; undefined for useRef. */
414
+ setterName?: string;
415
+ /** Synthesised only when the initializer is literal-composable. */
416
+ initialValue?: PropValue;
417
+ /** Initializer source text, truncated to 120 chars. */
418
+ initialRaw?: string;
419
+ typeKind: "string" | "number" | "boolean" | "null" | "array" | "object" | "unknown";
420
+ pos: SourcePosition;
421
+ setterCalls: SetterCallSite[];
422
+ flowsToJsx: StateJsxFlow[];
423
+ setterCalledInEffect: boolean;
424
+ }
425
+
426
+ export interface EffectFeatures {
427
+ fetch: boolean;
428
+ setInterval: boolean;
429
+ setTimeout: boolean;
430
+ addEventListener: boolean;
431
+ hasCleanup: boolean;
432
+ }
433
+
434
+ export interface HookDetail {
435
+ name: string;
436
+ pos: SourcePosition;
437
+ argCount: number;
438
+ /** Per arg: "arrow" | "function" | "identifier:x" | "literal:0" | "object" | "array" | "call" | "other". */
439
+ argsSummary: string[];
440
+ /** Raw source text per deps-array element; undefined when there is no array arg. */
441
+ deps: string[] | undefined;
442
+ /** Any deps element that is not a plain identifier (member access, spread, call…). */
443
+ depsHasComplex: boolean;
444
+ /** Hook is a deps-taking hook but no array literal was passed. */
445
+ missingDepsArray: boolean;
446
+ /** Component-scope identifiers read inside the arg-0 callback (sorted, unique). */
447
+ callbackReads: string[];
448
+ callbackIsInline: boolean;
449
+ /** The bare props object identifier is referenced inside the callback (analysis bail signal). */
450
+ readsBareParamsObject: boolean;
451
+ /** Only for useEffect / useLayoutEffect. */
452
+ effectFeatures?: EffectFeatures;
453
+ /** `const x = useMemo(...)` → "x". */
454
+ resultName?: string;
455
+ }
456
+
457
+ /** Value shape of an attribute passed to an uppercase (component) JSX child. */
458
+ export type AttrValueKind =
459
+ | "string"
460
+ | "number"
461
+ | "boolean-true"
462
+ | "null"
463
+ | "identifier"
464
+ | "member"
465
+ | "object-literal"
466
+ | "array-literal"
467
+ | "arrow"
468
+ | "function"
469
+ | "call"
470
+ | "jsx"
471
+ | "spread"
472
+ | "other";
473
+
474
+ export interface ChildAttrInfo {
475
+ tag: string;
476
+ /** "...spread" for JsxSpreadAttribute. */
477
+ attr: string;
478
+ valueKind: AttrValueKind;
479
+ /** Root identifier for identifier / member kinds. */
480
+ identifier?: string;
481
+ pos: SourcePosition;
482
+ }
483
+
484
+ /**
485
+ * How a component can be mounted in a test. Anything other than client/rsc is
486
+ * a component that cannot be rendered standalone, and the spec says so instead
487
+ * of emitting an assertion that cannot pass.
488
+ */
489
+ export type RenderMode = "client" | "rsc" | "document-root" | "compound-part" | "null";
490
+
491
+ /** Inferred shape of a value an async server component awaits. */
492
+ export type MockShape =
493
+ | { kind: "array"; element?: MockShape }
494
+ | { kind: "object"; fields: MockField[] }
495
+ | { kind: "string" }
496
+ | { kind: "number" }
497
+ | { kind: "date-string" }
498
+ | { kind: "truthy-object" }
499
+ | { kind: "set" }
500
+ | { kind: "map" }
501
+ | { kind: "date" }
502
+ | { kind: "unknown" };
503
+
504
+ export interface MockField {
505
+ name: string;
506
+ shape: MockShape;
507
+ /** Field is rendered as JSX text, so a spec can assert its value. */
508
+ rendersAsText: boolean;
509
+ /**
510
+ * The component compares this field to a literal, so the fixture uses it.
511
+ *
512
+ * Twenty-two of the dogfood web app's thirty-two page specs never rendered
513
+ * anything: the page looked up the viewer's own membership with
514
+ *
515
+ * memberships.find((m) => m.building_id === id && m.status === "active")
516
+ *
517
+ * and the synthesised row carried `status: "flawless-status"`, so the find
518
+ * returned undefined and the page called notFound(). The spec then reported
519
+ * the guard exit as a pass, and 117 of the page's 158 lines never ran.
520
+ *
521
+ * Not coverage-chasing: a membership row whose status is a placeholder is not
522
+ * a valid instance of what this page reads. The literal is written in the
523
+ * component's own source — the same class of evidence as every other field
524
+ * here, just read from a comparison rather than from a use.
525
+ */
526
+ equals?: string | number | boolean;
527
+ /**
528
+ * The component compares this field to one of its own props, by path.
529
+ *
530
+ * `memberships.find((m) => m.building_id === id)` where `const { id } = await
531
+ * params` — so the row only matches when its building_id IS the route param
532
+ * the page was rendered for, which is the only membership row that page is
533
+ * ever looking at. Resolved at emit time against the same props fixture the
534
+ * spec's DEFAULTS carries, so the two cannot drift apart.
535
+ */
536
+ equalsProp?: string[];
537
+ /**
538
+ * The component compares this field to a field of another top-level data
539
+ * dependency: `members.find((m) => m.user_id === user.id)` with `const user
540
+ * = await getCurrentUser()` two lines above. Neither side is a literal or a
541
+ * prop, so `equals`/`equalsProp` cannot see it — and the two synthesised
542
+ * values ("flawless-user_id" vs "flawless-id") never match, so the lookup
543
+ * fails and the page guard-exits. Resolved at emit time against the fixture
544
+ * the referenced dep actually mocks with, so the two agree by construction;
545
+ * dropped silently when that dep (or its field) resolves to no value.
546
+ */
547
+ equalsDep?: { var: string; field: string };
548
+ }
549
+
550
+ /** A module-level data dependency awaited by an async server component. */
551
+ export interface DataDepInfo {
552
+ /** Local binding name; undefined for a destructured binding. */
553
+ varName: string | undefined;
554
+ /** Callee as written: "listRecentInsights", "db.query.foo.findMany". */
555
+ callee: string;
556
+ /** Root identifier of the callee, for joining to moduleImports. */
557
+ calleeRoot: string;
558
+ /** Specifier the callee was imported from, when resolvable. */
559
+ specifier?: string;
560
+ awaited: boolean;
561
+ shape: MockShape;
562
+ /** Inference was contradictory or absent — the spec emits a TODO. */
563
+ uncertain: boolean;
564
+ /**
565
+ * Fixture value resolved from the dep's own DECLARATION when usage-based
566
+ * inference came up empty: today always from a Promise<T> return
567
+ * annotation ("annotation"); "openapi" values are joined at emit time from
568
+ * wrapsFetch and never stored here. See analyse/resolveDepReturns.ts.
569
+ */
570
+ inferredReturn?: {
571
+ value: PropValue;
572
+ provenance: "annotation" | "openapi";
573
+ /**
574
+ * The declaration is synchronous, so the mock must be too. Only ever set
575
+ * for a dep that is not awaited — `formatZAR(cents): string` mocked as
576
+ * `async () => ({})` made every money value on the page an object.
577
+ */
578
+ sync?: boolean;
579
+ /**
580
+ * The return TypeNode is the `boolean` KEYWORD (or Promise<boolean>) —
581
+ * read at the declaration, because the stored value cannot say: a
582
+ * `(): true` literal annotation and a `(): boolean` both store `true`,
583
+ * and only the second admits `false`. What makes a predicate flip
584
+ * provably reachable under the dep's own contract (B-19).
585
+ */
586
+ booleanAnnotated?: true;
587
+ /** The annotation is a union with a null/undefined arm — `null` is a
588
+ * legal return, so a truthy guard on this dep has a reachable false side. */
589
+ nullableAnnotated?: true;
590
+ };
591
+ /**
592
+ * The dep is an unannotated wrapper whose body performs exactly one
593
+ * fetch(); the emitter joins method+path to the OpenAPI response example
594
+ * (NextjsApiRoute.responseBodies) — the join lives at emit time because
595
+ * analyse() has no route table.
596
+ */
597
+ wrapsFetch?: { method: string; path: string };
598
+ pos: SourcePosition;
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // B-19: both sides of a decidable predicate
603
+ // ---------------------------------------------------------------------------
604
+
605
+ /** How a spec flips one predicate: which mock (or prop) moves, and to what. */
606
+ export type PredicateFlipKind =
607
+ /** A dep whose boolean-KEYWORD annotation admits false: `isTrustee(…)`. */
608
+ | "dep-bool"
609
+ /** A nullable-annotated dep behind a truthy exit guard: `if (session) redirect(…)`. */
610
+ | "dep-null"
611
+ /** An array dep flipped to [] so the empty side renders. */
612
+ | "dep-empty"
613
+ /** A prop (or destructured awaited-prop field) overridden via buildProps. */
614
+ | "prop";
615
+
616
+ /**
617
+ * One render branch a certified flip moves. `baselineSide` is the side the
618
+ * DEFAULT fixture evaluates to — the flip takes the other. Witnesses are
619
+ * text-position literals unconditional WITHIN their arm; a side with none
620
+ * can still be asserted by the other side's absence, but never stands alone.
621
+ */
622
+ export interface PredicateSiteInfo {
623
+ /** Value of the condition under the default fixture env. */
624
+ baselineSide: boolean;
625
+ trueWitness?: string;
626
+ falseWitness?: string;
627
+ /** Data-selection ternary: the single mocked callee each arm invokes. */
628
+ trueCallee?: string;
629
+ falseCallee?: string;
630
+ /** The site's own position is reachable under that leg's env. */
631
+ reachableAtDefaults: boolean;
632
+ reachableWhenFlipped: boolean;
633
+ pos: SourcePosition;
634
+ }
635
+
636
+ /**
637
+ * A predicate the emitter can invert with one per-test override, plus the
638
+ * sites that provably change side when it does. Only evidence-certified
639
+ * flips appear here; everything detected-but-refused lands in
640
+ * `predicateRefusals` with a named reason (the renderUndecidable rule:
641
+ * refusals are named, never silent).
642
+ */
643
+ export interface PredicateInversionInfo {
644
+ kind: PredicateFlipKind;
645
+ /** Local the value binds to ("userIsTrustee", "contractors", "session"),
646
+ * or the dotted prop path for a prop flip. */
647
+ varName: string;
648
+ /** Mocked binding the override targets (dep kinds). */
649
+ calleeRoot?: string;
650
+ specifier?: string;
651
+ /** mockResolvedValueOnce vs mockReturnValueOnce. */
652
+ awaited?: boolean;
653
+ /** buildProps override path (prop kind). */
654
+ propPath?: string[];
655
+ /** The flipped value; absent = `undefined` (a prop omitted). */
656
+ flip?: PropValue;
657
+ sites: PredicateSiteInfo[];
658
+ pos: SourcePosition;
659
+ }
660
+
661
+ export interface PredicateRefusal {
662
+ varName: string;
663
+ reason: /** The controlling callee has more than one call site — a Once-override
664
+ * would flip an unpredictable one. */
665
+ | "multi-call"
666
+ /** The control also decides an exit guard — that false side EXITS, and
667
+ * emitGuardTests owns it. */
668
+ | "guard-entangled"
669
+ /** Flipping provably does not change any site's side. */
670
+ | "invisible"
671
+ /** Every moved site lost its witnesses — nothing falsifiable to assert. */
672
+ | "unwitnessed"
673
+ /** Certified, but over the per-spec inversion cap. */
674
+ | "capped";
675
+ pos: SourcePosition;
676
+ }
677
+
678
+ export type GuardEffect =
679
+ | "notFound"
680
+ | "redirect"
681
+ | "permanentRedirect"
682
+ | "unauthorized"
683
+ | "forbidden"
684
+ | "throw";
685
+
686
+ /** An early-exit guard in a component body: `if (!x) notFound()`. */
687
+ export interface GuardInfo {
688
+ effect: GuardEffect;
689
+ callee: string;
690
+ /** Module the guard callee came from ("next/navigation"). */
691
+ specifier?: string;
692
+ /** Condition source text, one line, <= 80 chars. */
693
+ conditionRaw: string;
694
+ conditionKind: "falsy" | "truthy" | "comparison" | "complex";
695
+ /** dataDeps var names referenced by the condition, in source order. */
696
+ depVars: string[];
697
+ /** The mocked return value that makes this guard fire. */
698
+ triggerValue?: "undefined" | "null" | "empty-array";
699
+ pos: SourcePosition;
700
+ }
701
+
702
+ /** A fetch() call site inside the component body. */
703
+ export interface FetchCallInfo {
704
+ /** URL with interpolations replaced by :holes — "/api/buildings/:buildingId/ai/ask". */
705
+ urlTemplate: string;
706
+ /** Hole names in order: ["buildingId"]. */
707
+ holes: string[];
708
+ method: string;
709
+ pos: SourcePosition;
710
+ /** Call sits inside a useEffect/useLayoutEffect callback. */
711
+ inEffect: boolean;
712
+ }
713
+
714
+ /** A top-level import declaration of the analysed file, classified for scaffolding. */
715
+ export interface ModuleImportInfo {
716
+ specifier: string;
717
+ /** Local names bound by this declaration (default + named + namespace). */
718
+ names: string[];
719
+ /**
720
+ * Module-side export name per entry of `names`: `import { cn as cx }` records
721
+ * "cn" where `names` records "cx". Default and namespace entries repeat the
722
+ * local name — they have no module-side name.
723
+ */
724
+ importedNames?: string[];
725
+ /**
726
+ * The LOCAL names among `names` that were imported with a `type` specifier
727
+ * (`import { cva, type VariantProps } from "class-variance-authority"`).
728
+ *
729
+ * They are erased at compile time: the module does not export them at
730
+ * runtime. So they are not bindings a `vi.mock` factory can supply, and they
731
+ * are no evidence that the module needs mocking. Without this field a mixed
732
+ * statement read as two live imports, and EVERY shadcn/ui module's
733
+ * `import { cva, type VariantProps } from "class-variance-authority"` earned
734
+ * that package a factory mock — where `cva` is stubbed as a class-string
735
+ * producer, so the app's own `const buttonVariants = cva(…)` evaluated to a
736
+ * string and 76 specs of one fresh generation died on
737
+ * "buttonVariants is not a function".
738
+ *
739
+ * `isTypeOnly` covers the whole declaration; this names the individual
740
+ * specifiers of a MIXED one.
741
+ */
742
+ typeOnlyNames?: string[];
743
+ /**
744
+ * Names contributed by the component-tree walk — this file never imported
745
+ * them; some other file in the render does. A mocked module must cover them
746
+ * (its factory replaces every importer's view), but they carry NO evidence
747
+ * about whether the module should be mocked at all: a module whose own
748
+ * bindings are pure helpers stays real even when the graph elsewhere calls
749
+ * `timeAgo` from it. The mock-decision exemptions read this field.
750
+ */
751
+ mergedNames?: string[];
752
+ /** "component" iff any local name is used as a JSX tag root in the file. */
753
+ kind: "component" | "value";
754
+ isTypeOnly: boolean;
755
+ hasDefault: boolean;
756
+ /** Absolute path when the specifier resolves to a project-local module. */
757
+ resolvedPath?: string;
758
+ /** Resolved target file begins with a "use server" directive. */
759
+ isServerAction?: boolean;
760
+ /** Every top-level export of that module, for stubbing it wholesale. */
761
+ serverActionExports?: string[];
762
+ /**
763
+ * Module-side export name → the single literal value that action returns
764
+ * (redirect/notFound exits excluded; "default" for the default export).
765
+ * Only literal-derivable returns are recorded — see
766
+ * analyse/serverActionReturns.ts for the bail policy. Names absent here
767
+ * keep the honest {} stub and the server-action-stub TODO.
768
+ */
769
+ serverActionReturns?: Record<string, PropValue>;
770
+ /**
771
+ * Module-side export name → its own return annotation, for names this
772
+ * component IMPORTS but that never became a dataDep — a helper called inside
773
+ * JSX rather than awaited into a variable.
774
+ *
775
+ * Those got `vi.fn(async () => ({}))` on the reasoning that `{}` beats
776
+ * undefined. It does, against nothing; against the declaration it does not.
777
+ * `formatZAR(cents: number): string` was mocked async returning an object, so
778
+ * every money value on the dogfood app's finance pages rendered as one.
779
+ *
780
+ * `sync` mirrors the declaration: a synchronous function mocked as async
781
+ * hands the component a promise where it expects a value.
782
+ */
783
+ importedReturns?: Record<
784
+ string,
785
+ {
786
+ value: PropValue;
787
+ sync: boolean;
788
+ /** Return TypeNode is the boolean KEYWORD (not a `true` literal) —
789
+ * `false` is a legal return, so a predicate flip is contract-backed. */
790
+ booleanAnnotated?: true;
791
+ /** Annotation is a union with a null/undefined arm. */
792
+ nullableAnnotated?: true;
793
+ }
794
+ >;
795
+ /**
796
+ * Module-side export names whose declarations are DATA constants — literals,
797
+ * object/array literals of data, property-access chains; never a call, a
798
+ * construction, or a function value (analyse/dataExports.ts). When every
799
+ * value binding of the import is one, the RSC mock emitter leaves the module
800
+ * real: the factory replacement would starve transitive importers of the
801
+ * exports this component never named.
802
+ */
803
+ dataExports?: string[];
804
+ /**
805
+ * The LOCAL module behind this import only CALCULATES: no await, no async,
806
+ * no live (non-type) import, no top-level call (analyse/pureModule.ts).
807
+ * Such a module is not a data source however it is imported — the mock
808
+ * loop leaves it real. Without this, a tree-wide mock sweep replaces
809
+ * `splitCode` with {} and the render dies calling .map on the result.
810
+ */
811
+ isPureModule?: boolean;
812
+ /**
813
+ * Module-side export names whose declarations are ASYNC functions, for
814
+ * imports used as JSX tags. React's client renderer refuses an async
815
+ * function component outright, so a server page rendering `<InsightsCard>`
816
+ * under jsdom produces an empty container — the RSC spec must stub such a
817
+ * child rather than mount it. "default" names a default export.
818
+ */
819
+ asyncComponents?: string[];
820
+ pos: SourcePosition;
821
+ }
822
+
823
+ export interface ComponentInfo {
824
+ componentName: string;
825
+ filePath: string;
826
+ importSpecifier: string;
827
+ propsShape: PropsShape;
828
+ variants: PropsShape[];
829
+ hooks: string[];
830
+ unresolvedProps: string[];
831
+ interactions?: InteractionInfo;
832
+ /**
833
+ * An undecidable early return that renders nothing stands between the
834
+ * component's entry and the JSX described here — so at the props a spec can
835
+ * synthesise, it may well render nothing at all.
836
+ */
837
+ renderGuarded?: boolean;
838
+ /**
839
+ * renderPath could not decide WHICH branch renders, and the branches return
840
+ * content rather than nothing — so there is no render to describe at all.
841
+ *
842
+ * Distinct from `renderGuarded`, which means "a render was found, and an
843
+ * undecidable early return stands in front of it". This is the case where no
844
+ * render was found: `if (this.state.error) return <Fallback/>; return children;`
845
+ * is the canonical shape, and it arrives on every class component whose render
846
+ * branches on state, and on any function with an opaque guard returning content.
847
+ *
848
+ * README has always said such a component "produces no description at all".
849
+ * Nothing recorded that, so the emitters harvested the fallback branch and
850
+ * wrote assertions against markup the defaults never render.
851
+ */
852
+ renderUndecidable?: boolean;
853
+ /**
854
+ * The component is a React error boundary, and this is the literal copy its
855
+ * caught state renders.
856
+ *
857
+ * The one undecidable render whose BOTH branches a spec can reach: React's
858
+ * contract for getDerivedStateFromError says a throwing child puts the
859
+ * component here, so the fallback is assertable rather than refused.
860
+ */
861
+ errorBoundary?: { fallbackText: string[] };
862
+ /** A sentinel child passed to this component appears in the markup. */
863
+ childrenFlow?: boolean;
864
+ /** A className passed to this component lands on some rendered element. */
865
+ classNameFlow?: boolean;
866
+ /** Position of the component declaration. */
867
+ pos?: SourcePosition;
868
+ /** How the component leaves its module. "none" = same-file secondary component. */
869
+ exportKind?: "default" | "named" | "none";
870
+ /** Boundary name when `export { Local as Public }` renames the component. */
871
+ exportedAs?: string;
872
+ /** Async function component (server component in app-router projects). */
873
+ isAsync?: boolean;
874
+ /** File carries a "use client" prologue directive. */
875
+ isClientComponent?: boolean;
876
+ wrappers?: Array<"memo" | "forwardRef">;
877
+ /** JSX tag the parent used for this child (differs from componentName under aliasing). */
878
+ localTag?: string;
879
+ propUsage?: PropUsage[];
880
+ /**
881
+ * Type expressions the analyser gave up on ("Omit", "ButtonPrimitive.Props").
882
+ * Distinct from `unresolvedProps`, which names actual props — the two were
883
+ * previously conflated in one array that every consumer read as prop names.
884
+ */
885
+ unresolvedTypes?: string[];
886
+ /** Host element the props type is anchored to: "div", "button", "input". */
887
+ hostElement?: string;
888
+ /**
889
+ * Which host-element vocabulary THIS FILE's JSX is written in — "dom" for
890
+ * lowercase intrinsics, "native" for React Native's imported capitalised
891
+ * elements. Decided per file from its own imports, because an Expo app can also
892
+ * target web and a monorepo sweep crosses both. Absent means "dom", which is
893
+ * what every file analysed before this existed was.
894
+ */
895
+ hostDialect?: "dom" | "native";
896
+ /** Component renders JSX children (declared prop, or a children-taking host). */
897
+ acceptsChildren?: boolean;
898
+ /**
899
+ * Children flow into an `asChild` Slot or a bare <Slot>/<Slottable> — the
900
+ * fixture must pass an ELEMENT child (a text child makes Slot throw).
901
+ * analyse/childrenIntoSlot.ts.
902
+ */
903
+ childrenSlotted?: boolean;
904
+ /** prop → full literal option list, for grouped variant describes. */
905
+ variantOptions?: Record<string, PropValue[]>;
906
+ /** prop → option → its cva class string. Compared, never asserted verbatim. */
907
+ variantClasses?: Record<string, Record<string, string>>;
908
+ /** Lowercase host tag of the unconditionally-returned JSX root, when unambiguous. */
909
+ rootTag?: string;
910
+ /** How this component can be mounted; drives which spec template is used. */
911
+ renderMode?: RenderMode;
912
+ /** Why, in words — emitted into the spec and the run report. */
913
+ renderNotes?: string[];
914
+ /**
915
+ * For a compound part: the family its OWN file exports — the sibling that
916
+ * renders the package Root, the Content sibling when there is one, and
917
+ * this part's role. Derived same-file only (analyse/compoundFamily.ts);
918
+ * whether the family is actually wrappable (open-state Roots only) is the
919
+ * emitters' decision, keyed off `specifier`.
920
+ */
921
+ compoundFamily?: {
922
+ root: string;
923
+ content?: string;
924
+ part: "trigger" | "content" | "other";
925
+ specifier: string;
926
+ };
927
+ /** Literal text rendered unconditionally by the happy-path return. */
928
+ staticText?: string[];
929
+ /** Awaited module-level data dependencies (async server components only). */
930
+ dataDeps?: DataDepInfo[];
931
+ /** Early-exit guards: `if (!x) notFound()`. */
932
+ guards?: GuardInfo[];
933
+ /** Predicates a spec can invert with one override, with the sites that
934
+ * provably change side (async server components only). B-19. */
935
+ predicateInversions?: PredicateInversionInfo[];
936
+ /** Detected-but-refused inversions, each with a named reason. */
937
+ predicateRefusals?: PredicateRefusal[];
938
+ /** Declaration site of every prop seen on the props type (resolved or not). */
939
+ propPositions?: Record<string, SourcePosition>;
940
+ /** Props whose declared type is Promise<...> (e.g. Next.js 15 `params`). */
941
+ promiseProps?: string[];
942
+ state?: StateInfo[];
943
+ hookDetails?: HookDetail[];
944
+ childAttrs?: ChildAttrInfo[];
945
+ moduleImports?: ModuleImportInfo[];
946
+ /**
947
+ * moduleImports of the components rendered BELOW this unit — real code
948
+ * inside a spec's render. Drop awaits getUserDropInteractions even when
949
+ * the page itself imports only getDropsByAuthor, and the module mock is
950
+ * shared by the whole render: without the tree's data modules the real
951
+ * one runs (cookies() in a jsdom mount) or a {} fixture kills the mount.
952
+ */
953
+ treeImports?: ModuleImportInfo[];
954
+ /** Descendants' dataDeps — call-site shapes joined via calleeRoot. */
955
+ treeDataDeps?: DataDepInfo[];
956
+ /**
957
+ * What the CHILD components rendered by this unit's own JSX expect on the
958
+ * props they receive: tag → attr → the child's default value for it
959
+ * (analyse/childBridge.ts). A child reads more of a value than the
960
+ * parent's member accesses show — Drop hands RelativeTime a
961
+ * `drop.createdAt` nobody typed — so synth consults this when building an
962
+ * unresolved prop's stand-in, and analyse folds it into data fixtures.
963
+ */
964
+ childExpected?: Record<string, Record<string, unknown>>;
965
+ fetchCalls?: FetchCallInfo[];
966
+ /**
967
+ * GET/mutation endpoints this component reaches through the app's own fetch
968
+ * wrapper, resolved ONE hop: the screen calls an imported api function
969
+ * (`listBuildings()`), whose body calls a module-local fetch wrapper with a
970
+ * literal path (`apiFetch("/api/buildings")`). `fetchCalls` sees only a bare
971
+ * `fetch()` in the component body; a React Native app never has one — every
972
+ * call goes through `lib/api.ts` — so without this the load harvest is empty.
973
+ * Native-only (the harvest runs solely for host-dialect "native" files); the
974
+ * shape is FetchCallInfo so the load task treats it exactly like a direct
975
+ * fetch. B-16 wave 7.
976
+ */
977
+ apiCalls?: FetchCallInfo[];
978
+ /**
979
+ * The component reports errors itself — `console.error(...)`, or `.error(...)`
980
+ * on an imported binding such as a logger.
981
+ *
982
+ * An error boundary whose whole job is to log what it caught cannot also
983
+ * satisfy "mounts without console errors". Asserting that against
984
+ * `app/(auth)/error.tsx` failed the component for honouring its contract, so
985
+ * the mount spec drops that one assertion — and says why — while keeping the
986
+ * uncaught-exception and rendered-output assertions, which stay meaningful.
987
+ */
988
+ logsErrors?: string;
989
+ }
990
+
991
+ export interface ComponentTree {
992
+ entry: ComponentInfo;
993
+ children: ComponentTree[];
994
+ }
995
+
996
+ /** Which components in an entry file become roots of their own tree. */
997
+ export type RootMode = "primary" | "exported";
998
+
999
+ export interface AnalyseOptions {
1000
+ entry: string;
1001
+ depth: number;
1002
+ includeChildren: boolean;
1003
+ /** Default "primary" — one tree per file, as the tool has always behaved. */
1004
+ roots?: RootMode;
1005
+ }
1006
+
1007
+ export interface MockRoute {
1008
+ method: string;
1009
+ path: string;
1010
+ status: number;
1011
+ body: unknown;
1012
+ }
1013
+
1014
+ export interface ScaffoldOptions {
1015
+ outDir: string;
1016
+ variants: number;
1017
+ heaps?: HeapMode;
1018
+ force: boolean;
1019
+ /**
1020
+ * Leave an existing suite exactly as it is (status "kept", nothing written).
1021
+ * Set by perf-harness in changed-mode; ignored when no suite exists yet.
1022
+ */
1023
+ keepExisting?: boolean;
1024
+ report: boolean;
1025
+ provider: string | undefined;
1026
+ apiSpec: string | undefined;
1027
+ extraTrees?: ComponentTree[];
1028
+ suiteName?: string;
1029
+ nextjsHost?: boolean;
1030
+ mswSetup?: boolean;
1031
+ /**
1032
+ * Context providers the host mounts around each component (analyse/providerWraps
1033
+ * derives them; key is `${filePath}::${componentName}`). The route layout that
1034
+ * supplies them is not a component entry, so the provider is often no analysed
1035
+ * tree's member — SidebarProvider in the kiranism dogfood was used only by
1036
+ * app/dashboard/layout.tsx and mounting SidebarTrigger bare then died on
1037
+ * "useSidebar must be used within a SidebarProvider".
1038
+ */
1039
+ providerWraps?: Map<string, ProviderRef[]>;
1040
+ }
1041
+
1042
+ /**
1043
+ * A component that supplies context, referenced from OUTSIDE the analysed tree:
1044
+ * located by parsing the file the consuming hook lives in.
1045
+ */
1046
+ export interface ProviderRef {
1047
+ componentName: string;
1048
+ filePath: string;
1049
+ exportKind: "default" | "named";
1050
+ /** Boundary name when the provider is re-exported under an alias. */
1051
+ exportedAs?: string;
1052
+ }
1053
+
1054
+ export type TaskId =
1055
+ | "perf-harness"
1056
+ | "unit-browser"
1057
+ | "perf-static"
1058
+ | "a11y"
1059
+ | "react-rules"
1060
+ | "e2e"
1061
+ | "load";
1062
+
1063
+ export interface Finding {
1064
+ rule: string;
1065
+ severity: "error" | "warn";
1066
+ file: string;
1067
+ line: number;
1068
+ column: number;
1069
+ message: string;
1070
+ source: TaskId;
1071
+ }
1072
+
1073
+ /**
1074
+ * What kind of hand-work a planted TODO(flawless) marker asks for. The three
1075
+ * named-and-frequent kinds plus the two structural ones; everything else the
1076
+ * templates plant (missing assertions, undrivable forms, guarded interactions)
1077
+ * is "other" — on the dogfood app ~89% of markers fell in three kinds, which is
1078
+ * exactly the adoption-cost distribution the report needs to show.
1079
+ */
1080
+ export type TodoKind =
1081
+ | "uninferred-return-shape"
1082
+ | "compound-root-wrap"
1083
+ | "server-action-stub"
1084
+ | "unaddressable-target"
1085
+ // B-12. Not planted per spec like the others: it is recorded once per run
1086
+ // when the emitted global-setup.ts is still byte-identical to the stub, so
1087
+ // every route spec in the suite is navigating unauthenticated.
1088
+ | "e2e-auth-unfilled"
1089
+ | "other";
1090
+
1091
+ export interface TodoTally {
1092
+ kind: TodoKind;
1093
+ count: number;
1094
+ /** One emitted spec carrying such a marker — absolute; the report relativises. */
1095
+ exemplar: string;
1096
+ }
1097
+
1098
+ /** Records one marker of `kind` into the file the recorder was bound to. */
1099
+ export type TodoRecorder = (kind: TodoKind) => void;
1100
+
1101
+ /**
1102
+ * Tallies TODO(flawless) markers AT THE EMISSION SITES, as each one is planted.
1103
+ * These numbers must never be derived by grepping the emitted text: the marker
1104
+ * wording is presentation and changes freely, so a text-based count breaks
1105
+ * silently the moment a template is reworded.
1106
+ */
1107
+ export class TodoCounter {
1108
+ // Keyed by the file each recorder was bound to: tallying happens at RENDER
1109
+ // time, but a renderer's output is not necessarily written (colocated specs
1110
+ // are kept when they exist and --force is off), so the writer must be able
1111
+ // to retract a whole file's tallies without touching the others'.
1112
+ private byFile = new Map<string, Map<TodoKind, number>>();
1113
+
1114
+ add(kind: TodoKind, exemplar: string): void {
1115
+ const perKind = this.byFile.get(exemplar) ?? new Map<TodoKind, number>();
1116
+ perKind.set(kind, (perKind.get(kind) ?? 0) + 1);
1117
+ this.byFile.set(exemplar, perKind);
1118
+ }
1119
+
1120
+ /** A recorder bound to one emitted file, for threading through templates. */
1121
+ for(file: string): TodoRecorder {
1122
+ return (kind) => this.add(kind, file);
1123
+ }
1124
+
1125
+ /**
1126
+ * Forget everything tallied into `file`. For the writer that decided not to
1127
+ * write it: markers in a discarded render are not debt, and may name TODOs
1128
+ * the user already resolved in the file that was kept.
1129
+ */
1130
+ drop(file: string): void {
1131
+ this.byFile.delete(file);
1132
+ }
1133
+
1134
+ /** Sorted by kind: this lands verbatim in a committed, deterministic file. */
1135
+ toTallies(): TodoTally[] {
1136
+ const byKind = new Map<TodoKind, { count: number; exemplar: string }>();
1137
+ for (const [file, perKind] of this.byFile) {
1138
+ for (const [kind, count] of perKind) {
1139
+ const row = byKind.get(kind);
1140
+ if (!row) {
1141
+ byKind.set(kind, { count, exemplar: file });
1142
+ continue;
1143
+ }
1144
+ row.count += count;
1145
+ // Lexicographically smallest, so the exemplar — and REPORT.md with it —
1146
+ // is byte-identical however the trees happened to be ordered.
1147
+ if (file < row.exemplar) row.exemplar = file;
1148
+ }
1149
+ }
1150
+ return [...byKind.entries()]
1151
+ .map(([kind, r]) => ({ kind, count: r.count, exemplar: r.exemplar }))
1152
+ .sort((a, b) => (a.kind < b.kind ? -1 : 1));
1153
+ }
1154
+ }
1155
+
1156
+ export interface TaskResult {
1157
+ id: TaskId;
1158
+ ok: boolean;
1159
+ outputs: string[];
1160
+ findings: Finding[];
1161
+ summary: string;
1162
+ error?: string;
1163
+ /**
1164
+ * Where this run sat in the suite's lifecycle: "generated" (fresh outDir),
1165
+ * "refreshed" (existing suite, derived files rewritten), "kept" (existing
1166
+ * suite left untouched — changed-mode). Set only by perf-harness for now;
1167
+ * other tasks may adopt it. Absent means the task makes no such distinction.
1168
+ */
1169
+ status?: "generated" | "refreshed" | "kept";
1170
+ /**
1171
+ * Per-kind counts of the TODO(flawless) markers this task's emitters planted.
1172
+ * Optional and set only by unit-browser: the report distinguishes "measured,
1173
+ * zero" (empty array) from "never measured" (absent), so no other task is
1174
+ * widened by it.
1175
+ */
1176
+ handWork?: TodoTally[];
1177
+ /**
1178
+ * What `perf-static` knew about the React Compiler, and what it therefore did
1179
+ * not report. Set in EVERY state, because the states are the difference
1180
+ * between "the compiler memoises this", "nothing was found" and "we could not
1181
+ * tell" — and a zero that hides which of the three it was is the vacuous green
1182
+ * this tool refuses to print.
1183
+ *
1184
+ * `suppressed` is present only when rules were actually suppressed, so its
1185
+ * absence never means "suppressed nothing": read `state` for that.
1186
+ */
1187
+ reactCompiler?: ReactCompilerProbe & {
1188
+ /** Memo-family findings the compiler makes redundant, per rule. */
1189
+ suppressed?: { rule: string; count: number }[];
1190
+ };
1191
+ /**
1192
+ * `perf-static`'s spec-quality tally: prop-fixture gaps bucketed per component
1193
+ * (`SpecQualityBucket` says why that is not a finding), and deliberately absent
1194
+ * from `findings`. A task's finding count has to mean "work in your app worth
1195
+ * doing"; 713 of the dogfood run's 926 were this, and the headline lied.
1196
+ *
1197
+ * Absent when every prop synthesised — a real zero, not an unmeasured one.
1198
+ */
1199
+ specQuality?: {
1200
+ /** The rule this tally reports, so the id stays greppable in the JSON. */
1201
+ rule: "perf/unresolved-prop";
1202
+ /** Total props across every bucket, so the split is one subtraction. */
1203
+ props: number;
1204
+ /** Sorted by file, then component. */
1205
+ components: SpecQualityBucket[];
1206
+ };
1207
+ }
1208
+
1209
+ /**
1210
+ * One component's fixture gaps, named once however many trees render it.
1211
+ *
1212
+ * NOT a performance finding. `perf/unresolved-prop` says the analyser could not
1213
+ * invent a default value for a prop, so the generated spec passes `undefined`
1214
+ * for it — a limit of the tool's fixtures, which is spec-quality information an
1215
+ * adopter needs, and not a rerender risk in their app. It is counted per
1216
+ * component rather than per prop for the same reason: on the dogfood app the
1217
+ * per-prop count was 713 of 926 "perf findings", one component alone
1218
+ * contributing 148, and the 127 actionable findings underneath it read as noise.
1219
+ */
1220
+ export interface SpecQualityBucket {
1221
+ component: string;
1222
+ file: string;
1223
+ /** Prop names, sorted, so two runs of one tree diff clean. */
1224
+ props: string[];
1225
+ }
1226
+
1227
+ export interface RunContext {
1228
+ trees: ComponentTree[];
1229
+ outDir: string;
1230
+ variants: number;
1231
+ /** How much of the V8 heap the profile suite keeps; see HeapMode. */
1232
+ heaps: HeapMode;
1233
+ force: boolean;
1234
+ report: boolean;
1235
+ provider: string | undefined;
1236
+ apiSpec: string | undefined;
1237
+ inlineSummary: boolean;
1238
+ nextjs: NextjsContext | undefined;
1239
+ /**
1240
+ * The Expo project this run targets, or undefined. Mutually exclusive with
1241
+ * `nextjs` — a project is one or the other, and passing both is a UsageError.
1242
+ */
1243
+ expo?: ExpoContext;
1244
+ /**
1245
+ * Whether the React Compiler runs, read off the project's own configs. Set by
1246
+ * `runCore` in every mode — web mode has no other route to this fact, and
1247
+ * `perf-static`'s memo-family rules are exactly the work the compiler does by
1248
+ * itself. Absent only for a hand-built context, and a task must then say it
1249
+ * did not check rather than assume either way.
1250
+ */
1251
+ reactCompiler?: ReactCompilerProbe;
1252
+ /** Emit unit specs next to the analysed source files instead of <outDir>/unit. */
1253
+ colocate?: boolean;
1254
+ /** Every task id selected for this run, so tasks can honour cross-task deps. */
1255
+ selectedTasks?: TaskId[];
1256
+ /**
1257
+ * The run is changed-mode (`--changed [REF]`; true = HEAD). The perf-harness
1258
+ * task keeps an existing suite untouched under it: changed narrows the run,
1259
+ * never churns generation.
1260
+ */
1261
+ changed?: string | true;
1262
+ /** Context-provider wraps derived after analysis; see ScaffoldOptions.providerWraps. */
1263
+ providerWraps?: Map<string, ProviderRef[]>;
1264
+ }
1265
+
1266
+ export interface Task {
1267
+ id: TaskId;
1268
+ label: string;
1269
+ description: string;
1270
+ needsTree: boolean;
1271
+ run(ctx: RunContext): Promise<TaskResult>;
1272
+ }
1273
+
1274
+ /**
1275
+ * How much of the V8 heap the profiling suite keeps.
1276
+ *
1277
+ * A snapshot of a real app's heap is tens of megabytes, and the suite takes one
1278
+ * before and after every scenario. The dogfood run left 566MB on disk and paid
1279
+ * two full heap captures for every measurement it bracketed — an observer that
1280
+ * costs more than the thing observed.
1281
+ *
1282
+ * - `off` — no CDP capture at all. Fastest, and the measurements are the
1283
+ * component's rather than the component's plus a heap walk.
1284
+ * - `stress` — capture always, persist on a failure or a stress scenario. The
1285
+ * default, and what the suite has always done.
1286
+ * - `all` — persist every scenario. For hunting a leak, not for CI.
1287
+ */
1288
+ export type HeapMode = "off" | "stress" | "all";
1289
+
1290
+ export interface CliArgs {
1291
+ entry: string | undefined;
1292
+ out: string | undefined;
1293
+ depth: number;
1294
+ variants: number;
1295
+ heaps: HeapMode;
1296
+ /** Run only specs affected by files changed since this ref (true = HEAD). */
1297
+ changed: string | true | undefined;
1298
+ includeChildren: boolean;
1299
+ force: boolean;
1300
+ report: boolean;
1301
+ propsOverride: PropsShape | undefined;
1302
+ provider: string | undefined;
1303
+ apiSpec: string | undefined;
1304
+ tasks: TaskId[] | undefined;
1305
+ noInteractive: boolean;
1306
+ json: boolean;
1307
+ runTests: boolean;
1308
+ inlineSummary: boolean;
1309
+ nextjs: string | undefined;
1310
+ expo: string | undefined;
1311
+ appResolver: string | undefined;
1312
+ swaggerUrl: string | undefined;
1313
+ colocate: boolean;
1314
+ ci: boolean;
1315
+ roots: RootMode;
1316
+ exclude: string[];
1317
+ useDefaultExcludes: boolean;
1318
+ /** `--help` was asked for. parseArgs reports it; main() prints and exits. */
1319
+ help: boolean;
1320
+ /** `-V`/`--version` was asked for. Same split as `help`. */
1321
+ version: boolean;
1322
+ }
1323
+
1324
+ export type NextjsRouter = "app" | "pages";
1325
+
1326
+ /**
1327
+ * Whether the React Compiler runs for a project, as `detectReactCompiler` reads
1328
+ * it off disk — three states because a boolean would let `perf-static` suppress
1329
+ * the memo-family rules on a guess.
1330
+ *
1331
+ * - `on` — a config spells it on. The three memo-family rules name
1332
+ * work the compiler does by itself and are suppressed.
1333
+ * - `off` — a config was READ and does not turn it on. Rules run.
1334
+ * - `undetermined` — nothing readable. Rules run, and the task says the memo
1335
+ * findings may be pre-compiled noise. Never silent.
1336
+ */
1337
+ export type ReactCompilerState = "on" | "off" | "undetermined";
1338
+
1339
+ export interface ReactCompilerProbe {
1340
+ state: ReactCompilerState;
1341
+ /**
1342
+ * What the answer rests on, spelled the way a person would write it in the
1343
+ * config: `app.json experiments.reactCompiler`, `next.config.ts reactCompiler`,
1344
+ * `babel.config.js babel-plugin-react-compiler`. For `off` and `undetermined`
1345
+ * it names what was read instead, so the state is checkable whichever way it
1346
+ * fell — an evidence line that exists only on a suppression is a footnote to
1347
+ * one outcome, not evidence.
1348
+ */
1349
+ evidence: string;
1350
+ }
1351
+
1352
+ export interface NextjsApiRoute {
1353
+ path: string;
1354
+ methods: string[];
1355
+ file: string;
1356
+ /** Per-method response body (from OpenAPI example/schema). */
1357
+ responseBodies?: Record<string, unknown>;
1358
+ }
1359
+
1360
+ export interface NextjsContext {
1361
+ root: string;
1362
+ router: NextjsRouter;
1363
+ appResolver: string | undefined;
1364
+ apiRoutes: NextjsApiRoute[];
1365
+ }
1366
+
1367
+ /**
1368
+ * Which host-element vocabulary a run's components are written in.
1369
+ *
1370
+ * Not a synonym for "which framework mode" — an Expo app can also target web,
1371
+ * and a monorepo sweep can cross both. This names the ELEMENT SET the analysis
1372
+ * and the emitters have to reason about: lowercase DOM intrinsics (`<div>`), or
1373
+ * React Native's imported capitalised components (`<View>` from "react-native").
1374
+ *
1375
+ * "web" is the default everywhere and is what every run has always done.
1376
+ */
1377
+ export type Platform = "web" | "native";
1378
+
1379
+ /**
1380
+ * An Expo project, as `detectExpo` reads it off disk.
1381
+ *
1382
+ * The peer of NextjsContext, and deliberately shaped like it: a nullable context
1383
+ * object on RunContext rather than a boolean, so a task can branch once and the
1384
+ * facts it needs travel with the decision.
1385
+ */
1386
+ export interface ExpoContext {
1387
+ root: string;
1388
+ /** The expo-router app directory, when the project uses expo-router. */
1389
+ routerRoot: string | undefined;
1390
+ /**
1391
+ * Expo SDK major, read from the project's own `expo` dependency and never
1392
+ * defaulted. jest-expo's major must match it (jest-expo@54 <-> SDK 54), so a
1393
+ * guess here produces failures deep inside expo-modules-core that name
1394
+ * nothing. Absent only when the range is unreadable AND expo is not installed,
1395
+ * and the caller must then say so rather than substitute a number.
1396
+ */
1397
+ sdkMajor: number | undefined;
1398
+ /** expo-router major, same rule. */
1399
+ routerMajor: number | undefined;
1400
+ /**
1401
+ * The project compiles with the React Compiler, as `detectReactCompiler` reads
1402
+ * it (src/reactCompiler.ts) — app.json's `experiments.reactCompiler`, a
1403
+ * `reactCompiler: true` config flag, or the babel plugin. The bare boolean is
1404
+ * all this context has ever carried; the state AND the config that proved it
1405
+ * travel on `RunContext.reactCompiler`, which is what tasks read.
1406
+ *
1407
+ * Load-bearing for perf-static: the compiler memoises automatically, so
1408
+ * `perf/unstable-child-prop`, `perf/missing-memo-leaf` and
1409
+ * `perf/no-memoisation-with-children` name work it has already done. On the
1410
+ * dogfood app that is 136 of 168 findings — 81% of the task's entire output
1411
+ * (docs/archive/2026-08-expo-probes.md), which is why the task suppresses
1412
+ * those three rather than recommending the build's own output be handwritten.
1413
+ */
1414
+ reactCompiler: boolean;
1415
+ /** NativeWind is wired through babel (`jsxImportSource: "nativewind"`). */
1416
+ nativewind: boolean;
1417
+ /** Port `expo start --web` serves on; Metro's default is 8081, not 3000. */
1418
+ webPort: number;
1419
+ }