@lariocpt/flawless-react 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,744 @@
1
+ import path from "node:path";
2
+ import { synthesiseDefaults } from "../scaffold-tests/registry.ts";
3
+ import type {
4
+ AnalyseOptions,
5
+ ComponentInfo,
6
+ ComponentTree,
7
+ DataDepInfo,
8
+ ModuleImportInfo,
9
+ RenderMode,
10
+ } from "../types.ts";
11
+ import { bridgeDataShapes } from "./childBridge.ts";
12
+ import { childrenIntoSlot } from "./childrenIntoSlot.ts";
13
+ import { deriveCompoundFamily } from "./compoundFamily.ts";
14
+ import { dataConstantExports } from "./dataExports.ts";
15
+ import { extractChildAttrs } from "./extractChildAttrs.ts";
16
+ import { extractDataDeps } from "./extractDataDeps.ts";
17
+ import { extractFetchCalls } from "./extractFetchCalls.ts";
18
+ import { extractFlows } from "./extractFlows.ts";
19
+ import { extractGuards } from "./extractGuards.ts";
20
+ import { extractHookDetails, extractHooks } from "./extractHooks.ts";
21
+ import { extractInteractions } from "./extractInteractions.ts";
22
+ import { detectSelfLogging } from "./extractLogging.ts";
23
+ import { extractPredicates } from "./extractPredicates.ts";
24
+ import { extractProps } from "./extractProps.ts";
25
+ import { extractPropUsage } from "./extractPropUsage.ts";
26
+ import { extractRenderMode } from "./extractRenderMode.ts";
27
+ import { extractRootTag } from "./extractRootTag.ts";
28
+ import { extractState } from "./extractState.ts";
29
+ import { errorFallbackText, extractStaticText } from "./extractStaticText.ts";
30
+ import { harvestApiEndpoints } from "./harvestApiEndpoints.ts";
31
+ import { makeHostTagResolver } from "./hostTag.ts";
32
+ import { detectHostDialect } from "./nativeDialect.ts";
33
+ import { declaresComponentName, defaultExportComponentName, parseFile } from "./parseFile.ts";
34
+ import { isPureLocalModule } from "./pureModule.ts";
35
+ import { followHandoff, reexportTableOf } from "./reexportTable.ts";
36
+ import { buildComponentScope } from "./refs.ts";
37
+ import { initialRenderExpression, makeEnv } from "./renderPath.ts";
38
+ import {
39
+ annotatedExportReturns,
40
+ asyncComponentExports,
41
+ resolveDepReturns,
42
+ } from "./resolveDepReturns.ts";
43
+ import { resolveIndexKeyProps } from "./resolveIndexKeys.ts";
44
+ import { ModuleResolver } from "./resolveModule.ts";
45
+ import { resolveTagRootTags } from "./resolveTagRootTags.ts";
46
+ import { extractServerActionReturns } from "./serverActionReturns.ts";
47
+ import { exportedNames, SourceFileCache } from "./sourceCache.ts";
48
+ import { inheritSpreadSiblingProps } from "./spreadSiblings.ts";
49
+ import { walkChildren } from "./walkChildren.ts";
50
+
51
+ /**
52
+ * State shareable across EVERY entry of a sweep: both hold only
53
+ * filesystem-derived facts (parsed sources, prologue directives, tsconfig
54
+ * chains). Per-entry state — the visited sets above all — must never live
55
+ * here. Omit it and each call builds a fresh pair, so the public API is
56
+ * unchanged.
57
+ */
58
+ export interface AnalyseSharedContext {
59
+ cache: SourceFileCache;
60
+ resolver: ModuleResolver;
61
+ }
62
+
63
+ export function createAnalyseSharedContext(): AnalyseSharedContext {
64
+ return { cache: new SourceFileCache(), resolver: new ModuleResolver() };
65
+ }
66
+
67
+ export async function analyse(
68
+ opts: AnalyseOptions,
69
+ shared?: AnalyseSharedContext,
70
+ ): Promise<ComponentTree> {
71
+ const trees = await analyseFile({ ...opts, roots: "primary" }, shared);
72
+ const first = trees[0];
73
+ if (!first) {
74
+ throw new Error(`parseFile: could not locate a React component in ${opts.entry}`);
75
+ }
76
+ return first;
77
+ }
78
+
79
+ /**
80
+ * One tree per root component in the entry file.
81
+ *
82
+ * `roots: "primary"` keeps the historical behaviour (the file's main component).
83
+ * `roots: "exported"` returns one tree per *exported* component, which is what a
84
+ * whole-app sweep needs: `ui/card.tsx` exports 7 components, and pointing the
85
+ * tool at that file previously described only the first.
86
+ *
87
+ * Each root gets its OWN `visited` set. Sharing one would make the second root
88
+ * come back childless, because the first would already have claimed their
89
+ * shared children. The resolver and parse cache ARE shared — that is what keeps
90
+ * a 175-root sweep affordable.
91
+ */
92
+ export async function analyseFile(
93
+ opts: AnalyseOptions,
94
+ shared?: AnalyseSharedContext,
95
+ ): Promise<ComponentTree[]> {
96
+ const resolver = shared?.resolver ?? new ModuleResolver();
97
+ const cache = shared?.cache ?? new SourceFileCache();
98
+ const absPath = path.resolve(opts.entry);
99
+
100
+ let rootNames: Array<string | undefined> = [undefined];
101
+ if (opts.roots === "exported") {
102
+ const parsed = await parseFile(absPath, cache);
103
+ const exported = parsed.components.filter((c) => c.exportKind !== "none");
104
+ const chosen = exported.length > 0 ? exported : parsed.components.slice(0, 1);
105
+ rootNames = chosen.map((c) => c.componentName);
106
+ }
107
+
108
+ const out: ComponentTree[] = [];
109
+ for (const name of rootNames) {
110
+ out.push(
111
+ await analyseOne(absPath, 0, opts, new Set<string>(), opts.entry, resolver, name, cache),
112
+ );
113
+ }
114
+ return out;
115
+ }
116
+
117
+ async function analyseOne(
118
+ absPath: string,
119
+ depth: number,
120
+ opts: AnalyseOptions,
121
+ visited: Set<string>,
122
+ importSpecifier: string,
123
+ resolver: ModuleResolver,
124
+ preferredName: string | undefined,
125
+ cache: SourceFileCache,
126
+ ): Promise<ComponentTree> {
127
+ const parsed = await parseFile(absPath, cache);
128
+ const unit =
129
+ (preferredName
130
+ ? parsed.components.find((c) => c.componentName === preferredName)
131
+ : undefined) ?? parsed.components[0];
132
+ if (!unit) {
133
+ throw new Error(`parseFile: could not locate a React component in ${absPath}.`);
134
+ }
135
+ visited.add(`${absPath}::${unit.componentName}`);
136
+
137
+ const props = extractProps(
138
+ parsed.sourceFile,
139
+ unit.propsTypeNode,
140
+ unit.propsTypeLiteral,
141
+ parsed.imports,
142
+ absPath,
143
+ resolver,
144
+ cache,
145
+ );
146
+ const {
147
+ propsShape: typedProps,
148
+ variants,
149
+ unresolvedProps: unresolvedFromTypes,
150
+ unresolvedTypes,
151
+ propPositions,
152
+ promiseProps,
153
+ variantOptions,
154
+ variantClasses,
155
+ hostElement,
156
+ acceptsChildren,
157
+ } = props;
158
+ // A prop the type layer could not resolve, but which the body uses as an index
159
+ // key into a same-file literal table, has its values written out in that
160
+ // table. Recovering them here means the rest of the analysis — the render env,
161
+ // the interaction harvest, the spec's DEFAULTS — all see a real value.
162
+ const indexKeys = resolveIndexKeyProps(unit.bodyNode, parsed.sourceFile, unresolvedFromTypes);
163
+ const propsShape = { ...typedProps, ...indexKeys.defaults };
164
+ // Names bound by the destructured first param ARE props, whatever the type
165
+ // layer made of them. `({ searchParams }: PageProps<'/'>)` types the Next 15+
166
+ // contract through a generated-route generic no syntactic resolver can see
167
+ // through — without this, the binding is invisible to usage extraction, the
168
+ // {} floor, and the promise-wrap below, and the render dies on
169
+ // `searchParams.then` of undefined. Alias-aware, and `children` stays out:
170
+ // finalise removes it from the value world on purpose.
171
+ for (const local of unit.destructuredProps) {
172
+ const name = unit.destructuredAliases.get(local) ?? local;
173
+ if (name === "children") continue;
174
+ if (name in typedProps || props.unresolvedProps.includes(name)) continue;
175
+ props.unresolvedProps.push(name);
176
+ }
177
+ const unresolvedProps = unresolvedFromTypes.filter((p) => !(p in indexKeys.defaults));
178
+
179
+ // Decided once, from this file's own imports, before any extractor runs. A
180
+ // file with host elements to classify always has the import that proves the
181
+ // dialect, because a React Native element is only ever reachable through one.
182
+ const hostDialect = detectHostDialect(parsed.imports);
183
+ const hostTag = makeHostTagResolver(parsed.imports);
184
+
185
+ const hooks = extractHooks(unit.bodyNode);
186
+ const state = extractState(unit.bodyNode, parsed.sourceFile);
187
+ const scope = buildComponentScope(unit, props, state, unit.bodyNode);
188
+ const hookDetails = extractHookDetails(unit.bodyNode, parsed.sourceFile, scope);
189
+ const propUsage = extractPropUsage(unit.bodyNode, parsed.sourceFile, scope);
190
+ // `.then(...)` on a destructured prop IS promise evidence — and often the
191
+ // only evidence available: Next 15+ page props arrive typed through the
192
+ // framework's `PageProps<'/'>` generic, which the (checkerless) type layer
193
+ // cannot see through, so walkMembers never marks them. Stand the prop up as
194
+ // unresolved (→ the {} floor in synthesiseDefaults) and mark it for the
195
+ // spec's Promise.resolve wrap; otherwise the render dies on
196
+ // `undefined.then`, or on `use(undefined)` for a page that awaits.
197
+ for (const u of propUsage) {
198
+ if (u.member !== "then" || u.propName in propsShape) continue;
199
+ if (!unresolvedProps.includes(u.propName)) unresolvedProps.push(u.propName);
200
+ if (!promiseProps.includes(u.propName)) promiseProps.push(u.propName);
201
+ }
202
+ // `<Sibling {...props} />` in this same file: the forwarding is the
203
+ // consuming. NavLink resolves no props at all (Omit<T> & {…}) yet its
204
+ // default-mount spec dies inside NavLinkInner's `props.href.toString()`.
205
+ // `known` is RESOLVED shapes only — an already-unresolved prop must NOT be
206
+ // skipped: the sibling's member read is the evidence that arms its {}
207
+ // floor, and without it NavLink's href stays unresolved-but-unevidenced
208
+ // and the mount dies exactly the same way.
209
+ inheritSpreadSiblingProps(
210
+ unit,
211
+ parsed.components,
212
+ new Set<string>(Object.keys(propsShape)),
213
+ unresolvedProps,
214
+ propUsage,
215
+ parsed.sourceFile,
216
+ );
217
+ const childAttrs = extractChildAttrs(unit.bodyNode, parsed.sourceFile);
218
+ const fetchCalls = extractFetchCalls(unit.bodyNode, parsed.sourceFile);
219
+ // Endpoints reached through the app's own fetch wrapper (one hop). Native
220
+ // only: a React Native screen never has a bare fetch() for extractFetchCalls
221
+ // to find — it calls api functions that wrap it — so this is the entire load
222
+ // harvest on Expo, and running it on web (where fetchCalls already sees the
223
+ // direct calls) would only add resolution cost. B-16 wave 7.
224
+ const apiCalls =
225
+ hostDialect === "native"
226
+ ? harvestApiEndpoints(
227
+ unit.bodyNode,
228
+ parsed.sourceFile,
229
+ parsed.imports,
230
+ absPath,
231
+ resolver,
232
+ cache,
233
+ )
234
+ : [];
235
+ // The CLASS for a class component, not just render(): componentDidCatch is
236
+ // where an error boundary logs, and bodyNode is render(). Reading render()
237
+ // alone left logsErrors false for a component whose whole contract is to log
238
+ // what it caught, and the spec then asserted a clean console against it.
239
+ const logsErrors = detectSelfLogging(
240
+ unit.classNode ?? unit.bodyNode,
241
+ new Set(parsed.moduleImports.flatMap((mi) => mi.names)),
242
+ );
243
+ // The values the generated spec will actually pass. Everything below describes
244
+ // the render those values produce, rather than the last return in the body.
245
+ const renderEnv = makeEnv(propsShape, unresolvedProps, state);
246
+ // What each component tag in this render actually produces, so "is <button>
247
+ // ambiguous here" is answered with evidence rather than with a guess about
248
+ // what a name ending in Button probably renders.
249
+ const tagRootTags = await resolveTagRootTags(
250
+ unit.bodyNode,
251
+ parsed.sourceFile,
252
+ parsed.imports,
253
+ absPath,
254
+ resolver,
255
+ cache,
256
+ );
257
+ const interactions = extractInteractions(
258
+ unit.bodyNode,
259
+ parsed.sourceFile,
260
+ propsShape,
261
+ unresolvedProps,
262
+ renderEnv,
263
+ tagRootTags,
264
+ hostTag,
265
+ hostDialect,
266
+ new Set(state.map((st) => st.setterName).filter((n): n is string => n !== undefined)),
267
+ );
268
+ const initialRender = initialRenderExpression(unit.bodyNode, renderEnv);
269
+ const flows = extractFlows(unit.bodyNode, renderEnv, hostTag);
270
+ const rootTag = extractRootTag(unit.bodyNode, renderEnv, hostTag);
271
+ // Fixture-children shape: an asChild Slot / <Slot> child site cannot take
272
+ // the bare text the unit template otherwise passes (Slot clones elements).
273
+ const childrenSlotted = acceptsChildren && childrenIntoSlot(unit.bodyNode);
274
+ const staticText = extractStaticText(unit.bodyNode, renderEnv);
275
+ // Sibling components' modes, so a wrapper around a part is recognised as one
276
+ // too. Computed without local modes of their own: one hop is enough for the
277
+ // shadcn shape (SubContent → Content → MenuPrimitive.Popup) and keeps this
278
+ // free of ordering and cycle concerns.
279
+ const localModes = new Map<string, RenderMode>();
280
+ for (const other of parsed.components) {
281
+ if (other.componentName === unit.componentName) continue;
282
+ localModes.set(
283
+ other.componentName,
284
+ extractRenderMode(other.bodyNode, parsed.imports, other.isAsync).mode,
285
+ );
286
+ }
287
+ const render = extractRenderMode(
288
+ unit.bodyNode,
289
+ parsed.imports,
290
+ unit.isAsync,
291
+ renderEnv,
292
+ localModes,
293
+ );
294
+ // A part that cannot mount alone may still name its own wrapper: the family
295
+ // its file exports. Derived only for compound parts — everything else has
296
+ // nothing to wrap.
297
+ const compoundFamily =
298
+ render.mode === "compound-part"
299
+ ? deriveCompoundFamily(unit, parsed.components, parsed.imports)
300
+ : undefined;
301
+ // Data deps and guards only mean something for a server component that
302
+ // awaits its data; running them elsewhere would be noise. Deps whose usage
303
+ // told us nothing get a second chance from their own declaration
304
+ // (Promise<T> annotation, or a single-fetch body) — see resolveDepReturns.
305
+ // A same-file sibling's deps count too when this unit doesn't already dep
306
+ // on that callee: the spec's module mock replaces the module for the WHOLE
307
+ // render, and the sibling is real code inside it. CurrentUserAvatar awaits
308
+ // only getCurrentUserHandle, but the <UserAvatar> it renders awaits
309
+ // getUserByHandle and reads user.displayName.charAt(0) off the result —
310
+ // without the sibling's evidence the shared mock hands back {} and the
311
+ // mount dies in the child. Own evidence wins: it sits on this unit's
312
+ // primary render path.
313
+ const dataDeps = unit.isAsync
314
+ ? resolveDepReturns(
315
+ (() => {
316
+ const own = extractDataDeps(unit.bodyNode, parsed.sourceFile, parsed.imports);
317
+ const extras: DataDepInfo[] = [];
318
+ for (const c of parsed.components) {
319
+ if (c === unit || !c.isAsync) continue;
320
+ for (const d of extractDataDeps(c.bodyNode, parsed.sourceFile, parsed.imports)) {
321
+ if (
322
+ !own.some((o) => o.callee === d.callee) &&
323
+ !extras.some((e) => e.callee === d.callee)
324
+ )
325
+ extras.push(d);
326
+ }
327
+ }
328
+ return [...own, ...extras];
329
+ })(),
330
+ parsed.imports,
331
+ absPath,
332
+ resolver,
333
+ cache,
334
+ )
335
+ : [];
336
+ const guards = extractGuards(
337
+ unit.bodyNode,
338
+ parsed.sourceFile,
339
+ parsed.imports,
340
+ new Set(dataDeps.map((d) => d.varName).filter((v): v is string => v !== undefined)),
341
+ );
342
+
343
+ const moduleImports = parsed.moduleImports.map((mi) => {
344
+ const resolvedPath = resolver.resolve(mi.specifier, absPath);
345
+ if (!resolvedPath) return mi;
346
+ // A "use server" module cannot run under jsdom or in a browser bundle: its
347
+ // exports reach for the database, the mailer and next/cache. Flagging it here
348
+ // — where the resolver and the cache are in scope — lets both the spec
349
+ // emitter and the perf host stub it by name rather than by guesswork.
350
+ if (!cache.hasPrologueDirective(resolvedPath, "use server")) {
351
+ // Return annotations for the names this component imports, so a helper
352
+ // called inside JSX rather than awaited into a variable is mocked from its
353
+ // declaration instead of from `{}`. resolveDepReturns covers the awaited
354
+ // ones; this is the same evidence for the rest.
355
+ const importedReturns = annotatedExportReturns(mi, resolvedPath, resolver, cache);
356
+ // Async-ness of JSX-tag imports: the RSC emitter stubs an async child
357
+ // rather than mounting what React's client renderer refuses.
358
+ const asyncComponents =
359
+ mi.kind === "component" ? asyncComponentExports(mi, resolvedPath, resolver, cache) : [];
360
+ // Data-constant exports (config modules): when every name the component
361
+ // imports is one, the mock emitter leaves the module REAL — a factory
362
+ // replacement would starve transitive importers of the exports this
363
+ // component never named.
364
+ const dataExports =
365
+ mi.kind === "value" ? dataConstantExports(mi, resolvedPath, resolver, cache) : [];
366
+ // A local module that only CALCULATES (no await, no live imports, no
367
+ // top-level call) is not a data source no matter where it was imported
368
+ // from — the mock loop must leave it real, or `splitCode` comes back as
369
+ // {} and the render dies on segments.map.
370
+ const pure = !resolvedPath.includes("node_modules") && isPureLocalModule(resolvedPath);
371
+ return {
372
+ ...mi,
373
+ resolvedPath,
374
+ ...(Object.keys(importedReturns).length > 0 ? { importedReturns } : {}),
375
+ ...(asyncComponents.length > 0 ? { asyncComponents } : {}),
376
+ ...(dataExports.length > 0 ? { dataExports } : {}),
377
+ ...(pure ? { isPureModule: true } : {}),
378
+ };
379
+ }
380
+ const target = cache.get(resolvedPath);
381
+ // Only names with a single literal-derivable return land in the record;
382
+ // the emitters keep {} stubs (and the TODO) for everything absent.
383
+ const returns = target ? extractServerActionReturns(target) : {};
384
+ return {
385
+ ...mi,
386
+ resolvedPath,
387
+ isServerAction: true,
388
+ // Every export, not just the ones this component imports: a transitive
389
+ // importer needs the others, and a missing name fails the bundle.
390
+ serverActionExports: target ? exportedNames(target) : [...mi.names],
391
+ ...(Object.keys(returns).length > 0 ? { serverActionReturns: returns } : {}),
392
+ };
393
+ });
394
+
395
+ // B-19: predicates a spec can invert, certified against the same values the
396
+ // emitted mocks will hold. Async server components only — the same gate as
397
+ // dataDeps/guards, and the plane where the one-mount-all-true problem lives.
398
+ const predicates = unit.isAsync
399
+ ? extractPredicates({
400
+ bodyNode: unit.bodyNode,
401
+ sourceFile: parsed.sourceFile,
402
+ dataDeps,
403
+ guards,
404
+ moduleImports,
405
+ propsShape,
406
+ unresolvedProps,
407
+ staticText,
408
+ })
409
+ : { inversions: [], refusals: [] };
410
+
411
+ const info: ComponentInfo = {
412
+ componentName: unit.componentName,
413
+ filePath: absPath,
414
+ importSpecifier,
415
+ propsShape,
416
+ variants,
417
+ hooks,
418
+ unresolvedProps,
419
+ interactions,
420
+ pos: unit.pos,
421
+ exportKind: unit.exportKind,
422
+ exportedAs: unit.exportedAs,
423
+ isAsync: unit.isAsync,
424
+ isClientComponent: parsed.isClientComponent,
425
+ wrappers: unit.wrappers,
426
+ propPositions,
427
+ promiseProps,
428
+ unresolvedTypes,
429
+ variantOptions,
430
+ variantClasses,
431
+ acceptsChildren,
432
+ ...(childrenSlotted ? { childrenSlotted: true } : {}),
433
+ ...(hostElement ? { hostElement } : {}),
434
+ ...(hostDialect === "native" ? { hostDialect } : {}),
435
+ state,
436
+ hookDetails,
437
+ propUsage,
438
+ childAttrs,
439
+ fetchCalls,
440
+ ...(apiCalls.length > 0 ? { apiCalls } : {}),
441
+ staticText,
442
+ dataDeps,
443
+ guards,
444
+ ...(predicates.inversions.length > 0 ? { predicateInversions: predicates.inversions } : {}),
445
+ ...(predicates.refusals.length > 0 ? { predicateRefusals: predicates.refusals } : {}),
446
+ ...(rootTag ? { rootTag } : {}),
447
+ ...(initialRender?.guarded ? { renderGuarded: true } : {}),
448
+ // The THIRD state. renderGuarded is set only when an initial render EXISTS
449
+ // and is guarded, so a total bail — renderPath could not decide which branch
450
+ // runs, and every branch returns content — was indistinguishable from an
451
+ // unconditional render. Downstream that means a spec asserting on markup
452
+ // from a branch the defaults never take.
453
+ ...(initialRender ? {} : { renderUndecidable: true as const }),
454
+ // An error boundary is the exception to the refusal above: its fallback
455
+ // branch is reachable by construction, so the spec asserts both paths
456
+ // instead of declining both.
457
+ ...(unit.isErrorBoundary
458
+ ? { errorBoundary: { fallbackText: errorFallbackText(unit.bodyNode) } }
459
+ : {}),
460
+ childrenFlow: flows.children,
461
+ classNameFlow: flows.className,
462
+ renderMode: render.mode,
463
+ ...(render.note ? { renderNotes: [render.note] } : {}),
464
+ ...(compoundFamily ? { compoundFamily } : {}),
465
+ ...(logsErrors ? { logsErrors } : {}),
466
+ moduleImports,
467
+ };
468
+
469
+ let children: ComponentTree[] = [];
470
+ if (opts.includeChildren && depth < opts.depth) {
471
+ const localComponents = new Set(parsed.components.map((c) => c.componentName));
472
+ const refs = walkChildren(
473
+ unit.bodyNode,
474
+ parsed.imports,
475
+ absPath,
476
+ parsed.sourceFile,
477
+ resolver,
478
+ localComponents,
479
+ unit.componentName,
480
+ );
481
+ children = [];
482
+ const childAt = async (
483
+ file: string,
484
+ exportedName: string | undefined,
485
+ localTag: string,
486
+ ): Promise<ComponentTree | undefined> => {
487
+ const key = `${file}::${exportedName ?? "*"}`;
488
+ if (visited.has(key)) return undefined;
489
+ visited.add(key);
490
+ try {
491
+ const childTree = await analyseOne(
492
+ file,
493
+ depth + 1,
494
+ opts,
495
+ visited,
496
+ file,
497
+ resolver,
498
+ exportedName,
499
+ cache,
500
+ );
501
+ childTree.entry.localTag = localTag;
502
+ return childTree;
503
+ } catch (err) {
504
+ // Skip children we can't parse (no React component inside, etc.); record nothing —
505
+ // the user can re-run with --depth tuned, or edit the generated suite.
506
+ if (!(err instanceof Error && err.message.includes("could not locate a React component"))) {
507
+ throw err;
508
+ }
509
+ return undefined;
510
+ }
511
+ };
512
+ const throughHandoff = async (
513
+ file: string,
514
+ exportedName: string | undefined,
515
+ ): Promise<{ file: string; name: string } | undefined> => {
516
+ // A default import asks for the file's own primary component, so there is
517
+ // no name to look up.
518
+ if (!exportedName) return undefined;
519
+ const source = cache.get(file);
520
+ if (!source) return undefined;
521
+ const table = reexportTableOf(source);
522
+ // The cheap gate: a file that hands nothing along — nearly every file —
523
+ // is answered off a statement walk, with no component detection at all.
524
+ if (!table.named.has(exportedName) && table.stars.length === 0) return undefined;
525
+ if (declaresComponentName(file, exportedName, cache)) return undefined;
526
+ const held = await followHandoff(file, exportedName, {
527
+ read: (f) => cache.get(f),
528
+ resolve: (specifier, from) => resolver.resolve(specifier, from),
529
+ // A hand-off of `default` is answered by the far file's own default
530
+ // export, never by a declaration literally named "default".
531
+ declares: (f, name) =>
532
+ name === "default"
533
+ ? defaultExportComponentName(f, cache) !== undefined
534
+ : declaresComponentName(f, name, cache),
535
+ });
536
+ if (!held) return undefined;
537
+ // `export { default as Card } from './card'` is the shape nearly every
538
+ // design-system barrel uses, and the far side answers for the module's
539
+ // DEFAULT export: what the tree wants is the name that export carries at
540
+ // home, so the spec renders the component the file calls itself.
541
+ return {
542
+ file: held.file,
543
+ name:
544
+ held.name === "default"
545
+ ? (defaultExportComponentName(held.file, cache) ?? held.name)
546
+ : held.name,
547
+ };
548
+ };
549
+ for (const ref of refs) {
550
+ // The hand-off, tried FIRST. A Feature-Sliced component is imported from
551
+ // its slice's index, and an index hands the name along instead of
552
+ // declaring it — so the direct read either died there (no component in
553
+ // the file at all) or, worse, silently bound analyseOne's first-component
554
+ // fallback and made the parent a child of whatever the barrel happened to
555
+ // declare first. ton-console's EmptyApiKeys renders <CreateApiKeyModal>
556
+ // from '../index', and its spec mounted without the QueryClientProvider
557
+ // that modal calls through. Both dead ends cost the tree the child edge,
558
+ // and the transitive provider pass has nothing to inherit without it.
559
+ const held = await throughHandoff(ref.absPath, ref.exportedName);
560
+ const attempts = held
561
+ ? [
562
+ { file: held.file, name: held.name },
563
+ { file: ref.absPath, name: ref.exportedName },
564
+ ]
565
+ : [{ file: ref.absPath, name: ref.exportedName }];
566
+ for (const attempt of attempts) {
567
+ const tree = await childAt(attempt.file, attempt.name, ref.localName);
568
+ // The direct read stays as the fallback: a hand-off that leads nowhere
569
+ // must not lose the edge the old binding produced.
570
+ if (tree) {
571
+ children.push(tree);
572
+ break;
573
+ }
574
+ }
575
+ }
576
+ }
577
+
578
+ // The spec renders its descendants for real, and they fetch their own
579
+ // data: Drop awaits getUserDropInteractions though the page imports only
580
+ // getDropsByAuthor. Collect the tree's data imports and the call-site
581
+ // shapes the descendants read, so the shared module mock is built with
582
+ // the whole render's evidence. The unit's own evidence wins for a callee
583
+ // (it sits on the primary path); tree entries fill what the unit never
584
+ // touched.
585
+ if (children.length > 0) {
586
+ const ownCallees = new Set((info.dataDeps ?? []).map((d) => d.callee));
587
+ const extras = new Map<string, ModuleImportInfo>();
588
+ const depExtras: DataDepInfo[] = [];
589
+ // The child bridge (analyse/childBridge.ts): a rendered component knows
590
+ // more about the value it is handed than this unit's reads show — the
591
+ // RelativeTime that calls date.getTime() on `drop.createdAt`. Collect
592
+ // each tag's expected prop values (that component's OWN defaults, which
593
+ // already carry ITS descendants' bridges: the recursion is post-order).
594
+ // The whole subtree counts, not just direct children: `visited` gives a
595
+ // shared component to whichever branch claims it first, so ProfileFeed's
596
+ // <Drop> can hide under DropList while still being rendered by its body.
597
+ // Direct children register first — their evidence wins for a tag.
598
+ const childExpected: Record<string, Record<string, unknown>> = {};
599
+ for (const ct of children) {
600
+ const tag = ct.entry.localTag ?? ct.entry.componentName;
601
+ if (!childExpected[tag]) childExpected[tag] = synthesiseDefaults(ct.entry);
602
+ }
603
+ // A descendant parked AT the depth limit shows childless whatever it
604
+ // renders: the app page's tree sees Drop but not the RelativeTime
605
+ // buried one level under him, and the expectation from that truncated
606
+ // entry omits the Date RelativeTime demands. Resolve such a tag from a
607
+ // FRESH walk of its own file — full depth budget from there, parse
608
+ // cache already warm, so a component's expectations never depend on how
609
+ // far down the host's tree it happened to be discovered.
610
+ const truncated = new Map<string, ComponentTree>();
611
+ // A mocked module is served to EVERY importer in the graph, so its factory
612
+ // must cover every name ANY file imports from it — sonner: one file's
613
+ // { toast } stood for the whole mock while components/toaster's
614
+ // { Toaster } was dropped, and the real importer died at import time on
615
+ // the missing export. The walk merges per-specifier name sets (the unit's
616
+ // own statements included); a default arriving late is skipped — its
617
+ // index-0 slot belongs to the entry that registered first.
618
+ const mergeImport = (base: ModuleImportInfo, extra: ModuleImportInfo): ModuleImportInfo => {
619
+ const add = extra.names
620
+ .map((n, i) => ({ n, i }))
621
+ .filter(({ n, i }) => !base.names.includes(n) && !(extra.hasDefault && i === 0));
622
+ if (add.length === 0) return base;
623
+ // The added names' return annotations travel with them: RelativeTime's
624
+ // `timeAgo(d): string` is what makes the stub SYNC when the module ends
625
+ // up mocked anyway — an async stub rendered as a JSX child silently
626
+ // suspends the whole tree.
627
+ const returns: Record<string, unknown> = { ...base.importedReturns };
628
+ for (const { n, i } of add) {
629
+ const m = extra.importedNames?.[i] ?? extra.names[i] ?? n;
630
+ const r = extra.importedReturns?.[m];
631
+ if (r !== undefined && returns[m] === undefined) returns[m] = r;
632
+ }
633
+ const baseReturns = Object.keys(returns).length > 0;
634
+ return {
635
+ ...base,
636
+ names: [...base.names, ...add.map(({ n }) => n)],
637
+ importedNames: [
638
+ ...(base.importedNames ?? base.names),
639
+ ...add.map(({ n, i }) => extra.importedNames?.[i] ?? extra.names[i] ?? n),
640
+ ],
641
+ // A `type` specifier stays a `type` specifier whatever merges it: the
642
+ // name is erased at compile time either way, so losing this list in the
643
+ // merge would put the runtime-binding lie back.
644
+ ...(base.typeOnlyNames || extra.typeOnlyNames
645
+ ? {
646
+ typeOnlyNames: [
647
+ ...new Set([...(base.typeOnlyNames ?? []), ...(extra.typeOnlyNames ?? [])]),
648
+ ],
649
+ }
650
+ : {}),
651
+ mergedNames: [...(base.mergedNames ?? []), ...add.map(({ n }) => n)],
652
+ ...(base.dataExports || extra.dataExports
653
+ ? {
654
+ dataExports: [
655
+ ...new Set([...(base.dataExports ?? []), ...(extra.dataExports ?? [])]),
656
+ ],
657
+ }
658
+ : {}),
659
+ ...(baseReturns ? { importedReturns: returns as never } : {}),
660
+ };
661
+ };
662
+ // ONE MODULE, ONE MOCK. The page reaches a file as "./components/ui/button"
663
+ // and a child reaches the same file as "./ui/button". Keyed by specifier
664
+ // TEXT, those two views never merged: the child's entry was stored raw, so
665
+ // its names were never marked graph-added — which let a helper the page
666
+ // never named FORCE a whole-module factory — and the factory it forced
667
+ // covered only the child's names, dropping the `Button` the page renders:
668
+ // `[vitest] No "Button" export is defined on the "…/ui/button" mock`.
669
+ // resolvedPath IS the module's identity; a bare or unresolvable specifier
670
+ // has none, and its text is its identity (unchanged behaviour there).
671
+ const moduleKey = (mi: ModuleImportInfo): string => mi.resolvedPath ?? mi.specifier;
672
+ const walkTree = (trees: ComponentTree[]): void => {
673
+ for (const t of trees) {
674
+ for (const mi of t.entry.moduleImports ?? []) {
675
+ const key = moduleKey(mi);
676
+ const prev = extras.get(key);
677
+ if (prev) {
678
+ extras.set(key, mergeImport(prev, mi));
679
+ } else {
680
+ const own = moduleImports.find((m) => moduleKey(m) === key);
681
+ if (own) extras.set(key, mergeImport(own, mi));
682
+ else extras.set(key, mi);
683
+ }
684
+ }
685
+ for (const d of t.entry.dataDeps ?? []) {
686
+ if (!ownCallees.has(d.callee) && !depExtras.some((e) => e.callee === d.callee)) {
687
+ depExtras.push(d);
688
+ }
689
+ }
690
+ const tag = t.entry.localTag ?? t.entry.componentName;
691
+ if (!childExpected[tag]) {
692
+ childExpected[tag] = synthesiseDefaults(t.entry);
693
+ if (t.children.length === 0 && t.entry.childExpected === undefined) {
694
+ truncated.set(tag, t);
695
+ }
696
+ }
697
+ walkTree(t.children);
698
+ }
699
+ };
700
+ walkTree(children);
701
+ if (truncated.size > 0) {
702
+ const resolved = new Map<string, Record<string, unknown>>();
703
+ for (const [tag, t] of truncated) {
704
+ const key = `${t.entry.filePath}::${t.entry.componentName}`;
705
+ let exp = resolved.get(key);
706
+ if (exp === undefined) {
707
+ try {
708
+ const fresh = await analyseOne(
709
+ t.entry.filePath,
710
+ 0,
711
+ opts,
712
+ new Set<string>(),
713
+ t.entry.filePath,
714
+ resolver,
715
+ t.entry.componentName,
716
+ cache,
717
+ );
718
+ exp = synthesiseDefaults(fresh.entry);
719
+ } catch {
720
+ continue; // keep the partial expectation registered by the walk
721
+ }
722
+ resolved.set(key, exp);
723
+ }
724
+ childExpected[tag] = exp;
725
+ }
726
+ }
727
+ if (extras.size > 0) info.treeImports = [...extras.values()];
728
+ if (depExtras.length > 0) info.treeDataDeps = depExtras;
729
+
730
+ info.childExpected = childExpected;
731
+ if (info.dataDeps) bridgeDataShapes(unit.bodyNode, info.dataDeps, childExpected);
732
+ }
733
+
734
+ return { entry: info, children };
735
+ }
736
+
737
+ export { extractHooks } from "./extractHooks.ts";
738
+ export { extractInteractions } from "./extractInteractions.ts";
739
+ export { extractProps } from "./extractProps.ts";
740
+ export { parseFile } from "./parseFile.ts";
741
+ export { posOf } from "./pos.ts";
742
+ export { ModuleResolver, probeModuleFile } from "./resolveModule.ts";
743
+ export { SourceFileCache } from "./sourceCache.ts";
744
+ export { walkChildren } from "./walkChildren.ts";