@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,462 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { rendersNativeSource } from "../analyse/nativeDialect.ts";
5
+ import { ModuleResolver } from "../analyse/resolveModule.ts";
6
+ import { appPackageRoot } from "../packageJson.ts";
7
+ import {
8
+ findOrphanUnitSpecs,
9
+ type ScaffoldUnitBrowserOptions,
10
+ scaffoldUnitBrowser,
11
+ } from "../scaffold-tests/index.ts";
12
+ import { scaffoldNativeUnit } from "../scaffold-tests/nativeIndex.ts";
13
+ import { flattenTrees } from "../scaffold-tests/registry.ts";
14
+ import type { AliasEntry, MultiRootAlias, ReactExportShim } from "../scaffold-tests/templates.ts";
15
+ import type { RunContext, Task, TaskResult } from "../types.ts";
16
+ import { TodoCounter } from "../types.ts";
17
+
18
+ export const unitAndBrowser: Task = {
19
+ id: "unit-browser",
20
+ label: "Generate React unit + browser tests",
21
+ description: "Vitest + @testing-library/react units, Playwright functional browser specs",
22
+ needsTree: true,
23
+ async run(ctx: RunContext): Promise<TaskResult> {
24
+ if (ctx.trees.length === 0) {
25
+ return {
26
+ id: "unit-browser",
27
+ ok: false,
28
+ outputs: [],
29
+ findings: [],
30
+ summary: "no components to scaffold",
31
+ error: "no components",
32
+ };
33
+ }
34
+ // Expo runs the native plane instead of the DOM one. Not "as well as": the
35
+ // DOM suite pointed at React Native emits specs that run, pass and touch
36
+ // nothing, which is the one outcome this tool must never produce.
37
+ if (ctx.expo) {
38
+ const todos = new TodoCounter();
39
+ const res = await scaffoldNativeUnit({
40
+ trees: ctx.trees,
41
+ outDir: ctx.outDir,
42
+ expo: ctx.expo,
43
+ force: ctx.force,
44
+ todos,
45
+ });
46
+ for (const kept of res.kept) todos.drop(kept);
47
+ const names = flattenTrees(ctx.trees).map((c) => c.info.componentName);
48
+ const keptNote =
49
+ res.kept.length > 0
50
+ ? ` (${res.kept.length} write-once file(s) kept — delete one to regenerate it)`
51
+ : "";
52
+ return {
53
+ id: "unit-browser",
54
+ ok: true,
55
+ outputs: res.written,
56
+ findings: [],
57
+ summary: `native unit specs for ${names.length} component(s)${keptNote}`,
58
+ handWork: todos.toTallies(),
59
+ };
60
+ }
61
+
62
+ const projectRoot = ctx.nextjs?.root;
63
+ // Paths relative to where the user reads the summary from, capped so one
64
+ // big app can't turn the summary line into a wall of text.
65
+ const rel = (p: string): string => path.relative(projectRoot ?? process.cwd(), p) || p;
66
+ const listKept = (paths: string[]): string => {
67
+ const shown = paths.slice(0, 10).map(rel).join(", ");
68
+ return paths.length > 10 ? `${shown}, +${paths.length - 10} more` : shown;
69
+ };
70
+ // An orphan means the same thing at both exits of this task, so it is named
71
+ // the same way at both: the runner WILL execute this file and no component
72
+ // backs it any more. Named, never removed — a suite-dir spec carries no
73
+ // stamp, so nothing proves it is not hand work. See findOrphanUnitSpecs.
74
+ const orphanNote = (orphans: string[]): string =>
75
+ orphans.length > 0
76
+ ? ` (${orphans.length} unit spec(s) with no component behind them — kept, delete to drop: ${listKept(orphans)})`
77
+ : "";
78
+ // React Native source is not DOM-plane material. A spec imports the
79
+ // component, so its WHOLE graph loads, and anywhere in a native graph sits
80
+ // react-native: Flow source vite cannot parse, so the spec dies at collect
81
+ // with `SyntaxError: Unexpected token 'typeof'` and the run ends
82
+ // `Test Files no tests`. Measured on ospfranco/sol, a bare-RN app: 16
83
+ // specs, 16 collect failures, zero tests ever ran — a suite that cannot
84
+ // report anything is not a suite. The tool's native plane is `--expo`, and
85
+ // `--expo` refuses a non-Expo project by design, so there is no plane to
86
+ // emit for these and a NAMED skip is the honest answer. The perf side of the
87
+ // same rule lives in hostEligibility, which the host registry, the profile
88
+ // specs and REPORT.md all read, so the app is excluded the same way in all
89
+ // three rather than crashing in one and lying in the other two.
90
+ const nativeTrees = ctx.trees.filter(rendersNativeSource);
91
+ const trees =
92
+ nativeTrees.length === 0 ? ctx.trees : ctx.trees.filter((t) => !rendersNativeSource(t));
93
+ if (trees.length === 0) {
94
+ const who = flattenTrees(ctx.trees);
95
+ // The exit that strands them: specs written by an earlier run are still
96
+ // in `unit/`, and this run will not overwrite a single one of them.
97
+ const orphans = await findOrphanUnitSpecs(ctx.outDir, new Set());
98
+ return {
99
+ id: "unit-browser",
100
+ ok: true,
101
+ outputs: [],
102
+ findings: [],
103
+ summary:
104
+ `no unit specs: all ${who.length} component(s) render React Native source, which ` +
105
+ `no DOM plane can load (the native plane is --expo, Expo projects only)` +
106
+ orphanNote(orphans),
107
+ };
108
+ }
109
+ // Alias / react-pin anchor. A Next app is anchored at the app root; a
110
+ // plain vite project has no such marker, but its tsconfig sits right next
111
+ // to the analysed sources — anchor at each source directory and let
112
+ // nearestConfig() walk up from there. Without this, non-Next specs lost
113
+ // every tsconfig `paths` alias AND the shared-react pin, so `@/...`
114
+ // imports failed to resolve and the suite crashed on dual React.
115
+ const aliasRoots = ctx.nextjs
116
+ ? [ctx.nextjs.root]
117
+ : [...new Set(flattenTrees(trees).map((c) => path.dirname(c.info.filePath)))];
118
+ // Browser specs drive the perf-harness host; emitting them without it
119
+ // would hand the user a suite that cannot start.
120
+ const browserSpecs =
121
+ ctx.selectedTasks === undefined || ctx.selectedTasks.includes("perf-harness");
122
+ // Tallies the TODO(flawless) markers the templates plant, counted at each
123
+ // emission site — never by grepping the emitted text (see TodoCounter).
124
+ // The collector travels inside the options object: scaffoldUnitBrowser
125
+ // hands that object to renderUnitBrowser verbatim, whose options type
126
+ // declares `todos`. Built as a variable, not an inline literal, so the
127
+ // extra property passes the excess-property check; todoCounts.test.ts pins
128
+ // the passthrough end to end, because a rebuild of the object anywhere on
129
+ // the path would silently drop the counter and report zero debt.
130
+ const todos = new TodoCounter();
131
+ // The shared-react anchor follows the SOURCES, not the suite directory.
132
+ // Externalized deps (next-themes & co.) resolve "react" from the APP's
133
+ // tree — node's require, no alias involved — so the alias must anchor at
134
+ // that same physical copy or the app's libraries render with one React
135
+ // while the suite renders with another ("Cannot read properties of null
136
+ // (reading 'useContext')"). The suite's own copy would win this lookup
137
+ // the moment it is installed, splitting the graph exactly there; the
138
+ // harness closure (@testing-library, react, react-dom) is INLINED in the
139
+ // vitest config instead, so its bare specifiers ride the alias too. An
140
+ // app with no installed react falls through to the suite's copy, which
141
+ // now carries the app's version pin.
142
+ const pinFrom = ctx.nextjs ? ctx.nextjs.root : (aliasRoots[0] ?? ctx.outDir);
143
+ const pinFallback = ctx.nextjs ? undefined : ctx.outDir;
144
+ const aliasRes =
145
+ aliasRoots.length > 0
146
+ ? computeResolveAliases(aliasRoots, pinFrom, pinFallback)
147
+ : { aliases: [] as AliasEntry[], multiRoot: [] as MultiRootAlias[] };
148
+ // The react named-export shim (ReactExportShim): the app's own imports
149
+ // scanned against its installed react's exports, filled only with what
150
+ // Next's bundled copy proves to exist. Undefined for every app whose
151
+ // imports the core already answers.
152
+ const reactShim = detectCanaryReactExports(
153
+ flattenTrees(trees).map((c) => c.info.filePath),
154
+ nearestReactNodeModules(pinFrom) ??
155
+ (pinFallback ? nearestReactNodeModules(pinFallback) : undefined),
156
+ );
157
+ // Annotated, not inferred: the object is passed to scaffoldUnitBrowser as a
158
+ // variable, and TypeScript checks a variable's shape against a parameter
159
+ // only for what the TYPE declares — a field this literal invents, or one the
160
+ // scaffolder stops reading, is invisible. This is the hole that let the
161
+ // manifest's reactPinDir anchor exist at runtime while the scaffolder's own
162
+ // options type did not name it.
163
+ const scaffoldOptions: ScaffoldUnitBrowserOptions = {
164
+ browserSpecs,
165
+ trees,
166
+ outDir: ctx.outDir,
167
+ variants: ctx.variants,
168
+ mswSetup: ctx.nextjs !== undefined,
169
+ nextjs: ctx.nextjs !== undefined,
170
+ provider: ctx.provider,
171
+ resolveAliases: aliasRes.aliases.length > 0 ? aliasRes.aliases : undefined,
172
+ multiRootAliases: aliasRes.multiRoot.length > 0 ? aliasRes.multiRoot : undefined,
173
+ // The single-React anchor: vitest aliases only reach code VITE loads,
174
+ // so setup.ts redirects every NATIVE react/react-dom load (an
175
+ // externalised @testing-library/react requires its peer-linked suite
176
+ // copy otherwise — physically a second React, null dispatcher on every
177
+ // mount) to this same pair.
178
+ reactPinDir: aliasRes.pinDir,
179
+ reactShim,
180
+ // The app package's .env files for the emitted vitest config (server
181
+ // modules read DATABASE_URL & co. at import time; unset env crashes
182
+ // every importing spec at collection).
183
+ appEnvRoot: ctx.nextjs
184
+ ? ctx.nextjs.root
185
+ : aliasRoots[0]
186
+ ? appPackageRoot(aliasRoots[0])
187
+ : undefined,
188
+ colocate: ctx.colocate,
189
+ force: ctx.force,
190
+ apiRoutes: ctx.nextjs?.apiRoutes,
191
+ providerWraps: ctx.providerWraps,
192
+ todos,
193
+ };
194
+ const { written, skipped, keptEdited, keptLegacy, staleRemoved, staleKept, orphanUnitSpecs } =
195
+ await scaffoldUnitBrowser(scaffoldOptions);
196
+ // Tallying happened while rendering, but a kept colocated spec's render
197
+ // was discarded — its markers were never written this run, and may name
198
+ // TODOs the user already resolved in the file the writer kept. All three
199
+ // kept buckets discard their render, so all three retract their tallies.
200
+ for (const kept of [...skipped, ...keptEdited, ...keptLegacy]) todos.drop(kept);
201
+ // flattenTrees, not a raw walk: the same component appears in as many trees
202
+ // as there are pages rendering it, and counting the walk reported "unit
203
+ // tests for 606 component(s)" against an app the report itself says has 181.
204
+ const names = flattenTrees(trees).map((c) => c.info.componentName);
205
+ // Named, not silent: the count the user expected to see is in this line
206
+ // WITH the reason it is smaller, the same rule the auth-skip and kept-spec
207
+ // notes follow.
208
+ const nativeNote =
209
+ nativeTrees.length > 0
210
+ ? ` (${flattenTrees(nativeTrees).length} React Native component(s) skipped — no DOM plane can load react-native)`
211
+ : "";
212
+ const skippedNote =
213
+ skipped.length > 0
214
+ ? ` (${skipped.length} colocated spec(s) kept — rerun with --force to regenerate)`
215
+ : "";
216
+ const editedNote =
217
+ keptEdited.length > 0
218
+ ? ` (${keptEdited.length} hand-edited or reformatted colocated spec(s) kept — delete to regenerate: ${listKept(keptEdited)})`
219
+ : "";
220
+ const legacyNote =
221
+ keptLegacy.length > 0
222
+ ? ` (${keptLegacy.length} pre-stamp colocated spec(s) kept — delete to regenerate: ${listKept(keptLegacy)})`
223
+ : "";
224
+ const staleRemovedNote =
225
+ staleRemoved.length > 0
226
+ ? ` (${staleRemoved.length} stale browser spec(s) removed — the component is no longer in the host: ${listKept(staleRemoved)})`
227
+ : "";
228
+ const staleKeptNote =
229
+ staleKept.length > 0
230
+ ? ` (${staleKept.length} stale browser spec(s) kept — the component is no longer in the host and the file is hand-edited or unprovable; delete to remove: ${listKept(staleKept.map((k) => k.abs))})`
231
+ : "";
232
+ const browserNote = browserSpecs
233
+ ? ""
234
+ : " (browser specs skipped — add perf-harness to the task list for the host they need)";
235
+ const shimNote = reactShim
236
+ ? ` (react shim: ${[...reactShim.components, ...reactShim.functions].join(", ")} from Next's bundled react@${reactShim.canaryVersion})`
237
+ : "";
238
+ // Notes before the name list: REPORT.md truncates each task summary at
239
+ // 120 chars and the component list runs to thousands — a note appended
240
+ // after it would exist only in summary.json and stdout, and the kept
241
+ // listing is the one part of this line a user must act on.
242
+ return {
243
+ id: "unit-browser",
244
+ ok: true,
245
+ outputs: written,
246
+ findings: [],
247
+ summary: `unit tests for ${names.length} component(s)${nativeNote}${skippedNote}${editedNote}${legacyNote}${staleRemovedNote}${staleKeptNote}${orphanNote(orphanUnitSpecs)}${shimNote}${browserNote}: ${names.join(", ")}`,
248
+ handWork: todos.toTallies(),
249
+ };
250
+ },
251
+ };
252
+
253
+ /**
254
+ * vitest resolve.alias entries for a Next.js project: the tsconfig path
255
+ * aliases, plus exact react/react-dom pins so a monorepo with several React
256
+ * copies never pairs mismatched react + react-dom under jsdom.
257
+ */
258
+ function computeResolveAliases(
259
+ roots: string[],
260
+ pinFrom?: string,
261
+ pinFallback?: string,
262
+ ): {
263
+ aliases: AliasEntry[];
264
+ multiRoot: MultiRootAlias[];
265
+ pinDir?: string;
266
+ } {
267
+ const entries: AliasEntry[] = [];
268
+ const multiRoot: MultiRootAlias[] = [];
269
+ const targets = new Map<string, string[]>();
270
+ const resolver = new ModuleResolver();
271
+ // A multi-package run may put sources under several tsconfigs; collect every
272
+ // prefix's candidate targets first. One root → plain alias. Several roots
273
+ // claiming the same prefix with DIFFERENT targets cannot collapse into the
274
+ // one alias map vite offers — those become importer-aware plugin entries.
275
+ for (const projectRoot of roots) {
276
+ for (const p of resolver.aliasPatternsFor(projectRoot)) {
277
+ if (p.exact || p.suffix !== "") continue;
278
+ const target = p.targets[0];
279
+ if (!target) continue;
280
+ const dir = path.resolve(p.baseDir, target.replace(/\*.*$/, "")).replace(/\/+$/, "");
281
+ const arr = targets.get(p.prefix);
282
+ if (arr) {
283
+ if (!arr.includes(dir)) arr.push(dir);
284
+ } else targets.set(p.prefix, [dir]);
285
+ }
286
+ }
287
+ for (const [prefix, dirs] of targets) {
288
+ const only = dirs[0];
289
+ // An EMPTY prefix is a tsconfig catch-all — `"*": ["./src/*"]`, which is how
290
+ // React Native's shared tsconfig maps imports (ospfranco/sol). As a vite
291
+ // `resolve.alias` it is `{ find: /^/, replacement: <src>/ }`, and `^` matches
292
+ // EVERY id: vite's own `/@vite/env`, `/@id/…`, a relative `./Foo`, all
293
+ // rewritten into `src/`. The run then dies before one spec loads — sol: 16
294
+ // "Failed to load url /@vite/env" errors and `Test Files no tests`. The
295
+ // importer-aware plugin below is the safe spelling of the same mapping: it
296
+ // probes for the file and answers null when there is nothing there, so
297
+ // `react` and `/@vite/env` fall through to normal resolution untouched.
298
+ if (dirs.length === 1 && only !== undefined && prefix !== "")
299
+ entries.push({ find: prefix, replacement: only, prefix: true });
300
+ else multiRoot.push({ prefix, dirs });
301
+ }
302
+
303
+ // The shared-react pin. A Next suite runs on the repo's own vitest/RTL
304
+ // stack, so it anchors at the app root. A non-Next standalone suite
305
+ // anchors at the SOURCES' copy first (see the call site): externalized
306
+ // deps resolve react from the app tree through plain node require, so
307
+ // that copy is the one the whole graph must share. Only when the app
308
+ // tree carries no react at all does pinFallback join — the suite's own
309
+ // installed test stack, which is version-matched to the app by
310
+ // detectAppReact. nearestReactNodeModules's preference (the dir that
311
+ // carries @testing-library/react wins) keeps each leg honest.
312
+ const nmDir =
313
+ (pinFrom ? nearestReactNodeModules(pinFrom) : undefined) ??
314
+ (pinFallback ? nearestReactNodeModules(pinFallback) : undefined);
315
+ if (nmDir) {
316
+ const pin = (spec: string, rel: string): void => {
317
+ const abs = path.join(nmDir, rel);
318
+ if (existsSync(abs)) entries.push({ find: spec, replacement: abs, prefix: false });
319
+ };
320
+ pin("react/jsx-runtime", "react/jsx-runtime.js");
321
+ pin("react/jsx-dev-runtime", "react/jsx-dev-runtime.js");
322
+ pin("react-dom/client", "react-dom/client.js");
323
+ pin("react-dom/test-utils", "react-dom/test-utils.js");
324
+ // The document-root specs render through react-dom/server. Pinning every
325
+ // other react-dom subpath but not this one is worse than pinning none: the
326
+ // spec's React is the aliased copy while its renderer is a second one, so
327
+ // the SSR hook dispatcher is set on an instance the component cannot see and
328
+ // every hook call fails with "Cannot read properties of null".
329
+ pin("react-dom/server", "react-dom/server.js");
330
+ pin("react-dom/server.browser", "react-dom/server.browser.js");
331
+ pin("react-dom", "react-dom");
332
+ pin("react", "react");
333
+ // pnpm's realpath layout escapes every request-level pin above. When
334
+ // vitest INTERNALIZES a package (react-dom, testing-library — see the
335
+ // deps.inline note in the vitest config), the package's own
336
+ // require("react") runs from its realpath under
337
+ // <nm>/.pnpm/<name>@<ver>/node_modules/<name>/ and resolves by plain
338
+ // node rules — a second physical react beside the aliased one, same
339
+ // version, separate dispatcher ("Cannot read properties of null
340
+ // (reading 'useSyncExternalStore')"). A pattern against the RESOLVED
341
+ // path is the only hook that sees those loads.
342
+ const pnpmPin = (name: string): void => {
343
+ const abs = path.join(nmDir, name);
344
+ if (existsSync(abs))
345
+ entries.push({
346
+ find: `\\.pnpm/${name}@[^/]*/node_modules/${name}$`,
347
+ replacement: abs,
348
+ prefix: false,
349
+ raw: true,
350
+ });
351
+ };
352
+ pnpmPin("react");
353
+ pnpmPin("react-dom");
354
+ }
355
+ // The anchor travels with the aliases: setup.ts needs the SAME directory to
356
+ // pin the loads the aliases cannot reach (see ReactPinDir).
357
+ return { aliases: entries, multiRoot, pinDir: nmDir };
358
+ }
359
+
360
+ /**
361
+ * The node_modules whose react pair the whole suite should share. Prefer the
362
+ * dir that ALSO carries @testing-library/react: hoisted monorepos put the
363
+ * test stack at the workspace root, and its CJS require() will always find
364
+ * that root pair — so aliasing sources to any other copy guarantees the
365
+ * dual-React invalid-hook-call crash.
366
+ */
367
+ function nearestReactNodeModules(fromDir: string): string | undefined {
368
+ let fallback: string | undefined;
369
+ let dir = path.resolve(fromDir);
370
+ for (;;) {
371
+ const nm = path.join(dir, "node_modules");
372
+ if (existsSync(path.join(nm, "react")) && existsSync(path.join(nm, "react-dom"))) {
373
+ if (existsSync(path.join(nm, "@testing-library", "react"))) return nm;
374
+ fallback ??= nm;
375
+ }
376
+ const parent = path.dirname(dir);
377
+ if (parent === dir) return fallback;
378
+ dir = parent;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Detect React named exports the analysed sources import that the app's
384
+ * installed react does not carry but the copy Next bundles does, and
385
+ * describe the additive shim for them (ReactExportShim in
386
+ * scaffold-tests/templates.ts explains why this is not a core swap).
387
+ * Returns undefined when the core answers every import — the common case,
388
+ * and the cheap exit. The scan is a regex over the import statements, not a
389
+ * type-aware pass: only the set of names taken from the bare "react"
390
+ * specifier matters. A name NEITHER react exports stays unfilled — that is
391
+ * a genuine app bug and should fail the suite as loudly as it fails the
392
+ * app.
393
+ */
394
+ function detectCanaryReactExports(
395
+ sourceFiles: string[],
396
+ nmDir: string | undefined,
397
+ ): ReactExportShim | undefined {
398
+ if (!nmDir) return undefined;
399
+ const imported = new Set<string>();
400
+ // Default clause optional, `type` markers optional; braces capture every
401
+ // name including multiline ones ([^}]* spans newlines).
402
+ const importRe = /import\s+(?:type\s+)?[\w$]*\s*,?\s*\{([^}]*)\}\s*from\s*["']react["']/g;
403
+ for (const file of sourceFiles) {
404
+ let src: string;
405
+ try {
406
+ src = readFileSync(file, "utf8");
407
+ } catch {
408
+ continue;
409
+ }
410
+ for (const m of src.matchAll(importRe)) {
411
+ const clause = m[1] ?? "";
412
+ for (const raw of clause.split(",")) {
413
+ const first = raw
414
+ .trim()
415
+ .replace(/^type\s+/, "")
416
+ .split(/\s+as\s+/)[0];
417
+ const name = first?.trim() ?? "";
418
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) imported.add(name);
419
+ }
420
+ }
421
+ }
422
+ if (imported.size === 0) return undefined;
423
+ const coreDir = path.join(nmDir, "react");
424
+ const core = reactRuntimeExports(coreDir);
425
+ if (!core || core.size === 0) return undefined;
426
+ const missing = [...imported].filter((n) => !core.has(n));
427
+ if (missing.length === 0) return undefined;
428
+ // Evidence gate: fill only what the bundled copy actually exports, which
429
+ // is what the app was compiled and ships against.
430
+ const bundled = reactRuntimeExports(path.join(nmDir, "next", "dist", "compiled", "react"));
431
+ if (!bundled) return undefined;
432
+ const fills = missing.filter((n) => bundled.has(n));
433
+ if (fills.length === 0) return undefined;
434
+ const versionOf = (dir: string): string => {
435
+ try {
436
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as {
437
+ version?: string;
438
+ };
439
+ return pkg.version ?? "unknown";
440
+ } catch {
441
+ return "unknown";
442
+ }
443
+ };
444
+ return {
445
+ coreIndex: path.join(coreDir, "index.js"),
446
+ coreVersion: versionOf(coreDir),
447
+ canaryVersion: versionOf(path.join(nmDir, "next")),
448
+ components: fills.filter((n) => /^[A-Z]/.test(n)),
449
+ functions: fills.filter((n) => !/^[A-Z]/.test(n)),
450
+ };
451
+ }
452
+
453
+ /** Runtime export names of a CommonJS react build, loaded from its directory. */
454
+ function reactRuntimeExports(reactDir: string): Set<string> | undefined {
455
+ try {
456
+ const req = createRequire(path.join(reactDir, "resolver.cjs"));
457
+ const mod = req(path.join(reactDir, "index.js")) as Record<string, unknown>;
458
+ return new Set(Object.keys(mod));
459
+ } catch {
460
+ return undefined;
461
+ }
462
+ }