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