@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,270 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { GuardEffect, GuardInfo } from "../types.ts";
|
|
3
|
+
import type { ImportBinding } from "./parseFile.ts";
|
|
4
|
+
import { posOf } from "./pos.ts";
|
|
5
|
+
import { bodyStatements, collectRefs, hookCalleeName } from "./refs.ts";
|
|
6
|
+
|
|
7
|
+
const NAV_SPECIFIER = "next/navigation";
|
|
8
|
+
const CONDITION_RAW_MAX = 80;
|
|
9
|
+
|
|
10
|
+
/** Only these leave the render early in a way a spec can assert on. */
|
|
11
|
+
const NAV_EFFECTS = new Set<string>([
|
|
12
|
+
"notFound",
|
|
13
|
+
"redirect",
|
|
14
|
+
"permanentRedirect",
|
|
15
|
+
"unauthorized",
|
|
16
|
+
"forbidden",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Early-exit guards at the top of a component body: `if (!building) notFound()`.
|
|
21
|
+
*
|
|
22
|
+
* Purely syntactic and deliberately narrow. Only top-level IfStatements count —
|
|
23
|
+
* a guard nested inside another branch fires under conditions this analyser
|
|
24
|
+
* cannot reconstruct, so the emitted spec would assert a throw that never
|
|
25
|
+
* happens. Everything the classifier cannot name is reported as "complex" with
|
|
26
|
+
* no triggerValue rather than approximated: a wrong trigger produces a failing
|
|
27
|
+
* test in someone's repo, which is worse than emitting no assertion at all.
|
|
28
|
+
*/
|
|
29
|
+
export function extractGuards(
|
|
30
|
+
bodyNode: ts.Node,
|
|
31
|
+
sourceFile: ts.SourceFile,
|
|
32
|
+
imports: Map<string, ImportBinding>,
|
|
33
|
+
depVarNames: Set<string>,
|
|
34
|
+
): GuardInfo[] {
|
|
35
|
+
const out: GuardInfo[] = [];
|
|
36
|
+
|
|
37
|
+
for (const stmt of bodyStatements(bodyNode)) {
|
|
38
|
+
if (!ts.isIfStatement(stmt)) continue;
|
|
39
|
+
const effect = classifyEffect(stmt.thenStatement, imports);
|
|
40
|
+
if (!effect) continue;
|
|
41
|
+
|
|
42
|
+
const condition = classifyCondition(stmt.expression, sourceFile, depVarNames);
|
|
43
|
+
const guard: GuardInfo = {
|
|
44
|
+
effect: effect.effect,
|
|
45
|
+
callee: effect.callee,
|
|
46
|
+
conditionRaw: rawCondition(stmt.expression, sourceFile),
|
|
47
|
+
conditionKind: condition.kind,
|
|
48
|
+
depVars: condition.depVars,
|
|
49
|
+
pos: posOf(stmt, sourceFile),
|
|
50
|
+
};
|
|
51
|
+
if (effect.specifier !== undefined) guard.specifier = effect.specifier;
|
|
52
|
+
if (condition.triggerValue !== undefined) guard.triggerValue = condition.triggerValue;
|
|
53
|
+
out.push(guard);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether this if-statement's branch aborts the render the way a guard does —
|
|
61
|
+
* the same classification extractGuards applies, exposed so the fixture side
|
|
62
|
+
* can reason about comparison polarity: inside `if (cond) notFound()`, a
|
|
63
|
+
* fixture that makes `cond` true renders nothing.
|
|
64
|
+
*/
|
|
65
|
+
export function isGuardExit(stmt: ts.IfStatement, imports: Map<string, ImportBinding>): boolean {
|
|
66
|
+
return classifyEffect(stmt.thenStatement, imports) !== undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Effect
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
interface EffectMatch {
|
|
74
|
+
effect: GuardEffect;
|
|
75
|
+
callee: string;
|
|
76
|
+
specifier?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function classifyEffect(
|
|
80
|
+
thenStatement: ts.Statement,
|
|
81
|
+
imports: Map<string, ImportBinding>,
|
|
82
|
+
): EffectMatch | undefined {
|
|
83
|
+
const stmt = soleStatement(thenStatement);
|
|
84
|
+
if (!stmt) return undefined;
|
|
85
|
+
|
|
86
|
+
// A throw needs no provenance check: whatever it constructs, the render aborts.
|
|
87
|
+
if (ts.isThrowStatement(stmt)) return { effect: "throw", callee: throwCallee(stmt) };
|
|
88
|
+
|
|
89
|
+
const call = exitCall(stmt);
|
|
90
|
+
if (!call) return undefined;
|
|
91
|
+
// A qualified callee (`nav.notFound()`) is not proof of anything — the
|
|
92
|
+
// namespace could be any module — so only a bare identifier is accepted.
|
|
93
|
+
if (!ts.isIdentifier(call.expression)) return undefined;
|
|
94
|
+
const name = call.expression.text;
|
|
95
|
+
if (!NAV_EFFECTS.has(name)) return undefined;
|
|
96
|
+
|
|
97
|
+
// The whole point of the check: a project-local `notFound()` that renders a
|
|
98
|
+
// fallback would make an emitted "throws NEXT_NOT_FOUND" assertion a lie.
|
|
99
|
+
const binding = imports.get(name);
|
|
100
|
+
if (!binding || binding.specifier !== NAV_SPECIFIER) return undefined;
|
|
101
|
+
if (binding.isTypeOnly || binding.kind !== "named") return undefined;
|
|
102
|
+
// `import { redirect as notFound }` binds a name that means something else.
|
|
103
|
+
if (binding.importedName !== undefined && binding.importedName !== name) return undefined;
|
|
104
|
+
|
|
105
|
+
return { effect: name as GuardEffect, callee: name, specifier: NAV_SPECIFIER };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The single statement a guard branch may contain. A block holding anything
|
|
110
|
+
* beyond the exit does work the scaffolded spec would not reproduce, so it is
|
|
111
|
+
* not treated as a guard.
|
|
112
|
+
*/
|
|
113
|
+
function soleStatement(stmt: ts.Statement): ts.Statement | undefined {
|
|
114
|
+
if (!ts.isBlock(stmt)) return stmt;
|
|
115
|
+
return stmt.statements.length === 1 ? stmt.statements[0] : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function exitCall(stmt: ts.Statement): ts.CallExpression | undefined {
|
|
119
|
+
if (ts.isExpressionStatement(stmt) && ts.isCallExpression(stmt.expression)) {
|
|
120
|
+
return stmt.expression;
|
|
121
|
+
}
|
|
122
|
+
if (ts.isReturnStatement(stmt) && stmt.expression && ts.isCallExpression(stmt.expression)) {
|
|
123
|
+
return stmt.expression;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function throwCallee(stmt: ts.ThrowStatement): string {
|
|
129
|
+
const expr = stmt.expression;
|
|
130
|
+
if (ts.isNewExpression(expr) || ts.isCallExpression(expr)) {
|
|
131
|
+
return hookCalleeName(expr.expression) ?? "throw";
|
|
132
|
+
}
|
|
133
|
+
return "throw";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Condition
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
interface ConditionMatch {
|
|
141
|
+
kind: GuardInfo["conditionKind"];
|
|
142
|
+
depVars: string[];
|
|
143
|
+
triggerValue?: GuardInfo["triggerValue"];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function classifyCondition(
|
|
147
|
+
expr: ts.Expression,
|
|
148
|
+
sourceFile: ts.SourceFile,
|
|
149
|
+
depVarNames: Set<string>,
|
|
150
|
+
): ConditionMatch {
|
|
151
|
+
const depVars = referencedDeps(expr, sourceFile, depVarNames);
|
|
152
|
+
const shape = classifyShape(expr, sourceFile, depVarNames);
|
|
153
|
+
return { kind: shape.kind, depVars, triggerValue: shape.triggerValue };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function classifyShape(
|
|
157
|
+
expr: ts.Expression,
|
|
158
|
+
sourceFile: ts.SourceFile,
|
|
159
|
+
depVarNames: Set<string>,
|
|
160
|
+
): { kind: GuardInfo["conditionKind"]; triggerValue?: GuardInfo["triggerValue"] } {
|
|
161
|
+
const node = unwrapParens(expr);
|
|
162
|
+
|
|
163
|
+
if (ts.isIdentifier(node)) return { kind: "truthy" };
|
|
164
|
+
|
|
165
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
166
|
+
const operand = unwrapParens(node.operand);
|
|
167
|
+
if (ts.isIdentifier(operand)) return { kind: "falsy", triggerValue: "undefined" };
|
|
168
|
+
if (isLengthOfIdentifier(operand)) return { kind: "falsy", triggerValue: "empty-array" };
|
|
169
|
+
return { kind: "complex" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (ts.isBinaryExpression(node)) {
|
|
173
|
+
const op = node.operatorToken.kind;
|
|
174
|
+
if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
175
|
+
return classifyLogical(node, sourceFile, depVarNames);
|
|
176
|
+
}
|
|
177
|
+
if (op === ts.SyntaxKind.EqualsEqualsEqualsToken) {
|
|
178
|
+
return { kind: "comparison", triggerValue: strictEqualityTrigger(node) };
|
|
179
|
+
}
|
|
180
|
+
// `!==`, `==`, `<` … are recognisably comparisons, but none of them names a
|
|
181
|
+
// single mock value that makes the guard fire, so no trigger is invented.
|
|
182
|
+
if (isComparisonOperator(op)) return { kind: "comparison" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { kind: "complex" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `a || b` is always "complex": the operands are separate conditions. A trigger
|
|
190
|
+
* survives only when every operand fires on the same mocked value of the same
|
|
191
|
+
* single dep — otherwise no one value is known to trip the guard.
|
|
192
|
+
*/
|
|
193
|
+
function classifyLogical(
|
|
194
|
+
node: ts.BinaryExpression,
|
|
195
|
+
sourceFile: ts.SourceFile,
|
|
196
|
+
depVarNames: Set<string>,
|
|
197
|
+
): { kind: GuardInfo["conditionKind"]; triggerValue?: GuardInfo["triggerValue"] } {
|
|
198
|
+
const left = classifyCondition(node.left, sourceFile, depVarNames);
|
|
199
|
+
const right = classifyCondition(node.right, sourceFile, depVarNames);
|
|
200
|
+
const sameDep =
|
|
201
|
+
left.depVars.length === 1 && right.depVars.length === 1 && left.depVars[0] === right.depVars[0];
|
|
202
|
+
const sameTrigger = left.triggerValue !== undefined && left.triggerValue === right.triggerValue;
|
|
203
|
+
if (sameDep && sameTrigger) return { kind: "complex", triggerValue: left.triggerValue };
|
|
204
|
+
return { kind: "complex" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function strictEqualityTrigger(node: ts.BinaryExpression): GuardInfo["triggerValue"] | undefined {
|
|
208
|
+
const pairs: Array<[ts.Expression, ts.Expression]> = [
|
|
209
|
+
[node.left, node.right],
|
|
210
|
+
[node.right, node.left],
|
|
211
|
+
];
|
|
212
|
+
for (const [subject, literal] of pairs) {
|
|
213
|
+
const target = unwrapParens(subject);
|
|
214
|
+
const value = unwrapParens(literal);
|
|
215
|
+
if (ts.isIdentifier(target)) {
|
|
216
|
+
if (value.kind === ts.SyntaxKind.NullKeyword) return "null";
|
|
217
|
+
if (ts.isIdentifier(value) && value.text === "undefined") return "undefined";
|
|
218
|
+
}
|
|
219
|
+
if (isLengthOfIdentifier(target) && ts.isNumericLiteral(value) && value.text === "0") {
|
|
220
|
+
return "empty-array";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isComparisonOperator(op: ts.SyntaxKind): boolean {
|
|
227
|
+
return (
|
|
228
|
+
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
|
|
229
|
+
op === ts.SyntaxKind.EqualsEqualsToken ||
|
|
230
|
+
op === ts.SyntaxKind.ExclamationEqualsToken ||
|
|
231
|
+
op === ts.SyntaxKind.LessThanToken ||
|
|
232
|
+
op === ts.SyntaxKind.LessThanEqualsToken ||
|
|
233
|
+
op === ts.SyntaxKind.GreaterThanToken ||
|
|
234
|
+
op === ts.SyntaxKind.GreaterThanEqualsToken
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `x.length` on a bare identifier. `a.b.length` stays complex — `a` alone says nothing. */
|
|
239
|
+
function isLengthOfIdentifier(node: ts.Expression): boolean {
|
|
240
|
+
return (
|
|
241
|
+
ts.isPropertyAccessExpression(node) &&
|
|
242
|
+
ts.isIdentifier(node.expression) &&
|
|
243
|
+
node.name.text === "length"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function unwrapParens(expr: ts.Expression): ts.Expression {
|
|
248
|
+
let current = expr;
|
|
249
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
250
|
+
return current;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function referencedDeps(
|
|
254
|
+
expr: ts.Expression,
|
|
255
|
+
sourceFile: ts.SourceFile,
|
|
256
|
+
depVarNames: Set<string>,
|
|
257
|
+
): string[] {
|
|
258
|
+
const seen = new Set<string>();
|
|
259
|
+
const out: string[] = [];
|
|
260
|
+
for (const ref of collectRefs(expr, sourceFile, depVarNames, undefined)) {
|
|
261
|
+
if (seen.has(ref.name)) continue;
|
|
262
|
+
seen.add(ref.name);
|
|
263
|
+
out.push(ref.name);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rawCondition(expr: ts.Expression, sourceFile: ts.SourceFile): string {
|
|
269
|
+
return expr.getText(sourceFile).replace(/\s+/g, " ").trim().slice(0, CONDITION_RAW_MAX);
|
|
270
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { EffectFeatures, HookDetail } from "../types.ts";
|
|
3
|
+
import { posOf } from "./pos.ts";
|
|
4
|
+
import { type ComponentScope, collectRefs, hookCalleeName } from "./refs.ts";
|
|
5
|
+
|
|
6
|
+
const HOOK_RE = /^use[A-Z]/;
|
|
7
|
+
|
|
8
|
+
export function extractHooks(bodyNode: ts.Node): string[] {
|
|
9
|
+
const found = new Set<string>();
|
|
10
|
+
const visit = (n: ts.Node): void => {
|
|
11
|
+
if (ts.isCallExpression(n)) {
|
|
12
|
+
const fn = n.expression;
|
|
13
|
+
if (ts.isIdentifier(fn) && HOOK_RE.test(fn.text)) {
|
|
14
|
+
found.add(fn.text);
|
|
15
|
+
} else if (
|
|
16
|
+
ts.isPropertyAccessExpression(fn) &&
|
|
17
|
+
ts.isIdentifier(fn.name) &&
|
|
18
|
+
HOOK_RE.test(fn.name.text)
|
|
19
|
+
) {
|
|
20
|
+
found.add(fn.name.text);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
ts.forEachChild(n, visit);
|
|
24
|
+
};
|
|
25
|
+
ts.forEachChild(bodyNode, visit);
|
|
26
|
+
return [...found].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEP_HOOKS = new Set([
|
|
30
|
+
"useEffect",
|
|
31
|
+
"useLayoutEffect",
|
|
32
|
+
"useMemo",
|
|
33
|
+
"useCallback",
|
|
34
|
+
"useImperativeHandle",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** One HookDetail per hook call site (not deduped), in source order. */
|
|
38
|
+
export function extractHookDetails(
|
|
39
|
+
bodyNode: ts.Node,
|
|
40
|
+
sourceFile: ts.SourceFile,
|
|
41
|
+
scope: ComponentScope,
|
|
42
|
+
): HookDetail[] {
|
|
43
|
+
const out: HookDetail[] = [];
|
|
44
|
+
const visit = (n: ts.Node): void => {
|
|
45
|
+
if (ts.isCallExpression(n)) {
|
|
46
|
+
const name = calleeHookName(n.expression);
|
|
47
|
+
if (name !== undefined) out.push(detailFor(n, name, sourceFile, scope));
|
|
48
|
+
}
|
|
49
|
+
ts.forEachChild(n, visit);
|
|
50
|
+
};
|
|
51
|
+
ts.forEachChild(bodyNode, visit);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function calleeHookName(expr: ts.Expression): string | undefined {
|
|
56
|
+
if (ts.isIdentifier(expr) && HOOK_RE.test(expr.text)) return expr.text;
|
|
57
|
+
if (
|
|
58
|
+
ts.isPropertyAccessExpression(expr) &&
|
|
59
|
+
ts.isIdentifier(expr.name) &&
|
|
60
|
+
HOOK_RE.test(expr.name.text)
|
|
61
|
+
) {
|
|
62
|
+
return expr.name.text;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detailFor(
|
|
68
|
+
call: ts.CallExpression,
|
|
69
|
+
name: string,
|
|
70
|
+
sf: ts.SourceFile,
|
|
71
|
+
scope: ComponentScope,
|
|
72
|
+
): HookDetail {
|
|
73
|
+
const args = call.arguments;
|
|
74
|
+
|
|
75
|
+
const last = args[args.length - 1];
|
|
76
|
+
const depsArray = last !== undefined && ts.isArrayLiteralExpression(last) ? last : undefined;
|
|
77
|
+
const deps = depsArray?.elements.map((el) => el.getText(sf));
|
|
78
|
+
const depsHasComplex = depsArray?.elements.some((el) => !ts.isIdentifier(el)) ?? false;
|
|
79
|
+
|
|
80
|
+
const arg0 = args[0];
|
|
81
|
+
const callback =
|
|
82
|
+
arg0 !== undefined && (ts.isArrowFunction(arg0) || ts.isFunctionExpression(arg0))
|
|
83
|
+
? arg0
|
|
84
|
+
: undefined;
|
|
85
|
+
const isEffect = name === "useEffect" || name === "useLayoutEffect";
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name,
|
|
89
|
+
pos: posOf(call, sf),
|
|
90
|
+
argCount: args.length,
|
|
91
|
+
argsSummary: args.map((a) => summarizeArg(a, sf)),
|
|
92
|
+
deps,
|
|
93
|
+
depsHasComplex,
|
|
94
|
+
missingDepsArray: DEP_HOOKS.has(name) && deps === undefined,
|
|
95
|
+
callbackReads: callback ? readsInCallback(callback, sf, scope) : [],
|
|
96
|
+
callbackIsInline: callback !== undefined,
|
|
97
|
+
readsBareParamsObject:
|
|
98
|
+
callback !== undefined &&
|
|
99
|
+
scope.paramName !== undefined &&
|
|
100
|
+
paramsObjectEscapes(callback.body, scope.paramName),
|
|
101
|
+
effectFeatures: isEffect && callback ? effectFeaturesOf(callback) : undefined,
|
|
102
|
+
resultName: resultNameOf(call),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function summarizeArg(arg: ts.Expression, sf: ts.SourceFile): string {
|
|
107
|
+
if (ts.isArrowFunction(arg)) return "arrow";
|
|
108
|
+
if (ts.isFunctionExpression(arg)) return "function";
|
|
109
|
+
if (ts.isIdentifier(arg)) return `identifier:${arg.text}`;
|
|
110
|
+
if (
|
|
111
|
+
ts.isStringLiteral(arg) ||
|
|
112
|
+
ts.isNumericLiteral(arg) ||
|
|
113
|
+
arg.kind === ts.SyntaxKind.TrueKeyword ||
|
|
114
|
+
arg.kind === ts.SyntaxKind.FalseKeyword ||
|
|
115
|
+
arg.kind === ts.SyntaxKind.NullKeyword
|
|
116
|
+
) {
|
|
117
|
+
return `literal:${arg.getText(sf)}`;
|
|
118
|
+
}
|
|
119
|
+
if (ts.isObjectLiteralExpression(arg)) return "object";
|
|
120
|
+
if (ts.isArrayLiteralExpression(arg)) return "array";
|
|
121
|
+
if (ts.isCallExpression(arg)) return "call";
|
|
122
|
+
return "other";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Component-scope names read inside the callback body, canonicalised through
|
|
127
|
+
* propAliases, minus names shadowed by the callback's own parameters.
|
|
128
|
+
*/
|
|
129
|
+
function readsInCallback(
|
|
130
|
+
callback: ts.ArrowFunction | ts.FunctionExpression,
|
|
131
|
+
sf: ts.SourceFile,
|
|
132
|
+
scope: ComponentScope,
|
|
133
|
+
): string[] {
|
|
134
|
+
const names = new Set<string>([
|
|
135
|
+
...scope.propNames,
|
|
136
|
+
...scope.propAliases.keys(),
|
|
137
|
+
...scope.stateVars,
|
|
138
|
+
...scope.setterNames,
|
|
139
|
+
...scope.refNames,
|
|
140
|
+
...scope.memoResultNames,
|
|
141
|
+
...scope.derivedNames.keys(),
|
|
142
|
+
]);
|
|
143
|
+
const shadowed = ownParamNames(callback);
|
|
144
|
+
const reads = new Set<string>();
|
|
145
|
+
for (const ref of collectRefs(callback.body, sf, names, scope.paramName)) {
|
|
146
|
+
if (shadowed.has(ref.node.text)) continue;
|
|
147
|
+
reads.add(scope.propAliases.get(ref.name) ?? ref.name);
|
|
148
|
+
}
|
|
149
|
+
return [...reads].sort();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ownParamNames(fn: ts.ArrowFunction | ts.FunctionExpression): Set<string> {
|
|
153
|
+
const out = new Set<string>();
|
|
154
|
+
for (const p of fn.parameters) collectBindingNames(p.name, out);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function collectBindingNames(name: ts.BindingName, out: Set<string>): void {
|
|
159
|
+
if (ts.isIdentifier(name)) {
|
|
160
|
+
out.add(name.text);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const el of name.elements) {
|
|
164
|
+
if (ts.isBindingElement(el)) collectBindingNames(el.name, out);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The whole props object escapes: a bare `props` reference that is not a `props.x` receiver. */
|
|
169
|
+
function paramsObjectEscapes(body: ts.Node, paramName: string): boolean {
|
|
170
|
+
let found = false;
|
|
171
|
+
const visit = (n: ts.Node): void => {
|
|
172
|
+
if (found) return;
|
|
173
|
+
if (
|
|
174
|
+
ts.isIdentifier(n) &&
|
|
175
|
+
n.text === paramName &&
|
|
176
|
+
!(
|
|
177
|
+
ts.isPropertyAccessExpression(n.parent) &&
|
|
178
|
+
(n.parent.expression === n || n.parent.name === n)
|
|
179
|
+
)
|
|
180
|
+
) {
|
|
181
|
+
found = true;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
ts.forEachChild(n, visit);
|
|
185
|
+
};
|
|
186
|
+
visit(body);
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function effectFeaturesOf(callback: ts.ArrowFunction | ts.FunctionExpression): EffectFeatures {
|
|
191
|
+
const features: EffectFeatures = {
|
|
192
|
+
fetch: false,
|
|
193
|
+
setInterval: false,
|
|
194
|
+
setTimeout: false,
|
|
195
|
+
addEventListener: false,
|
|
196
|
+
hasCleanup: false,
|
|
197
|
+
};
|
|
198
|
+
const body = callback.body;
|
|
199
|
+
const visit = (n: ts.Node): void => {
|
|
200
|
+
if (ts.isCallExpression(n)) {
|
|
201
|
+
const callee = hookCalleeName(n.expression);
|
|
202
|
+
if (callee === "fetch") features.fetch = true;
|
|
203
|
+
else if (callee === "setInterval") features.setInterval = true;
|
|
204
|
+
else if (callee === "setTimeout") features.setTimeout = true;
|
|
205
|
+
else if (callee === "addEventListener") features.addEventListener = true;
|
|
206
|
+
}
|
|
207
|
+
ts.forEachChild(n, visit);
|
|
208
|
+
};
|
|
209
|
+
visit(body);
|
|
210
|
+
|
|
211
|
+
if (ts.isBlock(body)) {
|
|
212
|
+
features.hasCleanup = body.statements.some(
|
|
213
|
+
(s) =>
|
|
214
|
+
ts.isReturnStatement(s) &&
|
|
215
|
+
s.expression !== undefined &&
|
|
216
|
+
(ts.isArrowFunction(s.expression) ||
|
|
217
|
+
ts.isFunctionExpression(s.expression) ||
|
|
218
|
+
ts.isIdentifier(s.expression)),
|
|
219
|
+
);
|
|
220
|
+
} else {
|
|
221
|
+
features.hasCleanup = ts.isArrowFunction(body) || ts.isFunctionExpression(body);
|
|
222
|
+
}
|
|
223
|
+
return features;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resultNameOf(call: ts.CallExpression): string | undefined {
|
|
227
|
+
const parent = call.parent;
|
|
228
|
+
if (
|
|
229
|
+
parent !== undefined &&
|
|
230
|
+
ts.isVariableDeclaration(parent) &&
|
|
231
|
+
parent.initializer === call &&
|
|
232
|
+
ts.isIdentifier(parent.name)
|
|
233
|
+
) {
|
|
234
|
+
return parent.name.text;
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|