@lariocpt/flawless-react 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,919 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import type { ModuleImportInfo, SourcePosition } from "../types.ts";
4
+ import { ownReturns } from "./ownReturns.ts";
5
+ import { posOf } from "./pos.ts";
6
+ import type { SourceFileCache } from "./sourceCache.ts";
7
+
8
+ export type ImportBindingKind = "default" | "named" | "namespace";
9
+
10
+ export interface ImportBinding {
11
+ /** Module specifier as written: "./Card.tsx", "@/components/ui", "react". */
12
+ specifier: string;
13
+ kind: ImportBindingKind;
14
+ /** For `import { X as Y }`: the exported name "X" (local name is the map key). */
15
+ importedName?: string;
16
+ isTypeOnly: boolean;
17
+ pos: SourcePosition;
18
+ }
19
+
20
+ export interface ParsedComponentUnit {
21
+ componentName: string;
22
+ /** The arrow/function node whose body holds the JSX + hooks. */
23
+ bodyNode: ts.Node;
24
+ /**
25
+ * Raw first-param type (or FC type argument) exactly as written — an
26
+ * intersection, a qualified reference, anything. `propsTypeRef` /
27
+ * `propsTypeLiteral` keep their narrow meanings for existing callers; this
28
+ * is what the type resolver dispatches on.
29
+ */
30
+ propsTypeNode: ts.TypeNode | undefined;
31
+ /** Props type reference (`React.FC<Props>` arg or first-param type). */
32
+ propsTypeRef: ts.TypeReferenceNode | undefined;
33
+ /** Inline first-param object type: `({a}: {a: string})`. */
34
+ propsTypeLiteral: ts.TypeLiteralNode | undefined;
35
+ /** First param when it is a plain identifier ("props"). */
36
+ paramName: string | undefined;
37
+ /** Local names bound by a destructured first param. */
38
+ destructuredProps: string[];
39
+ /** local → original prop name for `{a: b}` destructuring aliases. */
40
+ destructuredAliases: Map<string, string>;
41
+ exportKind: "default" | "named" | "none";
42
+ /**
43
+ * The name this unit leaves the module under, when `export { Local as Public }`
44
+ * renames it. Importers must use this, not `componentName` — the internal name
45
+ * is not a module binding, so importing it yields `undefined` at runtime.
46
+ */
47
+ exportedAs?: string;
48
+ isAsync: boolean;
49
+ wrappers: Array<"memo" | "forwardRef">;
50
+ /**
51
+ * The class is a React ERROR BOUNDARY, proven by its own members.
52
+ *
53
+ * It is the one undecidable-render shape whose branches are BOTH reachable by
54
+ * construction: `getDerivedStateFromError` is React's contract for "put me in
55
+ * the error state when a child throws", so a spec can reach the fallback by
56
+ * rendering a child that throws, and the happy path by not doing that.
57
+ *
58
+ * Decided from the member names React itself dispatches on, never from the
59
+ * class being called something with "Boundary" in it.
60
+ */
61
+ isErrorBoundary?: boolean;
62
+ /**
63
+ * The class declaration, for a class component only.
64
+ *
65
+ * bodyNode is render(), which is what every JSX/hook walk wants — but it hides
66
+ * the rest of the class from anything that needs it. detectSelfLogging reads
67
+ * bodyNode, so componentDidCatch's console.error was invisible and logsErrors
68
+ * stayed false: the spec then asserted `expect(errors).toEqual([])` against a
69
+ * component whose entire contract is to log what it caught.
70
+ */
71
+ classNode?: ts.ClassDeclaration;
72
+ pos: SourcePosition;
73
+ }
74
+
75
+ export interface ParsedFile {
76
+ sourceFile: ts.SourceFile;
77
+ filePath: string;
78
+ isClientComponent: boolean;
79
+ /** Local-name → import binding, for JSX child + props-type resolution. */
80
+ imports: Map<string, ImportBinding>;
81
+ /** All detected components, primary first. */
82
+ components: ParsedComponentUnit[];
83
+ /** Top-level imports classified for scaffolding (vi.mock candidates etc.). */
84
+ moduleImports: ModuleImportInfo[];
85
+ }
86
+
87
+ /**
88
+ * With a `cache`, the parsed SourceFile comes from it (same parse settings) —
89
+ * a leaf shared by many entries in a sweep is then parsed once, not once per
90
+ * entry. Component detection still runs per call: its results are mutated by
91
+ * export fixups and would go stale if shared.
92
+ *
93
+ * `tolerateNoComponents` returns a file that parses but holds no component
94
+ * instead of throwing — the provider derivation joins hook modules, which are
95
+ * exactly such files. Reporting callers keep the default throw.
96
+ */
97
+ export async function parseFile(
98
+ entry: string,
99
+ cache?: SourceFileCache,
100
+ opts?: { tolerateNoComponents?: boolean },
101
+ ): Promise<ParsedFile> {
102
+ const sourceFile = cache
103
+ ? cache.get(entry)
104
+ : ts.createSourceFile(
105
+ entry,
106
+ await Bun.file(entry).text(),
107
+ ts.ScriptTarget.ES2022,
108
+ true,
109
+ ts.ScriptKind.TSX,
110
+ );
111
+ if (!sourceFile) {
112
+ throw new Error(`parseFile: could not read ${entry}.`);
113
+ }
114
+
115
+ // A file TypeScript could not parse cleanly is a file whose AST is a GUESS.
116
+ //
117
+ // ts.createSourceFile is error-tolerant by design: `return <div>` with no
118
+ // closing tag still yields a tree, and every extractor downstream reads it as
119
+ // if it were the source. Before this check, that file was analysed, described,
120
+ // and reported as `ok: true, 0 findings` — a clean run over something that
121
+ // does not compile. That is the silent-pass class this project scores itself
122
+ // on removing, and it was hiding in plain sight: `parseDiagnostics` is asserted
123
+ // in FIVE test files, every one of them on the tool's own OUTPUT, and nowhere
124
+ // on its input.
125
+ //
126
+ // Throwing routes it into the caller's existing unparseable channel (core.ts),
127
+ // which warns and records the file in REPORT.md's accounting — so it is
128
+ // reported rather than silently dropped, exactly as an unreadable file is.
129
+ //
130
+ // Measured before shipping: 111 fixture files and 241 real component files
131
+ // across both dogfood apps produce ZERO diagnostics, so this fires only on
132
+ // genuinely broken input.
133
+ const diagnostics = (sourceFile as unknown as { parseDiagnostics?: readonly ts.Diagnostic[] })
134
+ .parseDiagnostics;
135
+ if (diagnostics && diagnostics.length > 0) {
136
+ const first = diagnostics[0] as ts.Diagnostic;
137
+ const where =
138
+ first.start === undefined
139
+ ? ""
140
+ : ` at line ${sourceFile.getLineAndCharacterOfPosition(first.start).line + 1}`;
141
+ throw new Error(
142
+ `parseFile: ${entry} has ${diagnostics.length} syntax error(s) and cannot be analysed ` +
143
+ `honestly — the first is "${ts.flattenDiagnosticMessageText(first.messageText, " ")}"${where}. ` +
144
+ `Fix the file, or exclude it with --exclude.`,
145
+ );
146
+ }
147
+
148
+ const imports = collectImports(sourceFile);
149
+ const components = findComponents(sourceFile, imports);
150
+ if (components.length === 0 && !opts?.tolerateNoComponents) {
151
+ throw new Error(
152
+ `parseFile: could not locate a React component in ${entry}. ` +
153
+ `Expected an exported function/arrow component, a \`const Name: React.FC<...> = ...\` declaration, or a class extending React's Component/PureComponent whose render() returns JSX.`,
154
+ );
155
+ }
156
+ orderPrimaryFirst(components, entry);
157
+
158
+ return {
159
+ sourceFile,
160
+ filePath: entry,
161
+ isClientComponent: hasUseClientDirective(sourceFile),
162
+ imports,
163
+ components,
164
+ moduleImports: collectModuleImports(sourceFile, imports),
165
+ };
166
+ }
167
+
168
+ function hasUseClientDirective(sf: ts.SourceFile): boolean {
169
+ for (const stmt of sf.statements) {
170
+ if (!ts.isExpressionStatement(stmt) || !ts.isStringLiteral(stmt.expression)) break;
171
+ if (stmt.expression.text === "use client") return true;
172
+ }
173
+ return false;
174
+ }
175
+
176
+ export function collectImports(sf: ts.SourceFile): Map<string, ImportBinding> {
177
+ const out = new Map<string, ImportBinding>();
178
+ for (const stmt of sf.statements) {
179
+ if (!ts.isImportDeclaration(stmt)) continue;
180
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
181
+ const specifier = stmt.moduleSpecifier.text;
182
+ const clause = stmt.importClause;
183
+ if (!clause) continue;
184
+ const declTypeOnly = clause.isTypeOnly;
185
+ const pos = posOf(stmt, sf);
186
+ if (clause.name) {
187
+ out.set(clause.name.text, { specifier, kind: "default", isTypeOnly: declTypeOnly, pos });
188
+ }
189
+ if (clause.namedBindings) {
190
+ if (ts.isNamedImports(clause.namedBindings)) {
191
+ for (const el of clause.namedBindings.elements) {
192
+ out.set(el.name.text, {
193
+ specifier,
194
+ kind: "named",
195
+ importedName: el.propertyName?.text ?? el.name.text,
196
+ isTypeOnly: declTypeOnly || el.isTypeOnly,
197
+ pos,
198
+ });
199
+ }
200
+ } else if (ts.isNamespaceImport(clause.namedBindings)) {
201
+ out.set(clause.namedBindings.name.text, {
202
+ specifier,
203
+ kind: "namespace",
204
+ isTypeOnly: declTypeOnly,
205
+ pos,
206
+ });
207
+ }
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ function collectModuleImports(
214
+ sf: ts.SourceFile,
215
+ imports: Map<string, ImportBinding>,
216
+ ): ModuleImportInfo[] {
217
+ // Which local names appear as JSX tag roots anywhere in the file?
218
+ const jsxRoots = new Set<string>();
219
+ const visit = (n: ts.Node): void => {
220
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
221
+ const tag = n.tagName;
222
+ if (ts.isIdentifier(tag)) jsxRoots.add(tag.text);
223
+ else if (ts.isPropertyAccessExpression(tag)) {
224
+ let root: ts.Expression = tag.expression;
225
+ while (ts.isPropertyAccessExpression(root)) root = root.expression;
226
+ if (ts.isIdentifier(root)) jsxRoots.add(root.text);
227
+ }
228
+ }
229
+ ts.forEachChild(n, visit);
230
+ };
231
+ ts.forEachChild(sf, visit);
232
+
233
+ const out: ModuleImportInfo[] = [];
234
+ for (const stmt of sf.statements) {
235
+ if (!ts.isImportDeclaration(stmt)) continue;
236
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
237
+ const specifier = stmt.moduleSpecifier.text;
238
+ const clause = stmt.importClause;
239
+ if (!clause) {
240
+ // Side-effect import ("./globals.css").
241
+ out.push({
242
+ specifier,
243
+ names: [],
244
+ kind: "value",
245
+ isTypeOnly: false,
246
+ hasDefault: false,
247
+ pos: posOf(stmt, sf),
248
+ });
249
+ continue;
250
+ }
251
+ const names: string[] = [];
252
+ // Aligned with `names`; consumers key decisions (like the pure-utility mock
253
+ // exemption) on the module-side name, which aliasing hides from `names`.
254
+ const importedNames: string[] = [];
255
+ // LOCAL names carrying their own `type` modifier. The declaration's
256
+ // `isTypeOnly` says nothing about a MIXED one, and these names do not exist
257
+ // at runtime — see ModuleImportInfo.typeOnlyNames.
258
+ const typeOnlyNames: string[] = [];
259
+ let hasDefault = false;
260
+ if (clause.name) {
261
+ names.push(clause.name.text);
262
+ importedNames.push(clause.name.text);
263
+ hasDefault = true;
264
+ }
265
+ if (clause.namedBindings) {
266
+ if (ts.isNamedImports(clause.namedBindings)) {
267
+ for (const el of clause.namedBindings.elements) {
268
+ names.push(el.name.text);
269
+ importedNames.push((el.propertyName ?? el.name).text);
270
+ if (el.isTypeOnly) typeOnlyNames.push(el.name.text);
271
+ }
272
+ } else if (ts.isNamespaceImport(clause.namedBindings)) {
273
+ names.push(clause.namedBindings.name.text);
274
+ importedNames.push(clause.namedBindings.name.text);
275
+ }
276
+ }
277
+ const isTypeOnly =
278
+ clause.isTypeOnly ||
279
+ (names.length > 0 && names.every((n) => imports.get(n)?.isTypeOnly === true));
280
+ out.push({
281
+ specifier,
282
+ names,
283
+ importedNames,
284
+ ...(typeOnlyNames.length > 0 ? { typeOnlyNames } : {}),
285
+ kind: names.some((n) => jsxRoots.has(n)) ? "component" : "value",
286
+ isTypeOnly,
287
+ hasDefault,
288
+ pos: posOf(stmt, sf),
289
+ });
290
+ }
291
+ return out;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Component detection
296
+ // ---------------------------------------------------------------------------
297
+
298
+ function findComponents(
299
+ sf: ts.SourceFile,
300
+ imports: Map<string, ImportBinding>,
301
+ ): ParsedComponentUnit[] {
302
+ const units: ParsedComponentUnit[] = [];
303
+ const byName = new Map<string, ParsedComponentUnit>();
304
+ const renderHooks = renderHookNames(sf);
305
+
306
+ const add = (unit: ParsedComponentUnit): void => {
307
+ units.push(unit);
308
+ byName.set(unit.componentName, unit);
309
+ };
310
+
311
+ // Pass 1 — candidates from function declarations and variable statements.
312
+ for (const stmt of sf.statements) {
313
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && /^[A-Z]/.test(stmt.name.text)) {
314
+ // No name passed: the SCREAMING_CASE guard exists for value constants
315
+ // (`const NOOP = () => null`), and a function DECLARATION is not a value
316
+ // constant — `function SEO() { …; return null; }` is the canonical
317
+ // effect-only head-manager component and must stay detected.
318
+ if (!stmt.body || !looksLikeComponentBody(stmt, renderHooks)) continue;
319
+ const mods = stmt.modifiers ?? [];
320
+ const hasExport = mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
321
+ const hasDefault = mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
322
+ add({
323
+ componentName: stmt.name.text,
324
+ bodyNode: stmt,
325
+ ...propsFromParams(stmt),
326
+ exportKind: hasDefault ? "default" : hasExport ? "named" : "none",
327
+ isAsync: mods.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword),
328
+ wrappers: [],
329
+ pos: posOf(stmt, sf),
330
+ });
331
+ continue;
332
+ }
333
+
334
+ if (ts.isClassDeclaration(stmt) && stmt.name) {
335
+ const unit = classComponentUnit(stmt, sf, imports);
336
+ if (unit) {
337
+ add(unit);
338
+ continue;
339
+ }
340
+ }
341
+
342
+ if (ts.isVariableStatement(stmt)) {
343
+ const hasExport = (stmt.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
344
+ for (const decl of stmt.declarationList.declarations) {
345
+ if (!ts.isIdentifier(decl.name) || !/^[A-Z]/.test(decl.name.text)) continue;
346
+ if (!decl.initializer) continue;
347
+ const unit = unitFromInitializer(
348
+ decl.name.text,
349
+ decl,
350
+ decl.initializer,
351
+ sf,
352
+ byName,
353
+ renderHooks,
354
+ );
355
+ if (!unit) continue;
356
+ unit.exportKind = hasExport ? "named" : "none";
357
+ add(unit);
358
+ }
359
+ }
360
+ }
361
+
362
+ // Pass 2 — export fixups.
363
+ for (const stmt of sf.statements) {
364
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
365
+ const expr = stmt.expression;
366
+ if (ts.isIdentifier(expr)) {
367
+ const unit = byName.get(expr.text);
368
+ if (unit) unit.exportKind = "default";
369
+ continue;
370
+ }
371
+ const unwrapped = unwrapWrappers(expr);
372
+ if (unwrapped.inner && ts.isIdentifier(unwrapped.inner)) {
373
+ const unit = byName.get(unwrapped.inner.text);
374
+ if (unit) {
375
+ unit.exportKind = "default";
376
+ unit.wrappers = [...unwrapped.wrappers, ...unit.wrappers];
377
+ }
378
+ continue;
379
+ }
380
+ const fn = unwrapped.inner ?? expr;
381
+ if (
382
+ (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
383
+ looksLikeComponentBody(fn, renderHooks)
384
+ ) {
385
+ const name = componentNameFromFilename(sf.fileName);
386
+ add({
387
+ componentName: name,
388
+ bodyNode: fn,
389
+ ...propsFromParams(fn),
390
+ exportKind: "default",
391
+ isAsync: (fn.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword),
392
+ wrappers: unwrapped.wrappers,
393
+ pos: posOf(stmt, sf),
394
+ });
395
+ }
396
+ continue;
397
+ }
398
+
399
+ if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
400
+ for (const el of stmt.exportClause.elements) {
401
+ const localName = el.propertyName?.text ?? el.name.text;
402
+ const unit = byName.get(localName);
403
+ if (!unit) continue;
404
+ if (el.name.text === "default") unit.exportKind = "default";
405
+ else {
406
+ if (unit.exportKind === "none") unit.exportKind = "named";
407
+ // `export { Internal as Public }` — Public is what an importer must
408
+ // write; Internal is module-private past this point.
409
+ if (el.propertyName && el.name.text !== localName) unit.exportedAs = el.name.text;
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ return units;
416
+ }
417
+
418
+ function unitFromInitializer(
419
+ name: string,
420
+ decl: ts.VariableDeclaration,
421
+ init: ts.Expression,
422
+ sf: ts.SourceFile,
423
+ byName: Map<string, ParsedComponentUnit>,
424
+ renderHooks: RenderHookImports,
425
+ ): ParsedComponentUnit | undefined {
426
+ // `(arrow) satisfies FC<Props>` / `as FC<Props>` states the props type just
427
+ // like a declared `const X: FC<Props>` does — the cast wrappers cannot change
428
+ // what the initializer evaluates to, so they are peeled before dispatch and
429
+ // their FC type argument feeds the same props-type path.
430
+ let castFcRef: ts.TypeReferenceNode | undefined;
431
+ for (let e: ts.Expression = init; ; ) {
432
+ if (ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
433
+ castFcRef ??= fcTypeRef(e.type);
434
+ e = e.expression;
435
+ } else if (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) {
436
+ e = e.expression;
437
+ } else {
438
+ break;
439
+ }
440
+ }
441
+ const peeled = unwrapTrivialExpression(init);
442
+
443
+ // const X: React.FC<Props> = arrow | fnexpr — the declared type wins over a cast.
444
+ const fcRef = fcTypeRef(decl.type) ?? castFcRef;
445
+ const looksLikeFC = fcRef !== undefined;
446
+
447
+ if (ts.isArrowFunction(peeled) || ts.isFunctionExpression(peeled)) {
448
+ if (!looksLikeFC && !looksLikeComponentBody(peeled, renderHooks, name)) return undefined;
449
+ const fcPropsArg = fcRef?.typeArguments?.[0];
450
+ const fromParams = propsFromParams(peeled);
451
+ const propsTypeRef =
452
+ fcPropsArg && ts.isTypeReferenceNode(fcPropsArg) ? fcPropsArg : fromParams.propsTypeRef;
453
+ const propsTypeLiteral =
454
+ fcPropsArg && ts.isTypeLiteralNode(fcPropsArg) ? fcPropsArg : fromParams.propsTypeLiteral;
455
+ // `React.FC<Props>` states the props type more precisely than the
456
+ // destructured parameter, so it wins when both are present.
457
+ const propsTypeNode = fcPropsArg ?? fromParams.propsTypeNode;
458
+ return {
459
+ componentName: name,
460
+ bodyNode: peeled,
461
+ propsTypeNode,
462
+ propsTypeRef,
463
+ propsTypeLiteral,
464
+ paramName: fromParams.paramName,
465
+ destructuredProps: fromParams.destructuredProps,
466
+ destructuredAliases: fromParams.destructuredAliases,
467
+ exportKind: "none",
468
+ isAsync: (peeled.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword),
469
+ wrappers: [],
470
+ pos: posOf(decl, sf),
471
+ };
472
+ }
473
+
474
+ // const X = memo(...) / forwardRef(...) — possibly nested.
475
+ const unwrapped = unwrapWrappers(peeled);
476
+ if (unwrapped.wrappers.length === 0 || !unwrapped.inner) return undefined;
477
+ const inner = unwrapped.inner;
478
+ if (ts.isIdentifier(inner)) {
479
+ const target = byName.get(inner.text);
480
+ if (target) {
481
+ target.wrappers = [...unwrapped.wrappers, ...target.wrappers];
482
+ }
483
+ return undefined;
484
+ }
485
+ if (
486
+ (ts.isArrowFunction(inner) || ts.isFunctionExpression(inner)) &&
487
+ (looksLikeFC || looksLikeComponentBody(inner, renderHooks, name))
488
+ ) {
489
+ // The props type, most-specific first: a declared `const X: FC<Props>`
490
+ // (or cast), then the wrapper's own generics (memo<P> / forwardRef<E, P>),
491
+ // then whatever the inner function's first parameter says. The wrapper
492
+ // sources were silently dropped before (FR-011) — an empty shape with
493
+ // empty unresolved records, invisible in every report.
494
+ const fromParams = propsFromParams(inner);
495
+ const declared = fcRef?.typeArguments?.[0] ?? unwrapped.propsTypeArg;
496
+ const propsTypeRef =
497
+ declared && ts.isTypeReferenceNode(declared) ? declared : fromParams.propsTypeRef;
498
+ const propsTypeLiteral =
499
+ declared && ts.isTypeLiteralNode(declared) ? declared : fromParams.propsTypeLiteral;
500
+ return {
501
+ componentName: name,
502
+ bodyNode: inner,
503
+ ...fromParams,
504
+ propsTypeNode: declared ?? fromParams.propsTypeNode,
505
+ propsTypeRef,
506
+ propsTypeLiteral,
507
+ exportKind: "none",
508
+ isAsync: (inner.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword),
509
+ wrappers: unwrapped.wrappers,
510
+ pos: posOf(decl, sf),
511
+ };
512
+ }
513
+ return undefined;
514
+ }
515
+
516
+ /** Peel memo()/forwardRef() (bare or React.-qualified), outermost-first. */
517
+ function unwrapWrappers(expr: ts.Expression): {
518
+ wrappers: Array<"memo" | "forwardRef">;
519
+ inner: ts.Expression | undefined;
520
+ /**
521
+ * The props type the wrapper calls declare — memo<P>'s first argument,
522
+ * forwardRef<E, P>'s second. Innermost wins: it sits closest to the
523
+ * function whose props it types. Dropping these was FR-011 — a
524
+ * forwardRef<E, Props> component extracted an empty shape with nothing
525
+ * even recorded as unresolved.
526
+ */
527
+ propsTypeArg: ts.TypeNode | undefined;
528
+ } {
529
+ const wrappers: Array<"memo" | "forwardRef"> = [];
530
+ let propsTypeArg: ts.TypeNode | undefined;
531
+ let current: ts.Expression = expr;
532
+ for (;;) {
533
+ if (!ts.isCallExpression(current)) break;
534
+ const callee = current.expression;
535
+ const calleeName = ts.isIdentifier(callee)
536
+ ? callee.text
537
+ : ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)
538
+ ? callee.name.text
539
+ : undefined;
540
+ if (calleeName !== "memo" && calleeName !== "forwardRef") break;
541
+ wrappers.push(calleeName);
542
+ const arg = calleeName === "memo" ? current.typeArguments?.[0] : current.typeArguments?.[1];
543
+ if (arg) propsTypeArg = arg;
544
+ const first = current.arguments[0];
545
+ if (!first) return { wrappers, inner: undefined, propsTypeArg };
546
+ current = first;
547
+ }
548
+ return { wrappers, inner: wrappers.length > 0 ? current : undefined, propsTypeArg };
549
+ }
550
+
551
+ function propsFromParams(fn: { parameters: ts.NodeArray<ts.ParameterDeclaration> }): {
552
+ propsTypeNode: ts.TypeNode | undefined;
553
+ propsTypeRef: ts.TypeReferenceNode | undefined;
554
+ propsTypeLiteral: ts.TypeLiteralNode | undefined;
555
+ paramName: string | undefined;
556
+ destructuredProps: string[];
557
+ destructuredAliases: Map<string, string>;
558
+ } {
559
+ const first = fn.parameters[0];
560
+ const empty = {
561
+ propsTypeNode: undefined,
562
+ propsTypeRef: undefined,
563
+ propsTypeLiteral: undefined,
564
+ paramName: undefined,
565
+ destructuredProps: [] as string[],
566
+ destructuredAliases: new Map<string, string>(),
567
+ };
568
+ if (!first) return empty;
569
+
570
+ const propsTypeRef = first.type && ts.isTypeReferenceNode(first.type) ? first.type : undefined;
571
+ const propsTypeLiteral = first.type && ts.isTypeLiteralNode(first.type) ? first.type : undefined;
572
+
573
+ let paramName: string | undefined;
574
+ const destructuredProps: string[] = [];
575
+ const destructuredAliases = new Map<string, string>();
576
+ if (ts.isIdentifier(first.name)) {
577
+ paramName = first.name.text;
578
+ } else if (ts.isObjectBindingPattern(first.name)) {
579
+ for (const el of first.name.elements) {
580
+ if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
581
+ destructuredProps.push(el.name.text);
582
+ if (el.propertyName && ts.isIdentifier(el.propertyName)) {
583
+ destructuredAliases.set(el.name.text, el.propertyName.text);
584
+ }
585
+ }
586
+ }
587
+ return {
588
+ propsTypeNode: first.type,
589
+ propsTypeRef,
590
+ propsTypeLiteral,
591
+ paramName,
592
+ destructuredProps,
593
+ destructuredAliases,
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Peels wrappers that cannot change what an expression evaluates to:
599
+ * parentheses, `as` / `satisfies` casts, and non-null assertions. Exported for
600
+ * other detectors that must not be fooled by `(null)` or `x satisfies T`.
601
+ */
602
+ export function unwrapTrivialExpression(e: ts.Expression): ts.Expression {
603
+ let current = e;
604
+ for (;;) {
605
+ if (
606
+ ts.isParenthesizedExpression(current) ||
607
+ ts.isAsExpression(current) ||
608
+ ts.isSatisfiesExpression(current) ||
609
+ ts.isNonNullExpression(current)
610
+ ) {
611
+ current = current.expression;
612
+ } else {
613
+ return current;
614
+ }
615
+ }
616
+ }
617
+
618
+ /**
619
+ * `t` as an FC/FunctionComponent type reference (bare or React.-qualified),
620
+ * undefined for anything else. One test for the declared-type and cast forms,
621
+ * so they cannot drift apart.
622
+ */
623
+ function fcTypeRef(t: ts.TypeNode | undefined): ts.TypeReferenceNode | undefined {
624
+ if (!t || !ts.isTypeReferenceNode(t)) return undefined;
625
+ const n = t.typeName;
626
+ const name = ts.isQualifiedName(n)
627
+ ? ts.isIdentifier(n.right)
628
+ ? n.right.text
629
+ : undefined
630
+ : n.text;
631
+ return name === "FC" || name === "FunctionComponent" ? t : undefined;
632
+ }
633
+
634
+ /**
635
+ * A body renders even without JSX in two decidable shapes: every one of its
636
+ * OWN returns is the `null` literal (an effect-only component — it renders
637
+ * nothing by construction), or a return is a call to a render hook like
638
+ * base-ui's useRender, where the element comes out of the hook. Both triggers
639
+ * are syntactic; anything less certain still requires JSX. A SCREAMING_CASE
640
+ * name marks a value constant (`const NOOP = () => null`), so under such a
641
+ * name the null-only trigger stands down — JSX and render hooks still count.
642
+ */
643
+ function looksLikeComponentBody(
644
+ fn: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration,
645
+ renderHooks: RenderHookImports,
646
+ componentName?: string,
647
+ ): boolean {
648
+ if (containsJsx(fn)) return true;
649
+
650
+ const screamingCase = componentName !== undefined && /^[A-Z][A-Z0-9_]+$/.test(componentName);
651
+
652
+ const isRenderHookCall = (e: ts.Expression): boolean => {
653
+ if (!ts.isCallExpression(e)) return false;
654
+ const callee = e.expression;
655
+ if (ts.isIdentifier(callee)) return renderHooks.names.has(callee.text);
656
+ return (
657
+ ts.isPropertyAccessExpression(callee) &&
658
+ ts.isIdentifier(callee.expression) &&
659
+ renderHooks.namespaces.has(callee.expression.text) &&
660
+ callee.name.text === "useRender"
661
+ );
662
+ };
663
+
664
+ // A ternary renders through the hook when ANY branch is a hook call — the
665
+ // other branch (typically null) renders nothing, which is fine.
666
+ const returnsRenderHook = (e: ts.Expression | undefined): boolean => {
667
+ if (e === undefined) return false;
668
+ const inner = unwrapTrivialExpression(e);
669
+ if (isRenderHookCall(inner)) return true;
670
+ return (
671
+ ts.isConditionalExpression(inner) &&
672
+ (returnsRenderHook(inner.whenTrue) || returnsRenderHook(inner.whenFalse))
673
+ );
674
+ };
675
+
676
+ const isNullLiteral = (e: ts.Expression | undefined): boolean =>
677
+ e !== undefined && unwrapTrivialExpression(e).kind === ts.SyntaxKind.NullKeyword;
678
+
679
+ if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) {
680
+ return returnsRenderHook(fn.body) || (!screamingCase && isNullLiteral(fn.body));
681
+ }
682
+ if (!fn.body || !ts.isBlock(fn.body)) return false;
683
+
684
+ const returns = ownReturns(fn.body);
685
+ if (returns.length === 0) return false;
686
+ if (!screamingCase && returns.every((r) => isNullLiteral(r.expression))) return true;
687
+ return returns.some((r) => returnsRenderHook(r.expression));
688
+ }
689
+
690
+ interface RenderHookImports {
691
+ /** Locals that ARE a render hook: `useRender(...)` called bare. */
692
+ names: Set<string>;
693
+ /** Namespace/default locals of use-render modules: `NS.useRender(...)`. */
694
+ namespaces: Set<string>;
695
+ }
696
+
697
+ /**
698
+ * Local names bound to render hooks: an import named useRender, or anything
699
+ * imported from a module specifier ending in /use-render. Namespace and
700
+ * default imports of such modules live in a separate set and match only the
701
+ * `NS.useRender(...)` property-access form — a default import CALLED as a bare
702
+ * identifier is not detected (no such module shape has been seen in the wild;
703
+ * detection stays syntactic rather than guessing).
704
+ */
705
+ function renderHookNames(sf: ts.SourceFile): RenderHookImports {
706
+ const names = new Set<string>();
707
+ const namespaces = new Set<string>();
708
+ for (const stmt of sf.statements) {
709
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
710
+ const clause = stmt.importClause;
711
+ if (!clause) continue;
712
+ const fromUseRenderModule = /\/use-render$/.test(stmt.moduleSpecifier.text);
713
+ if (clause.name && fromUseRenderModule) namespaces.add(clause.name.text);
714
+ const bindings = clause.namedBindings;
715
+ if (!bindings) continue;
716
+ if (ts.isNamedImports(bindings)) {
717
+ for (const el of bindings.elements) {
718
+ const imported = (el.propertyName ?? el.name).text;
719
+ if (imported === "useRender" || fromUseRenderModule) names.add(el.name.text);
720
+ }
721
+ } else if (ts.isNamespaceImport(bindings) && fromUseRenderModule) {
722
+ namespaces.add(bindings.name.text);
723
+ }
724
+ }
725
+ return { names, namespaces };
726
+ }
727
+
728
+ function containsJsx(node: ts.Node): boolean {
729
+ let found = false;
730
+ const visit = (n: ts.Node): void => {
731
+ if (found) return;
732
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
733
+ found = true;
734
+ return;
735
+ }
736
+ ts.forEachChild(n, visit);
737
+ };
738
+ visit(node);
739
+ return found;
740
+ }
741
+
742
+ export function componentNameFromFilename(fileName: string): string {
743
+ let base = path.basename(fileName).replace(/\.(tsx|ts|jsx|js)$/, "");
744
+ if (base === "index") base = path.basename(path.dirname(fileName));
745
+ return base
746
+ .split(/[-_.\s]+/)
747
+ .filter(Boolean)
748
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1))
749
+ .join("");
750
+ }
751
+
752
+ /** Primary = default export → PascalCase(filename) match → first exported → first. */
753
+ function orderPrimaryFirst(units: ParsedComponentUnit[], filePath: string): void {
754
+ const pick =
755
+ units.find((u) => u.exportKind === "default") ??
756
+ units.find(
757
+ (u) => u.exportKind !== "none" && u.componentName === componentNameFromFilename(filePath),
758
+ ) ??
759
+ units.find((u) => u.exportKind !== "none") ??
760
+ units[0];
761
+ if (!pick) return;
762
+ const idx = units.indexOf(pick);
763
+ if (idx > 0) {
764
+ units.splice(idx, 1);
765
+ units.unshift(pick);
766
+ }
767
+ }
768
+
769
+ /**
770
+ * A React CLASS component, or undefined.
771
+ *
772
+ * findComponents handled function declarations and variable statements only, so
773
+ * `class X extends React.Component` was skipped and parseFile threw "could not
774
+ * locate a React component" — caught by core.ts as a silent warning and exit 0.
775
+ * sourceCache has counted an exported class as an export since it was written,
776
+ * so the two layers disagreed about the same file.
777
+ *
778
+ * Detected from EVIDENCE, not a name: it must extend Component or PureComponent
779
+ * (bare, or through a namespace as `React.Component`) AND its render() must
780
+ * contain JSX. A class that extends something else is not a React component, and
781
+ * this refuses to guess that it might be.
782
+ */
783
+ function classComponentUnit(
784
+ stmt: ts.ClassDeclaration,
785
+ sf: ts.SourceFile,
786
+ imports: Map<string, ImportBinding>,
787
+ ): ParsedComponentUnit | undefined {
788
+ if (!stmt.name) return undefined;
789
+ const base = (stmt.heritageClauses ?? [])
790
+ .filter((h) => h.token === ts.SyntaxKind.ExtendsKeyword)
791
+ .flatMap((h) => h.types)[0];
792
+ if (!base) return undefined;
793
+ const baseName = ts.isIdentifier(base.expression)
794
+ ? base.expression.text
795
+ : ts.isPropertyAccessExpression(base.expression) && ts.isIdentifier(base.expression.name)
796
+ ? base.expression.name.text
797
+ : undefined;
798
+ if (baseName !== "Component" && baseName !== "PureComponent") return undefined;
799
+ // The name is not the evidence — the IMPORT is. A local `class Component {}`
800
+ // shadowing nothing made `class Sneaky extends Component` a detected React
801
+ // component, which is precisely what the comment above claims this does not do.
802
+ //
803
+ // A namespace access (`React.Component`) is checked on its ROOT: `React` must
804
+ // itself be the react import, however it was named.
805
+ const rootName = ts.isPropertyAccessExpression(base.expression)
806
+ ? ts.isIdentifier(base.expression.expression)
807
+ ? base.expression.expression.text
808
+ : undefined
809
+ : baseName;
810
+ if (rootName === undefined) return undefined;
811
+ const binding = imports.get(rootName);
812
+ if (binding?.specifier !== "react") return undefined;
813
+
814
+ const render = stmt.members.find(
815
+ (m): m is ts.MethodDeclaration =>
816
+ ts.isMethodDeclaration(m) && ts.isIdentifier(m.name) && m.name.text === "render",
817
+ );
818
+ if (!render?.body || !containsJsx(render)) return undefined;
819
+
820
+ const mods = ts.getModifiers(stmt) ?? [];
821
+ // `class X extends Component<Props, State>` — the props type is the FIRST type
822
+ // argument, which is the class analogue of a function's first parameter.
823
+ const propsArg = base.typeArguments?.[0];
824
+ return {
825
+ componentName: stmt.name.text,
826
+ // render(), not the class: it is where the JSX is, and every downstream
827
+ // extractor walks bodyNode looking for exactly that.
828
+ bodyNode: render,
829
+ propsTypeNode: propsArg,
830
+ propsTypeRef: propsArg && ts.isTypeReferenceNode(propsArg) ? propsArg : undefined,
831
+ propsTypeLiteral: propsArg && ts.isTypeLiteralNode(propsArg) ? propsArg : undefined,
832
+ // A class reads `this.props.x`. There is no parameter to name and nothing is
833
+ // destructured, and saying otherwise would have the prop extractors look for
834
+ // bindings that are not there.
835
+ paramName: undefined,
836
+ destructuredProps: [],
837
+ destructuredAliases: new Map<string, string>(),
838
+ exportKind: mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
839
+ ? "default"
840
+ : mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
841
+ ? "named"
842
+ : "none",
843
+ isAsync: false,
844
+ wrappers: [],
845
+ ...(stmt.members.some(
846
+ (m) =>
847
+ (ts.isMethodDeclaration(m) || ts.isPropertyDeclaration(m)) &&
848
+ ts.isIdentifier(m.name) &&
849
+ (m.name.text === "getDerivedStateFromError" || m.name.text === "componentDidCatch"),
850
+ )
851
+ ? { isErrorBoundary: true as const }
852
+ : {}),
853
+ classNode: stmt,
854
+ pos: posOf(stmt, sf),
855
+ };
856
+ }
857
+
858
+ /**
859
+ * Whether this file DECLARES a component under `name`, read off the top-level
860
+ * declarations without running the detection above. It answers only the
861
+ * question a re-export hop asks: is the name I was handed this file's own, or
862
+ * one it hands along? A component named any other way (a member of an object,
863
+ * an import passed straight back out) answers no, and that is the safe side —
864
+ * the search then finds no module that declares the name and the child edge
865
+ * stays exactly where it was.
866
+ */
867
+ export function declaresComponentName(abs: string, name: string, cache: SourceFileCache): boolean {
868
+ const source = cache.get(abs);
869
+ if (!source) return false;
870
+ for (const stmt of source.statements) {
871
+ if (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) {
872
+ if (stmt.name?.text === name) return true;
873
+ } else if (ts.isVariableStatement(stmt)) {
874
+ for (const d of stmt.declarationList.declarations) {
875
+ if (ts.isIdentifier(d.name) && d.name.text === name) return true;
876
+ }
877
+ }
878
+ }
879
+ return false;
880
+ }
881
+
882
+ /**
883
+ * The name this file's DEFAULT export answers under, or undefined when it has
884
+ * no default export or answers with a value that is not a component (an object
885
+ * literal, a string). `export { default as Card } from './card'` hands the
886
+ * default export along, and the chain needs to know what to call it on the far
887
+ * side: `export default function Foo` and `export default Foo` answer "Foo",
888
+ * while an anonymous one (`export default memo(...)`) answers "default" —
889
+ * which is what the caller looks up, and the file's own component is then
890
+ * picked by the usual route.
891
+ */
892
+ export function defaultExportComponentName(
893
+ abs: string,
894
+ cache: SourceFileCache,
895
+ ): string | undefined {
896
+ const source = cache.get(abs);
897
+ if (!source) return undefined;
898
+ for (const stmt of source.statements) {
899
+ // `export default function Foo` parses as a FunctionDeclaration carrying
900
+ // BOTH the export and default keywords — not as an ExportAssignment — so it
901
+ // must be read off the modifiers or it is invisible here.
902
+ if (
903
+ (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) &&
904
+ (stmt.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
905
+ ) {
906
+ return stmt.name?.text ?? "default";
907
+ }
908
+ if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue;
909
+ const e = stmt.expression;
910
+ if (ts.isFunctionDeclaration(e) || ts.isClassDeclaration(e)) return e.name?.text ?? "default";
911
+ if (ts.isIdentifier(e)) return e.text;
912
+ // A call (memo, forwardRef, HOC) or a function expression is a component
913
+ // the file never named. Anything else — an object, a string, an array — is
914
+ // not one, and the walk should not stop here for it.
915
+ if (ts.isCallExpression(e) || ts.isNewExpression(e)) return "default";
916
+ if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return "default";
917
+ }
918
+ return undefined;
919
+ }