@lariocpt/flawless-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,794 @@
1
+ /**
2
+ * vi.mock() emission, shared by every spec kind.
3
+ *
4
+ * This used to live inline in rscSpec, which meant client specs got no mocks at
5
+ * all: a client component importing next/navigation, or calling a server action
6
+ * through useActionState, blew up on import. constrainedSpec had the same hole —
7
+ * a layout importing "server-only" failed at collection time.
8
+ *
9
+ * The two modes differ in exactly one way, and it matters:
10
+ *
11
+ * rsc — allowlist, then mock every remaining project-local import. A server
12
+ * component's whole job is fetching, so its imports are data access
13
+ * and stubbing them is the point.
14
+ * client — allowlist ONLY. A client component's imports are mostly UI and
15
+ * helpers: mocking `cn` would strip every className off the markup,
16
+ * and mocking @/components/ui/button would replace the very button
17
+ * the interaction tests click. Anything not on the list is left real.
18
+ */
19
+ import path from "node:path";
20
+ import type {
21
+ ComponentInfo,
22
+ DataDepInfo,
23
+ ModuleImportInfo,
24
+ NextjsApiRoute,
25
+ PropValue,
26
+ TodoRecorder,
27
+ } from "../types.ts";
28
+ import { type DepFieldResolver, fieldValueLiteral, mockLiteral } from "./mockValues.ts";
29
+ import { withRouteParams } from "./registry.ts";
30
+
31
+ export type MockMode = "client" | "rsc";
32
+
33
+ export interface EmittedMocks {
34
+ /** vi.mock(...) statements, in import source order. */
35
+ statements: string[];
36
+ /** Specifiers actually mocked — for the run report. */
37
+ mocked: string[];
38
+ }
39
+
40
+ /**
41
+ * Modules whose real implementation must survive: React itself, asset imports,
42
+ * and the Next.js surface that is not on the allowlist below.
43
+ *
44
+ * Only consulted in "rsc" mode — "client" mode reaches nothing past the
45
+ * allowlist, so this regex never has to grow to protect it.
46
+ */
47
+ const MOCK_SKIP =
48
+ /^(react($|\/)|next\/(?!navigation|headers|cache)|.*\.(css|scss|sass|less|svg|png|jpe?g|gif|woff2?)$)/;
49
+
50
+ /**
51
+ * Bindings whose modules must stay real even in "rsc" mode: pure, synchronous,
52
+ * class-string producers. Mocking them strips every className off the markup —
53
+ * or, with the generic async stub, injects a Promise where a string belongs and
54
+ * className renders as "[object Promise]".
55
+ *
56
+ * Membership is checked against the module-side (IMPORTED) name, so
57
+ * `import { cn as cx }` is still recognised. The decision is made per MODULE,
58
+ * merging every import statement of the same specifier: collectModuleImports
59
+ * emits one ModuleImportInfo per statement, and exempting one statement while
60
+ * another statement's mock replaces the whole module factory would erase the
61
+ * very binding the exemption promised to keep.
62
+ *
63
+ * every imported binding pure → the module is not mocked at all;
64
+ * mixed pure + data → the module IS mocked, and each pure binding
65
+ * gets a synchronous join stub (under the
66
+ * module-side export name) beside the data stubs.
67
+ *
68
+ * Names the render graph adds (ModuleImportInfo.mergedNames) are COVERED by a
69
+ * forced factory but never FORCE one: the mock decision is evidence of what
70
+ * this render needs, and no file relying on the mock named them.
71
+ */
72
+ const PURE_UTILITY_BINDINGS = new Set(["cn", "clsx", "cva", "twMerge", "tw"]);
73
+
74
+ /**
75
+ * Class-string producer stand-in for a pure binding inside a mocked module:
76
+ * synchronous and string-valued, because the component drops its result
77
+ * straight into className — vi.fn(async …) there renders "[object Promise]".
78
+ */
79
+ const PURE_UTILITY_STUB = `vi.fn((...args: unknown[]) => args.filter(Boolean).join(" "))`;
80
+
81
+ interface MergedBinding {
82
+ /** Module-side export name — `cn` for `import { cn as cx }`. The returns map
83
+ * is keyed by it, since that is what the declaration is named. */
84
+ moduleName?: string;
85
+ local: string;
86
+ isDefault: boolean;
87
+ pure: boolean;
88
+ /** The declaration is a module-side data constant (ModuleImportInfo
89
+ * .dataExports): a value read, never a dep to stub. */
90
+ data: boolean;
91
+ /** The name only exists because the render graph imports it — no file that
92
+ * relies on the MOCK named it. It must be COVERED by the factory (a mocked
93
+ * module serves every importer), but it must never FORCE the mock: lib/utils,
94
+ * pure for the unit's `{ cn }`, gained a graph-only `timeAgo` and got mocked
95
+ * `async () => ({})` — a promise rendered as a child, an empty container. */
96
+ graphOnly?: boolean;
97
+ }
98
+
99
+ interface MergedModule {
100
+ /** Original specifier texts (dataDeps records these, not the re-based one). */
101
+ specifiers: Set<string>;
102
+ bindings: MergedBinding[];
103
+ /** Module-side export name → its own return annotation, merged across the
104
+ * declarations that import from this module. See ModuleImportInfo. */
105
+ returns: Map<string, { value: PropValue; sync: boolean }>;
106
+ }
107
+
108
+ /** Default and namespace entries have no module-side name; the local stands in. */
109
+ function membershipName(mi: ModuleImportInfo, index: number): string {
110
+ return mi.importedNames?.[index] ?? mi.names[index] ?? "";
111
+ }
112
+
113
+ function mergeInto(groups: Map<string, MergedModule>, mi: ModuleImportInfo, key: string): void {
114
+ let group = groups.get(key);
115
+ if (!group) {
116
+ group = { specifiers: new Set(), bindings: [], returns: new Map() };
117
+ groups.set(key, group);
118
+ }
119
+ group.specifiers.add(mi.specifier);
120
+ for (const [name, r] of Object.entries(mi.importedReturns ?? {})) group.returns.set(name, r);
121
+ mi.names.forEach((local, i) => {
122
+ // A `type` specifier is erased at compile time, so the module does not
123
+ // export that name at RUNTIME. It can neither force a mock — every shadcn
124
+ // `ui/*` module's `import { cva, type VariantProps }` did, and the factory
125
+ // that follows replaced `cva` with a class-string stub, which turned the
126
+ // app's own `const buttonVariants = cva(…)` into a string — nor be a factory
127
+ // entry: `VariantProps: vi.fn(…)` bound a name no importer can call.
128
+ if (mi.typeOnlyNames?.includes(local)) return;
129
+ const moduleName = membershipName(mi, i);
130
+ const isDefault = mi.hasDefault && i === 0;
131
+ const graphOnly = !isDefault && mi.mergedNames?.includes(local) === true;
132
+ // Dedup on the MODULE-side name: the factory keys on it, and two files
133
+ // importing { toast } and { toast as t } must produce ONE `toast:` entry,
134
+ // not two — a duplicate key in the factory object is at best noise. A
135
+ // default is NOT that binding: its module-side name is only the local
136
+ // text, and `default:` coexists with a same-named export.
137
+ const existing =
138
+ group.bindings.find(
139
+ (b) =>
140
+ !isDefault &&
141
+ !b.isDefault &&
142
+ (b.moduleName ?? b.local) === moduleName &&
143
+ (!b.graphOnly || graphOnly),
144
+ ) ??
145
+ (!graphOnly ? group.bindings.find((b) => b.local === local) : undefined) ??
146
+ (graphOnly
147
+ ? group.bindings.find((b) => b.local === local && (b.moduleName ?? b.local) === moduleName)
148
+ : undefined);
149
+ if (existing) return;
150
+ group.bindings.push({
151
+ local,
152
+ moduleName,
153
+ isDefault,
154
+ pure: PURE_UTILITY_BINDINGS.has(membershipName(mi, i)) || mi.isPureModule === true,
155
+ data: mi.dataExports?.includes(membershipName(mi, i)) ?? false,
156
+ graphOnly,
157
+ });
158
+ });
159
+ }
160
+
161
+ /**
162
+ * `specDir` is the absolute directory the spec will be written to. It matters:
163
+ * vitest resolves a relative vi.mock() specifier against the file containing the
164
+ * call, not against the component under test. Emitting the component's own
165
+ * "./actions" verbatim would mock a sibling of the SPEC — usually a module that
166
+ * does not exist, so the mock silently does nothing and the real module loads.
167
+ * Colocated specs happen to be immune; specs under flawless/unit/ are not.
168
+ */
169
+ /**
170
+ * OpenAPI response example for a fetch shape, when the route table has one.
171
+ * Shared by the mock emitter (Path B of the dep-return inference) and the
172
+ * fetch-spec fixtures in templates.ts — one join, one normalisation.
173
+ */
174
+ export function apiRouteFixture(
175
+ urlTemplate: string,
176
+ method: string,
177
+ apiRoutes: NextjsApiRoute[] | undefined,
178
+ ): unknown {
179
+ if (!apiRoutes) return undefined;
180
+ const wanted = normalizeApiPath(urlTemplate);
181
+ for (const r of apiRoutes) {
182
+ if (normalizeApiPath(r.path) !== wanted) continue;
183
+ const body = r.responseBodies?.[method.toUpperCase()] ?? r.responseBodies?.GET;
184
+ if (body !== undefined) return body;
185
+ }
186
+ return undefined;
187
+ }
188
+
189
+ /** Compare /api/x/{id}/y, /api/x/:id/y and /api/x/:buildingId/y as the same shape. */
190
+ export function normalizeApiPath(p: string): string {
191
+ return p
192
+ .replace(/\{[^}]+\}/g, ":_")
193
+ .replace(/:[A-Za-z0-9_]+/g, ":_")
194
+ .replace(/\/+$/, "");
195
+ }
196
+
197
+ // Source interpolated into an emitted block comment: a star-slash inside it
198
+ // (a literal * in a fetch path, say) would close the comment early and leave
199
+ // the tail as broken code in the generated spec.
200
+ function commentSafe(text: string): string {
201
+ return text.split("*/").join("* /");
202
+ }
203
+
204
+ /**
205
+ * The fixture an uncertain/unknown dep still gets: the annotation-derived
206
+ * value the analyser stored, or the OpenAPI example joined here — emit time
207
+ * is where the route table lives. Undefined means the dep really is a gap:
208
+ * the {} stub, the TODO and the RSC skip escape all key off that.
209
+ */
210
+ export function inferredDepReturn(
211
+ dep: DataDepInfo,
212
+ apiRoutes?: NextjsApiRoute[],
213
+ ): { value: PropValue; provenance: "annotation" | "openapi" } | undefined {
214
+ if (dep.inferredReturn) return dep.inferredReturn;
215
+ if (dep.wrapsFetch) {
216
+ const body = apiRouteFixture(dep.wrapsFetch.path, dep.wrapsFetch.method, apiRoutes);
217
+ if (body !== undefined) return { provenance: "openapi", value: body as PropValue };
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ export function emitModuleMocks(
223
+ info: ComponentInfo,
224
+ mode: MockMode,
225
+ specDir?: string,
226
+ todo?: TodoRecorder,
227
+ apiRoutes?: NextjsApiRoute[],
228
+ ): EmittedMocks {
229
+ const statements: string[] = [];
230
+ const mocked: string[] = [];
231
+ const seen = new Set<string>();
232
+
233
+ const push = (mi: ModuleImportInfo, make: (spec: string) => string): void => {
234
+ const spec = mockSpecifier(mi, specDir);
235
+ if (seen.has(spec)) return;
236
+ seen.add(spec);
237
+ statements.push(make(spec));
238
+ mocked.push(spec);
239
+ };
240
+
241
+ // RSC specs render their descendants, which import their own data modules;
242
+ // the tree's imports join the unit's (deduped by specifier at analyse time,
243
+ // and again through `seen`/`mergeInto` here). Client mode renders no async
244
+ // server tree, so it stays on the unit's imports alone.
245
+ const mockInputs =
246
+ mode === "rsc"
247
+ ? [...(info.moduleImports ?? []), ...(info.treeImports ?? [])]
248
+ : (info.moduleImports ?? []);
249
+
250
+ // Merge the data-path statements per module BEFORE deciding anything: the
251
+ // pure-utility exemption and the mock factory must both see every binding the
252
+ // component imports from a module, however many statements deliver them.
253
+ const dataGroups = new Map<string, MergedModule>();
254
+ if (mode === "rsc") {
255
+ for (const mi of mockInputs) {
256
+ if (!reachesDataPath(mi)) continue;
257
+ // EVERY name a data-path statement imports is a module-side data
258
+ // constant → the module stays out of the groups, and the mock loop
259
+ // below passes it by entirely. The statement never stubs anything,
260
+ // and a factory replacement for it would starve transitive importers
261
+ // (json-ld reading SITE_INFO off a mock that only knew
262
+ // SOURCE_CODE_GITHUB_REPO — the chanhdai collection wall).
263
+ if (
264
+ mi.dataExports !== undefined &&
265
+ mi.names.length > 0 &&
266
+ mi.names.every((_, i) => mi.dataExports?.includes(membershipName(mi, i)))
267
+ ) {
268
+ continue;
269
+ }
270
+ mergeInto(dataGroups, mi, mockSpecifier(mi, specDir));
271
+ }
272
+ }
273
+
274
+ for (const mi of mockInputs) {
275
+ if (mi.isTypeOnly) continue;
276
+
277
+ // --- allowlist, evaluated before the kind/MOCK_SKIP gates ------------------
278
+ // next/link is kind:"component" and is matched by MOCK_SKIP, so it would be
279
+ // excluded twice over if these ran in the other order.
280
+ if (mi.specifier === "server-only") {
281
+ push(mi, () => `vi.mock("server-only", () => ({}));`);
282
+ continue;
283
+ }
284
+ if (mi.specifier === "next/navigation") {
285
+ push(mi, navigationMock);
286
+ continue;
287
+ }
288
+ if (mi.specifier === "next/headers") {
289
+ push(mi, headersMock);
290
+ continue;
291
+ }
292
+ if (mi.specifier === "next/link") {
293
+ push(mi, (spec) => linkPassthroughMock(mi, spec));
294
+ continue;
295
+ }
296
+ if (mi.specifier === "swr") {
297
+ push(mi, () => swrMock());
298
+ continue;
299
+ }
300
+ if (mi.specifier.startsWith("next/font/")) {
301
+ push(mi, (spec) => fontMock(mi, spec));
302
+ continue;
303
+ }
304
+ if (mi.isServerAction) {
305
+ push(mi, (spec) => serverActionMock(mi, spec, todo));
306
+ continue;
307
+ }
308
+
309
+ // --- everything else: server specs only -----------------------------------
310
+ if (mode === "client") continue;
311
+ // An async server CHILD cannot mount here: React's client renderer refuses
312
+ // an async function component, and a page containing one renders an empty
313
+ // container — every assertion after that is against nothing. The child is
314
+ // a data-fetching unit with its own spec, so this spec stubs it to null
315
+ // rather than claiming anything about its markup. Only when EVERY name
316
+ // imported from the module is such a component — a module mixing async and
317
+ // sync exports would need a factory half stub, half real, which lies about
318
+ // one of the halves.
319
+ if (mi.kind === "component" && allAsyncComponents(mi)) {
320
+ push(mi, (spec) => asyncChildStubMock(mi, spec));
321
+ continue;
322
+ }
323
+ if (!reachesDataPath(mi)) continue;
324
+ const group = dataGroups.get(mockSpecifier(mi, specDir));
325
+ if (!group) continue;
326
+ // Pure/data-only module → keep it real. GRAPH-ONLY names do not count:
327
+ // the exemption asks "does any file that renders NEED this mocked?" —
328
+ // lib/utils (pure `cn` for the unit) gained a graph-only `timeAgo` and
329
+ // was mocked `async () => ({})`; a promise rendered as a child suspends
330
+ // the tree and the spec asserted against an empty container.
331
+ if (group.bindings.length > 0 && group.bindings.every((b) => b.pure || b.data || b.graphOnly))
332
+ continue;
333
+ push(mi, (spec) => dataModuleMock(group, info, spec, todo, apiRoutes));
334
+ }
335
+
336
+ return { statements, mocked };
337
+ }
338
+
339
+ /**
340
+ * Roots whose deps cannot be mocked coherently: bare + dotted callees mixed
341
+ * on one binding, or one dotted path prefixing another. The emitter falls
342
+ * back to the honest {} for these — and the RSC skip escape must fire too,
343
+ * or the runtime crash on the missing member reads as an application
344
+ * failure (FR-014's exact failure mode, confined to the conflict shapes).
345
+ * One predicate, consumed by dataModuleMock AND uncertainEscape, so the
346
+ * fallback and the escape can never disagree.
347
+ */
348
+ export function dottedMockConflicts(deps: DataDepInfo[]): boolean {
349
+ const byRoot = new Map<string, DataDepInfo[]>();
350
+ for (const d of deps) {
351
+ const group = byRoot.get(d.calleeRoot) ?? [];
352
+ group.push(d);
353
+ byRoot.set(d.calleeRoot, group);
354
+ }
355
+ for (const group of byRoot.values()) {
356
+ const dotted = group.filter((d) => d.callee !== d.calleeRoot);
357
+ if (dotted.length === 0) continue;
358
+ if (dotted.length !== group.length) return true;
359
+ if (dottedDepTree(dotted, () => "conflict-probe") === undefined) return true;
360
+ }
361
+ return false;
362
+ }
363
+
364
+ /**
365
+ * Nested object source for dotted deps sharing one root binding:
366
+ * db.query.users.findMany + db.query.posts.findFirst →
367
+ * `{ query: { users: { findMany: <stub> }, posts: { findFirst: <stub> } } }`.
368
+ * Undefined on any conflict — one path prefixing another, or two deps
369
+ * claiming the same leaf differently — so the caller can fall back honestly.
370
+ */
371
+ function dottedDepTree(
372
+ deps: DataDepInfo[],
373
+ stubOf: (dep: DataDepInfo, seed: string) => string,
374
+ ): string | undefined {
375
+ interface TreeNode {
376
+ children: Map<string, TreeNode>;
377
+ leaf?: string;
378
+ }
379
+ const root: TreeNode = { children: new Map() };
380
+ for (const dep of deps) {
381
+ const segs = dep.callee.split(".").slice(1);
382
+ let cur = root;
383
+ for (const [i, seg] of segs.entries()) {
384
+ if (cur.leaf !== undefined) return undefined;
385
+ const last = i === segs.length - 1;
386
+ let next = cur.children.get(seg);
387
+ if (!next) {
388
+ next = { children: new Map() };
389
+ cur.children.set(seg, next);
390
+ }
391
+ if (last) {
392
+ if (next.children.size > 0) return undefined;
393
+ // Two deps on the same full path (the call appears twice): the first
394
+ // stub stands — same callee, same shape source.
395
+ next.leaf ??= stubOf(dep, seg);
396
+ }
397
+ cur = next;
398
+ }
399
+ }
400
+ const print = (node: TreeNode): string => {
401
+ if (node.leaf !== undefined) return node.leaf;
402
+ const entries = [...node.children.entries()].map(([k, v]) => `${k}: ${print(v)}`);
403
+ return `{ ${entries.join(", ")} }`;
404
+ };
405
+ return print(root);
406
+ }
407
+
408
+ /**
409
+ * The rsc-mode gates ahead of the data-module mock, shared by the merge
410
+ * pre-pass — grouping an allowlisted statement here would double-mock it.
411
+ */
412
+ /** Every name this declaration imports resolves to an async function. */
413
+ function allAsyncComponents(mi: ModuleImportInfo): boolean {
414
+ const asyncSet = new Set(mi.asyncComponents ?? []);
415
+ if (asyncSet.size === 0 || mi.names.length === 0) return false;
416
+ return mi.names.every((_, i) => {
417
+ const moduleName = mi.hasDefault && i === 0 ? "default" : membershipName(mi, i);
418
+ return asyncSet.has(moduleName);
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Replaces a module whose every imported name is an async server component
424
+ * with null-rendering stubs. `() => null` and not a marker element: the page
425
+ * spec claims nothing about the child, and any markup here could collide with
426
+ * text the page's own assertions look for.
427
+ */
428
+ function asyncChildStubMock(mi: ModuleImportInfo, spec: string): string {
429
+ const entries = mi.names.map((_, i) => {
430
+ const key = mi.hasDefault && i === 0 ? "default" : membershipName(mi, i);
431
+ return `${key}: () => null`;
432
+ });
433
+ return `// ${mi.names.join(", ")}: async server child(ren) with their own specs — a client render cannot await them.
434
+ vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
435
+ }
436
+
437
+ function reachesDataPath(mi: ModuleImportInfo): boolean {
438
+ if (mi.isTypeOnly || mi.isServerAction) return false;
439
+ if (
440
+ ["server-only", "next/navigation", "next/headers", "next/link"].includes(mi.specifier) ||
441
+ mi.specifier.startsWith("next/font/")
442
+ ) {
443
+ return false;
444
+ }
445
+ return mi.kind === "value" && !MOCK_SKIP.test(mi.specifier);
446
+ }
447
+
448
+ /**
449
+ * Bare and aliased specifiers ("next/link", "@/lib/dal") mean the same thing from
450
+ * any file, so they pass through. Only a relative specifier has to be re-based
451
+ * onto the spec's own directory.
452
+ */
453
+ export function mockSpecifier(mi: ModuleImportInfo, specDir: string | undefined): string {
454
+ if (!mi.specifier.startsWith(".")) return mi.specifier;
455
+ if (!specDir || !mi.resolvedPath) return mi.specifier;
456
+ let rel = path.relative(specDir, mi.resolvedPath).split(path.sep).join("/");
457
+ if (!rel.startsWith(".")) rel = `./${rel}`;
458
+ return rel.replace(/\.(tsx|ts|jsx|js)$/, "");
459
+ }
460
+
461
+ /**
462
+ * swr@2.5 beta reads its cache with React 19's use(). The root layout's
463
+ * preload() puts a LIVE Promise into the cache; a client badge's first
464
+ * read hits it, use() throws the thenable, and renderToStaticMarkup —
465
+ * which cannot await one — dies with "A component suspended while
466
+ * responding to synchronous input" (aurorascharff RootLayout). A
467
+ * deterministic sync surface fixes it for good: config is a pass-
468
+ * through, preload does nothing, and reads answer empty-cache shapes
469
+ * instead of thenables. The factory lists the module's public surface:
470
+ * a missing export is a loud import-time error, so the superset is the
471
+ * safe direction.
472
+ */
473
+ function swrMock(): string {
474
+ const read =
475
+ "vi.fn(() => ({ data: undefined, error: undefined, isLoading: false, isValidating: false, mutate: vi.fn() }))";
476
+ return `vi.mock("swr", () => ({
477
+ default: ${read},
478
+ useSWR: ${read},
479
+ SWRConfig: (props: { children?: unknown }) => props.children ?? null,
480
+ SWRProvider: (props: { children?: unknown }) => props.children ?? null,
481
+ preload: vi.fn(),
482
+ mutate: vi.fn(),
483
+ useSWRConfig: vi.fn(() => ({ mutate: vi.fn(), revalidate: vi.fn() })),
484
+ }));`;
485
+ }
486
+
487
+ function navigationMock(): string {
488
+ // `routerMock` is hoisted, and it has to be.
489
+ //
490
+ // useRouter used to return a FRESH object with FRESH spies on every call, so
491
+ // there was no handle to assert against: `router.push("/x")` inside a handler
492
+ // wrote to a spy the spec could never see. A navigation is one of the few
493
+ // things a click provably does, and it was unobservable.
494
+ //
495
+ // vi.hoisted is the only way to share a value INTO the factory: vi.mock
496
+ // factories are lifted above every import in the module, so a plain const
497
+ // declared here is still in the temporal dead zone when the factory runs.
498
+ return `const routerMock = vi.hoisted(() => ({
499
+ push: vi.fn(),
500
+ replace: vi.fn(),
501
+ refresh: vi.fn(),
502
+ back: vi.fn(),
503
+ forward: vi.fn(),
504
+ prefetch: vi.fn(),
505
+ }));
506
+ vi.mock("next/navigation", () => ({
507
+ notFound: vi.fn(() => { throw new Error("notFound() called"); }),
508
+ // These abort rendering in Next; a plain vi.fn() would let a guarded
509
+ // component run straight past its own guard and crash on the data it guards.
510
+ redirect: vi.fn(() => { throw new Error("NEXT_REDIRECT"); }),
511
+ permanentRedirect: vi.fn(() => { throw new Error("NEXT_REDIRECT"); }),
512
+ usePathname: vi.fn(() => "/"),
513
+ useRouter: vi.fn(() => routerMock),
514
+ useSearchParams: vi.fn(() => new URLSearchParams()),
515
+ useParams: vi.fn(() => ({})),
516
+ // Layout-segment hooks: a component that imports next/navigation gets this
517
+ // per-spec mock INSTEAD of the global one, so every hook the global factory
518
+ // exports has to be here too — a missing name is a "[vitest] No export"
519
+ // error at import time.
520
+ useSelectedLayoutSegment: vi.fn(() => null),
521
+ useSelectedLayoutSegments: vi.fn(() => []),
522
+ }));`;
523
+ }
524
+
525
+ function headersMock(): string {
526
+ return `vi.mock("next/headers", () => ({
527
+ headers: vi.fn(async () => new Headers()),
528
+ // The real cookie jar is Map-like AND has get/getAll/set/delete: which one a
529
+ // page calls is the page's choice ((await cookies()).has(SLOW) in
530
+ // ProfileFeed), so the stub answers every spelling.
531
+ cookies: vi.fn(async () => ({
532
+ get: vi.fn(() => undefined),
533
+ getAll: vi.fn(() => []),
534
+ has: vi.fn(() => false),
535
+ set: vi.fn(),
536
+ delete: vi.fn(),
537
+ })),
538
+ }));`;
539
+ }
540
+
541
+ /**
542
+ * The async factory form is required, not stylistic: vi.mock factories are
543
+ * hoisted above every import in the module, so a synchronous factory that closed
544
+ * over a top-level `createElement` binding would throw
545
+ * "Cannot access 'createElement' before initialization".
546
+ */
547
+ function linkPassthroughMock(mi: ModuleImportInfo, spec: string): string {
548
+ // Named exports ride along: the real hooks read the same AppRouterContext,
549
+ // and a name the file imports but the factory omits is a "[vitest] No
550
+ // export" error the first time the component calls it (useLinkStatus in
551
+ // nav-link.tsx). Keys are module-side names — aliasing is the importer's.
552
+ const keys = mi.importedNames ?? mi.names;
553
+ const named = keys
554
+ .filter((_, i) => !(mi.hasDefault && i === 0))
555
+ .map((n) =>
556
+ n === "useLinkStatus" ? `useLinkStatus: vi.fn(() => ({ pending: false }))` : `${n}: vi.fn()`,
557
+ );
558
+ return `vi.mock(${JSON.stringify(spec)}, async () => {
559
+ // The real <Link> reads Next's AppRouterContext, which jsdom has no way to
560
+ // provide. A passthrough keeps the anchor, its href and its text queryable.
561
+ const { createElement } = await import("react");
562
+ return {
563
+ default: (props: { href?: string | { pathname?: string }; children?: unknown }) => {
564
+ const { href, children, ...rest } = props;
565
+ const to = typeof href === "string" ? href : (href?.pathname ?? "#");
566
+ return createElement("a", { ...rest, href: to } as never, children as never);
567
+ },${named.length > 0 ? `\n ${named.join(", ")},` : ""}
568
+ };
569
+ });`;
570
+ }
571
+
572
+ /**
573
+ * next/font loaders run at module scope and reach for the filesystem, so the
574
+ * import throws before any test body runs. The stub is keyed on the names the
575
+ * file actually imports rather than a catch-all Proxy — we know them exactly.
576
+ */
577
+ function fontMock(mi: ModuleImportInfo, spec: string): string {
578
+ const names = mi.hasDefault ? mi.names.slice(1) : mi.names;
579
+ const loader = `() => ({ className: "", variable: "", style: { fontFamily: "" } })`;
580
+ const entries = names.map((n) => `${n}: ${loader}`);
581
+ if (mi.hasDefault) entries.unshift(`default: ${loader}`);
582
+ if (entries.length === 0) return `vi.mock(${JSON.stringify(spec)}, () => ({}));`;
583
+ return `vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
584
+ }
585
+
586
+ /**
587
+ * A "use server" module. An unresolved name's stub resolves to {} rather than
588
+ * undefined because these land in useActionState, whose state is read as
589
+ * `state && "error" in state` — undefined would crash the first render.
590
+ * vi.fn() is a real function, so `action.bind(null, id)` in the JSX still
591
+ * works. A name the analyser resolved (serverActionReturns) gets the action's
592
+ * real literal return instead of the {} stand-in.
593
+ *
594
+ * The factory is keyed by MODULE-side names, never the local aliases: the
595
+ * module exports "createPost" whether the component called it that or
596
+ * "saveIt" (or reached it through `import * as actions`), and a factory
597
+ * keyed by the local binding exports a name the module never had while
598
+ * leaving the real one unmocked.
599
+ */
600
+ function serverActionMock(mi: ModuleImportInfo, spec: string, todo?: TodoRecorder): string {
601
+ if (mi.names.length === 0) return `vi.mock(${JSON.stringify(spec)}, () => ({}));`;
602
+ const returns = mi.serverActionReturns ?? {};
603
+ const stub = (moduleName: string): string => {
604
+ const v = returns[moduleName];
605
+ return v === undefined
606
+ ? "vi.fn(async () => ({}))"
607
+ : `vi.fn(async () => (${JSON.stringify(v)}))`;
608
+ };
609
+ // The factory replaces the WHOLE module, so it binds the module's whole
610
+ // export surface: the component's own imports and every sibling a
611
+ // transitive importer might pull. The imported names are only the fallback
612
+ // for when the export list could not be read.
613
+ const namedIndices = mi.names.map((_, i) => i).filter((i) => !(mi.hasDefault && i === 0));
614
+ const surface = mi.serverActionExports?.length
615
+ ? mi.serverActionExports
616
+ : namedIndices.map((i) => membershipName(mi, i));
617
+ const exportNames = [...new Set(surface)].filter((n) => n !== "default");
618
+ const entries = exportNames.map((n) => `${n}: ${stub(n)}`);
619
+ const wantsDefault = mi.hasDefault || (mi.serverActionExports ?? []).includes("default");
620
+ if (wantsDefault) entries.unshift(`default: ${stub("default")}`);
621
+ // The TODO retires only when EVERY export of the module resolved (and the
622
+ // surface is non-empty: exportedNames cannot see `export * from` barrels,
623
+ // and an empty list proves nothing resolved, not that everything did).
624
+ const required = [...exportNames, ...(wantsDefault ? ["default"] : [])];
625
+ if (required.length > 0 && required.every((n) => returns[n] !== undefined)) {
626
+ return `vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
627
+ }
628
+ todo?.("server-action-stub");
629
+ return `// TODO(flawless): server action stubbed — give it a realistic return value.
630
+ vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
631
+ }
632
+
633
+ /**
634
+ * A project-local module a server component reads data from — every import
635
+ * statement of it merged, so the factory covers every binding at once.
636
+ */
637
+ function dataModuleMock(
638
+ group: MergedModule,
639
+ info: ComponentInfo,
640
+ spec: string,
641
+ todo?: TodoRecorder,
642
+ apiRoutes?: NextjsApiRoute[],
643
+ ): string {
644
+ if (group.bindings.length === 0) return `vi.mock(${JSON.stringify(spec)}, () => ({}));`;
645
+
646
+ // The descendants' call-site evidence joins the unit's: the mock is shared
647
+ // by the whole render, and it is the CHILD — Drop, not the page — whose
648
+ // `interactions.liked` says what getUserDropInteractions has to return.
649
+ // Analyse already dropped any callee the unit itself deps on.
650
+ const allDeps = [...(info.dataDeps ?? []), ...(info.treeDataDeps ?? [])];
651
+ const depsHere = allDeps.filter(
652
+ (d) => d.specifier !== undefined && group.specifiers.has(d.specifier),
653
+ );
654
+ let uncertain = false;
655
+
656
+ // Resolves an `equalsDep` note — `m.user_id === user.id` — to the literal
657
+ // the referenced dep's own fixture holds, whichever channel that fixture
658
+ // came from. All of the component's deps count, not just this module's:
659
+ // `members` and `user` usually come from different modules. One hop only
660
+ // (fieldValueLiteral's rule); anything unreadable drops the note.
661
+ const depFieldValue: DepFieldResolver = (varName, field) => {
662
+ const dep = allDeps.find((d) => d.varName === varName);
663
+ if (!dep) return undefined;
664
+ if (dep.uncertain || dep.shape.kind === "unknown") {
665
+ const v = inferredDepReturn(dep, apiRoutes)?.value;
666
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
667
+ const fv = (v as Record<string, unknown>)[field];
668
+ if (fv !== undefined) return JSON.stringify(fv);
669
+ }
670
+ return undefined;
671
+ }
672
+ if (dep.shape.kind === "object") {
673
+ const f = dep.shape.fields.find((x) => x.name === field);
674
+ if (f) return fieldValueLiteral(f, withRouteParams(info.propsShape));
675
+ }
676
+ return undefined;
677
+ };
678
+
679
+ // The stub expression for ONE dep — shared by root-level bindings and the
680
+ // leaves of a dotted tree. Sets the module-level `uncertain` flag exactly
681
+ // where the old inline logic did. `fill` is the dep's own return-annotation
682
+ // value, when one resolved: usage testimony still decides the SHAPE and any
683
+ // compared value, but a field usage could only synthesise a placeholder for
684
+ // takes the declaration's value — `job.status` read into a lookup says
685
+ // nothing about legal statuses, while `Promise<Job>`'s z.enum names one.
686
+ const depStub = (dep: DataDepInfo, seed: string, fill?: unknown): string => {
687
+ if (dep.uncertain || dep.shape.kind === "unknown") {
688
+ // Usage said nothing, but the dep's own declaration may have: the
689
+ // annotation-derived value, or the OpenAPI example for the fetch the
690
+ // wrapper performs. Only a dep with neither is an admitted gap.
691
+ const inf = inferredDepReturn(dep, apiRoutes);
692
+ if (inf) {
693
+ const provenance =
694
+ inf.provenance === "annotation"
695
+ ? `/* inferred from ${commentSafe(dep.callee)}'s return annotation */`
696
+ : `/* inferred from the OpenAPI example for ${commentSafe(`${dep.wrapsFetch?.method} ${dep.wrapsFetch?.path}`)} */`;
697
+ const lit = JSON.stringify(inf.value);
698
+ return dep.awaited
699
+ ? `vi.fn(async () => (${lit})) ${provenance}`
700
+ : `vi.fn(() => (${lit})) ${provenance}`;
701
+ }
702
+ uncertain = true;
703
+ return dep.awaited ? "vi.fn(async () => ({}))" : "vi.fn(() => ({}))";
704
+ }
705
+ // Shape inferred from how the awaited value is actually used in the body.
706
+ // The props fixture the spec's own DEFAULTS is built from, so a field
707
+ // compared against a route param gets that param's value.
708
+ const literal = mockLiteral(
709
+ dep.shape,
710
+ seed,
711
+ withRouteParams(info.propsShape),
712
+ depFieldValue,
713
+ fill,
714
+ );
715
+ return dep.awaited ? `vi.fn(async () => (${literal}))` : `vi.fn(() => (${literal}))`;
716
+ };
717
+
718
+ const entry = (b: MergedBinding): string => {
719
+ // The factory REPLACES the module's exports: importers read the
720
+ // MODULE-side name. A renamed import ({ Toaster as SonnerToaster }) under
721
+ // the local key would leave the real importer facing a missing export.
722
+ const key = b.isDefault ? "default" : b.moduleName || b.local;
723
+ // The factory replaces the whole module, so a pure class-string helper
724
+ // beside the data exports has to be re-supplied here — synchronously.
725
+ if (b.pure) return `${key}: ${PURE_UTILITY_STUB}`;
726
+ // A default-imported dep is still a dep: its calleeRoot is the local
727
+ // binding name. The old `default: vi.fn()` shortcut resolved undefined —
728
+ // worse than {} — and, once deps could be enriched, dropped the very
729
+ // fixture whose presence had suppressed the RSC skip escape.
730
+ const deps = depsHere.filter((d) => d.calleeRoot === b.local);
731
+ const dep = deps[0];
732
+ if (!dep) {
733
+ if (b.isDefault) return "default: vi.fn()";
734
+ // An UPPER-CASE import never called as a function is a component by
735
+ // the JSX naming convention (lucide icons, and every icon set that
736
+ // reaches a const map: `const Icon = ICONS[kind]; <Icon/>`). The
737
+ // {} fallback made it `vi.fn(async () => ({}))` — mounted as JSX it
738
+ // returns a PROMISE, React 19 awaits components that then, and the
739
+ // whole tree suspends: the spec asserted against an empty container.
740
+ // Pass children through synchronously; the tree stays queryable.
741
+ if (/^[A-Z]/.test(b.local))
742
+ return `${key}: vi.fn((props: { children?: React.ReactNode }) => props.children ?? null)`;
743
+ // No usage evidence — but the DECLARATION may still say. A formatter
744
+ // called inside JSX never becomes a dataDep, and `formatZAR(cents:
745
+ // number): string` was stubbed `async () => ({})`: asynchronous for a
746
+ // synchronous function, and an object where the page renders a string.
747
+ const ann = group.returns.get(b.moduleName ?? b.local);
748
+ if (ann) {
749
+ const lit = JSON.stringify(ann.value);
750
+ const prov = `/* inferred from ${commentSafe(b.local)}'s return annotation */`;
751
+ return ann.sync
752
+ ? `${key}: vi.fn(() => (${lit})) ${prov}`
753
+ : `${key}: vi.fn(async () => (${lit})) ${prov}`;
754
+ }
755
+ // Neither usage nor declaration. {} is a better stand-in than undefined:
756
+ // it survives member access and only fails on .length/.map, which is a
757
+ // legible failure rather than a crash.
758
+ uncertain = true;
759
+ // A graph-only name (some other file imports it; nothing here awaits it —
760
+ // awaiting makes a dep) renders at worst, and a promise child silently
761
+ // suspends the whole tree. SYNC {} fails loudly where it appears, and
762
+ // `await {}` still resolves.
763
+ if (b.graphOnly) return `${key}: vi.fn(() => ({}))`;
764
+ return `${key}: vi.fn(async () => ({}))`;
765
+ }
766
+ // `db.query.users.findMany()` needs the vi.fn at the LEAF of the dotted
767
+ // path — mocking the root as a function made `.query` undefined and the
768
+ // spec threw on correct code with no escape (FR-014).
769
+ const dotted = deps.filter((d) => d.callee !== d.calleeRoot);
770
+ if (dotted.length > 0) {
771
+ const tree = dottedDepTree(dotted, depStub);
772
+ if (tree !== undefined && deps.length === dotted.length) return `${key}: ${tree}`;
773
+ // Bare + dotted on one binding, or one path prefixing another: a
774
+ // callable object stub would be inventing semantics. The honest {}
775
+ // keeps the TODO and the RSC skip escape alive.
776
+ uncertain = true;
777
+ return `${key}: vi.fn(async () => ({}))`;
778
+ }
779
+ return `${key}: ${depStub(dep, b.local, group.returns.get(b.moduleName ?? b.local)?.value)}`;
780
+ };
781
+
782
+ const entries = [
783
+ ...group.bindings.filter((b) => b.isDefault).map(entry),
784
+ ...group.bindings.filter((b) => !b.isDefault).map(entry),
785
+ ];
786
+
787
+ // One marker (and one tally) per module mock, however many of its bindings
788
+ // were uninferrable — the marker is what the reader answers, not the binding.
789
+ if (uncertain) todo?.("uninferred-return-shape");
790
+ const note = uncertain
791
+ ? "// TODO(flawless): some return shapes could not be inferred — give them fixtures.\n"
792
+ : "";
793
+ return `${note}vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
794
+ }