|
1 | 1 | import { getContextualType, typeMatchesSpecifier } from "@typescript-eslint/type-utils" |
2 | | -import { AST_NODE_TYPES, ESLintUtils, type TSESTree as Node } from "@typescript-eslint/utils" |
3 | | -import { type Program, type Type, type TypeChecker, isExpression } from "typescript" |
| 2 | +import { AST_NODE_TYPES, ESLintUtils, type TSESTree as ESNode } from "@typescript-eslint/utils" |
| 3 | +import { type Program, type Node as TSNode, type Type, type TypeChecker, isExpression } from "typescript" |
4 | 4 |
|
5 | 5 | import { createRule } from "@/shared/create" |
6 | 6 | import { isType } from "@/shared/is" |
@@ -74,15 +74,15 @@ export default createRule({ |
74 | 74 | // Populated from `import { createStore, sample } from "effector"` (handles renames too). |
75 | 75 | const effectorImports = new Map<string, "factory" | "operator">() |
76 | 76 |
|
77 | | - type ComponentFunction = Node.FunctionDeclaration | Node.FunctionExpression | Node.ArrowFunctionExpression |
| 77 | + type ComponentFunction = ESNode.FunctionDeclaration | ESNode.FunctionExpression | ESNode.ArrowFunctionExpression |
78 | 78 |
|
79 | 79 | const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME.core}]` |
80 | 80 |
|
81 | 81 | return { |
82 | 82 | // ── Phase 1: Collect effector imports ────────────────────────────────── |
83 | 83 |
|
84 | 84 | [`${importSelector} > ImportSpecifier[imported.type="Identifier"]`]: ( |
85 | | - node: Node.ImportSpecifier & { imported: Node.Identifier }, |
| 85 | + node: ESNode.ImportSpecifier & { imported: ESNode.Identifier }, |
86 | 86 | ) => { |
87 | 87 | const imported = node.imported.name |
88 | 88 | const local = node.local.name |
@@ -164,78 +164,68 @@ export default createRule({ |
164 | 164 | // by callee type against the effector package |
165 | 165 | // - Anything remaining is treated as a custom factory |
166 | 166 |
|
167 | | - "CallExpression": (node: Node.CallExpression) => { |
| 167 | + "CallExpression": (node: ESNode.CallExpression) => { |
168 | 168 | const isWithinRender = stack.render.at(-1) ?? false |
169 | 169 | if (!isWithinRender) return |
170 | 170 |
|
171 | 171 | const calleeName = getCalleeName(node.callee) |
172 | 172 |
|
173 | 173 | // Tier 1: known effector import — report immediately, skip type analysis |
174 | 174 | const importType = calleeName ? effectorImports.get(calleeName) : undefined |
175 | | - if (importType === "factory") { |
176 | | - context.report({ node, messageId: "noFactoryInRender", data: { name: calleeName } }) |
177 | | - return |
178 | | - } |
179 | | - if (importType === "operator") { |
180 | | - context.report({ node, messageId: "noOperatorInRender", data: { name: calleeName } }) |
181 | | - return |
| 175 | + switch (importType) { |
| 176 | + case "factory": |
| 177 | + return context.report({ node, messageId: "noFactoryInRender", data: { name: calleeName } }) |
| 178 | + case "operator": |
| 179 | + return context.report({ node, messageId: "noOperatorInRender", data: { name: calleeName } }) |
182 | 180 | } |
183 | 181 |
|
184 | 182 | // Tier 2: return type contains effector units — classify via callee type |
185 | 183 | const returnType = services.getTypeAtLocation(node) |
186 | | - if (!hasEffectorUnitInType(returnType, checker, services.program)) return |
| 184 | + const ctx: TraverseCtx = { node: services.esTreeNodeToTSNodeMap.get(node), checker, program: services.program } |
| 185 | + |
| 186 | + if (!hasEffectorUnitInType(ctx, returnType)) return |
187 | 187 |
|
188 | 188 | const calleeType = services.getTypeAtLocation(node.callee) |
| 189 | + const displayName = calleeName ?? "<expression>" |
| 190 | + |
189 | 191 | if (typeMatchesSpecifier(calleeType, REACT_HOOKS_SPEC, services.program)) return |
190 | 192 |
|
191 | | - if (typeMatchesSpecifier(calleeType, EFFECTOR_FACTORY_SPEC, services.program)) { |
192 | | - context.report({ node, messageId: "noFactoryInRender", data: { name: calleeName ?? "<expression>" } }) |
193 | | - return |
194 | | - } |
195 | | - if (typeMatchesSpecifier(calleeType, EFFECTOR_OPERATOR_SPEC, services.program)) { |
196 | | - context.report({ node, messageId: "noOperatorInRender", data: { name: calleeName ?? "<expression>" } }) |
197 | | - return |
198 | | - } |
| 193 | + if (typeMatchesSpecifier(calleeType, EFFECTOR_FACTORY_SPEC, services.program)) |
| 194 | + return context.report({ node, messageId: "noFactoryInRender", data: { name: displayName } }) |
199 | 195 |
|
200 | | - context.report({ |
201 | | - node, |
202 | | - messageId: "noCustomFactoryInRender", |
203 | | - data: { name: calleeName ?? "<expression>" }, |
204 | | - }) |
| 196 | + if (typeMatchesSpecifier(calleeType, EFFECTOR_OPERATOR_SPEC, services.program)) |
| 197 | + return context.report({ node, messageId: "noOperatorInRender", data: { name: displayName } }) |
| 198 | + |
| 199 | + context.report({ node, messageId: "noCustomFactoryInRender", data: { name: displayName } }) |
205 | 200 | }, |
206 | 201 | } |
207 | 202 | }, |
208 | 203 | }) |
209 | 204 |
|
210 | 205 | const UseRegex = /^use[A-Z0-9].*$/ |
211 | 206 |
|
212 | | -function getCalleeName(callee: Node.CallExpression["callee"]): string | null { |
| 207 | +function getCalleeName(callee: ESNode.Expression): string | null { |
213 | 208 | if (callee.type === AST_NODE_TYPES.Identifier) return callee.name |
214 | | - if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) { |
| 209 | + if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) |
215 | 210 | return callee.property.name |
216 | | - } |
217 | | - return null |
| 211 | + else return null |
218 | 212 | } |
219 | 213 |
|
| 214 | +type TraverseCtx = { node: TSNode; checker: TypeChecker; program: Program } |
| 215 | + |
220 | 216 | // Walks the type structure up to `depth` levels of object nesting to find effector units. |
221 | 217 | // Unions don't consume depth — they are alternative shapes at the same level. |
222 | | -function hasEffectorUnitInType(type: Type, checker: TypeChecker, program: Program, depth = 3): boolean { |
223 | | - if (isType.unit(type, program) || isType.domain(type, program)) return true |
| 218 | +function hasEffectorUnitInType(ctx: TraverseCtx, type: Type, depth = 3): boolean { |
| 219 | + if (isType.unit(type, ctx.program)) return true |
224 | 220 | if (depth <= 0) return false |
225 | 221 |
|
226 | 222 | // For unions, getProperties() only returns common properties across all members. |
227 | 223 | // We must recurse into each member to check their individual properties for userland factories. |
228 | | - if (type.isUnion()) { |
229 | | - return type.types.some((t) => hasEffectorUnitInType(t, checker, program, depth)) |
230 | | - } |
| 224 | + if (type.isUnion()) return type.types.some((type) => hasEffectorUnitInType(ctx, type, depth)) |
231 | 225 |
|
232 | | - const properties = type.getProperties() |
233 | | - for (const prop of properties) { |
234 | | - const firstDeclaration = prop.declarations?.[0] |
235 | | - const propType = firstDeclaration |
236 | | - ? checker.getTypeOfSymbolAtLocation(prop, firstDeclaration) |
237 | | - : checker.getTypeOfSymbol(prop) |
238 | | - if (hasEffectorUnitInType(propType, checker, program, depth - 1)) return true |
| 226 | + for (const property of type.getProperties()) { |
| 227 | + const type = ctx.checker.getTypeOfSymbolAtLocation(property, ctx.node) |
| 228 | + if (hasEffectorUnitInType(ctx, type, depth - 1)) return true |
239 | 229 | } |
240 | 230 |
|
241 | 231 | return false |
|
0 commit comments