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