diff --git a/.changeset/yummy-words-unite.md b/.changeset/yummy-words-unite.md new file mode 100644 index 0000000..f7c7282 --- /dev/null +++ b/.changeset/yummy-words-unite.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-effector": patch +--- + +Tighten `mandatory-scope-binding` to only flag potential unit invocations diff --git a/src/rules/mandatory-scope-binding/fixtures/model.ts b/src/rules/mandatory-scope-binding/fixtures/model.ts index b764632..77b3fed 100644 --- a/src/rules/mandatory-scope-binding/fixtures/model.ts +++ b/src/rules/mandatory-scope-binding/fixtures/model.ts @@ -1,6 +1,7 @@ import { createEffect, createEvent } from "effector" export const clicked = createEvent() +export const mounted = createEvent() export const fetchFx = createEffect(() => {}) -export const $$ = { context: { outputs: { clicked } } } +export const $$ = { context: { outputs: { clicked, mounted } } } diff --git a/src/rules/mandatory-scope-binding/mandatory-scope-binding.md b/src/rules/mandatory-scope-binding/mandatory-scope-binding.md index 07d192a..99dbf16 100644 --- a/src/rules/mandatory-scope-binding/mandatory-scope-binding.md +++ b/src/rules/mandatory-scope-binding/mandatory-scope-binding.md @@ -4,17 +4,16 @@ description: Forbid Event and Effect usage without useUnit in React components # effector/mandatory-scope-binding -Forbids `Event` and `Effect` usage without `useUnit` in React components. -This ensures `Fork API` compatibility and allows writing isomorphic code for SSR apps. +Forbids `EventCallable` and `Effect` usage without `useUnit` in React. This ensures `Fork API` compatibility for easy testing via `fork()` and running isomorphic code in SSR/SSG apps. ```tsx const increment = createEvent() // 👍 Event usage is wrapped with `useUnit` const GoodButton = () => { - const incrementEvent = useUnit(increment) + const onClick = useUnit(increment) - return + return } // 👎 Event is not wrapped with `useUnit` - component is not suitable for isomorphic SSR app @@ -22,3 +21,23 @@ const BadButton = () => { return } ``` + +This rule doesn't enforce using a `Scope` by itself – your app will run scopeless unless configured. However, when you do, `mandatory-scope-binding` rule ensures no additional work needed to ensure `Scope` is not lost. + +### Custom Hooks and Components + +You don't need `useUnit` everywhere – passing a unit straight to a custom `effector` aware hook or component whose signature openly declares a unit-typed parameter is fine. It is assumed the receiver takes responsibility of binding event to `Scope` via `useUnit`. + +```tsx +type Props = { event: EventCallable } +const PressButton = ({ event }: Props) => + +// 👍 PressButton's `event` is typed as a Unit – just pass it in, no issue +const Page = () => +``` + +::: warning Receiver Type Guarantee +A receiver typed as a plain function `(arg: T) => R` does not qualify as `effector`-aware. TypeScript's structural typing allows units to satisfy such signatures, but the receiver makes no promise to bind the unit to a `Scope`. + +To fix this, either type the parameter explicitly as a unit (`EventCallable` / `Effect`) to signal that the receiver is responsible for scope binding, or wrap the unit with `useUnit` at the call site. +::: diff --git a/src/rules/mandatory-scope-binding/mandatory-scope-binding.test.ts b/src/rules/mandatory-scope-binding/mandatory-scope-binding.test.ts index 9030196..b340bc8 100644 --- a/src/rules/mandatory-scope-binding/mandatory-scope-binding.test.ts +++ b/src/rules/mandatory-scope-binding/mandatory-scope-binding.test.ts @@ -37,23 +37,6 @@ ruleTester.run("mandatory-scope-binding", rule, { } `, }, - { - name: "store via useStore", - code: tsx` - import React from "react" - import { useStore } from "effector-react" - - import { fetchFx } from "${fixture("model")}" - - const Button: React.FC = () => { - const loading = useStore(fetchFx.pending) - - if (loading) return null - - return - } - `, - }, { name: "effect via useUnit", code: tsx` @@ -117,7 +100,7 @@ ruleTester.run("mandatory-scope-binding", rule, { import React from "react" import { useUnit } from "effector-react" - import { fetchFx, clicked } from "${fixture("model")}" + import { fetchFx, clicked, mounted } from "${fixture("model")}" const Button = () => { const { fn, mount, loading } = useUnit({ fn: clicked, mount: mounted, loading: fetchFx.pending }) @@ -165,132 +148,202 @@ ruleTester.run("mandatory-scope-binding", rule, { `, }, { - name: "scope package import", + name: "hook argument declaration", code: tsx` import React from "react" - import { useStore } from "effector-react/scope" + import { type EventCallable } from "effector" + import { useUnit } from "effector-react" - import { fetchFx } from "${fixture("model")}" + function useMounted(event: EventCallable) { + const fn = useUnit(event) - export const Render = () => <>{useStore(fetchFx.pending)} + React.useEffect(() => void fn(), []) + } `, }, { - name: "aliased import", + name: "static metadata access on a unit", code: tsx` import React from "react" - import { useUnit as use } from "effector-react/scope" - import { fetchFx } from "${fixture("model")}" + import { clicked, fetchFx } from "${fixture("model")}" - export const Render = () => <>{use(fetchFx.pending)} + const Button = () => ( +
+ {clicked.shortName} +
+ ) `, }, { - name: "star import", + name: "event via custom effector hook", code: tsx` import React from "react" - import * as eff from "effector-react" + import { type EventCallable } from "effector" + import { mounted } from "${fixture("model")}" - import { fetchFx } from "${fixture("model")}" + declare function useMounted(event: EventCallable): void - export const Render = () => <>{eff.useUnit(fetchFx.pending)} + function Component() { + useMounted(mounted) + + return + } `, }, - ], - invalid: [ { - name: "event: annotated", + name: "event via custom effector hook (generic)", code: tsx` import React from "react" - import { createEvent } from "effector" + import { type EventCallable } from "effector" + import { mounted } from "${fixture("model")}" - const clicked = createEvent() + declare function useThing>(unit: T): void - const Button: React.FC = () => { - return + function Component() { + useThing(mounted) + + return } `, - errors: [{ messageId: "useUnitNeeded", line: 7, column: 27, data: { name: "clicked" } }], }, { - name: "event: inferred forwardRef", + name: "event in custom effector component", code: tsx` import React from "react" - import { createEvent } from "effector" + import { type EventCallable } from "effector" + import { mounted } from "${fixture("model")}" - import { clicked } from "${fixture("model")}" + type Props = { onPress: EventCallable } + const MyButton = (props: Props) => null - const Button = React.forwardRef((props, ref) => + } + `, + }, + { + name: "event via custom effector hook (object shape)", + code: tsx` + import React from "react" + import { type EventCallable } from "effector" + import { mounted } from "${fixture("model")}" - const Button = () => - const Link = () => { - return click + type Config = { onEnter: EventCallable } + declare function useLifecycle(cfg: Config): void + + function Component() { + useLifecycle({ onEnter: mounted }) + + return } `, - errors: [ - { messageId: "useUnitNeeded", line: 6, column: 39, data: { name: "clicked" } }, - { messageId: "useUnitNeeded", line: 8, column: 22, data: { name: "clicked" } }, - ], }, { - name: "event: function expression inferred jsx", + name: "event via custom effector hook (object shape shorthand)", + code: tsx` + import React from "react" + import { type EventCallable, createEvent } from "effector" + + const onEnter = createEvent() + + type Config = { onEnter: EventCallable } + declare function useLifecycle(cfg: Config): void + + function Component() { + useLifecycle({ onEnter }) + + return + } + `, + }, + { + name: "event (member) via custom hook (object shape)", + code: tsx` + import React from "react" + import { type EventCallable } from "effector" + import * as model from "${fixture("model")}" + + type Config = { onEnter: EventCallable } + declare function useLifecycle(cfg: Config): void + + function Component() { + useLifecycle({ onEnter: model.mounted }) + + return + } + `, + }, + { + name: "event (member) in custom effector component", + code: tsx` + import React from "react" + import { type EventCallable } from "effector" + import * as model from "${fixture("model")}" + + type Props = { onPress: EventCallable } + const MyButton = (props: Props) => null + + function Component() { + return + } + `, + }, + ], + invalid: [ + { + name: "event via plain component (react annotated)", code: tsx` import React from "react" import { createEvent } from "effector" - import { clicked } from "${fixture("model")}" + const clicked = createEvent() - const Button = function ButtonView() { + const Button: React.FC = () => { return } `, errors: [{ messageId: "useUnitNeeded", line: 7, column: 27, data: { name: "clicked" } }], }, { - name: "event: function declaration inferred jsx", + name: "event via plain component (inferred via forwardRef)", code: tsx` import React from "react" import { createEvent } from "effector" import { clicked } from "${fixture("model")}" - function Button() { - return - } + const Button = React.forwardRef((props, ref) => - } + const Button = () => `, - errors: [{ messageId: "useUnitNeeded", line: 7, column: 27, data: { name: "fetchFx" } }], + errors: [{ messageId: "useUnitNeeded", line: 6, column: 39, data: { name: "clicked" } }], }, { - name: "effect: inferred memo", + name: "effect via plain component (inferred via memo)", code: tsx` import { memo } from "react" import { createEvent } from "effector" @@ -302,7 +355,7 @@ ruleTester.run("mandatory-scope-binding", rule, { errors: [{ messageId: "useUnitNeeded", line: 6, column: 49, data: { name: "fetchFx" } }], }, { - name: "effect in useEffect", + name: "effect call in useEffect", code: tsx` import React from "react" @@ -317,25 +370,7 @@ ruleTester.run("mandatory-scope-binding", rule, { errors: [{ messageId: "useUnitNeeded", line: 6, column: 30, data: { name: "fetchFx" } }], }, { - name: "event in useEffect cleanup", - code: tsx` - import React from "react" - import { createEvent } from "effector" - - const unmounted = createEvent() - - function Button() { - React.useEffect(() => { - return () => unmounted() - }, []) - - return - } - `, - errors: [{ messageId: "useUnitNeeded", line: 8, column: 18, data: { name: "unmounted" } }], - }, - { - name: "event in callback", + name: "event call in callback", code: tsx` import React from "react" @@ -346,22 +381,7 @@ ruleTester.run("mandatory-scope-binding", rule, { errors: [{ messageId: "useUnitNeeded", line: 5, column: 45, data: { name: "clicked" } }], }, { - name: "effect in callback", - code: tsx` - import { useEvent } from "react" - - import { clicked } from "${fixture("model")}" - - function Button() { - const fn = useEvent(clicked) - - return - } - `, - errors: [{ messageId: "useUnitNeeded", line: 6, column: 23, data: { name: "clicked" } }], - }, - { - name: "event inside hook", + name: "event call inside hook", code: tsx` import { useEffect } from "react" @@ -374,7 +394,7 @@ ruleTester.run("mandatory-scope-binding", rule, { errors: [{ messageId: "useUnitNeeded", line: 6, column: 16, data: { name: "clicked" } }], }, { - name: "event inside weird hook (name inference)", + name: "event call inside weird hook (name inference)", code: tsx` import { useEffect } from "react" @@ -394,7 +414,7 @@ ruleTester.run("mandatory-scope-binding", rule, { ], }, { - name: "component with union return type", + name: "react component with union return type", code: tsx` import React from "react" import { createEvent } from "effector" @@ -409,7 +429,7 @@ ruleTester.run("mandatory-scope-binding", rule, { errors: [{ messageId: "useUnitNeeded", line: 8, column: 32, data: { name: "clicked" } }], }, { - name: "component with union inferred contextual type", + name: "react component with union inferred contextual type", code: tsx` import React from "react" import { createEvent } from "effector" @@ -426,5 +446,139 @@ ruleTester.run("mandatory-scope-binding", rule, { { messageId: "useUnitNeeded", line: 9, column: 15, data: { name: "clicked" } }, ], }, + { + name: "event (member) direct call", + code: tsx` + import React from "react" + import * as model from "${fixture("model")}" + + function Button() { + React.useEffect(() => void model.clicked(), []) + + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 5, column: 30, data: { name: "clicked" } }], + }, + { + name: "event (member) in jsx plain component", + code: tsx` + import React from "react" + import * as model from "${fixture("model")}" + + const Button = () => + `, + errors: [{ messageId: "useUnitNeeded", line: 4, column: 39, data: { name: "clicked" } }], + }, + { + name: "event in useState (lazy initializer)", + code: tsx` + import React from "react" + import { useState } from "react" + import { clicked } from "${fixture("model")}" + + function Component() { + const [s, setS] = useState(clicked) + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 6, column: 30, data: { name: "clicked" } }], + }, + { + name: "event in custom non-unit hook", + code: tsx` + import React from "react" + import { mounted } from "${fixture("model")}" + + declare function useLeave(fn: () => void): void + + function Component() { + useLeave(mounted) + + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 7, column: 12, data: { name: "mounted" } }], + }, + { + name: "event in plain custom non-unit component", + code: tsx` + import React from "react" + import { mounted } from "${fixture("model")}" + + type Props = { onClick: () => void } + const MyButton = (props: Props) => null + + function Component() { + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 8, column: 29, data: { name: "mounted" } }], + }, + { + name: "event in custom plain hook (object shape)", + code: tsx` + import React from "react" + import { mounted } from "${fixture("model")}" + + type Config = { onLeave: () => void } + declare function useLifecycle(cfg: Config): void + + function Component() { + useLifecycle({ onLeave: mounted }) + + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 8, column: 27, data: { name: "mounted" } }], + }, + { + name: "event in custom plain hook (object shape shorthand)", + code: tsx` + import React from "react" + import { createEvent } from "effector" + + const onLeave = createEvent() + + type Config = { onLeave: () => void } + declare function useLifecycle(cfg: Config): void + + function Component() { + useLifecycle({ onLeave }) + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 10, column: 18, data: { name: "onLeave" } }], + }, + { + name: "effect (member) direct call", + code: tsx` + import React from "react" + import * as model from "${fixture("model")}" + + function Component() { + React.useEffect(() => void model.fetchFx(), []) + + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 5, column: 30, data: { name: "fetchFx" } }], + }, + { + name: "event (member) in custom plain hook", + code: tsx` + import React from "react" + import * as model from "${fixture("model")}" + + declare function useLeave(fn: () => void): void + + function Component() { + useLeave(model.mounted) + + return + } + `, + errors: [{ messageId: "useUnitNeeded", line: 7, column: 12, data: { name: "mounted" } }], + }, ], }) diff --git a/src/rules/mandatory-scope-binding/mandatory-scope-binding.ts b/src/rules/mandatory-scope-binding/mandatory-scope-binding.ts index b5fe6cc..9ac7bcb 100644 --- a/src/rules/mandatory-scope-binding/mandatory-scope-binding.ts +++ b/src/rules/mandatory-scope-binding/mandatory-scope-binding.ts @@ -1,6 +1,6 @@ -import { getContextualType, typeMatchesSpecifier } from "@typescript-eslint/type-utils" +import { getContextualType } from "@typescript-eslint/type-utils" import { ESLintUtils, type TSESTree as Node } from "@typescript-eslint/utils" -import { isExpression } from "typescript" +import ts from "typescript" import { createRule } from "@/shared/create" import { isType } from "@/shared/is" @@ -11,11 +11,10 @@ export default createRule({ meta: { type: "problem", docs: { - description: "Forbid `Event` and `Effect` usage without `useUnit` in React components.", + description: "Forbid `Event` and `Effect` usage without `useUnit` in React.", }, messages: { - useUnitNeeded: - '"{{ name }}" must be wrapped with `useUnit` from `effector-react` before usage inside React components.', + useUnitNeeded: '"{{ name }}" must be wrapped with `useUnit` from `effector-react` before usage inside React.', }, schema: [], }, @@ -24,20 +23,51 @@ export default createRule({ const services = ESLintUtils.getParserServices(context) const checker = services.program.getTypeChecker() - const stack = { render: [] as boolean[], hook: [] as boolean[] } + const inRender: boolean[] = [] + const inHook: boolean[] = [] - type ComponentFunction = Node.FunctionDeclaration | Node.FunctionExpression | Node.ArrowFunctionExpression + /** check if the expression is used in a context specifically expecting a unit */ + const isExpectingUnit = (slot: UsageNode): boolean => { + const tsnode = services.esTreeNodeToTSNodeMap.get(slot) as ts.Expression + const type = checker.getContextualType(tsnode) + + if (type) return isType.event(type, services.program) || isType.effect(type, services.program) + else return false + } + + const check = (mode: "call" | "arg" | "prop" | "jsx", node: UsageNode) => { + const rendering = inRender.at(-1) ?? false + if (!rendering) return + + const type = services.getTypeAtLocation(node) + if (!isType.event(type, services.program) && !isType.effect(type, services.program)) return + + if (mode === "call") return report(node) // direct call => always dangerous + + const delegated = isExpectingUnit(node), + // jsx receivers and `use*` callees are contractually *assumed* to bind, so we can + // delegate scope binding to them; any other call carries no such guarantee + eligible = mode === "jsx" || (inHook.at(-1) ?? false) + + if (eligible && delegated) return + else return report(node) + } + + const report = (node: UsageNode) => { + const name = nameOf.expression.simple(node) ?? "" + context.report({ node, messageId: "useUnitNeeded", data: { name } }) + } return { // detect react render contexts - [`FunctionDeclaration, FunctionExpression, ArrowFunctionExpression`]: (node: ComponentFunction) => { + [`:matches(${selector.function})`]: (node: ComponentNode) => { // propagate when already in render context (callbacks and general purpose hooks) - const current = stack.render.at(-1) ?? false - if (current) return void stack.render.push(true) + const current = inRender.at(-1) ?? false + if (current) return void inRender.push(true) /* === detect a react hook === */ const name = nameOf.function(node) - if (name && UseRegex.test(name.name)) return void stack.render.push(true) + if (name && UseRegex.test(name.name)) return void inRender.push(true) const tsnode = services.esTreeNodeToTSNodeMap.get(node) @@ -49,51 +79,75 @@ export default createRule({ ? returnType.types.some((type) => isType.jsx(type, services.program)) : isType.jsx(returnType, services.program) - if (isJSX) return void stack.render.push(true) + if (isJSX) return void inRender.push(true) /* === detect a react component by inferred contextual type === */ - const inferred = (isExpression(tsnode) && getContextualType(checker, tsnode)) || checker.getUnknownType() + const inferred = (ts.isExpression(tsnode) && getContextualType(checker, tsnode)) || checker.getUnknownType() const isComponent = inferred.isUnion() ? inferred.types.some((type) => isType.component(type, services.program)) : isType.component(inferred, services.program) - if (isComponent) return void stack.render.push(true) + if (isComponent) return void inRender.push(true) - return void stack.render.push(false) + return void inRender.push(false) }, - [`:matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression):exit`]: () => - void stack.render.pop(), + [`:matches(${selector.function}):exit`]: () => void inRender.pop(), // bail from tracking classes - "ClassDeclaration": () => void stack.render.push(false), - "ClassDeclaration:exit": () => void stack.render.pop(), + "ClassDeclaration": () => void inRender.push(false), + "ClassDeclaration:exit": () => void inRender.pop(), + // detect contexts where we may delegate `useUnit` binding to the callee "CallExpression": (node: Node.CallExpression) => { - const type = services.getTypeAtLocation(node.callee) - - const hook = ["useStore", "useStoreMap", "useList", "useEvent", "useUnit"] // useGate excluded - const specifier = { from: "package" as const, package: "effector-react", name: hook } + const id = nameOf.callee(node.callee), + isEnteringHook = id !== null && UseRegex.test(id.name) - const isHook = typeMatchesSpecifier(type, specifier, services.program) - return void stack.hook.push(isHook) + inHook.push(isEnteringHook) }, + "CallExpression:exit": () => void inHook.pop(), - "Identifier": (node: Node.Identifier) => { - const isWithinRender = stack.render.at(-1) ?? false - if (!isWithinRender) return + // direct invocation site `event()` & `model.event()` + // - receiver is being invoked directly, always dangerous + [`${selector.callee.direct}, ${selector.callee.member}`]: (node: UsageNode) => check("call", node), - const isWithinHook = stack.hook.at(-1) ?? false - if (isWithinHook) return + // argument position `fn(event)` & `fn(model.event)` + // - dangerous unless scope binding delegated (arg expects unit) + [`${selector.arg.direct}, ${selector.arg.member}`]: (node: UsageNode) => check("arg", node), - const type = services.getTypeAtLocation(node) - if (!isType.event(type, services.program) && !isType.effect(type, services.program)) return + // one-level-deep object-property position `fn({ key: event })` & `fn({ key: model.event })` + // - dangerous unless scope binding delegated (key expects unit) + [`${selector.prop.direct}, ${selector.prop.member}`]: (node: UsageNode) => check("prop", node), - context.report({ node, messageId: "useUnitNeeded", data: { name: node.name } }) - }, + // jsx expression slot ``, `` + // - dangerous unless scope binding delegated (prop expects unit) + [`${selector.jsx.direct}, ${selector.jsx.member}`]: (node: UsageNode) => check("jsx", node), } }, }) +type ComponentNode = Node.FunctionDeclaration | Node.FunctionExpression | Node.ArrowFunctionExpression +type UsageNode = Node.Identifier | Node.MemberExpression + const UseRegex = /^use[A-Z0-9].*$/ + +const selector = { + function: "FunctionDeclaration, FunctionExpression, ArrowFunctionExpression", + callee: { + direct: "CallExpression > Identifier.callee", + member: "CallExpression > MemberExpression[computed=false].callee", + }, + arg: { + direct: "CallExpression > Identifier:not(.callee)", + member: "CallExpression > MemberExpression[computed=false]:not(.callee)", + }, + prop: { + direct: "CallExpression > ObjectExpression > Property > Identifier.value", + member: "CallExpression > ObjectExpression > Property > MemberExpression[computed=false].value", + }, + jsx: { + direct: "JSXExpressionContainer > Identifier", + member: "JSXExpressionContainer > MemberExpression[computed=false]", + }, +} diff --git a/src/rules/no-getState/no-getState.ts b/src/rules/no-getState/no-getState.ts index bc69cdc..ff61bfb 100644 --- a/src/rules/no-getState/no-getState.ts +++ b/src/rules/no-getState/no-getState.ts @@ -1,7 +1,8 @@ -import { ESLintUtils, type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" +import { ESLintUtils, type TSESTree as Node } from "@typescript-eslint/utils" import { createRule } from "@/shared/create" import { isType } from "@/shared/is" +import { nameOf } from "@/shared/name" export default createRule({ name: "no-getState", @@ -31,7 +32,7 @@ export default createRule({ const isStore = isType.store(type, services.program) if (!isStore) return - const name = toName(node.callee.object) + const name = nameOf.expression.simple(node.callee.object) if (name) context.report({ node, messageId: "named", data: { name } }) else context.report({ node, messageId: "anonymous" }) @@ -39,9 +40,3 @@ export default createRule({ } }, }) - -const toName = (node: Node.Expression) => { - if (node.type === NodeType.Identifier) return node.name - if (node.type === NodeType.MemberExpression && !node.computed) return node.property.name - return null -} diff --git a/src/shared/name.ts b/src/shared/name.ts index bf574e7..5f9c7f9 100644 --- a/src/shared/name.ts +++ b/src/shared/name.ts @@ -2,6 +2,7 @@ import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-e type FunctionNode = Node.FunctionDeclaration | Node.FunctionExpression | Node.ArrowFunctionExpression +// infer function name from its declaration or assignment function functionToName(node: FunctionNode): Node.Identifier | null { if (node.id) return node.id @@ -19,4 +20,18 @@ function functionToName(node: FunctionNode): Node.Identifier | null { return null } -export const nameOf = { function: functionToName } +// extract callee (function) name as invoked +function calleeToName(callee: Node.Expression): Node.Identifier | null { + if (callee.type === NodeType.Identifier) return callee + else if (callee.type === NodeType.MemberExpression && callee.property.type === NodeType.Identifier) + return callee.property + else return null +} + +function simpleExpressionToName(node: Node.Expression): string | null { + if (node.type === NodeType.Identifier) return node.name + if (node.type === NodeType.MemberExpression && !node.computed) return node.property.name + return null +} + +export const nameOf = { function: functionToName, callee: calleeToName, expression: { simple: simpleExpressionToName } }