@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,1977 @@
1
+ /**
2
+ * Context providers the mount planes cannot see.
3
+ *
4
+ * A component under a real layout mounts inside SidebarProvider, InfobarProvider,
5
+ * whatever — but the sweeps analyse COMPONENTS (src/components/**), and the
6
+ * layout that supplies the context is a route file outside the entry glob. The
7
+ * kiranism dogfood: every Sidebar* and Infobar* mount failed with "useSidebar
8
+ * must be used within a SidebarProvider" because nothing in the analysed tree
9
+ * had ever imported SidebarProvider — only app/dashboard/layout.tsx does.
10
+ *
11
+ * Three derivations, strongest evidence first:
12
+ *
13
+ * 1. HOOK CALL. A component that CALLS useX needs XProvider mounted. Same-file
14
+ * call → the provider lives in the component's own file; cross-file call →
15
+ * the hook's import resolves to the provider's file, and only that file's
16
+ * provider counts, so an unrelated FooProvider elsewhere never wraps an
17
+ * unrelated useFoo caller.
18
+ *
19
+ * 2. NAMESPACE COMPOUND. shadcn primitives wrap a third-party namespace
20
+ * (cmdk, base-ui): CommandInput renders <CommandPrimitive.Input> and throws
21
+ * on undefined store context unless <Command> — the same file's bare
22
+ * <CommandPrimitive> renderer, or its <ToastPrimitive.Provider> renderer —
23
+ * is mounted around it.
24
+ *
25
+ * 3. TRANSITIVE REQUIREMENT. AppSidebar never calls useSidebar — its child
26
+ * <Sidebar> does, and the error surfaces at AppSidebar's mount. Whatever a
27
+ * component renders, it must also satisfy.
28
+ *
29
+ * A hand-off is invisible to all three: in a Feature-Sliced app the file a
30
+ * component imports is `export * from './model'`, so the hook it names, and the
31
+ * library that hook calls three files away, sit in a module the import never
32
+ * mentions. Derive follows the re-export table (see reexportTable) whenever the
33
+ * imported file does not declare the hook itself.
34
+ *
35
+ * The provider may be no analysed component at all (the layout-only case), so
36
+ * its file is PARSED here for the export shape — non-async and exported is the
37
+ * whole gate; a provider that cannot mount bare takes down the wraps it guards,
38
+ * and the dogfood failure then names it.
39
+ */
40
+
41
+ import { existsSync, readFileSync, statSync } from "node:fs";
42
+ import path from "node:path";
43
+ import ts from "typescript";
44
+ import { flattenTrees } from "../scaffold-tests/registry.ts";
45
+ import type { ComponentTree, ProviderRef } from "../types.ts";
46
+ import {
47
+ type ImportBinding,
48
+ type ParsedComponentUnit,
49
+ type ParsedFile,
50
+ parseFile,
51
+ } from "./parseFile.ts";
52
+ import { reexportTableOf } from "./reexportTable.ts";
53
+ import { ModuleResolver } from "./resolveModule.ts";
54
+ import type { SourceFileCache } from "./sourceCache.ts";
55
+
56
+ /**
57
+ * Props whose value is a component the render tree mounts. Chakra and
58
+ * styled-components use `as`, MUI uses `component` plus the `components` /
59
+ * `slots` object maps, Radix-adjacent APIs use `fallback`, and the general form
60
+ * is `render`. `<Button as={Link}>` renders `<Link>`: no JSX tag ever names it,
61
+ * so the tag walk below is blind to it and the provider requirement vanishes.
62
+ *
63
+ * Measured on tonkeeper/ton-console: 7 files write `as={Link}` with Link from
64
+ * react-router-dom, and every spec of those components died at mount with
65
+ * `Cannot destructure property 'basename' of useContext(...) as it is null`
66
+ * from react-router-dom's own `LinkWithRef` — 18 failures, the component never
67
+ * having written a `<Link>` to blame.
68
+ */
69
+ const COMPONENT_PASSING_PROPS = new Set([
70
+ "as",
71
+ "asComponent",
72
+ "component",
73
+ "components",
74
+ "slots",
75
+ "fallback",
76
+ "fallbackAs",
77
+ "render",
78
+ ]);
79
+
80
+ /** React's own hooks — none of them names a provider. */
81
+ const CORE_HOOKS = new Set([
82
+ "useCallback",
83
+ "useContext",
84
+ "useDebugValue",
85
+ "useDeferredValue",
86
+ "useEffect",
87
+ "useId",
88
+ "useImperativeHandle",
89
+ "useInsertionEffect",
90
+ "useLayoutEffect",
91
+ "useMemo",
92
+ "useOptimistic",
93
+ "useReducer",
94
+ "useRef",
95
+ "useSyncExternalStore",
96
+ "useTransition",
97
+ "useActionState",
98
+ "useFormState",
99
+ "useFormStatus",
100
+ "useActionCount",
101
+ "useReportError",
102
+ ]);
103
+
104
+ export const providerKey = (info: { filePath: string; componentName: string }): string =>
105
+ `${info.filePath}::${info.componentName}`;
106
+
107
+ const provKey = (ref: ProviderRef): string => `${ref.filePath}::${ref.componentName}`;
108
+
109
+ /** Internal import (relative or tsconfig alias): its bindings are not primitives. */
110
+ const isInternalSpecifier = (s: string): boolean =>
111
+ s.startsWith(".") || s.startsWith("@/") || s.startsWith("~/");
112
+
113
+ /** Every bare module specifier that carries a shim's provider-gated surface:
114
+ * the canonical `module` plus any alias that re-exports the same thing. */
115
+ const shimModules = (spec: { module: string; modules?: string[] }): string[] => [
116
+ spec.module,
117
+ ...(spec.modules ?? []),
118
+ ];
119
+
120
+ /**
121
+ * Providers the project cannot be expected to import: the context lives in a
122
+ * node_modules library, and its hooks throw when no library provider sits
123
+ * above the mount — "No QueryClient set, use QueryClientProvider to set one"
124
+ * is the dominant class when a suite first meets a react-query app. The route
125
+ * tree mounts these and NO analysed component ever imports them, so the
126
+ * derivation synthesises a generated provider module instead.
127
+ *
128
+ * Placement is the importing package's root: bare library imports from the
129
+ * generated file resolve through THAT package's node_modules. An outDir shim
130
+ * would resolve through the suite's, and workspaces that hoist nothing (bun's
131
+ * per-package default) could never find the library there.
132
+ */
133
+ interface LibraryShimSpec {
134
+ module: string;
135
+ /**
136
+ * Other bare module specifiers that carry the SAME provider-gated surface:
137
+ * `react-router-dom` re-exports the `react-router` core (v5 and v6 do that,
138
+ * v7 moved it and left the old name as the alias), so the same hook or
139
+ * component can arrive under either name and needs the same provider. The
140
+ * generated wrap imports the provider from whichever of these the importing
141
+ * package can actually resolve — see shimImportModule for why "always the
142
+ * canonical one" was measured to be wrong.
143
+ */
144
+ modules?: string[];
145
+ hooks: string[];
146
+ /**
147
+ * Provider-gated COMPONENTS, the same requirement seen from the other side:
148
+ * a file that renders `<FormattedMessage>` calls no hook of the library at
149
+ * all, and the mount throws exactly the same "needs to exist in the
150
+ * component tree". saleor-dashboard is the evidence — 1038 files import
151
+ * react-intl and most of them only ever render the component.
152
+ */
153
+ components?: string[];
154
+ componentName: string;
155
+ fileBase: string;
156
+ /**
157
+ * Provider component names this shim makes redundant. The data router serves
158
+ * every hook and tag the MemoryRouter wrap exists for, so a component that
159
+ * calls `useNavigate` AND `useLoaderData` gets one provider rather than two
160
+ * nested ones — the outer would only be shadowed by the inner.
161
+ */
162
+ supersedes?: string[];
163
+ /**
164
+ * Oldest installed version of the resolved module this shim may be emitted
165
+ * against, as `[major, minor]`. A wrap that imports a name the installed
166
+ * library never exported does not skip itself politely: the unresolved import
167
+ * kills every spec that imports the file. A version that cannot be read is
168
+ * accepted — refusing a capable library over an unreadable package.json is
169
+ * the worse bet.
170
+ */
171
+ minVersion?: readonly [number, number];
172
+ /**
173
+ * Imports for the generated module (the React types included). `{{module}}`
174
+ * is replaced with the specifier the provider is actually imported from —
175
+ * which for a module with alias names is not always `module`. See
176
+ * shimImportModule.
177
+ */
178
+ imports: string;
179
+ /**
180
+ * Module-level declarations emitted between the imports and the exported
181
+ * provider, for a shim whose body needs a helper of its own. Blank lines are
182
+ * added by the template.
183
+ */
184
+ helpers?: string;
185
+ /** Component body: mounts the library's real provider. */
186
+ body: string;
187
+ }
188
+
189
+ const LIBRARY_SHIMS: LibraryShimSpec[] = [
190
+ {
191
+ module: "@tanstack/react-query",
192
+ hooks: [
193
+ "useInfiniteQuery",
194
+ "useIsFetching",
195
+ "useIsMutating",
196
+ "useMutation",
197
+ "useMutationState",
198
+ "useQueries",
199
+ "useQuery",
200
+ "useQueryClient",
201
+ "useQueryErrorResetBoundary",
202
+ "useSuspenseInfiniteQuery",
203
+ "useSuspenseQuery",
204
+ ],
205
+ componentName: "FlawlessQueryClientProvider",
206
+ fileBase: "flawless-query-client.tsx",
207
+ imports: `import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
208
+ import { type ReactNode, useState } from "react";`,
209
+ body: ` // Fresh client per mount — state must not leak between specs — and no
210
+ // retries: a spec never reaches a real backend, and a retry storm buries
211
+ // the one error worth seeing.
212
+ const [client] = useState(
213
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
214
+ );
215
+ return <QueryClientProvider client={client}>{children}</QueryClientProvider>;`,
216
+ },
217
+ {
218
+ // nakama is a client-rendered router-dom SPA: CommandPalette calls
219
+ // useNavigate and the mount dies with "useNavigate() may be used only in
220
+ // the context of a <Router>". MemoryRouter is the standard test context.
221
+ module: "react-router-dom",
222
+ // The same surface is also exported by `react-router` itself: v5 and v6
223
+ // re-export the core through react-router-dom, v7 moved it and left the old
224
+ // name as the alias. saleor-dashboard writes both (146 files against
225
+ // react-router-dom, 48 against react-router), and a component that renders
226
+ // <Link> calls no hook at all — "You should not use <Link> outside a
227
+ // <Router>" is what the mount says for it.
228
+ modules: ["react-router"],
229
+ // `Outlet` and `Navigate` join `Link` here on measurement, not on the
230
+ // library's surface list: tonkeeper/ton-console's Layout and LayoutSolid
231
+ // render <Outlet /> and died with `Cannot destructure property 'basename'
232
+ // of useContext(...) as it is null` — the same missing RouterContext, from
233
+ // a tag the walk could always see. Under MemoryRouter an <Outlet /> with
234
+ // no routes renders nothing, which is the honest shape of a unit test: the
235
+ // layout is what the spec mounts, the routed child is not in it.
236
+ components: ["Link", "NavLink", "Navigate", "Outlet"],
237
+ hooks: [
238
+ "useHref",
239
+ "useLinkClickHandler",
240
+ "useLocation",
241
+ "useNavigate",
242
+ "useOutlet",
243
+ "useOutletContext",
244
+ "useParams",
245
+ "useRoutes",
246
+ "useSearchParams",
247
+ ],
248
+ componentName: "FlawlessRouterProvider",
249
+ fileBase: "flawless-router.tsx",
250
+ imports: `import type { ReactNode } from "react";
251
+ import { MemoryRouter } from "{{module}}";`,
252
+ body: ` // An in-memory router rooted at "/": navigation hooks work, nothing moves
253
+ // a real history, and no spec leaks location state into the next.
254
+ // The data-router hooks (useLoaderData, useFetcher, useSubmit …) are NOT
255
+ // served from here — MemoryRouter leaves DataRouterContext empty and they
256
+ // throw for it. They get FlawlessDataRouterProvider below instead.
257
+ return <MemoryRouter>{children}</MemoryRouter>;`,
258
+ },
259
+ {
260
+ // The same router from the other side of 6.4. These hooks read the DATA
261
+ // router and `invariant` out under MemoryRouter — "`useLoaderData` must be
262
+ // used within a data router" — 110 failures across 26 spec files of
263
+ // code-forge-io/react-router-devtools and 28 across 11 of
264
+ // remix-run/remix-store: both apps' components ARE route modules, and no
265
+ // wrap short of a data router can mount them. The names are read off
266
+ // react-router 7.9.3's own useDataRouterContext/useDataRouterState call
267
+ // sites, minus `useNavigate`, which MemoryRouter does serve (nakama is the
268
+ // proof) — plus `useRouterState`, whose invariant fires as its unstable
269
+ // alias.
270
+ //
271
+ // The component side of the same split, probed rather than assumed — each
272
+ // tag rendered alone under both wraps in remix-store's own suite:
273
+ // PROBE Form: memory=useSubmit must be used within a data router
274
+ // PROBE ScrollRestoration: memory=useMatches must be used within a data router
275
+ // PROBE Link / Outlet: memory=<no throw> (they stay above)
276
+ // `<Form>` is what killed unit/Welcome.test.tsx and the custom-server
277
+ // Home of code-forge-io/react-router-devtools: the tag is a router
278
+ // component, the file never names a data hook, so the hook walk found
279
+ // nothing to key on. `<Await>` was also probed and is NOT gated — given
280
+ // the `resolve` a generated spec actually passes (none) it mounts under
281
+ // MemoryRouter; the remix-store `useFetcher` deaths that looked like it
282
+ // were a wrapper-reached data hook, fixed separately.
283
+ components: ["Form", "ScrollRestoration"],
284
+ module: "react-router-dom",
285
+ modules: ["react-router"],
286
+ // The library's data APIs (`createMemoryRouter`, `RouterProvider`) ship
287
+ // from 6.4; on 5.x or 6.0-6.3 this wrap is not offered and those hooks keep
288
+ // failing with the honest message the library itself writes.
289
+ minVersion: [6, 4],
290
+ hooks: [
291
+ "unstable_useRouterState",
292
+ "useActionData",
293
+ "useBlocker",
294
+ "useFetcher",
295
+ "useFetchers",
296
+ "useLoaderData",
297
+ "useMatches",
298
+ "useNavigation",
299
+ "useRevalidator",
300
+ "useRouteError",
301
+ "useRouteLoaderData",
302
+ "useRouterState",
303
+ "useScrollRestoration",
304
+ "useSubmit",
305
+ "useViewTransitionState",
306
+ ],
307
+ supersedes: ["FlawlessRouterProvider"],
308
+ componentName: "FlawlessDataRouterProvider",
309
+ fileBase: "flawless-data-router.tsx",
310
+ imports: `import type { ReactNode } from "react";
311
+ import { createMemoryRouter, RouterProvider, useRouteError } from "{{module}}";`,
312
+ helpers: `/** Rethrows instead of rendering: see the provider below for why. */
313
+ function FlawlessRouteError(): never {
314
+ throw useRouteError();
315
+ }`,
316
+ body: ` // An in-memory DATA router rooted at "/". The hooks above read loader and
317
+ // action data off it and get NOTHING — undefined, not invented data — which
318
+ // is the honest unit shape: the route module mounts, its data does not exist.
319
+ //
320
+ // RouterProvider wraps the tree in its own error boundary, and its default
321
+ // error UI is <h2>Unexpected Application Error!</h2><h3>…</h3>. Measured: that
322
+ // markup is what a throwing component leaves behind even with
323
+ // \`errorElement: null\`, so a spec asserting a heading would pass against the
324
+ // router's error page. Rethrowing the route error makes React walk past the
325
+ // boundary and out of render(), so a crash fails its own spec with its own
326
+ // error, exactly as it does under MemoryRouter.
327
+ //
328
+ // Built per render rather than memoised so \`children\` stays current for a
329
+ // caller that re-renders the wrap; unit specs mount once, and a fresh router
330
+ // per mount is what MemoryRouter does internally too.
331
+ const router = createMemoryRouter(
332
+ [{ path: "*", element: <>{children}</>, errorElement: <FlawlessRouteError /> }],
333
+ { initialEntries: ["/"] },
334
+ );
335
+ return <RouterProvider router={router} />;`,
336
+ },
337
+ {
338
+ // Form-through-context: useFormContext / useController / useWatch /
339
+ // useFieldArray / useFormState read the form off React context and get
340
+ // NULL with no FormProvider above the mount, which the library then
341
+ // dereferences — "Cannot read properties of null (reading 'control')".
342
+ // Wrapping is what the library's own test docs prescribe. Discovered on
343
+ // invoify's shadcn form.tsx, where it is also NOT the whole story: that
344
+ // FormField spec passes no props at all, because its contract arrives as
345
+ // one opaque spread (...props: ControllerProps<…> from node_modules), so
346
+ // with the wrap in place it fails one level deeper on the missing `name`.
347
+ // A required prop the analyser cannot see is a different gap, and stays a
348
+ // documented tail rather than being papered over with a invented form.
349
+ // A component that supplies `control` itself is wrapped for nothing: an
350
+ // outer provider loses to an explicit control, so the wrap stays harmless.
351
+ //
352
+ // React 19 exports its own useFormState, and the gate matches on the
353
+ // IMPORTED MODULE as well as the name (deriveContextProviderWraps), so
354
+ // listing it here cannot wrap a component that got it from "react".
355
+ module: "react-hook-form",
356
+ hooks: [
357
+ "useController",
358
+ "useFieldArray",
359
+ "useFormState",
360
+ "useWatch",
361
+ // The read-only accessor the rest of the family is built on, and the one
362
+ // shadcn's form.tsx imports directly.
363
+ "useFormContext",
364
+ ],
365
+ componentName: "FlawlessFormProvider",
366
+ fileBase: "flawless-form.tsx",
367
+ imports: `import type { ReactNode } from "react";
368
+ import { FormProvider, useForm } from "react-hook-form";`,
369
+ body: ` // A form with no defaultValues and nothing wired to submit: a field
370
+ // component's contract is about the field, not about data, and an unset value
371
+ // is the honest starting point for a spec that supplies none. Fresh per mount
372
+ // so one spec's typing cannot leak into the next spec's formState.
373
+ const methods = useForm();
374
+ return <FormProvider {...methods}>{children}</FormProvider>;`,
375
+ },
376
+ {
377
+ // The dominant failure class when a suite first meets a react-intl app:
378
+ // "[React Intl] Could not find required `intl` object. <IntlProvider>
379
+ // needs to exist in the component tree" — 715 of saleor-dashboard's 1171
380
+ // failing spec files, and the same shape anywhere the app formats copy
381
+ // through the library. An IntlProvider with a locale and no message table
382
+ // resolves every message back to its own `defaultMessage`, which is the
383
+ // literal text the source wrote and the specs assert on: so the wrap makes
384
+ // the mount work WITHOUT inventing a translation, and an assertion that
385
+ // still fails is failing on the component, not on a missing context.
386
+ // `useIntl().formatMessage` reads the same empty table for the same reason.
387
+ module: "react-intl",
388
+ hooks: ["useIntl", "useCreateIntl", "useFormatters"],
389
+ components: [
390
+ "FormattedMessage",
391
+ "FormattedDate",
392
+ "FormattedNumber",
393
+ "FormattedTime",
394
+ "FormattedPlural",
395
+ "FormattedList",
396
+ "FormattedListParts",
397
+ "FormattedRelativeTime",
398
+ "FormattedHTMLMessage",
399
+ ],
400
+ componentName: "FlawlessIntlProvider",
401
+ fileBase: "flawless-intl.tsx",
402
+ imports: `import type { ReactNode } from "react";
403
+ import { IntlProvider } from "react-intl";`,
404
+ body: ` // "en" is not a claim about the app's locales, it is the locale of the
405
+ // strings the source itself wrote. Nothing is fetched, so nothing can hang a
406
+ // spec; onError is left alone because a genuinely missing message is a real
407
+ // finding and should still print.
408
+ return <IntlProvider locale="en">{children}</IntlProvider>;`,
409
+ },
410
+ ];
411
+
412
+ /**
413
+ * Provider name → the provider that makes it redundant, read off the shim
414
+ * table. Applied where a component's requirements are finalised, so the rule
415
+ * survives the transitive walk that produced the pair.
416
+ */
417
+ const SUPERSEDED_BY: Map<string, string> = new Map(
418
+ LIBRARY_SHIMS.flatMap((s) => (s.supersedes ?? []).map((n) => [n, s.componentName] as const)),
419
+ );
420
+
421
+ export interface LibraryProviderShim {
422
+ /** Bare module specifier whose hooks need the wrap. */
423
+ module: string;
424
+ /** Alias module specifiers for the same surface (see LibraryShimSpec). */
425
+ modules?: string[];
426
+ /** Module-side hook names that require the provider above them. */
427
+ hooks: string[];
428
+ /** Module-side component names that require it too (see LibraryShimSpec). */
429
+ components?: string[];
430
+ /** The generated component to wrap with. */
431
+ componentName: string;
432
+ /** Absolute path of the module that exports componentName. */
433
+ filePath: string;
434
+ }
435
+
436
+ /** Nearest directory whose package.json claims this file. */
437
+ function packageRootOf(file: string): string {
438
+ const fallback = path.dirname(path.resolve(file));
439
+ let dir = fallback;
440
+ for (;;) {
441
+ try {
442
+ if (statSync(path.join(dir, "package.json")).isFile()) return dir;
443
+ } catch {
444
+ // Keep walking: a directory with no package.json is simply not one.
445
+ }
446
+ const parent = path.dirname(dir);
447
+ if (parent === dir) return fallback;
448
+ dir = parent;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Every bare module specifier the harness answers with a provider shim — the
454
+ * canonical name plus its alias modules (`react-router` beside
455
+ * `react-router-dom`). Exported for the bootability report: a shimmed module
456
+ * needs no package on disk, so calling it "not installed" would be a false
457
+ * alarm about a suite that mounts fine.
458
+ */
459
+ export function providerShimModules(): string[] {
460
+ return LIBRARY_SHIMS.flatMap((spec) => [spec.module, ...(spec.modules ?? [])]);
461
+ }
462
+
463
+ /** One shim per (library, importing package) pair the analysis can reach. */
464
+ export function libraryProviderShimsFor(componentFiles: string[]): LibraryProviderShim[] {
465
+ const roots = new Set<string>();
466
+ for (const file of componentFiles) roots.add(packageRootOf(file));
467
+ const out: LibraryProviderShim[] = [];
468
+ for (const spec of LIBRARY_SHIMS) {
469
+ for (const root of roots) {
470
+ const filePath = path.join(root, ".flawless", spec.fileBase);
471
+ // A library too old for this shim's surface is not a wrap to skip: the
472
+ // file would import a name the installed package never exported, and one
473
+ // unresolved import takes down every spec that reads the shim. Refusing
474
+ // to OFFER the shim keeps such a component on whichever wrap the library
475
+ // can serve — and on the library's own honest error for the rest.
476
+ if (!shimAvailableAt(spec, filePath)) continue;
477
+ out.push({
478
+ module: spec.module,
479
+ modules: spec.modules,
480
+ hooks: spec.hooks,
481
+ components: spec.components,
482
+ componentName: spec.componentName,
483
+ filePath,
484
+ });
485
+ }
486
+ }
487
+ return out;
488
+ }
489
+
490
+ /** The shims a wrap map actually references — a scaffold writes those files
491
+ * only when something in the run will import them. */
492
+ export function referencedLibraryShims(
493
+ wraps: Map<string, ProviderRef[]> | undefined,
494
+ componentFiles: string[],
495
+ ): LibraryProviderShim[] {
496
+ if (!wraps || wraps.size === 0) return [];
497
+ const used = new Set<string>();
498
+ for (const refs of wraps.values()) {
499
+ for (const r of refs) used.add(`${r.filePath}::${r.componentName}`);
500
+ }
501
+ return libraryProviderShimsFor(componentFiles).filter((s) =>
502
+ used.has(`${s.filePath}::${s.componentName}`),
503
+ );
504
+ }
505
+
506
+ /**
507
+ * Which specifier the generated shim imports its provider from, and what
508
+ * version of it the importing package will actually get.
509
+ *
510
+ * The canonical name is what most apps import, not what every app HAS. The
511
+ * router shim says `react-router-dom`, and an app on react-router v7 has only
512
+ * `react-router` — measured on remix-run/remix-store: `react-router@7.18.2`
513
+ * installed, `react-router-dom` absent from both node_modules and package.json.
514
+ * There the generated file's own import is unresolvable, and that is not a
515
+ * skipped wrap: `Failed to resolve import "react-router-dom" from
516
+ * "flawless-router.tsx"` kills every spec that imports the shim, so components
517
+ * that never touched the router die too. Asking the importing package what it
518
+ * has is the difference between a suite that mounts and one that cannot load.
519
+ *
520
+ * Canonical first, so an app carrying both names keeps the behaviour it was
521
+ * measured with (saleor-dashboard: react-router-dom and react-router, both
522
+ * 5.3.4), and falling back to canonical when nothing resolves keeps the loud
523
+ * failure the shim was designed to be.
524
+ *
525
+ * Resolution walks up from the shim file's own directory because that is the
526
+ * walk the import performs at runtime: `<pkg>/.flawless/`, then
527
+ * `<pkg>/node_modules`, then out through a workspace.
528
+ *
529
+ * `min` is the same question asked one level finer. Two apps of the same family
530
+ * can carry different capabilities (react-router-dom 5.x has no
531
+ * `createMemoryRouter`; 6.4 introduced it), and an alias that is too old is
532
+ * skipped for the next candidate rather than emitted as a broken import.
533
+ */
534
+ function shimImportModule(
535
+ spec: LibraryShimSpec,
536
+ fromShimFile: string,
537
+ min: readonly [number, number] | undefined = spec.minVersion,
538
+ ): { module: string; version?: string } {
539
+ const fromDir = path.dirname(fromShimFile);
540
+ let firstResolved: { module: string; version?: string } | undefined;
541
+ for (const candidate of shimModules(spec)) {
542
+ let dir = path.resolve(fromDir);
543
+ for (;;) {
544
+ const packageJson = path.join(dir, "node_modules", candidate, "package.json");
545
+ if (existsSync(packageJson)) {
546
+ const found = { module: candidate, version: installedVersion(packageJson) };
547
+ firstResolved ??= found;
548
+ if (min === undefined || meetsMinimum(found.version, min)) return found;
549
+ break;
550
+ }
551
+ const parent = path.dirname(dir);
552
+ if (parent === dir) break;
553
+ dir = parent;
554
+ }
555
+ }
556
+ return firstResolved ?? { module: spec.module };
557
+ }
558
+
559
+ /** The version a package.json names, or undefined when it cannot be read. */
560
+ function installedVersion(packageJson: string): string | undefined {
561
+ try {
562
+ const raw = JSON.parse(readFileSync(packageJson, "utf8")) as { version?: unknown };
563
+ return typeof raw.version === "string" ? raw.version : undefined;
564
+ } catch {
565
+ return undefined;
566
+ }
567
+ }
568
+
569
+ /** `[major, minor]` of an installed version; a range or a non-version is none. */
570
+ function meetsMinimum(version: string | undefined, min: readonly [number, number]): boolean {
571
+ // Unreadable is accepted, not refused: see LibraryShimSpec.minVersion.
572
+ if (version === undefined) return true;
573
+ const got = /^\D*(\d+)(?:\.(\d+))?/.exec(version);
574
+ if (!got) return true;
575
+ const [major, minor] = [Number(got[1]), Number(got[2] ?? 0)];
576
+ return major > min[0] || (major === min[0] && minor >= min[1]);
577
+ }
578
+
579
+ /** Whether this shim may be emitted for a package root at all: false only on
580
+ * positive evidence that the library the shim imports is too old for it. */
581
+ function shimAvailableAt(spec: LibraryShimSpec, shimFilePath: string): boolean {
582
+ if (spec.minVersion === undefined) return true;
583
+ return meetsMinimum(shimImportModule(spec, shimFilePath).version, spec.minVersion);
584
+ }
585
+
586
+ /** Source for the generated module: the real library provider, mounted fresh
587
+ * per mount so state never leaks between specs. `shimFilePath` is where the
588
+ * generated file will sit, which is what decides the library's import name. */
589
+ export function libraryShimSource(
590
+ module: string,
591
+ componentName: string,
592
+ shimFilePath?: string,
593
+ ): string {
594
+ const spec = LIBRARY_SHIMS.find((s) => s.module === module && s.componentName === componentName);
595
+ if (!spec) return "";
596
+ const from = shimFilePath ? shimImportModule(spec, shimFilePath).module : spec.module;
597
+ const prelude = [spec.imports.replaceAll("{{module}}", from), spec.helpers]
598
+ .filter(Boolean)
599
+ .join("\n\n");
600
+ return `// Generated by flawless-react. Do not edit: regenerations overwrite it.
601
+ // The provider lives here, inside the importing package, because a bare
602
+ // import of ${from} only resolves through this package's node_modules.
603
+ ${prelude}
604
+
605
+ export function ${componentName}({ children }: { children?: ReactNode }) {
606
+ ${spec.body}
607
+ }
608
+ `;
609
+ }
610
+
611
+ interface NamespaceFacts {
612
+ /** Components that render N.<member> without already rendering N.<something>.Provider. */
613
+ consumers: Set<string>;
614
+ /** The file's own provider for that namespace, if it has one. */
615
+ provider?: ProviderRef;
616
+ }
617
+
618
+ /**
619
+ * Third-party compound families inside one file: every component that renders
620
+ * `Namespace.Member` JSX needs the file's provider component — the one that
621
+ * renders `Namespace.Provider` (base-ui shape: ToastProvider), else the one
622
+ * that renders the bare `Namespace` (cmdk shape: Command wrapping
623
+ * <CommandPrimitive>). Members are identified anywhere in the body because a
624
+ * part's root is often a styled wrapper, not the primitive itself.
625
+ */
626
+ /** A JSX tag as (base identifier, last member): Identifier → base only;
627
+ * `<A.B.C>` parses as a PropertyAccessExpression chain (TS 5.x) — QualifiedName
628
+ * handling stays for old ASTs. */
629
+ function tagNameParts(tag: ts.JsxTagNameExpression | ts.QualifiedName): {
630
+ base: string;
631
+ member?: string;
632
+ } {
633
+ if (ts.isIdentifier(tag)) return { base: tag.text };
634
+ if (ts.isQualifiedName(tag)) {
635
+ return ts.isIdentifier(tag.left)
636
+ ? { base: tag.left.text, member: tag.right.text }
637
+ : { base: "" };
638
+ }
639
+ if (ts.isPropertyAccessExpression(tag)) {
640
+ const parts: string[] = [];
641
+ let node: ts.Node = tag;
642
+ while (ts.isPropertyAccessExpression(node)) {
643
+ parts.unshift(node.name.text);
644
+ node = node.expression;
645
+ }
646
+ return ts.isIdentifier(node)
647
+ ? { base: node.text, member: parts[parts.length - 1] }
648
+ : { base: "" };
649
+ }
650
+ return { base: "" };
651
+ }
652
+
653
+ /**
654
+ * Whether the provider can be mounted with NO props and still supply what
655
+ * consumers read from the context. A typed-literal prop beyond `children`
656
+ * that is neither optional nor rescued by a destructuring default cannot
657
+ * mount bare: <ChartProvider value={...}> mounts with value undefined and
658
+ * every consumer's context check throws right back out — the wrap only
659
+ * relocates the error, so the wrap must not be added. Anything the shape
660
+ * analysis cannot judge (a `props` identifier, a type reference instead of
661
+ * a literal) is assumed mountable: the rule stays opportunistic.
662
+ */
663
+ function providerMountsBare(unit: ParsedComponentUnit): boolean {
664
+ const lit = unit.propsTypeLiteral;
665
+ if (!lit) return true;
666
+ // bodyNode is the function for `export function P(...)` and the body for
667
+ // other shapes — the parameter lives on either node, and parent links are
668
+ // not guaranteed, so check the node itself and its parent directly.
669
+ const fnNode = [unit.bodyNode, unit.bodyNode.parent].find(
670
+ (n): n is ts.FunctionLikeDeclaration =>
671
+ n !== undefined && ts.isFunctionLike(n) && n.parameters.length > 0,
672
+ );
673
+ const firstParam = fnNode?.parameters[0];
674
+ const pattern =
675
+ firstParam && ts.isObjectBindingPattern(firstParam.name) ? firstParam.name : undefined;
676
+ const defaulted = new Set<string>();
677
+ if (pattern) {
678
+ for (const el of pattern.elements) {
679
+ if (el.initializer && ts.isIdentifier(el.name)) defaulted.add(el.name.text);
680
+ }
681
+ }
682
+ return lit.members.every((m) => {
683
+ if (!ts.isPropertySignature(m) || !m.name || !ts.isIdentifier(m.name)) return true;
684
+ const name = m.name.text;
685
+ if (name === "children" || m.questionToken) return true;
686
+ return defaulted.has(name);
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Providers whose NAME breaks the `useX` → `XProvider` convention: the hook
692
+ * reads a context object, and the provider component renders that object's
693
+ * `.Provider` — chanhdai's `useCollapsible` is fed by `CollapsibleWithContext`
694
+ * (exported, no less, as `Collapsible`). The context object is the ground
695
+ * truth both the hook and the provider must mention, so read it: a hook
696
+ * supplies a context when it calls `useContext(Ctx)` itself or reaches one
697
+ * through hooks defined in the same file.
698
+ */
699
+ /** A context object as one file mentions it: imported (module, exported name)
700
+ * or locally defined (no module). Resolved to (file::name) keys by the caller,
701
+ * which owns the async ModuleResolver. */
702
+ interface RawCtxRef {
703
+ module?: string;
704
+ name: string;
705
+ }
706
+
707
+ const rawCtxRefFor = (ident: string, imports: Map<string, ImportBinding>): RawCtxRef => {
708
+ const b = imports.get(ident);
709
+ if (!b || b.isTypeOnly) return { name: ident };
710
+ return { module: b.specifier, name: b.importedName ?? ident };
711
+ };
712
+
713
+ interface CtxFacts {
714
+ /** Module-side hook name → provider component name in the same file. */
715
+ providerByHook: Map<string, string>;
716
+ /** Module-side hook name → every context object it reads (transitively). */
717
+ readsRaw: Map<string, Set<RawCtxRef>>;
718
+ /** Component name → the first context object it renders as `<Ctx.Provider>`. */
719
+ suppliesRaw: Map<string, RawCtxRef>;
720
+ /** Module-side hook name → shim-library modules whose provider-gated hooks
721
+ * it calls, possibly through local wrappers (the useSaveTelegramSettings
722
+ * shape: a project hook wrapping useMutation). */
723
+ libsByHook: Map<string, Set<string>>;
724
+ /** Hook name → every callee identifier in its body (direct, not closed
725
+ * over). Derive follows the ones that are cross-file import bindings so
726
+ * two-level wrappers — useMcpServerDialogState → useMcpServerDetailQuery
727
+ * → useQuery — still reach the library. */
728
+ callsByHook: Map<string, Set<string>>;
729
+ /** Component name → bare JSX tag names that component's body renders. The
730
+ * library shim table reads it for the components a file IMPORTS: rendering
731
+ * a provider-gated component requires the provider exactly like calling its
732
+ * hook does, and calls are all the scan above can see. */
733
+ tagsByFn: Map<string, Set<string>>;
734
+ /** Hook names this file declares, as opposed to handing along. */
735
+ declaredHooks: Set<string>;
736
+ /** `export { useX, useY as useZ } from './m'`: the exported name → the module
737
+ * it came from and the name that module uses. Re-export barrels are how a
738
+ * Feature-Sliced app hands a hook to the component that imports its index. */
739
+ reexportedHooks: Map<string, { specifier: string; imported: string }[]>;
740
+ /** The bare `export * from './m'` specifiers, which carry every name. */
741
+ starSpecifiers: string[];
742
+ }
743
+
744
+ function ctxFactsOf(parsed: ParsedFile): CtxFacts {
745
+ // Local hook definitions: name → body node.
746
+ const bodies = new Map<string, ts.Node>();
747
+ const collect = (n: ts.Node): void => {
748
+ if (ts.isFunctionDeclaration(n) && n.name && n.body) {
749
+ if (/^use[A-Z]/.test(n.name.text) && !CORE_HOOKS.has(n.name.text)) {
750
+ bodies.set(n.name.text, n.body);
751
+ }
752
+ } else if (ts.isVariableStatement(n)) {
753
+ for (const d of n.declarationList.declarations) {
754
+ if (
755
+ ts.isIdentifier(d.name) &&
756
+ /^use[A-Z]/.test(d.name.text) &&
757
+ !CORE_HOOKS.has(d.name.text) &&
758
+ d.initializer &&
759
+ ts.isFunctionLike(d.initializer) &&
760
+ d.initializer.body
761
+ ) {
762
+ bodies.set(d.name.text, d.initializer.body);
763
+ }
764
+ }
765
+ }
766
+ ts.forEachChild(n, collect);
767
+ };
768
+ for (const stmt of parsed.sourceFile.statements) collect(stmt);
769
+
770
+ // The file's own hooks, as opposed to the ones it hands along: the barrel
771
+ // walk hops only when a name is not declared here.
772
+ const declaredHooks = new Set(bodies.keys());
773
+ const { named: reexportedHooks, stars: starSpecifiers } = reexportTableOf(parsed.sourceFile);
774
+
775
+ const direct = new Map<string, { ctx: Set<string>; calls: Set<string>; libs: Set<string> }>();
776
+ for (const [hook, body] of bodies) {
777
+ const ctx = new Set<string>();
778
+ const calls = new Set<string>();
779
+ const libs = new Set<string>();
780
+ const scan = (n: ts.Node): void => {
781
+ if (ts.isCallExpression(n)) {
782
+ // `useContext(Ctx)` and `React.useContext(Ctx)` — the namespace form
783
+ // is how `import * as React` files write it.
784
+ const fn = ts.isIdentifier(n.expression)
785
+ ? n.expression.text
786
+ : ts.isPropertyAccessExpression(n.expression)
787
+ ? n.expression.name.text
788
+ : undefined;
789
+ const firstArg = n.arguments[0];
790
+ if (fn === undefined) {
791
+ // nothing to key on
792
+ } else if (fn === "useContext" && firstArg && ts.isIdentifier(firstArg)) {
793
+ ctx.add(firstArg.text);
794
+ } else {
795
+ calls.add(fn);
796
+ // A callee that is an IMPORT of a provider-gated library hook means
797
+ // this local hook needs the library's provider too — the
798
+ // useSaveTelegramSettings shape: a project hook wrapping useMutation.
799
+ const b = parsed.imports.get(fn);
800
+ if (b && !b.isTypeOnly) {
801
+ for (const s of LIBRARY_SHIMS) {
802
+ if (shimModules(s).includes(b.specifier) && s.hooks.includes(b.importedName ?? fn)) {
803
+ libs.add(s.module);
804
+ }
805
+ }
806
+ }
807
+ }
808
+ }
809
+ ts.forEachChild(n, scan);
810
+ };
811
+ scan(body);
812
+ direct.set(hook, { ctx, calls, libs });
813
+ }
814
+
815
+ // Transitively close over local hook calls: useCollapsibleAnimation reads
816
+ // the context through the useCollapsible it calls.
817
+ const read = new Map<string, Set<string>>();
818
+ for (const [hook, { ctx }] of direct) read.set(hook, new Set(ctx));
819
+ const readLibs = new Map<string, Set<string>>();
820
+ for (const [hook, { libs }] of direct) readLibs.set(hook, new Set(libs));
821
+ const readCalls = new Map<string, Set<string>>();
822
+ for (const [hook, { calls }] of direct) readCalls.set(hook, new Set(calls));
823
+ let grew = true;
824
+ while (grew) {
825
+ grew = false;
826
+ for (const [hook, { calls }] of direct) {
827
+ const mine = read.get(hook);
828
+ const mineLibs = readLibs.get(hook);
829
+ const mineCalls = readCalls.get(hook);
830
+ if (!mine || !mineLibs || !mineCalls) continue;
831
+ for (const called of calls) {
832
+ if (called === hook) continue;
833
+ const sub = read.get(called);
834
+ if (sub)
835
+ for (const ident of sub) {
836
+ if (!mine.has(ident)) {
837
+ mine.add(ident);
838
+ grew = true;
839
+ }
840
+ }
841
+ const subLibs = readLibs.get(called);
842
+ if (subLibs)
843
+ for (const m of subLibs) {
844
+ if (!mineLibs.has(m)) {
845
+ mineLibs.add(m);
846
+ grew = true;
847
+ }
848
+ }
849
+ // Callers reach libraries through local hooks too: merge the whole
850
+ // callee set so the cross-file walk below sees calls the chain makes.
851
+ const subCalls = readCalls.get(called);
852
+ if (subCalls)
853
+ for (const c2 of subCalls) {
854
+ if (!mineCalls.has(c2)) {
855
+ mineCalls.add(c2);
856
+ grew = true;
857
+ }
858
+ }
859
+ }
860
+ }
861
+ }
862
+
863
+ // Components rendering <Ctx.Provider> — the supplier per context object,
864
+ // both by identifier text (same-file pairing) and by raw (module, name)
865
+ // reference for the cross-file join.
866
+ const supplier = new Map<string, string>();
867
+ const suppliesRaw = new Map<string, RawCtxRef>();
868
+ for (const u of parsed.components) {
869
+ const visit = (n: ts.Node): void => {
870
+ if (
871
+ (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
872
+ ts.isPropertyAccessExpression(n.tagName) &&
873
+ n.tagName.name.text === "Provider" &&
874
+ ts.isIdentifier(n.tagName.expression)
875
+ ) {
876
+ const ident = n.tagName.expression.text;
877
+ supplier.set(ident, u.componentName);
878
+ if (!suppliesRaw.has(u.componentName)) {
879
+ suppliesRaw.set(u.componentName, rawCtxRefFor(ident, parsed.imports));
880
+ }
881
+ }
882
+ ts.forEachChild(n, visit);
883
+ };
884
+ visit(u.bodyNode);
885
+ }
886
+
887
+ const providerByHook = new Map<string, string>();
888
+ for (const [hook, ctxs] of read) {
889
+ for (const ident of ctxs) {
890
+ const p = supplier.get(ident);
891
+ if (p && p !== hook) {
892
+ providerByHook.set(hook, p);
893
+ break;
894
+ }
895
+ }
896
+ }
897
+ const readsRaw = new Map<string, Set<RawCtxRef>>();
898
+ for (const [hook, ctxs] of read) {
899
+ const refs = new Set<RawCtxRef>();
900
+ for (const ident of ctxs) {
901
+ const r = rawCtxRefFor(ident, parsed.imports);
902
+ if (![...refs].some((e) => e.module === r.module && e.name === r.name)) refs.add(r);
903
+ }
904
+ readsRaw.set(hook, refs);
905
+ }
906
+ const libsByHook = new Map<string, Set<string>>();
907
+ for (const [hook, libs] of readLibs) if (libs.size > 0) libsByHook.set(hook, libs);
908
+ // The JSX tags each of this file's components renders, keyed by component
909
+ // name — the same requirement, seen where the hook scan above is blind: a
910
+ // component that renders <FormattedMessage> calls no hook of the library at
911
+ // all. A bare tag is recorded because that is what an import binding names,
912
+ // and so is a component handed through a prop (`as={Link}`, `slots={{ root:
913
+ // Link }}`) — same mount, same provider, no tag. `Namespace.Member` stays
914
+ // namespaceFacts' business.
915
+ const tagsByFn = new Map<string, Set<string>>();
916
+ for (const u of parsed.components) {
917
+ if (!u.bodyNode) continue;
918
+ const tags = new Set<string>();
919
+ const passedByProp = (expr: ts.Expression): void => {
920
+ if (ts.isIdentifier(expr)) {
921
+ tags.add(expr.text);
922
+ return;
923
+ }
924
+ if (ts.isObjectLiteralExpression(expr)) {
925
+ for (const prop of expr.properties) {
926
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.initializer)) {
927
+ tags.add(prop.initializer.text);
928
+ }
929
+ }
930
+ }
931
+ };
932
+ const visitTags = (n: ts.Node): void => {
933
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
934
+ const { base, member } = tagNameParts(n.tagName);
935
+ if (base && !member) tags.add(base);
936
+ } else if (ts.isJsxAttribute(n) && ts.isIdentifier(n.name)) {
937
+ if (COMPONENT_PASSING_PROPS.has(n.name.text) && n.initializer) {
938
+ const expr = ts.isJsxExpression(n.initializer) ? n.initializer.expression : n.initializer;
939
+ if (expr) passedByProp(expr);
940
+ }
941
+ }
942
+ ts.forEachChild(n, visitTags);
943
+ };
944
+ visitTags(u.bodyNode);
945
+ tagsByFn.set(u.componentName, tags);
946
+ }
947
+ return {
948
+ providerByHook,
949
+ readsRaw,
950
+ suppliesRaw,
951
+ libsByHook,
952
+ callsByHook: readCalls,
953
+ tagsByFn,
954
+ declaredHooks,
955
+ reexportedHooks,
956
+ starSpecifiers,
957
+ };
958
+ }
959
+
960
+ export function namespaceFacts(parsed: ParsedFile): NamespaceFacts {
961
+ const external = new Set<string>();
962
+ for (const [local, b] of parsed.imports) {
963
+ if (b.isTypeOnly || isInternalSpecifier(b.specifier)) continue;
964
+ external.add(local);
965
+ }
966
+ const consumers = new Set<string>();
967
+ if (external.size === 0) return { consumers };
968
+ // Per unit, which external namespaces it touches as `<N>` (bare) or as
969
+ // `<N.Member>`. A namespace only becomes a COMPOUND family when the file
970
+ // shows both sides: members somewhere, and a provider — a `<N.Provider>`
971
+ // renderer (base-ui) or a bare `<N>` renderer (cmdk). Icon imports render
972
+ // bare with no members — they are leaves, not providers, and the pairing
973
+ // requirement is what keeps them out.
974
+ const usage: {
975
+ bare: Set<string>;
976
+ bareAll: Set<string>;
977
+ provider: Set<string>;
978
+ root: Set<string>;
979
+ member: Set<string>;
980
+ }[] = [];
981
+ for (const u of parsed.components) {
982
+ const rec = {
983
+ bare: new Set<string>(),
984
+ bareAll: new Set<string>(),
985
+ provider: new Set<string>(),
986
+ root: new Set<string>(),
987
+ member: new Set<string>(),
988
+ };
989
+ const visit = (n: ts.Node): void => {
990
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
991
+ const { base, member } = tagNameParts((n as ts.JsxOpeningLikeElement).tagName);
992
+ if (!member && ts.isIdentifier((n as ts.JsxOpeningLikeElement).tagName)) {
993
+ rec.bareAll.add(base);
994
+ }
995
+ if (external.has(base)) {
996
+ if (!member) rec.bare.add(base);
997
+ // `.Root` cuts both ways: base-ui's N.Root REQUIRES the context its
998
+ // N.Provider supplies (Toast.Root), while radix's namespace shape
999
+ // makes N.Root the provider itself (Dialog.Root). It is recorded as
1000
+ // both a candidate and a member; providerFor demotes it wherever an
1001
+ // explicit N.Provider renderer exists in the same file.
1002
+ else if (member === "Provider") rec.provider.add(base);
1003
+ else if (member === "Root") {
1004
+ rec.root.add(base);
1005
+ rec.member.add(base);
1006
+ } else rec.member.add(base);
1007
+ }
1008
+ }
1009
+ ts.forEachChild(n, visit);
1010
+ };
1011
+ visit(u.bodyNode);
1012
+ usage.push(rec);
1013
+ }
1014
+ const memberBases = new Set(usage.flatMap((r) => [...r.member]));
1015
+ // A base-ui file names its provider out loud (`<N.Provider>`); a radix
1016
+ // namespace file never does — there the `<N.Root>` renderer IS the
1017
+ // provider. Where an explicit provider exists, `.Root` demotes back to
1018
+ // a plain member (base-ui's Toast.Root needs the ToastProvider wrap).
1019
+ const explicitProvider = (base: string): boolean => usage.some((r) => r.provider.has(base));
1020
+ const rootIsProvider = (r: { root: Set<string> }, base: string): boolean =>
1021
+ r.root.has(base) && !explicitProvider(base);
1022
+ // `const ToastProvider = ToastPrimitives.Provider` — the shadcn shape, where
1023
+ // the provider is an ALIAS of the namespace's own `.Provider` rather than a
1024
+ // component declaration. No parsed unit renders it, so a file full of members
1025
+ // (`Toast` = `ToastPrimitives.Root`) looked unwrappable while exporting
1026
+ // exactly the provider that would have wrapped them: invoify's Toast, Toaster
1027
+ // and every variant spec died on "must be used within ToastProvider". Only an
1028
+ // EXPORTED alias is a wrap — an import is the only way the mount reaches it —
1029
+ // and it outranks the Root-as-provider reading, because a radix Root consumes
1030
+ // that same context: wrapping its members in the Root just reproduces the
1031
+ // throw one level up.
1032
+ const aliasByBase = new Map<string, string[]>();
1033
+ const exportedAs = new Map<string, string>();
1034
+ const markExported = (local: string, exported: string): void => {
1035
+ if (!exportedAs.has(local)) exportedAs.set(local, exported);
1036
+ };
1037
+ const isExportStatement = (n: ts.Node): boolean =>
1038
+ ts.canHaveModifiers(n) &&
1039
+ (ts.getModifiers(n) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
1040
+ for (const stmt of parsed.sourceFile.statements) {
1041
+ if (ts.isVariableStatement(stmt)) {
1042
+ for (const d of stmt.declarationList.declarations) {
1043
+ if (!ts.isIdentifier(d.name)) continue;
1044
+ if (isExportStatement(stmt)) markExported(d.name.text, d.name.text);
1045
+ const init = d.initializer;
1046
+ if (
1047
+ init &&
1048
+ ts.isPropertyAccessExpression(init) &&
1049
+ ts.isIdentifier(init.expression) &&
1050
+ init.name.text === "Provider" &&
1051
+ external.has(init.expression.text)
1052
+ ) {
1053
+ aliasByBase.set(init.expression.text, [
1054
+ ...(aliasByBase.get(init.expression.text) ?? []),
1055
+ d.name.text,
1056
+ ]);
1057
+ }
1058
+ }
1059
+ } else if (ts.isExportDeclaration(stmt)) {
1060
+ const clause = stmt.exportClause;
1061
+ if (clause && ts.isNamedExports(clause)) {
1062
+ for (const spec of clause.elements) {
1063
+ markExported((spec.propertyName ?? spec.name).text, spec.name.text);
1064
+ }
1065
+ }
1066
+ } else if (
1067
+ (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) &&
1068
+ stmt.name &&
1069
+ isExportStatement(stmt)
1070
+ ) {
1071
+ markExported(stmt.name.text, stmt.name.text);
1072
+ }
1073
+ }
1074
+ const aliasProviderFor = (base: string): ProviderRef | undefined => {
1075
+ for (const local of aliasByBase.get(base) ?? []) {
1076
+ const exported = exportedAs.get(local);
1077
+ if (exported !== undefined)
1078
+ return { componentName: exported, filePath: parsed.filePath, exportKind: "named" };
1079
+ }
1080
+ return undefined;
1081
+ };
1082
+ const providerFor = (base: string): number =>
1083
+ usage.findIndex((r, i) => {
1084
+ const unit = parsed.components[i];
1085
+ return (
1086
+ unit !== undefined &&
1087
+ (r.provider.has(base) || r.bare.has(base) || rootIsProvider(r, base)) &&
1088
+ unit.exportKind !== "none" &&
1089
+ !unit.isAsync &&
1090
+ providerMountsBare(unit)
1091
+ );
1092
+ });
1093
+ let provider: ProviderRef | undefined;
1094
+ for (const base of memberBases) {
1095
+ // An explicit <N.Provider> renderer is the file's own mount surface and
1096
+ // wins; with none, an exported alias of <N.Provider> beats the Root
1097
+ // reading; with neither, the Root (or bare) renderer provides.
1098
+ const aliased = explicitProvider(base) ? undefined : aliasProviderFor(base);
1099
+ const pi = aliased ? -1 : providerFor(base);
1100
+ const unit = pi === -1 ? undefined : parsed.components[pi];
1101
+ // `providerFor` already excludes a unit the file does not export; restating
1102
+ // it here is what narrows `exportKind` to the two kinds a mount can import.
1103
+ const fromUnit: ProviderRef | undefined =
1104
+ unit && unit.exportKind !== "none"
1105
+ ? {
1106
+ componentName: unit.componentName,
1107
+ filePath: parsed.filePath,
1108
+ exportKind: unit.exportKind,
1109
+ exportedAs: unit.exportedAs,
1110
+ }
1111
+ : undefined;
1112
+ const ref = aliased ?? fromUnit;
1113
+ if (!ref) continue;
1114
+ // A unit that mounts the provider itself — as the <N.Provider> tag OR as
1115
+ // the file's wrapper around it (Toaster renders <ToastProvider>, not
1116
+ // <ToastPrimitive.Provider>) — supplies its own context and a nested
1117
+ // second provider can actively break it.
1118
+ const providerTag = ref.componentName;
1119
+ usage.forEach((r, ui) => {
1120
+ const consumer = parsed.components[ui];
1121
+ if (
1122
+ consumer &&
1123
+ r.member.has(base) &&
1124
+ (aliased || (!r.provider.has(base) && !rootIsProvider(r, base))) &&
1125
+ ui !== pi &&
1126
+ !r.bareAll.has(providerTag)
1127
+ ) {
1128
+ consumers.add(consumer.componentName);
1129
+ }
1130
+ });
1131
+ provider ??= ref;
1132
+ }
1133
+ return { consumers, provider };
1134
+ }
1135
+
1136
+ /**
1137
+ * Order a component's wrap list OUTERmost-first so the nest is correct. The
1138
+ * emitter opens refs in list order and closes them in reverse, so refs[0] is
1139
+ * the outermost. A provider's own body runs between its open and close, so
1140
+ * every provider ITS requirements name must sit OUTSIDE it: if X requires Y,
1141
+ * Y precedes X. The closure that feeds the list already makes it closed under
1142
+ * requirements, so a required Y is always present to order against. Ties keep
1143
+ * the historical alphabetical order, so a list whose providers owe nothing to
1144
+ * one another renders byte-identical to before; a requirement cycle (no valid
1145
+ * nest — the app is broken at that mount) falls back to that same order and
1146
+ * fails red, which is the honest answer.
1147
+ */
1148
+ function orderOutermostFirst(
1149
+ refs: ProviderRef[],
1150
+ reqs: Map<string, Map<string, ProviderRef>>,
1151
+ ): ProviderRef[] {
1152
+ const byName = [...refs].sort((a, b) =>
1153
+ a.componentName < b.componentName ? -1 : a.componentName > b.componentName ? 1 : 0,
1154
+ );
1155
+ if (refs.length < 2) return byName;
1156
+ const pos = new Map(refs.map((r, i) => [provKey(r), i]));
1157
+ const inList = (k: string) => pos.has(k);
1158
+ // indeg[X] = how many in-list providers X requires (each must come before X).
1159
+ const indeg = new Map<string, number>();
1160
+ const dependents = new Map<string, string[]>(); // Y -> [X that require Y]
1161
+ for (const r of refs) indeg.set(provKey(r), 0);
1162
+ for (const r of refs) {
1163
+ const k = provKey(r);
1164
+ for (const y of reqs.get(k)?.keys() ?? []) {
1165
+ if (!inList(y)) continue;
1166
+ indeg.set(k, (indeg.get(k) ?? 0) + 1);
1167
+ let arr = dependents.get(y);
1168
+ if (!arr) {
1169
+ arr = [];
1170
+ dependents.set(y, arr);
1171
+ }
1172
+ arr.push(k);
1173
+ }
1174
+ }
1175
+ const out: ProviderRef[] = [];
1176
+ const nextReady = (): ProviderRef | undefined => {
1177
+ const avail = refs
1178
+ .filter((r) => (indeg.get(provKey(r)) ?? 0) === 0 && !out.includes(r))
1179
+ .sort((a, b) =>
1180
+ a.componentName < b.componentName ? -1 : a.componentName > b.componentName ? 1 : 0,
1181
+ );
1182
+ return avail[0];
1183
+ };
1184
+ while (out.length < refs.length) {
1185
+ const next = nextReady();
1186
+ if (next === undefined) break; // a requirement cycle: no valid nest
1187
+ out.push(next);
1188
+ const k = provKey(next);
1189
+ for (const d of dependents.get(k) ?? []) {
1190
+ indeg.set(d, (indeg.get(d) ?? 1) - 1);
1191
+ }
1192
+ }
1193
+ if (out.length < refs.length) return byName; // cycle: fall back to alphabetical
1194
+ return out;
1195
+ }
1196
+
1197
+ /** `${filePath}::${componentName}` → providers to mount around that component. */
1198
+ export async function deriveContextProviderWraps(
1199
+ trees: ComponentTree[],
1200
+ cache: SourceFileCache,
1201
+ shims?: (c: { info: { filePath: string } }) => LibraryProviderShim[],
1202
+ ): Promise<Map<string, ProviderRef[]>> {
1203
+ const flat = flattenTrees(trees);
1204
+ // parseFile is per-file work the sweep's cache already pays for; failures to
1205
+ // read a resolved file skip silently — derivation is opportunistic. Hook
1206
+ // modules often carry no component at all (nakama's use-auth.ts is exactly
1207
+ // one), so the no-component throw must not erase their import tables.
1208
+ const parse = async (file: string) => {
1209
+ try {
1210
+ return await parseFile(file, cache, { tolerateNoComponents: true });
1211
+ } catch {
1212
+ return undefined;
1213
+ }
1214
+ };
1215
+ const parsedCache = new Map<string, ParsedFile | undefined>();
1216
+ const parseOnce = async (file: string) => {
1217
+ let p = parsedCache.get(file);
1218
+ if (p === undefined && !parsedCache.has(file)) {
1219
+ p = await parse(file);
1220
+ parsedCache.set(file, p);
1221
+ }
1222
+ return p;
1223
+ };
1224
+ // Synthetic render edges (the dynamic-render rule below): component key →
1225
+ // the components a project-hook value hands it. Merged into `edges` before
1226
+ // the closure so the worklist closes them like any render edge.
1227
+ const synthEdges = new Map<string, Set<string>>();
1228
+ const nsCache = new Map<string, NamespaceFacts>();
1229
+ const nsOnce = async (file: string) => {
1230
+ let f = nsCache.get(file);
1231
+ if (!f) {
1232
+ const parsed = await parseOnce(file);
1233
+ f = parsed ? namespaceFacts(parsed) : { consumers: new Set<string>() };
1234
+ nsCache.set(file, f);
1235
+ }
1236
+ return f;
1237
+ };
1238
+ const ctxCache = new Map<string, CtxFacts>();
1239
+ const ctxOnce = (parsed: ParsedFile): CtxFacts => {
1240
+ let f = ctxCache.get(parsed.filePath);
1241
+ if (!f) {
1242
+ f = ctxFactsOf(parsed);
1243
+ ctxCache.set(parsed.filePath, f);
1244
+ }
1245
+ return f;
1246
+ };
1247
+
1248
+ // The cross-file context join. Hook and provider need not share a file:
1249
+ // nakama's `useAuth` reads `AuthContext` imported from `auth-context-shared`,
1250
+ // and `AuthProvider` renders `<AuthContext.Provider>` from `auth-context.tsx`
1251
+ // — the context object is the join key, so resolve each mention to a
1252
+ // (module, exported-name) pair and index every component that renders one.
1253
+ const resolver = new ModuleResolver();
1254
+
1255
+ // Dynamic render from a project hook. devtools' ContentPanel does
1256
+ // `const { Component } = useTabs()` and renders `<Component />`: the tag is
1257
+ // an identifier the body binds from a project hook's return, so walkChildren
1258
+ // has no import to resolve, no render edge exists, and the closure has
1259
+ // nothing to close — the registry values include PageTab, whose own spec
1260
+ // gets its data router while the spec that mounts it through the dynamic
1261
+ // element dies on useMatches. The rule: every component the hook's values
1262
+ // can hand out is owed by the component exactly like a statically rendered
1263
+ // child.
1264
+ //
1265
+ // Bounded on purpose: the hook's file, then one import hop inside it, depth
1266
+ // two inside a definition (an array of objects whose value is an arrow
1267
+ // returning JSX — the registry shape), sixteen components. Past the budget
1268
+ // the walk drops out and the shape stays a hole, the way the barrel hands
1269
+ // issue #117 documents.
1270
+ const dynCache = new Map<string, Promise<{ file: string; componentName: string }[]>>();
1271
+ /** Every descendant of `node`, depth first: the public compiler API walks
1272
+ * one level only, and the rule below needs the whole hook and component
1273
+ * bodies. */
1274
+ const eachDescendant = (node: ts.Node, visit: (n: ts.Node) => void): void => {
1275
+ ts.forEachChild(node, (child) => {
1276
+ visit(child);
1277
+ eachDescendant(child, visit);
1278
+ });
1279
+ };
1280
+ /** The hook's body node in `sf`: a function declaration or a const arrow,
1281
+ * the two shapes ctxFactsOf's local-hook collection matches. */
1282
+ const hookBodyOf = (sf: ts.SourceFile, name: string): ts.Node | undefined => {
1283
+ for (const stmt of sf.statements) {
1284
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name && stmt.body) return stmt.body;
1285
+ if (!ts.isVariableStatement(stmt)) continue;
1286
+ for (const d of stmt.declarationList.declarations) {
1287
+ if (!ts.isIdentifier(d.name) || d.name.text !== name) continue;
1288
+ let init = d.initializer;
1289
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression;
1290
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
1291
+ return init.body ?? init;
1292
+ }
1293
+ }
1294
+ return undefined;
1295
+ };
1296
+ /** The (file, name) a component mention in `p` names: a local declaration
1297
+ * first, then one value-import hop (the registry shape: the tab file
1298
+ * renders <PageTab /> imported from its own module). A mention that is
1299
+ * neither resolves to nothing. */
1300
+ const componentRefOf = async (
1301
+ p: ParsedFile,
1302
+ name: string,
1303
+ ): Promise<{ file: string; componentName: string } | undefined> => {
1304
+ if (p.components.some((u) => u.componentName === name))
1305
+ return { file: p.filePath, componentName: name };
1306
+ const b = p.imports.get(name);
1307
+ if (!b || b.isTypeOnly) return undefined;
1308
+ const target = resolver.resolve(b.specifier, p.filePath);
1309
+ if (!target) return undefined;
1310
+ const tp = await parseOnce(target);
1311
+ const exported = b.importedName ?? name;
1312
+ if (tp?.components.some((u) => u.componentName === exported))
1313
+ return { file: target, componentName: exported };
1314
+ return undefined;
1315
+ };
1316
+ /** Follow one definition initializer for component mentions, on a depth
1317
+ * budget: array and object members step in, arrow/function bodies do not,
1318
+ * and a JSX tag or plain identifier is collected wherever it appears
1319
+ * within budget. `add` resolves the mention to a (file, name). */
1320
+ const collectAsync = async (
1321
+ node: ts.Node | undefined,
1322
+ file: ParsedFile,
1323
+ depth: number,
1324
+ add: (name: string, inFile: ParsedFile) => Promise<void>,
1325
+ ): Promise<void> => {
1326
+ if (!node || depth < 0) return;
1327
+ // `as const` / `satisfies` / parens name the same value one layer down —
1328
+ // devtools' registry is `[...] as const` — so unwrap without paying depth.
1329
+ if (
1330
+ ts.isAsExpression(node) ||
1331
+ ts.isSatisfiesExpression(node) ||
1332
+ ts.isParenthesizedExpression(node)
1333
+ )
1334
+ return collectAsync(node.expression, file, depth, add);
1335
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
1336
+ const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName;
1337
+ if (ts.isIdentifier(tag) && /^[A-Z]/.test(tag.text)) await add(tag.text, file);
1338
+ return;
1339
+ }
1340
+ if (ts.isArrayLiteralExpression(node)) {
1341
+ for (const el of node.elements) await collectAsync(el, file, depth - 1, add);
1342
+ return;
1343
+ }
1344
+ if (ts.isObjectLiteralExpression(node)) {
1345
+ for (const prop of node.properties) {
1346
+ // A shorthand property's initializer IS its name: { component: X }
1347
+ // and { component: X } both name the same mention.
1348
+ const value = ts.isPropertyAssignment(prop)
1349
+ ? prop.initializer
1350
+ : ts.isShorthandPropertyAssignment(prop)
1351
+ ? prop.name
1352
+ : undefined;
1353
+ if (value) await collectAsync(value, file, depth - 1, add);
1354
+ }
1355
+ return;
1356
+ }
1357
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
1358
+ await collectAsync(node.body, file, depth, add);
1359
+ return;
1360
+ }
1361
+ if (ts.isBlock(node)) {
1362
+ for (const stmt of node.statements) {
1363
+ if (ts.isReturnStatement(stmt) && stmt.expression)
1364
+ await collectAsync(stmt.expression, file, depth, add);
1365
+ }
1366
+ return;
1367
+ }
1368
+ if (ts.isIdentifier(node) && /^[A-Z]/.test(node.text)) await add(node.text, file);
1369
+ };
1370
+ /** The components a hook's values can hand out, keyed by (file, hook
1371
+ * name): the hook body's own JSX, plus the definitions its value
1372
+ * identifiers import — the registry the hook selects from. Cached: a hook
1373
+ * shared by several components parses once, and the slice bounds the
1374
+ * fan-out a pathological registry could otherwise give the edges. */
1375
+ const dynamicRenderablesOf = async (
1376
+ file: string,
1377
+ hookName: string,
1378
+ ): Promise<{ file: string; componentName: string }[]> => {
1379
+ const key = `${file}::${hookName}`;
1380
+ let p = dynCache.get(key);
1381
+ if (!p) {
1382
+ p = (async () => {
1383
+ const parsed = await parseOnce(file);
1384
+ if (!parsed) return [];
1385
+ const body = hookBodyOf(parsed.sourceFile, hookName);
1386
+ if (!body) return [];
1387
+ const found = new Map<string, { file: string; componentName: string }>();
1388
+ const add = async (name: string, inFile: ParsedFile): Promise<void> => {
1389
+ const r = await componentRefOf(inFile, name);
1390
+ if (r) found.set(`${r.file}::${r.componentName}`, r);
1391
+ };
1392
+ eachDescendant(body, (n) => {
1393
+ const tag =
1394
+ ts.isJsxElement(n) && ts.isIdentifier(n.openingElement.tagName)
1395
+ ? n.openingElement.tagName
1396
+ : ts.isJsxSelfClosingElement(n) && ts.isIdentifier(n.tagName)
1397
+ ? n.tagName
1398
+ : undefined;
1399
+ if (tag && /^[A-Z]/.test(tag.text)) void add(tag.text, parsed);
1400
+ });
1401
+ // The value identifiers the body holds: the registry the hook selects
1402
+ // from, one file hop into each imported definition. A hop is paid per
1403
+ // TARGET file — the import table maps local names, and two names from
1404
+ // one module are the same hop.
1405
+ const hopped = new Set<string>();
1406
+ const hopTargets = new Map<string, string>();
1407
+ eachDescendant(body, (n) => {
1408
+ if (!ts.isIdentifier(n)) return;
1409
+ const b = parsed.imports.get(n.text);
1410
+ if (!b || b.isTypeOnly) return;
1411
+ const target = resolver.resolve(b.specifier, parsed.filePath);
1412
+ if (!target || hopped.has(target)) return;
1413
+ hopped.add(target);
1414
+ hopTargets.set(target, b.importedName ?? n.text);
1415
+ });
1416
+ for (const [target, name] of hopTargets) {
1417
+ const tp = await parseOnce(target);
1418
+ if (!tp) continue;
1419
+ // The top-level definition of the imported name; a barrel that only
1420
+ // re-exports it declares nothing and ends the walk.
1421
+ let def: ts.Node | undefined;
1422
+ for (const stmt of tp.sourceFile.statements) {
1423
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name && stmt.body)
1424
+ def = stmt.body;
1425
+ else if (ts.isVariableStatement(stmt)) {
1426
+ for (const d of stmt.declarationList.declarations) {
1427
+ if (ts.isIdentifier(d.name) && d.name.text === name && d.initializer)
1428
+ def = d.initializer;
1429
+ }
1430
+ }
1431
+ if (def) break;
1432
+ }
1433
+ if (def) await collectAsync(def, tp, 2, add);
1434
+ }
1435
+ return [...found.values()].slice(0, 16);
1436
+ })();
1437
+ dynCache.set(key, p);
1438
+ }
1439
+ return p;
1440
+ };
1441
+
1442
+ const ctxKeyOf = (file: string, ref: RawCtxRef): string | undefined => {
1443
+ if (!ref.module) return `${file}::${ref.name}`;
1444
+ const resolved = resolver.resolve(ref.module, file);
1445
+ return resolved ? `${resolved}::${ref.name}` : undefined;
1446
+ };
1447
+ const supplierByKey = new Map<string, { file: string; componentName: string }[]>();
1448
+ for (const file of new Set(flat.map((c) => c.info.filePath))) {
1449
+ const parsed = await parseOnce(file);
1450
+ if (!parsed) continue;
1451
+ for (const [componentName, ref] of ctxOnce(parsed).suppliesRaw) {
1452
+ const key = ctxKeyOf(file, ref);
1453
+ if (!key) continue;
1454
+ const arr = supplierByKey.get(key);
1455
+ if (arr) arr.push({ file, componentName });
1456
+ else supplierByKey.set(key, [{ file, componentName }]);
1457
+ }
1458
+ }
1459
+
1460
+ // The convention index, for hooks that arrive from a BARE module. When a
1461
+ // component's hook comes from node_modules (never parsed) the context join
1462
+ // above has no key: the context object the hook reads lives in the library,
1463
+ // so no project file mentions it. What project files DO mention is the
1464
+ // app's own wrapper — saleor's `useTheme` comes from `@saleor/macaw-ui-next`,
1465
+ // and the app's exported `ThemeProvider` (src/theme/provider.tsx) renders
1466
+ // macaw's `ThemeProvider`, imported from that very module. Index every
1467
+ // exported component by (name, module of a binding its body renders): the
1468
+ // convention name plus the same-module binding is the pairing the context
1469
+ // object would have supplied. All three conjuncts — exported, named per the
1470
+ // hook's convention, rendering a binding from the hook's own module — or
1471
+ // nothing; a `ThemeProvider` that renders some other kit's provider must
1472
+ // not catch a hook from a different one.
1473
+ const conventionSuppliers = new Map<string, { file: string; componentName: string }[]>();
1474
+ for (const file of new Set(flat.map((c) => c.info.filePath))) {
1475
+ const parsed = await parseOnce(file);
1476
+ if (!parsed) continue;
1477
+ const facts = ctxOnce(parsed);
1478
+ for (const u of parsed.components) {
1479
+ if (u.exportKind === "none") continue;
1480
+ for (const tag of facts.tagsByFn.get(u.componentName) ?? []) {
1481
+ const b = parsed.imports.get(tag);
1482
+ if (!b || b.isTypeOnly || b.specifier.startsWith(".") || b.specifier.startsWith("/"))
1483
+ continue;
1484
+ const key = `${u.componentName}::${b.specifier}`;
1485
+ const arr = conventionSuppliers.get(key);
1486
+ if (arr) arr.push({ file, componentName: u.componentName });
1487
+ else conventionSuppliers.set(key, [{ file, componentName: u.componentName }]);
1488
+ }
1489
+ }
1490
+ }
1491
+
1492
+ // compKey → provKey → ref. A Map, not a Set: the closure below merges along
1493
+ // component edges and the dedupe is by provider identity.
1494
+ const reqs = new Map<string, Map<string, ProviderRef>>();
1495
+ const addReq = (compKey: string, ref: ProviderRef) => {
1496
+ if (provKey(ref) === compKey) return; // never wraps itself
1497
+ let m = reqs.get(compKey);
1498
+ if (!m) {
1499
+ m = new Map();
1500
+ reqs.set(compKey, m);
1501
+ }
1502
+ m.set(provKey(ref), ref);
1503
+ };
1504
+
1505
+ for (const c of flat) {
1506
+ const compKey = providerKey(c.info);
1507
+ for (const hook of new Set((c.info.hookDetails ?? []).map((h) => h.name))) {
1508
+ if (!/^use[A-Z]/.test(hook) || CORE_HOOKS.has(hook)) continue;
1509
+ // Where does the hook come from? Imported → its module; otherwise the
1510
+ // component's own file (the co-located shadcn shape). An alias
1511
+ // (`import { useSidebar as useSb }`) binds a local name the call site
1512
+ // uses, but the provider is named after the MODULE-side hook.
1513
+ let sourceFile = c.info.filePath;
1514
+ let moduleSide = hook;
1515
+ let hookSpec: string | undefined;
1516
+ for (const mi of c.info.moduleImports ?? []) {
1517
+ if (mi.isTypeOnly) continue;
1518
+ const i = mi.names.indexOf(hook);
1519
+ if (i === -1) continue;
1520
+ hookSpec = mi.specifier;
1521
+ if (mi.resolvedPath) {
1522
+ sourceFile = mi.resolvedPath;
1523
+ moduleSide = mi.importedNames?.[i] ?? hook;
1524
+ }
1525
+ // A bare library import binds the name too — but there is no file to
1526
+ // parse, so the derivation stays on the component's own file and the
1527
+ // shim table below decides.
1528
+ break;
1529
+ }
1530
+ if (!moduleSide.startsWith("use")) continue; // aliased to a non-hook name
1531
+ const candidate = `${moduleSide.slice(3)}Provider`;
1532
+ const parsed = await parseOnce(sourceFile);
1533
+ let unit = parsed?.components.find((u) => u.componentName === candidate);
1534
+ let providerFile = sourceFile;
1535
+ if (parsed && (!unit || unit.exportKind === "none")) {
1536
+ // The convention missed — find the component that supplies the
1537
+ // context this hook actually reads, however it is named.
1538
+ const facts = ctxOnce(parsed);
1539
+ const supplied = facts.providerByHook.get(moduleSide);
1540
+ if (supplied) unit = parsed.components.find((u) => u.componentName === supplied);
1541
+ // Still nothing, and the pairing lives across files: join the context
1542
+ // objects this hook reads against the components that render them.
1543
+ if (!unit) {
1544
+ for (const ref of facts.readsRaw.get(moduleSide) ?? []) {
1545
+ const key = ctxKeyOf(sourceFile, ref);
1546
+ if (!key) continue;
1547
+ for (const cand of supplierByKey.get(key) ?? []) {
1548
+ const candParsed = await parseOnce(cand.file);
1549
+ const u = candParsed?.components.find((x) => x.componentName === cand.componentName);
1550
+ if (u && u.exportKind !== "none" && !u.isAsync && providerMountsBare(u)) {
1551
+ unit = u;
1552
+ providerFile = cand.file;
1553
+ break;
1554
+ }
1555
+ }
1556
+ if (unit) break;
1557
+ }
1558
+ }
1559
+ // The bare-module convention. The hook's own module is in node_modules
1560
+ // (never parsed), so neither lookup above can see the pairing: the
1561
+ // context object lives in the library. The app's wrapper, however, is
1562
+ // project code — an exported component named per the hook's convention
1563
+ // that renders a binding imported from the very module the hook came
1564
+ // from (saleor's `useTheme` ← `@saleor/macaw-ui-next`, supplied by
1565
+ // the app's own `ThemeProvider` around macaw's).
1566
+ if (
1567
+ !unit &&
1568
+ hookSpec &&
1569
+ sourceFile === c.info.filePath &&
1570
+ !hookSpec.startsWith(".") &&
1571
+ !hookSpec.startsWith("/")
1572
+ ) {
1573
+ for (const sup of conventionSuppliers.get(`${candidate}::${hookSpec}`) ?? []) {
1574
+ const supParsed = await parseOnce(sup.file);
1575
+ const u = supParsed?.components.find((x) => x.componentName === sup.componentName);
1576
+ if (u && u.exportKind !== "none" && !u.isAsync && providerMountsBare(u)) {
1577
+ unit = u;
1578
+ providerFile = sup.file;
1579
+ break;
1580
+ }
1581
+ }
1582
+ }
1583
+ }
1584
+ if (unit && unit.exportKind !== "none" && !unit.isAsync && providerMountsBare(unit)) {
1585
+ addReq(compKey, {
1586
+ componentName: unit.componentName,
1587
+ filePath: providerFile,
1588
+ exportKind: unit.exportKind,
1589
+ exportedAs: unit.exportedAs,
1590
+ });
1591
+ } else if (!unit) {
1592
+ // Nothing in the project pairs with this hook — a library context
1593
+ // does. Either the hook is imported from the library itself, or the
1594
+ // module-side hook is a project WRAPPER whose body calls one —
1595
+ // possibly through more wrappers across files: McpServerDialog →
1596
+ // useMcpServerDialogState → useMcpServerDetailQuery → useQuery, and
1597
+ // the component never names the library at all.
1598
+ const libs = new Set<string>(hookSpec ? [hookSpec] : []);
1599
+ // Which LIBRARY hooks the wrapper chain actually calls. Two shims can
1600
+ // share a module — react-router's MemoryRouter wrap and its data wrap
1601
+ // are the same specifier — so "the chain reached a shimmed module"
1602
+ // cannot pick between them: the table's order did, and the first entry
1603
+ // won. VersionDropdown asked for MemoryRouter while dying of
1604
+ // useRouteLoaderData.
1605
+ const libHooks = new Set<string>();
1606
+ const seenHooks = new Set<string>();
1607
+ // Convention providers the chain reached through a BARE module: a
1608
+ // wrapper that calls a library hook has no file to walk, but the app
1609
+ // may still ship the convention provider for it (SaleorLogo ←
1610
+ // `@dashboard/theme` ← useMacawTheme ← `@saleor/macaw-ui-next`,
1611
+ // supplied by the app's own ThemeProvider).
1612
+ const convRefs: { file: string; componentName: string }[] = [];
1613
+ const convSeen = new Set<string>();
1614
+ const walkLibs = async (file: string, name: string): Promise<void> => {
1615
+ const key = `${file}::${name}`;
1616
+ if (seenHooks.has(key)) return;
1617
+ seenHooks.add(key);
1618
+ const p = await parseOnce(file);
1619
+ if (!p) return;
1620
+ const f = ctxOnce(p);
1621
+ for (const m of f.libsByHook.get(name) ?? []) libs.add(m);
1622
+ for (const called of f.callsByHook.get(name) ?? []) {
1623
+ if (called === name) continue;
1624
+ const b = p.imports.get(called);
1625
+ if (!b || b.isTypeOnly) continue;
1626
+ const side = b.importedName ?? called;
1627
+ for (const s of LIBRARY_SHIMS) {
1628
+ if (shimModules(s).includes(b.specifier) && s.hooks.includes(side)) {
1629
+ libHooks.add(side);
1630
+ }
1631
+ }
1632
+ const target = resolver.resolve(b.specifier, file);
1633
+ if (target) {
1634
+ await walkLibs(target, side);
1635
+ } else if (
1636
+ /^use[A-Z]/.test(side) &&
1637
+ !b.specifier.startsWith(".") &&
1638
+ !b.specifier.startsWith("/")
1639
+ ) {
1640
+ // The call dies at a bare LIBRARY module — node_modules is never
1641
+ // walked — but the app's own wrapper for it, if it exists, is
1642
+ // project code indexed by (convention name, that very module).
1643
+ // The key uses the MODULE-side name: `import { useTheme as
1644
+ // useMacawTheme }` asks for ThemeProvider, not MacawThemeProvider.
1645
+ for (const sup of conventionSuppliers.get(
1646
+ `${side.slice(3)}Provider::${b.specifier}`,
1647
+ ) ?? []) {
1648
+ const ck = `${sup.file}::${sup.componentName}`;
1649
+ if (!convSeen.has(ck)) {
1650
+ convSeen.add(ck);
1651
+ convRefs.push(sup);
1652
+ }
1653
+ }
1654
+ }
1655
+ }
1656
+ // The barrel hop. A re-export table declares nothing, so a chain that
1657
+ // reaches one ends there — and in a Feature-Sliced app almost every
1658
+ // import reaches one. ton-console's CurrencyRate imports useRateQuery
1659
+ // from `src/entities/rates`, which is `export * from './model'`,
1660
+ // whose index is `export { useRateQuery } from './queries'`, and
1661
+ // queries.ts is where useQuery is called. The chain stopped at the
1662
+ // first barrel and the component got no provider: 87
1663
+ // `No QueryClient set` failures in that one app. The named form is
1664
+ // exact so it goes first; `export *` only says "ask the target", so
1665
+ // it is tried when no named hop carries the name. Precision survives
1666
+ // the fan-out either way, because libs are only ever recorded from a
1667
+ // file that DECLARES the hook.
1668
+ //
1669
+ // One shape the hop cannot carry: a barrel handing the LIBRARY itself
1670
+ // along — `export { useQuery } from '@tanstack/react-query'`, or the
1671
+ // star form. The specifier is bare and the resolver refuses it on
1672
+ // purpose (node_modules is never walked), so the chain ends with the
1673
+ // library unrecorded and the component unwrapped. Measured against
1674
+ // every corpus app (2026-10-22): the only such barrels are zudoku's,
1675
+ // in a package its sweep does not cover — documented as the family's
1676
+ // last hole in issue #117, not fixed without a witness to exercise
1677
+ // it.
1678
+ if (f.declaredHooks.has(name)) return;
1679
+ const hops = f.reexportedHooks.get(name) ?? [];
1680
+ for (const hop of hops) {
1681
+ const target = resolver.resolve(hop.specifier, file);
1682
+ if (target) await walkLibs(target, hop.imported);
1683
+ }
1684
+ if (hops.length > 0) return;
1685
+ for (const spec of f.starSpecifiers) {
1686
+ const target = resolver.resolve(spec, file);
1687
+ if (target) await walkLibs(target, name);
1688
+ }
1689
+ };
1690
+ if (parsed) await walkLibs(sourceFile, moduleSide);
1691
+ const candidates = (shims?.(c) ?? []).filter(
1692
+ (s) =>
1693
+ shimModules(s).some((m) => libs.has(m)) &&
1694
+ (shimModules(s).includes(hookSpec ?? "")
1695
+ ? s.hooks.includes(moduleSide)
1696
+ : libHooks.size === 0 || s.hooks.some((h) => libHooks.has(h))),
1697
+ );
1698
+ // Of the shims that fit, the one that makes another redundant wins: a
1699
+ // wrapper calling useNavigate AND useLoaderData needs the data router,
1700
+ // which serves navigation too, not a wrap the mount will shadow.
1701
+ const shim =
1702
+ candidates.find(
1703
+ (s) =>
1704
+ !candidates.some(
1705
+ (other) =>
1706
+ other !== s && SUPERSEDED_BY.get(s.componentName) === other.componentName,
1707
+ ),
1708
+ ) ?? candidates[0];
1709
+ if (shim) {
1710
+ addReq(compKey, {
1711
+ componentName: shim.componentName,
1712
+ filePath: shim.filePath,
1713
+ exportKind: "named",
1714
+ });
1715
+ }
1716
+ for (const sup of convRefs) {
1717
+ const supParsed = await parseOnce(sup.file);
1718
+ const u = supParsed?.components.find((x) => x.componentName === sup.componentName);
1719
+ if (u && u.exportKind !== "none" && !u.isAsync && providerMountsBare(u)) {
1720
+ addReq(compKey, {
1721
+ componentName: u.componentName,
1722
+ filePath: sup.file,
1723
+ exportKind: u.exportKind,
1724
+ exportedAs: u.exportedAs,
1725
+ });
1726
+ }
1727
+ }
1728
+ }
1729
+ }
1730
+ // The library COMPONENT ladder: rendering a provider-gated component asks
1731
+ // for that library's provider exactly like calling its hook does. This was
1732
+ // the gap saleor-dashboard fell into — components that render
1733
+ // <FormattedMessage> and never name `useIntl`, so the hook walk above found
1734
+ // nothing to key on and the mount died on the missing provider.
1735
+ const own = await parseOnce(c.info.filePath);
1736
+ const ownTags = own ? ctxOnce(own).tagsByFn.get(c.info.componentName) : undefined;
1737
+ if (own && ownTags) {
1738
+ for (const s of shims?.(c) ?? []) {
1739
+ if (!s.components || s.components.length === 0) continue;
1740
+ if (reqs.get(compKey)?.has(`${s.filePath}::${s.componentName}`)) continue;
1741
+ for (const tag of ownTags) {
1742
+ const b = own.imports.get(tag);
1743
+ if (!b || b.isTypeOnly || !shimModules(s).includes(b.specifier)) continue;
1744
+ if (!s.components.includes(b.importedName ?? tag)) continue;
1745
+ addReq(compKey, {
1746
+ componentName: s.componentName,
1747
+ filePath: s.filePath,
1748
+ exportKind: "named",
1749
+ });
1750
+ break;
1751
+ }
1752
+ }
1753
+ }
1754
+ // Dynamic render from a project hook: <Component /> whose tag the body
1755
+ // binds by destructuring a project hook's return. devtools' ContentPanel
1756
+ // gets useTabs' chosen tab this way, and the registry's values are then
1757
+ // owed exactly like statically rendered children — with the edge, PageTab's
1758
+ // data router reaches the spec that mounts it through the dynamic element.
1759
+ // Only shorthand/plain bindings count: an alias or a default is a shape
1760
+ // the rule declines rather than guesses at, and a prop of the same shape
1761
+ // stays the spec's business.
1762
+ if (own) {
1763
+ const unit = own.components.find((u) => u.componentName === c.info.componentName);
1764
+ const body = unit?.bodyNode;
1765
+ if (body) {
1766
+ const boundToHook = new Map<string, string>(); // bound name -> hook local name
1767
+ eachDescendant(body, (m) => {
1768
+ if (!ts.isVariableDeclaration(m) || !m.initializer) return;
1769
+ const init = ts.isAwaitExpression(m.initializer)
1770
+ ? m.initializer.expression
1771
+ : m.initializer;
1772
+ if (
1773
+ !ts.isCallExpression(init) ||
1774
+ !ts.isIdentifier(init.expression) ||
1775
+ !/^use[A-Z]/.test(init.expression.text) ||
1776
+ CORE_HOOKS.has(init.expression.text)
1777
+ )
1778
+ return;
1779
+ const hookLocal = init.expression.text;
1780
+ if (ts.isObjectBindingPattern(m.name)) {
1781
+ for (const el of m.name.elements) {
1782
+ if (
1783
+ ts.isIdentifier(el.name) &&
1784
+ !el.propertyName &&
1785
+ !el.initializer &&
1786
+ /^[A-Z]/.test(el.name.text)
1787
+ )
1788
+ boundToHook.set(el.name.text, hookLocal);
1789
+ }
1790
+ } else if (ts.isIdentifier(m.name) && /^[A-Z]/.test(m.name.text)) {
1791
+ boundToHook.set(m.name.text, hookLocal);
1792
+ }
1793
+ });
1794
+ if (boundToHook.size > 0) {
1795
+ const rendered = new Set<string>();
1796
+ eachDescendant(body, (n) => {
1797
+ const tag = ts.isJsxOpeningElement(n)
1798
+ ? n.tagName
1799
+ : ts.isJsxSelfClosingElement(n)
1800
+ ? n.tagName
1801
+ : undefined;
1802
+ if (tag && ts.isIdentifier(tag) && /^[A-Z]/.test(tag.text)) rendered.add(tag.text);
1803
+ });
1804
+ for (const tag of rendered) {
1805
+ const hookLocal = boundToHook.get(tag);
1806
+ if (!hookLocal) continue;
1807
+ let sourceFile: string | undefined;
1808
+ const b = own.imports.get(hookLocal);
1809
+ if (b && !b.isTypeOnly) {
1810
+ sourceFile = resolver.resolve(b.specifier, own.filePath);
1811
+ if (!sourceFile) continue; // bare module — no project body to walk
1812
+ } else sourceFile = own.filePath; // co-located with the component
1813
+ for (const r of await dynamicRenderablesOf(sourceFile, hookLocal)) {
1814
+ let s = synthEdges.get(compKey);
1815
+ if (!s) {
1816
+ s = new Set();
1817
+ synthEdges.set(compKey, s);
1818
+ }
1819
+ s.add(`${r.file}::${r.componentName}`);
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+ }
1825
+ const ns = await nsOnce(c.info.filePath);
1826
+ if (ns.provider && ns.consumers.has(c.info.componentName)) {
1827
+ addReq(compKey, ns.provider);
1828
+ }
1829
+ }
1830
+
1831
+ // Transitive requirement: a mount fails at the ancestor whose untouched leaf
1832
+ // threw, so what a mount must satisfy reaches past its own body. Two ways in:
1833
+ // the analysis trees record what RENDERS what (a leaf's providers owed by its
1834
+ // ancestor), and a provider that is itself wrapped OWES its own requirements
1835
+ // to whoever mounts it. Both are monotonic merges by provider identity, so
1836
+ // one fixpoint over both closes the graph — cycles converge because
1837
+ // self-references drop.
1838
+ const edges = new Map<string, Set<string>>();
1839
+ const walk = (t: ComponentTree): void => {
1840
+ const pk = providerKey(t.entry);
1841
+ for (const c of t.children) {
1842
+ const ck = providerKey(c.entry);
1843
+ if (ck !== pk) {
1844
+ let s = edges.get(pk);
1845
+ if (!s) {
1846
+ s = new Set();
1847
+ edges.set(pk, s);
1848
+ }
1849
+ s.add(ck);
1850
+ }
1851
+ walk(c);
1852
+ }
1853
+ };
1854
+ for (const t of trees) walk(t);
1855
+ // The dynamic-render rule above recorded render edges the analysis trees
1856
+ // cannot see — a dynamic tag has no import to resolve and no static tag to
1857
+ // walk. Merged in before the worklist, the closure closes them like any
1858
+ // render edge: ordering, supersedence and cycle handling stay untouched.
1859
+ for (const [pk, kids] of synthEdges) {
1860
+ let s = edges.get(pk);
1861
+ if (!s) {
1862
+ s = new Set();
1863
+ edges.set(pk, s);
1864
+ }
1865
+ for (const ck of kids) s.add(ck);
1866
+ }
1867
+ // The closure runs as a WORKLIST, not a level pass: a wrapped provider may
1868
+ // owe several hops further (it renders a child, that child wraps another
1869
+ // provider, …), and a level pass moves only one hop — on ton-console's page
1870
+ // trees that is thousands of passes before the graph settles. Instead: the
1871
+ // queue seeds with every component that already owes something and its
1872
+ // render parents, and every time a component's requirements GROW, the
1873
+ // components that owe it — the ones that render it, and the ones that now
1874
+ // wrap it (a wrapped provider's new requirement is owed by its wrapper too)
1875
+ // — take a turn. Each (component, provider) pair is added once, so the work
1876
+ // is linear in the number of pairs, and a cycle is paid for once as well.
1877
+ const owedTo = new Map<string, Set<string>>(); // key -> components that render it
1878
+ for (const [pk, kids] of edges) {
1879
+ for (const ck of kids) {
1880
+ let s = owedTo.get(ck);
1881
+ if (!s) {
1882
+ s = new Set();
1883
+ owedTo.set(ck, s);
1884
+ }
1885
+ s.add(pk);
1886
+ }
1887
+ }
1888
+ const queue: string[] = [];
1889
+ const queued = new Set<string>();
1890
+ const enqueue = (k: string) => {
1891
+ if (!queued.has(k)) {
1892
+ queued.add(k);
1893
+ queue.push(k);
1894
+ }
1895
+ };
1896
+ // Seed: every component that OWES something. A component with requirements
1897
+ // owes them to whoever renders it (the render edges recorded above), and it
1898
+ // owes its wrapped providers' requirements to itself. A component with no
1899
+ // requirements of its own is seeded only when a dependency grows.
1900
+ for (const pk of reqs.keys()) {
1901
+ enqueue(pk);
1902
+ for (const owner of owedTo.get(pk) ?? []) enqueue(owner);
1903
+ }
1904
+ let qi = 0;
1905
+ while (qi < queue.length) {
1906
+ const pk = queue[qi];
1907
+ qi += 1;
1908
+ if (pk === undefined) continue;
1909
+ queued.delete(pk);
1910
+ // A render parent can be queued before it owes anything of its own, so
1911
+ // the map may not exist yet — create it the way addReq does, and let the
1912
+ // empty case fall out in `out` below.
1913
+ let mine = reqs.get(pk);
1914
+ if (!mine) {
1915
+ mine = new Map();
1916
+ reqs.set(pk, mine);
1917
+ }
1918
+ // What pk owes: the components it RENDERS (the tree edges above) and the
1919
+ // providers it WRAPS — its own requirements. The second is the rule a
1920
+ // wrapper owes its payload: ProjectProvider mounts INSIDE the component,
1921
+ // and its body calls useUserQuery -> useQuery, so the component's spec
1922
+ // needs the QueryClientProvider that ProjectProvider's own spec has. A
1923
+ // shim ref names a generated file no component analysis carries, so its
1924
+ // "own requirements" look up empty and it is a no-op.
1925
+ const deps = new Set<string>(edges.get(pk) ?? []);
1926
+ for (const key of mine.keys()) deps.add(key);
1927
+ deps.delete(pk);
1928
+ const added: string[] = [];
1929
+ for (const dk of deps) {
1930
+ const dep = reqs.get(dk);
1931
+ if (!dep) continue;
1932
+ for (const [key, ref] of dep) {
1933
+ // A dependency that wraps pk itself is a cycle: addReq would no-op
1934
+ // the self-pair, but recording it as a new wrap edge re-enqueues pk
1935
+ // forever. Self-references drop, as they always have.
1936
+ if (key === pk || mine.has(key)) continue;
1937
+ addReq(pk, ref);
1938
+ added.push(key);
1939
+ // pk now WRAPS key: record it, or pk's dependents never learn that
1940
+ // pk owes key's requirements.
1941
+ let owed = owedTo.get(key);
1942
+ if (!owed) {
1943
+ owed = new Set();
1944
+ owedTo.set(key, owed);
1945
+ }
1946
+ owed.add(pk);
1947
+ }
1948
+ }
1949
+ if (added.length === 0) continue;
1950
+ // pk must pull each newly wrapped provider's OWN requirements, and every
1951
+ // component that renders pk (or now wraps it) must pull pk's new ones.
1952
+ enqueue(pk);
1953
+ for (const owner of owedTo.get(pk) ?? []) enqueue(owner);
1954
+ }
1955
+
1956
+ const out = new Map<string, ProviderRef[]>();
1957
+ for (const [compKey, m] of reqs) {
1958
+ // The closure completed the SET; the nest needs an ORDER. A provider's
1959
+ // body runs between its own open and close, so everything its requirements
1960
+ // name must sit OUTSIDE it: if X requires Y, Y precedes X. Untied, the
1961
+ // historical alphabetical order stands, so a spec whose providers owe
1962
+ // nothing to each other renders exactly as it did.
1963
+ const refs = orderOutermostFirst([...m.values()], reqs);
1964
+ // One provider can make another redundant (LibraryShimSpec.supersedes): the
1965
+ // data router already serves every hook and tag MemoryRouter does, so a
1966
+ // component needing both gets one wrap, not a shadowed one inside the other.
1967
+ const present = new Set(refs.map((r) => r.componentName));
1968
+ const kept = refs.filter((r) => {
1969
+ const by = SUPERSEDED_BY.get(r.componentName);
1970
+ return by === undefined || !present.has(by);
1971
+ });
1972
+ if (kept.length > 0) {
1973
+ out.set(compKey, kept);
1974
+ }
1975
+ }
1976
+ return out;
1977
+ }