@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,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
|
+
}
|