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