UNPKG

eslint-plugin-effector

Version:

Enforcing best practices for Effector

1,355 lines 62.2 kB
import { ASTUtils, AST_NODE_TYPES, ESLintUtils } from "@typescript-eslint/utils"; import { getContextualType, typeMatchesSpecifier } from "@typescript-eslint/type-utils"; import ts, { isExpression } from "typescript"; import esquery from "esquery"; //#region package.json var name = "eslint-plugin-effector"; var version = "0.19.0"; //#endregion //#region src/shared/create.ts const createRule = ESLintUtils.RuleCreator((name) => `https://eslint.effector.dev/rules/${name}`); //#endregion //#region src/shared/is.ts const check$1 = (symbol, types, from) => { const name = symbol.getName(); const declarations = symbol.declarations ?? []; return types.includes(name) && declarations.map((decl) => decl.getSourceFile().fileName).some((fname) => fname.includes("node_modules") && fname.includes(from)); }; const isType = { store: (type, program) => typeMatchesSpecifier(type, { from: "package", package: "effector", name: ["Store", "StoreWritable"] }, program), event: (type, program) => typeMatchesSpecifier(type, { from: "package", package: "effector", name: ["Event", "EventCallable"] }, program), effect: (type, program) => typeMatchesSpecifier(type, { from: "package", package: "effector", name: "Effect" }, program), domain: (type, program) => typeMatchesSpecifier(type, { from: "package", package: "effector", name: "Domain" }, program), unit: (type, program) => { return typeMatchesSpecifier(type, { from: "package", package: "effector", name: [ "Store", "StoreWritable", "Event", "EventCallable", "Effect", "Domain" ] }, program); }, gate: (type) => { const symbol = type.getSymbol() ?? type.aliasSymbol; return symbol ? check$1(symbol, ["Gate"], "effector") : false; }, jsx: (type, program) => { return typeMatchesSpecifier(type, { from: "package", package: "react", name: [ "Element", "ReactNode", "ReactElement" ] }, program); }, component: (type, program) => { return typeMatchesSpecifier(type, { from: "package", package: "react", name: [ "FC", "FunctionComponent", "ComponentType", "ComponentClass", "ForwardRefRenderFunction" ] }, program); } }; //#endregion //#region src/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.ts var enforce_effect_naming_convention_default = createRule({ name: "enforce-effect-naming-convention", meta: { type: "problem", docs: { description: "Enforce Fx as a suffix for any Effector Effect." }, messages: { invalid: "Effect \"{{ current }}\" should be named with `Fx` suffix, rename it to \"{{ fixed }}\"", rename: "Rename \"{{ current }}\" to \"{{ fixed }}\"" }, schema: [], hasSuggestions: true }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); return { [`${selector$14.variable}, ${selector$14.array.identifier}, ${selector$14.array.assignment}, ${selector$14.function.identifier}, ${selector$14.function.assignment}`]: (node) => { const type = services.getTypeAtLocation(node); if (!isType.effect(type, services.program)) return; const data = { current: node.name, fixed: node.name + "Fx" }; if (node.typeAnnotation) return context.report({ node, messageId: "invalid", data }); const suggestion = { messageId: "rename", data: { current: node.name, fixed: data.fixed }, fix: (fixer) => fixer.replaceText(node, data.fixed) }; context.report({ node, messageId: "invalid", data, suggest: [suggestion] }); }, [`${selector$14.shape.identifier}, ${selector$14.shape.assignment}`]: (node) => { const type = services.getTypeAtLocation(node.value); const ident = node.value.type === AST_NODE_TYPES.Identifier ? node.value : node.value.left; if (!isType.effect(type, services.program)) return; const data = { current: ident.name, fixed: ident.name + "Fx" }; const suggestion = { messageId: "rename", data: { current: ident.name, fixed: data.fixed }, fix: (fixer) => node.shorthand ? fixer.insertTextAfter(node.key, `: ${data.fixed}`) : fixer.replaceText(ident, data.fixed) }; context.report({ node: ident, messageId: "invalid", data, suggest: [suggestion] }); } }; } }); const FxRegex = /Fx$/; const selector$14 = { variable: `VariableDeclarator > Identifier.id[name!=${FxRegex}]`, array: { identifier: `ArrayPattern > Identifier.elements[name!=${FxRegex}]`, assignment: `ArrayPattern > AssignmentPattern > Identifier.left[name!=${FxRegex}]` }, shape: { identifier: `ObjectPattern > Property:has(> Identifier.value[name!=${FxRegex}])`, assignment: `ObjectPattern > Property:has(> AssignmentPattern:has(> Identifier.left[name!=${FxRegex}]))` }, function: { identifier: `:function > Identifier.params[name!=${FxRegex}]`, assignment: `:function > AssignmentPattern > Identifier.left[name!=${FxRegex}]` } }; //#endregion //#region src/shared/package.ts const PACKAGE_NAME$1 = { core: /^effector(?:\u002Fcompat)?$/, react: /^effector-react$/, storage: /^@?effector-storage(\u002F[\w-]+)*$/ }; //#endregion //#region src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts var enforce_exhaustive_useUnit_destructuring_default = createRule({ name: "enforce-exhaustive-useUnit-destructuring", meta: { type: "problem", docs: { description: "Ensure all units passed to useUnit are properly destructured." }, messages: { unusedKey: "Property \"{{name}}\" is passed but not destructured.", missingKey: "Property \"{{name}}\" is destructured but not passed in the unit object." }, schema: [], defaultOptions: [] }, create(context) { const importedAs = /* @__PURE__ */ new Set(); return { [selector$13.import]: (node) => void importedAs.add(node.local.name), [`${selector$13.variable.shape}:has(> ${selector$13.call}:has(${selector$13.arg.shape}))`](node) { if (!importedAs.has(node.init.callee.name)) return; const provided = shapeToKeyMap(node.init.arguments[0]); const consumed = shapeToKeyMap(node.id); if (provided === null || consumed === null) return; for (const { type, name } of check(provided, consumed)) if (type === "unused") context.report({ node: node.init.arguments[0], messageId: "unusedKey", data: { name } }); else context.report({ node: node.id, messageId: "missingKey", data: { name } }); }, [`${selector$13.variable.list}:has(> ${selector$13.call}:has(${selector$13.arg.list}))`](node) { if (!importedAs.has(node.init.callee.name)) return; const provided = listToKeyMap(node.init.arguments[0]); const consumed = listToKeyMap(node.id); if (provided === null || consumed === null) return; for (const { type, name } of check(provided, consumed)) if (type === "unused") context.report({ node: node.init.arguments[0], messageId: "unusedKey", data: { name } }); else context.report({ node: node.id, messageId: "missingKey", data: { name } }); } }; } }); const selector$13 = { import: `ImportDeclaration[source.value=${PACKAGE_NAME$1.react}] > ImportSpecifier[imported.name=useUnit]`, variable: { shape: "VariableDeclarator[id.type=ObjectPattern]", list: "VariableDeclarator[id.type=ArrayPattern]" }, call: "CallExpression.init[arguments.length=1][callee.type=Identifier]", arg: { shape: "ObjectExpression.arguments", list: "ArrayExpression.arguments" } }; function toName$1(key, node) { if (node.type === AST_NODE_TYPES.Identifier) return node.name; if (node.type === AST_NODE_TYPES.Literal) return String(node.value); if (node.type === AST_NODE_TYPES.MemberExpression && node.property.type === AST_NODE_TYPES.Identifier) return `${toName$1(key, node.object)}.${node.property.name}`; return `<unknown at ${key}>`; } function toKey(prop) { if (prop.computed) return null; else if (prop.key.type === AST_NODE_TYPES.Identifier) return prop.key.name; else return prop.key.value; } function* check(provided, consumed) { for (const [key, node] of provided) if (!consumed.has(key)) yield { type: "unused", name: toName$1(key, node) }; for (const [key, node] of consumed) if (!provided.has(key)) yield { type: "missing", name: toName$1(key, node) }; } function shapeToKeyMap(shape) { const map = /* @__PURE__ */ new Map(); for (const prop of shape.properties) { if (prop.type !== AST_NODE_TYPES.Property) return null; const key = toKey(prop); if (key === null) return null; else map.set(key, prop.key); } return map; } function listToKeyMap(list) { const map = /* @__PURE__ */ new Map(); for (const [index, element] of list.elements.entries()) { if (element === null) continue; if (element.type === AST_NODE_TYPES.RestElement || element.type === AST_NODE_TYPES.SpreadElement) return null; map.set(index, element); } return map; } //#endregion //#region src/rules/enforce-gate-naming-convention/enforce-gate-naming-convention.ts var enforce_gate_naming_convention_default = createRule({ name: "enforce-gate-naming-convention", meta: { type: "problem", docs: { description: "Enforce a Gate is named capitalized like a React Component" }, messages: { invalid: "Gate \"{{ current }}\" should be named with first capital letter, rename it to \"{{ fixed }}\"", rename: "Rename \"{{ current }}\" to \"{{ fixed }}\"" }, schema: [], hasSuggestions: true }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); return { [`VariableDeclarator[id.name=${GateRegex}]`]: (node) => { const type = services.getTypeAtLocation(node); if (!isType.gate(type)) return; const current = node.id.name; const fixed = current[0].toUpperCase() + current.slice(1); const data = { current, fixed }; const suggestion = { messageId: "rename", data: { current, fixed }, fix: (fixer) => fixer.replaceText(node.id, fixed) }; context.report({ node: node.id, messageId: "invalid", data, suggest: [suggestion] }); } }; } }); const GateRegex = /^[^A-Z]/; //#endregion //#region src/rules/enforce-store-naming-convention/enforce-store-naming-convention.ts var enforce_store_naming_convention_default = createRule({ name: "enforce-store-naming-convention", meta: { type: "problem", docs: { description: "Enforce $ as a prefix/postfix for any Effector `Store`" }, messages: { invalid: "Store \"{{ current }}\" should be named with a `$` {{ convention }}, rename it to \"{{ fixed }}\"", rename: "Rename \"{{ current }}\" to \"{{ fixed }}\"" }, schema: [{ type: "object", properties: { mode: { type: "string", enum: ["prefix", "postfix"] } } }], hasSuggestions: true }, defaultOptions: [{ mode: "prefix" }], create: (context, [options]) => { const services = ESLintUtils.getParserServices(context); const selector = createSelector(options.mode === "prefix" ? PrefixRegex : PostfixRegex); const rename = (node) => { const trimmed = node.name.replace(options.mode === "prefix" ? /\$+$/g : /^\$+/g, ""); const fixed = options.mode === "prefix" ? `$${trimmed}` : `${trimmed}$`; return { current: node.name, convention: options.mode, fixed }; }; return { [`${selector.variable}, ${selector.array.identifier}, ${selector.array.assignment}, ${selector.function.identifier}, ${selector.function.assignment}`]: (node) => { const type = services.getTypeAtLocation(node); if (!isType.store(type, services.program)) return; const data = rename(node); if (node.typeAnnotation) return context.report({ node, messageId: "invalid", data }); const suggestion = { messageId: "rename", data: { current: node.name, fixed: data.fixed }, fix: (fixer) => fixer.replaceText(node, data.fixed) }; context.report({ node, messageId: "invalid", data, suggest: [suggestion] }); }, [`${selector.shape.identifier}, ${selector.shape.assignment}`]: (node) => { const type = services.getTypeAtLocation(node.value); const ident = node.value.type === AST_NODE_TYPES.Identifier ? node.value : node.value.left; if (!isType.store(type, services.program)) return; const data = rename(ident); const suggestion = { messageId: "rename", data: { current: ident.name, fixed: data.fixed }, fix: (fixer) => node.shorthand ? fixer.insertTextAfter(node.key, `: ${data.fixed}`) : fixer.replaceText(ident, data.fixed) }; context.report({ node: ident, messageId: "invalid", data, suggest: [suggestion] }); } }; } }); const PrefixRegex = /^[^$]/; const PostfixRegex = /[^$]$/; const createSelector = (regex) => ({ variable: `VariableDeclarator > Identifier.id[name=${regex}]`, array: { identifier: `ArrayPattern > Identifier.elements[name=${regex}]`, assignment: `ArrayPattern > AssignmentPattern > Identifier.left[name=${regex}]` }, shape: { identifier: `ObjectPattern > Property:has(> Identifier.value[name=${regex}])`, assignment: `ObjectPattern > Property:has(> AssignmentPattern:has(> Identifier.left[name=${regex}]))` }, function: { identifier: `:function > Identifier.params[name=${regex}]`, assignment: `:function > AssignmentPattern > Identifier.left[name=${regex}]` } }); //#endregion //#region src/rules/keep-options-order/keep-options-order.ts var keep_options_order_default = createRule({ name: "keep-options-order", meta: { type: "problem", docs: { description: "Enforce options order for Effector methods" }, messages: { invalidOrder: `Order of options should be \`{{ correctOrder }}\`, but found \`{{ currentOrder }}\`.`, changeOrder: "Sort options to follow the recommended order." }, schema: [], hasSuggestions: true }, defaultOptions: [], create: (context) => { const source = context.sourceCode; const imports = /* @__PURE__ */ new Set(); return { [`${`ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`} > ${selector$12.method}`]: (node) => imports.add(node.local.name), [`CallExpression${selector$12.call}:has(${selector$12.argument})`]: (node) => { if (!imports.has(node.callee.name)) return; const [config] = node.arguments; if (config.properties.some((prop) => prop.type === AST_NODE_TYPES.SpreadElement || prop.key.type !== AST_NODE_TYPES.Identifier)) return; const properties = config.properties; const current = properties.map((prop) => prop.key.name); if (isCorrectOrder(current)) return; const correctOrder = TRUE_ORDER.filter((item) => current.includes(item)); const othersOrder = current.filter((item) => !TRUE_ORDER.includes(item)); const order = [...correctOrder, ...othersOrder]; const snippets = properties.toSorted((a, b) => order.indexOf(a.key.name) - order.indexOf(b.key.name)).map((prop) => source.getText(prop)); const suggestion = { messageId: "changeOrder", fix: (fixer) => [fixer.replaceText(config, `{ ${snippets.join(", ")} }`)] }; const data = { correctOrder: correctOrder.join(" -> "), currentOrder: current.join(" -> ") }; context.report({ node: config, messageId: "invalidOrder", data, suggest: [suggestion] }); } }; } }); const TRUE_ORDER = [ "clock", "source", "filter", "fn", "target", "greedy", "batch", "name" ]; const selector$12 = { method: `ImportSpecifier[imported.name=/(sample|guard)/]`, call: `[callee.type="Identifier"][arguments.length=1]`, argument: `ObjectExpression.arguments` }; const isCorrectOrder = (current) => { let seen = -1; for (const item of current) { const index = TRUE_ORDER.indexOf(item); const placement = index === -1 ? Infinity : index; if (placement <= seen) return false; seen = placement; } return true; }; //#endregion //#region src/shared/name.ts function functionToName(node) { if (node.id) return node.id; if (node.parent.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier) return node.parent.id; if (node.parent.type === AST_NODE_TYPES.AssignmentExpression && node.parent.left.type === AST_NODE_TYPES.Identifier) return node.parent.left; if (node.parent.type === AST_NODE_TYPES.Property && node.parent.key.type === AST_NODE_TYPES.Identifier) return node.parent.key; if (node.parent.type === AST_NODE_TYPES.AssignmentPattern && node.parent.left.type === AST_NODE_TYPES.Identifier) return node.parent.left; return null; } function calleeToName(callee) { if (callee.type === AST_NODE_TYPES.Identifier) return callee; else if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) return callee.property; else return null; } function simpleExpressionToName(node) { if (node.type === AST_NODE_TYPES.Identifier) return node.name; if (node.type === AST_NODE_TYPES.MemberExpression && !node.computed) return node.property.name; return null; } const nameOf = { function: functionToName, callee: calleeToName, expression: { simple: simpleExpressionToName } }; //#endregion //#region src/rules/mandatory-scope-binding/mandatory-scope-binding.ts var mandatory_scope_binding_default = createRule({ name: "mandatory-scope-binding", meta: { type: "problem", docs: { 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." }, schema: [] }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); const checker = services.program.getTypeChecker(); const inRender = []; const inHook = []; /** check if the expression is used in a context specifically expecting a unit */ const isExpectingUnit = (slot) => { const tsnode = services.esTreeNodeToTSNodeMap.get(slot); const type = checker.getContextualType(tsnode); if (type) return isType.event(type, services.program) || isType.effect(type, services.program); else return false; }; const check = (mode, node) => { if (!(inRender.at(-1) ?? false)) return; const type = services.getTypeAtLocation(node); if (!isType.event(type, services.program) && !isType.effect(type, services.program)) return; if (mode === "call") return report(node); const delegated = isExpectingUnit(node); if ((mode === "jsx" || (inHook.at(-1) ?? false)) && delegated) return; else return report(node); }; const report = (node) => { const name = nameOf.expression.simple(node) ?? "<expression>"; context.report({ node, messageId: "useUnitNeeded", data: { name } }); }; return { [`:matches(${selector$11.function})`]: (node) => { if (inRender.at(-1) ?? false) return void inRender.push(true); const name = nameOf.function(node); if (name && UseRegex$1.test(name.name)) return void inRender.push(true); const tsnode = services.esTreeNodeToTSNodeMap.get(node); const signature = checker.getSignatureFromDeclaration(tsnode); const returnType = signature ? checker.getReturnTypeOfSignature(signature) : checker.getVoidType(); if (returnType.isUnion() ? returnType.types.some((type) => isType.jsx(type, services.program)) : isType.jsx(returnType, services.program)) return void inRender.push(true); const inferred = ts.isExpression(tsnode) && getContextualType(checker, tsnode) || checker.getUnknownType(); if (inferred.isUnion() ? inferred.types.some((type) => isType.component(type, services.program)) : isType.component(inferred, services.program)) return void inRender.push(true); inRender.push(false); }, [`:matches(${selector$11.function}):exit`]: () => void inRender.pop(), "ClassDeclaration": () => void inRender.push(false), "ClassDeclaration:exit": () => void inRender.pop(), "CallExpression": (node) => { const id = nameOf.callee(node.callee), isEnteringHook = id !== null && UseRegex$1.test(id.name); inHook.push(isEnteringHook); }, "CallExpression:exit": () => void inHook.pop(), [`${selector$11.callee.direct}, ${selector$11.callee.member}`]: (node) => check("call", node), [`${selector$11.arg.direct}, ${selector$11.arg.member}`]: (node) => check("arg", node), [`${selector$11.prop.direct}, ${selector$11.prop.member}`]: (node) => check("prop", node), [`${selector$11.jsx.direct}, ${selector$11.jsx.member}`]: (node) => check("jsx", node) }; } }); const UseRegex$1 = /^use[A-Z0-9].*$/; const selector$11 = { 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]" } }; //#endregion //#region src/shared/locate.ts const property = (key, node) => node.properties.find((prop) => prop.type == AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === key); const locate = { property }; //#endregion //#region src/rules/no-ambiguity-target/no-ambiguity-target.ts var no_ambiguity_target_default = createRule({ name: "no-ambiguity-target", meta: { type: "problem", docs: { description: "Forbid ambiguous target in `sample` and `guard`." }, messages: { ambiguous: "Method `{{ method }}` both specifies `target` option and assigns the result to a variable. Consider removing one of them." }, schema: [] }, defaultOptions: [], create: (context) => { const imports = /* @__PURE__ */ new Set(); const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`; const usageStack = []; return { "ReturnStatement": () => usageStack.push(true), "ReturnStatement:exit": () => usageStack.pop(), "VariableDeclarator": () => usageStack.push(true), "VariableDeclarator:exit": () => usageStack.pop(), "ObjectExpression": () => usageStack.push(true), "ObjectExpression:exit": () => usageStack.pop(), "BlockStatement": () => usageStack.push(false), "BlockStatement:exit": () => usageStack.pop(), [`${importSelector} > ${selector$10.method}`]: (node) => imports.add(node.local.name), [`CallExpression[callee.type="Identifier"]`]: (node) => { if (!imports.has(node.callee.name)) return; if (!(usageStack.at(-1) ?? false)) return; const [config] = node.arguments; if (config?.type !== AST_NODE_TYPES.ObjectExpression) return; if (!locate.property("target", config)) return; context.report({ node, messageId: "ambiguous", data: { method: node.callee.name } }); } }; } }); const selector$10 = { method: `ImportSpecifier[imported.name=/(sample|guard)/]` }; //#endregion //#region src/rules/no-domain-unit-creators/no-domain-unit-creators.ts var no_domain_unit_creators_default = createRule({ name: "no-domain-unit-creators", meta: { type: "suggestion", docs: { description: "Disallow using Domain methods to create units." }, messages: { avoid: "Avoid using `.{{ method }}` on a Domain instance. Use a standard factory unit creator `{{ factory }}` with a `domain` option instead." }, schema: [] }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); return { [`CallExpression:has(> ${selector$9.member})`]: (node) => { const name = node.callee.property.name; if (!METHODS.has(name)) return; const type = services.getTypeAtLocation(node.callee.object); if (!isType.domain(type, services.program)) return; const factory = ALIAS_MAP.get(name) ?? name; context.report({ node, messageId: "avoid", data: { method: name, factory } }); } }; } }); const ALIAS_MAP = (/* @__PURE__ */ new Map()).set("event", "createEvent").set("store", "createStore").set("effect", "createEffect").set("domain", "createDomain"); const METHODS = new Set([...ALIAS_MAP.values(), ...ALIAS_MAP.keys()]); const selector$9 = { member: `MemberExpression.callee[property.type="Identifier"]` }; //#endregion //#region src/rules/no-duplicate-clock-or-source-array-values/no-duplicate-clock-or-source-array-values.ts var no_duplicate_clock_or_source_array_values_default = createRule({ name: "no-duplicate-clock-or-source-array-values", meta: { type: "problem", docs: { description: "Forbid providing duplicate units in `clock` and `source` arrays in `sample` and `guard`." }, messages: { duplicate: "`{{ field }}` contains a duplicate unit `{{ unit }}`.", remove: "Remove duplicate unit `{{ unit }}`." }, schema: [], hasSuggestions: true }, defaultOptions: [], create: (context) => { const imports = /* @__PURE__ */ new Set(); const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`; const analyze = (node, field) => { const seen = /* @__PURE__ */ new Map(); const entries = node.elements.filter((item) => item !== null).filter((item) => item.type !== AST_NODE_TYPES.SpreadElement); for (const entry of entries) { const root = traverseToRoot$1(entry); if (!root) continue; const name = [root.node.name, ...root.path].join("."); if (seen.has(name)) report(entry, name, field); else seen.set(name, entry); } }; const report = (node, name, field) => { const data = { field, unit: name }; const suggestion = { messageId: "remove", data: { unit: name }, fix: function* (fixer) { yield fixer.remove(node); const before = context.sourceCode.getTokenBefore(node); if (before?.value === ",") yield fixer.remove(before); } }; context.report({ node, messageId: "duplicate", data, suggest: [suggestion] }); }; return { [`${importSelector} > ${selector$8.method}`]: (node) => imports.add(node.local.name), [`CallExpression${selector$8.call}:has(${selector$8.argument})`]: (node) => { if (!imports.has(node.callee.name)) return; const [config] = node.arguments; const clock = locate.property("clock", config); const source = locate.property("source", config); if (clock?.value?.type === AST_NODE_TYPES.ArrayExpression) analyze(clock.value, "clock"); if (source?.value?.type === AST_NODE_TYPES.ArrayExpression) analyze(source.value, "source"); } }; } }); const selector$8 = { method: `ImportSpecifier[imported.name=/(sample|guard)/]`, call: `[callee.type="Identifier"][arguments.length=1]`, argument: `ObjectExpression.arguments` }; function traverseToRoot$1(node, path = []) { if (node.type === AST_NODE_TYPES.Identifier) return { node, path }; if (node.type === AST_NODE_TYPES.MemberExpression && node.property.type === AST_NODE_TYPES.Identifier) return traverseToRoot$1(node.object, [node.property.name, ...path]); return null; } //#endregion //#region src/rules/no-duplicate-on/no-duplicate-on.ts var no_duplicate_on_default = createRule({ name: "no-duplicate-on", meta: { type: "problem", docs: { description: "Forbid duplicate `.on` calls on Stores." }, messages: { duplicate: "Method `.on` is called on store `{{ store }}` more than once for `{{ unit }}`." }, schema: [] }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); const map = /* @__PURE__ */ new Map(); return { [`CallExpression[callee.property.name="on"]`]: (node) => { const type = services.getTypeAtLocation(node.callee.object); if (!isType.store(type, services.program)) return; const arg = node.arguments[0]; if (!arg || arg.type === AST_NODE_TYPES.SpreadElement) return; const units = arg.type === AST_NODE_TYPES.ArrayExpression ? arg.elements.filter((item) => item !== null && item.type !== AST_NODE_TYPES.SpreadElement) : [arg]; const scope = context.sourceCode.getScope(node); const store = identify("store", node.callee.object, scope); if (!store) return; const set = map.get(store.id) ?? /* @__PURE__ */ new Set(); for (const unit of units) { const instance = identify("unit", unit, scope); if (!instance) continue; if (set.has(instance.id)) { const data = { store: store.name, unit: instance.name }; context.report({ messageId: "duplicate", node: unit, data }); } else set.add(instance.id); } map.set(store.id, set); } }; } }); function traverseToRoot(node, path = []) { if (node.type === AST_NODE_TYPES.Identifier) return { node, path }; if (node.type === AST_NODE_TYPES.MemberExpression && node.property.type === AST_NODE_TYPES.Identifier) return traverseToRoot(node.object, [node.property.name, ...path]); return null; } const STORE_METHODS = ["on", "reset"]; function traverseStoreToRoot(node, path = []) { if (node.type === AST_NODE_TYPES.Identifier) return { node, path }; if (node.type === AST_NODE_TYPES.MemberExpression && node.property.type === AST_NODE_TYPES.Identifier) return traverseStoreToRoot(node.object, [node.property.name, ...path]); if (node.type === AST_NODE_TYPES.CallExpression && node.callee.type === AST_NODE_TYPES.MemberExpression) { if (node.callee.property.type === AST_NODE_TYPES.Identifier && STORE_METHODS.includes(node.callee.property.name)) return traverseStoreToRoot(node.callee.object, path); } return null; } function raiseStoreToVariable(node) { let current = node; while (current.parent) { if (current.parent.type === AST_NODE_TYPES.VariableDeclarator) return current.parent; if (current.parent.type !== AST_NODE_TYPES.MemberExpression || current.parent.object !== current) return null; if (current.parent.property.type !== AST_NODE_TYPES.Identifier || !STORE_METHODS.includes(current.parent.property.name)) return null; const grandparent = current.parent.parent; if (grandparent?.type !== AST_NODE_TYPES.CallExpression || grandparent.callee !== current.parent) return null; current = current.parent.parent; } return null; } function findSuitableRoot(type, node) { if (type === "unit") return traverseToRoot(node); const root = traverseStoreToRoot(node); if (root) return root; const declarator = raiseStoreToVariable(node); if (declarator && declarator.id.type === AST_NODE_TYPES.Identifier) return { node: declarator.id, path: [] }; return null; } function identify(type, node, scope) { const root = findSuitableRoot(type, node); if (!root) return null; const variable = ASTUtils.findVariable(scope, root.node); if (!variable) return null; return { id: `${variable.$id}+${root.path.join(".")}`, name: [variable.name, ...root.path].join(".") }; } //#endregion //#region src/rules/no-forward/no-forward.ts var no_forward_default = createRule({ name: "no-forward", meta: { type: "problem", docs: { description: "Prefer `sample` over `forward`." }, messages: { noForward: "Use `sample` operator instead of `forward` as a more universal approach.", replaceWithSample: "Replace `forward` with `sample`." }, hasSuggestions: true, schema: [] }, defaultOptions: [], create: (context) => { let sample; const forwards = /* @__PURE__ */ new Map(); const source = context.sourceCode; const visitorKeys = source.visitorKeys; const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`; return { [`${importSelector} > ${selector$7.forward}`]: (node) => forwards.set(node.local.name, node), [`${importSelector} > ${selector$7.sample}`]: (node) => sample = node.local.name, [`CallExpression${selector$7.call}:has(${selector$7.argument})`]: (node) => { if (!forwards.has(node.callee.name)) return; const config = {}; const arg = node.arguments[0]; config.clock = locate.property("from", arg)?.value; config.target = locate.property("to", arg)?.value; if (config.target) { const [call] = esquery.match(config.target, query$2.prepend, { visitorKeys }).map((node) => node).filter((node) => node === config.target); if (call) [config.target, config.fn] = [call.callee.object, call.arguments[0]]; } if (config.clock && !config.fn) { const [call] = esquery.match(config.clock, query$2.map, { visitorKeys }).map((node) => node).filter((node) => node === config.clock); if (call) [config.clock, config.fn] = [call.callee.object, call.arguments[0]]; } const code = [ "clock", "fn", "target" ].filter((key) => config[key] !== void 0).map((key) => `${key}: ${source.getText(config[key])}`).join(", "); context.report({ messageId: "noForward", node: node.callee, suggest: [{ messageId: "replaceWithSample", fix: function* (fixer) { const fn = sample ?? "sample"; yield fixer.replaceText(node, `${fn}({ ${code} })`); if (!sample) yield fixer.insertTextAfter(forwards.get(node.callee.name), `, sample`); } }] }); } }; } }); const selector$7 = { forward: `ImportSpecifier[imported.name="forward"]`, sample: `ImportSpecifier[imported.name="sample"]`, call: `[callee.type="Identifier"][arguments.length=1]`, argument: `ObjectExpression.arguments` }; const query$2 = { map: esquery.parse("CallExpression[arguments.length=1]:has(> :first-child:expression.arguments):has(> MemberExpression.callee:has(Identifier.property[name='map']))"), prepend: esquery.parse("CallExpression[arguments.length=1]:has(> :first-child:expression.arguments):has(> MemberExpression.callee:has(Identifier.property[name='prepend']))") }; //#endregion //#region src/rules/no-getState/no-getState.ts var no_getState_default = createRule({ name: "no-getState", meta: { type: "problem", docs: { description: "Forbid `.getState` calls on Effector stores." }, messages: { named: "Method `.getState` used on store `{{ name }}` can lead to race conditions. Replace with with `sample` or `attach`.", anonymous: "Method `.getState` used on store can lead to race conditions. Replace with with `sample` or `attach`." }, schema: [] }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); return { [`CallExpression[callee.type="MemberExpression"][callee.property.name="getState"]`]: (node) => { const type = services.getTypeAtLocation(node.callee.object); if (!isType.store(type, services.program)) return; const name = nameOf.expression.simple(node.callee.object); if (name) context.report({ node, messageId: "named", data: { name } }); else context.report({ node, messageId: "anonymous" }); } }; } }); //#endregion //#region src/rules/no-guard/no-guard.ts var no_guard_default = createRule({ name: "no-guard", meta: { type: "problem", docs: { description: "Prefer `sample` over `guard`." }, messages: { noGuard: "Use `sample` operator instead of `guard` as a more universal approach.", replaceWithSample: "Replace `guard` with `sample`." }, hasSuggestions: true, schema: [] }, defaultOptions: [], create: (context) => { let sample; const guards = /* @__PURE__ */ new Map(); const source = context.sourceCode; const visitorKeys = source.visitorKeys; const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`; return { [`${importSelector} > ${selector$6.guard}`]: (node) => guards.set(node.local.name, node), [`${importSelector} > ${selector$6.sample}`]: (node) => sample = node.local.name, [`CallExpression${selector$6.call}`]: (node) => { if (!guards.has(node.callee.name)) return; const config = {}; if (node.arguments.length === 1 && node.arguments[0].type === AST_NODE_TYPES.ObjectExpression) { const [arg] = node.arguments; for (const key of [ "clock", "source", "filter", "target" ]) config[key] = locate.property(key, arg)?.value; } else if (node.arguments.length === 2 && node.arguments[1].type === AST_NODE_TYPES.ObjectExpression) { const [clock, arg] = node.arguments; config.clock = clock; for (const key of [ "source", "filter", "target" ]) config[key] = locate.property(key, arg)?.value; } else return; if (config.target) { const [call] = esquery.match(config.target, query$1.prepend, { visitorKeys }).map((node) => node).filter((node) => node === config.target); if (call) [config.target, config.fn] = [call.callee.object, call.arguments[0]]; } const code = [ "clock", "source", "filter", "fn", "target" ].filter((key) => config[key] !== void 0).map((key) => `${key}: ${source.getText(config[key])}`).join(", "); context.report({ messageId: "noGuard", node: node.callee, suggest: [{ messageId: "replaceWithSample", fix: function* (fixer) { const fn = sample ?? "sample"; yield fixer.replaceText(node, `${fn}({ ${code} })`); if (!sample) yield fixer.insertTextAfter(guards.get(node.callee.name), `, sample`); } }] }); } }; } }); const selector$6 = { guard: `ImportSpecifier[imported.name="guard"]`, sample: `ImportSpecifier[imported.name="sample"]`, call: `[callee.type="Identifier"]` }; const query$1 = { prepend: esquery.parse("CallExpression[arguments.length=1]:has(:first-child:expression.arguments):has(> MemberExpression.callee:has(Identifier.property[name='prepend']))") }; //#endregion //#region src/rules/no-patronum-debug/no-patronum-debug.ts var no_patronum_debug_default = createRule({ name: "no-patronum-debug", meta: { type: "problem", docs: { description: "Disallow the use of `patronum` `debug`." }, messages: { unexpected: "Unexpected `debug` call.", remove: "Remove this `debug` call." }, schema: [], hasSuggestions: true }, defaultOptions: [], create: (context) => { const debugs = /* @__PURE__ */ new Set(); return { [`${`ImportDeclaration[source.value=${PACKAGE_NAME}]`} > ${selector$5.debug}`]: (node) => debugs.add(node.local.name), [`CallExpression:matches(${selector$5.call})`]: (node) => { const name = toName(node); if (!debugs.has(name)) return; context.report({ messageId: "unexpected", node: node.callee, suggest: [{ messageId: "remove", fix: (fixer) => { if (node.parent.type === AST_NODE_TYPES.ExpressionStatement) return fixer.remove(node.parent); else return fixer.replaceText(node, "undefined"); } }] }); } }; } }); const PACKAGE_NAME = /^patronum(?:\u002Fdebug)?$/; const selector$5 = { debug: `ImportSpecifier[imported.name="debug"]`, call: `[callee.type=Identifier], [callee.object.type=Identifier]` }; const toName = (node) => { switch (node.callee.type) { case AST_NODE_TYPES.Identifier: return node.callee.name; case AST_NODE_TYPES.MemberExpression: return node.callee.object.name; } }; //#endregion //#region src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts const EFFECTOR_FACTORIES = new Set([ "createStore", "createEvent", "createEffect", "createDomain", "createApi", "restore" ]); const EFFECTOR_OPERATORS = new Set([ "sample", "guard", "forward", "merge", "split", "combine", "attach" ]); const REACT_HOOKS_SPEC = { from: "package", package: "react", name: [ "useState", "useEffect", "useLayoutEffect", "useCallback", "useMemo", "useRef", "useReducer", "useImperativeHandle", "useDebugValue", "useDeferredValue", "useTransition", "useId", "useSyncExternalStore", "useInsertionEffect", "useContext" ] }; const EFFECTOR_FACTORY_SPEC = { from: "package", package: "effector", name: [...EFFECTOR_FACTORIES] }; const EFFECTOR_OPERATOR_SPEC = { from: "package", package: "effector", name: [...EFFECTOR_OPERATORS] }; const EFFECTOR_FACTORIO_SHAPE = [ "useModel", "createModel", "Provider", "@@unitShape" ]; var no_units_spawn_in_render_default = createRule({ name: "no-units-spawn-in-render", meta: { type: "problem", docs: { description: "Forbid creating Effector units or calling operators inside React components or hooks." }, messages: { noFactoryInRender: "Creating Effector units with \"{{ name }}\" inside React component or hook is forbidden, since it may cause memory leaks and other bugs.", noOperatorInRender: "Using Effector operator \"{{ name }}\" inside React component or hook is forbidden, since it may cause memory leaks and other bugs.", noCustomFactoryInRender: "Creating Effector units with \"{{ name }}\" inside React component or hook is forbidden, since it may cause memory leaks and other bugs. If this is a false positive, add \"{{ name }}\" to the allowlist in the detectCustomFactories option." }, schema: [{ type: "object", properties: { detectCustomFactories: { oneOf: [{ type: "boolean" }, { type: "object", properties: { allowlist: { type: "array", items: { type: "string" }, uniqueItems: true } }, required: ["allowlist"], additionalProperties: false }] } }, additionalProperties: false }] }, defaultOptions: [{ detectCustomFactories: true }], create: (context, [options]) => { const services = ESLintUtils.getParserServices(context); const checker = services.program.getTypeChecker(); const { detectCustomFactories } = options; const allowlist = typeof detectCustomFactories === "object" ? new Set(detectCustomFactories.allowlist) : void 0; const stack = { render: [] }; const effectorImports = /* @__PURE__ */ new Map(); return { [`${`ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`} > ImportSpecifier[imported.type="Identifier"]`]: (node) => { const imported = node.imported.name; const local = node.local.name; if (EFFECTOR_FACTORIES.has(imported)) effectorImports.set(local, "factory"); else if (EFFECTOR_OPERATORS.has(imported)) effectorImports.set(local, "operator"); }, [`FunctionDeclaration, FunctionExpression, ArrowFunctionExpression`]: (node) => { if (stack.render.at(-1) ?? false) return void stack.render.push(true); const name = nameOf.function(node); if (name && UseRegex.test(name.name)) return void stack.render.push(true); const tsnode = services.esTreeNodeToTSNodeMap.get(node); const signature = checker.getSignatureFromDeclaration(tsnode); const returnType = signature ? checker.getReturnTypeOfSignature(signature) : checker.getVoidType(); if (returnType.isUnion() ? returnType.types.some((type) => isType.jsx(type, services.program)) : isType.jsx(returnType, services.program)) return void stack.render.push(true); const inferred = isExpression(tsnode) && getContextualType(checker, tsnode) || checker.getUnknownType(); if (inferred.isUnion() ? inferred.types.some((type) => isType.component(type, services.program)) : isType.component(inferred, services.program)) return void stack.render.push(true); stack.render.push(false); }, [`:matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression):exit`]: () => void stack.render.pop(), "ClassDeclaration": () => void stack.render.push(false), "ClassDeclaration:exit": () => void stack.render.pop(), "CallExpression": (node) => { if (!(stack.render.at(-1) ?? false)) return; const calleeName = getCalleeName(node.callee); switch (calleeName ? effectorImports.get(calleeName) : void 0) { case "factory": return context.report({ node, messageId: "noFactoryInRender", data: { name: calleeName } }); case "operator": return context.report({ node, messageId: "noOperatorInRender", data: { name: calleeName } }); } if (detectCustomFactories === false) return; const returnType = services.getTypeAtLocation(node); if (!hasEffectorUnitInType({ node: services.esTreeNodeToTSNodeMap.get(node), checker, program: services.program }, returnType)) return; const calleeType = services.getTypeAtLocation(node.callee); const displayName = calleeName ?? "<expression>"; if (typeMatchesSpecifier(calleeType, REACT_HOOKS_SPEC, services.program)) return; if (isEffectorFactorioHook(node.callee, services.getTypeAtLocation)) return; if (typeMatchesSpecifier(calleeType, EFFECTOR_FACTORY_SPEC, services.program)) return context.report({ node, messageId: "noFactoryInRender", data: { name: displayName } }); if (typeMatchesSpecifier(calleeType, EFFECTOR_OPERATOR_SPEC, services.program)) return context.report({ node, messageId: "noOperatorInRender", data: { name: displayName } }); if (allowlist && calleeName && allowlist.has(calleeName)) return; context.report({ node, messageId: "noCustomFactoryInRender", data: { name: displayName } }); } }; } }); const UseRegex = /^use[A-Z0-9].*$/; function getCalleeName(callee) { if (callee.type === AST_NODE_TYPES.Identifier) return callee.name; if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) return callee.property.name; else return null; } function hasEffectorUnitInType(ctx, type, depth = 3) { if (isType.unit(type, ctx.program)) return true; if (depth <= 0) return false; if (type.isUnion()) return type.types.some((type) => hasEffectorUnitInType(ctx, type, depth)); for (const property of type.getProperties()) if (hasEffectorUnitInType(ctx, ctx.checker.getTypeOfSymbolAtLocation(property, ctx.node), depth - 1)) return true; return false; } function isEffectorFactorioHook(callee, getTypeAtLocation) { if (callee.type !== AST_NODE_TYPES.MemberExpression) return false; if (callee.property.type !== AST_NODE_TYPES.Identifier) return false; if (callee.property.name !== "useModel") return false; const objectType = getTypeAtLocation(callee.object); const propertyNames = new Set(objectType.getProperties().map((p) => p.getName())); return EFFECTOR_FACTORIO_SHAPE.every((name) => propertyNames.has(name)); } //#endregion //#region src/rules/no-unnecessary-combination/no-unnecessary-combination.ts var no_unnecessary_combination_default = createRule({ name: "no-unnecessary-combination", meta: { type: "suggestion", docs: { description: "Forbid unnecessary combinations in `clock` and `source`." }, messages: { unnecessary: "{{ method }} is used under the hood of {{ property }} in {{ operator }}, you can omit it." }, schema: [] }, defaultOptions: [], create: (context) => { const services = ESLintUtils.getParserServices(context); const operators = /* @__PURE__ */ new Set(); const combinators = /* @__PURE__ */ new Map(); const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`; return { [`${importSelector} > ${selector$4.operator}`]: (node) => operators.add(node.local.name), [`${importSelector} > ${selector$4.combinator}`]: (node) => combinators.set(node.local.name, node.imported.name), [`CallExpression${selector$4.call}:has(${selector$4.argument})`]: (node) => { if (!operators.has(node.callee.name)) return; const [config] = node.arguments; const clock = locate.property("clock", config)?.value; const source = locate.property("source", config)?.value; if (clock?.type === AST_NODE_TYPES.CallExpression && clock.callee.type === AST_NODE_TYPES.Identifier) { if (combinators.get(clock.callee.name) === "merge") { const data = { method: clock.callee.name, property: "clock", operator: node.callee.name }; context.report({ node: clock, messageId: "unnecessary", data }); } } if (source?.type === AST_NODE_TYPES.CallExpression && source.callee.type === AST_NODE_TYPES.Identifier) { const method = combinators.get(source.callee.name); if (!method) return; if (method === "combine" && source.arguments.length > 1 && isFunction(source.arguments.at(-1), services)) return; const data = { method: source.callee.name, property: "source", operator: node.callee.name }; context.report({ node: source, messageId: "unnecessary", data }); } } }; } }); const selector$4 = { operator: `ImportSpecifier[imported.name=/(sample|guard)/]`, combinator: `ImportSpecifier[imported.name=/(combine|merge)/]`, call: `[callee.type="Identifier"][arguments.length=1]`, argument: `ObjectExpression.arguments` }; function isFunction(node, services) { if (node.type === AST_NODE_TYPES.ArrowFunctionExpression) return true; else if (node.type === AST_NODE_TYPES.FunctionExpression) return true; else if (node.type === AST_NODE_TYPES.Identifier) { const checker = services.program.getTypeChecker(); const tsnode = services.esTreeNodeToTSNodeMap.get(node); return checker.getTypeAtLocation(tsnode).getCallSignatures().length > 0; } else return false; } //#endregion //#region src/rules/no-unnecessary-duplication/no-unnecessary-duplication.ts var no_unnecessary_duplication_default = createRule({ name: "no-unnecessary-duplication", meta: { type: "problem", docs: { description: "Forbid duplicate `source` and `clock` in `sample` and `guard`." }, messages: { duplicate: "Method `{{ method }}` has the same value for `source` and `clock`. Consider using only one of them.", removeClock: "Remove the `clock`", removeSource: "Remove the `source`" }, schema: [], hasSuggestions: true }, defaultOptions: [], create: (context) => { const imports = /* @__PURE__ */ new Set(); return { [`${`ImportDeclaration[source.value=${PACKAGE_NAME$1.core}]`} > ${selector$3.method}`]: (node) => imports.add(node