UNPKG

@ospm/eslint-plugin-react-signals-hooks

Version:

ESLint plugin for React Signals hooks - enforces best practices, performance optimizations, and integration patterns for @preact/signals-react usage in React projects

378 lines 20.8 kB
/** biome-ignore-all assist/source/organizeImports: off */ import { ESLintUtils, AST_NODE_TYPES, } from "@typescript-eslint/utils"; import { PerformanceOperations } from "./utils/performance-constants.js"; import { endPhase, startPhase, recordMetric, startTracking, trackOperation, createPerformanceTracker, DEFAULT_PERFORMANCE_BUDGET, } from "./utils/performance.js"; import { buildSuffixRegex, hasSignalSuffix } from "./utils/suffix.js"; import { getRuleDocUrl } from "./utils/urls.js"; function getSeverity(messageId, options) { if (!options?.severity) { return "error"; } switch (messageId) { case "invalidSignalName": { return options.severity.invalidSignalName ?? "error"; } case "invalidComputedName": { return options.severity.invalidComputedName ?? "error"; } default: { return "error"; } } } function isValidSignalName(name, suffixRegex) { if (!hasSignalSuffix(name, suffixRegex)) { return false; } if (!/^[a-z]/.test(name)) { return false; } if (name.startsWith("use") && name.length > 2 && typeof name[2] === "string" && /^[A-Z]/.test(name[2])) { return false; } return true; } function getFixedName(originalName, suffix) { let fixedName = originalName; if (fixedName.startsWith("use") && fixedName.length > 3) { fixedName = fixedName.slice(3); } if (fixedName.length > 0) { fixedName = fixedName.charAt(0).toLowerCase() + fixedName.slice(1); } if (!fixedName.endsWith(suffix)) { fixedName += suffix; } return fixedName; } const ruleName = "signal-variable-name"; export const signalVariableNameRule = ESLintUtils.RuleCreator((name) => { return getRuleDocUrl(name); })({ name: ruleName, meta: { type: "suggestion", fixable: "code", hasSuggestions: false, docs: { description: 'Enforces consistent naming conventions for signal and computed variables. Signal variables should end with "Signal" (e.g., `countSignal`), start with a lowercase letter, and not use the "use" prefix to avoid confusion with React hooks. This improves code readability and maintainability by making signal usage immediately obvious.', url: getRuleDocUrl(ruleName), }, messages: { invalidSignalName: "Signal variable '{{name}}' should end with '{{expectedSuffix}}', start with lowercase, and not start with 'use'", invalidComputedName: "Computed variable '{{name}}' should end with '{{expectedSuffix}}', start with lowercase, and not start with 'use'", }, schema: [ { type: "object", properties: { renameOnly: { type: "boolean", description: "When true, only rename identifiers without adding .value/.peek() accessors", default: false, }, performance: { type: "object", properties: { maxTime: { type: "number", minimum: 1 }, maxMemory: { type: "number", minimum: 1 }, maxNodes: { type: "number", minimum: 1 }, enableMetrics: { type: "boolean" }, logMetrics: { type: "boolean" }, maxOperations: { type: "object", properties: Object.fromEntries(Object.entries(PerformanceOperations).map(([key]) => [ key, { type: "number", minimum: 1 }, ])), }, }, additionalProperties: false, }, severity: { type: "object", properties: { invalidSignalName: { type: "string", enum: ["error", "warn", "off"], }, invalidComputedName: { type: "string", enum: ["error", "warn", "off"], }, }, additionalProperties: false, }, suffix: { type: "string", minLength: 1 }, }, additionalProperties: false, }, ], }, defaultOptions: [ { performance: DEFAULT_PERFORMANCE_BUDGET, renameOnly: false, }, ], create(context, [option]) { const perfKey = `${ruleName}:${context.filename}:${Date.now()}`; startPhase(perfKey, "ruleInit"); const perf = createPerformanceTracker(perfKey, option?.performance); if (option?.performance?.enableMetrics === true) { startTracking(context, perfKey, option.performance, ruleName); } if (option?.performance?.enableMetrics === true && option.performance.logMetrics === true) { console.info(`${ruleName}: Initializing rule for file: ${context.filename}`); console.info(`${ruleName}: Rule configuration:`, option); } recordMetric(perfKey, "config", { performance: { enableMetrics: option?.performance?.enableMetrics, logMetrics: option?.performance?.logMetrics, }, }); trackOperation(perfKey, PerformanceOperations.ruleInit); endPhase(perfKey, "ruleInit"); let nodeCount = 0; function shouldContinue() { nodeCount++; if (typeof option?.performance?.maxNodes === "number" && nodeCount > option.performance.maxNodes) { trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded); return false; } return true; } const suffix = typeof option?.suffix === "string" && option.suffix.length > 0 ? option.suffix : "Signal"; const suffixRegex = buildSuffixRegex(suffix); startPhase(perfKey, "ruleExecution"); // Track local identifiers and namespaces for creators const signalCreatorLocals = new Set(["signal"]); const computedCreatorLocals = new Set(["computed"]); const creatorNamespaces = new Set(); return { "*": (node) => { if (!shouldContinue()) { endPhase(perfKey, "recordMetrics"); return; } perf.trackNode(node); trackOperation(perfKey, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing); }, [AST_NODE_TYPES.Program](node) { for (const stmt of node.body) { if (stmt.type === AST_NODE_TYPES.ImportDeclaration && typeof stmt.source.value === "string" && stmt.source.value === "@preact/signals-react") { for (const spec of stmt.specifiers) { if (spec.type === AST_NODE_TYPES.ImportSpecifier) { if ("name" in spec.imported) { if (spec.imported.name === "signal") { signalCreatorLocals.add(spec.local.name); } else if (spec.imported.name === "computed") { computedCreatorLocals.add(spec.local.name); } } } else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) { creatorNamespaces.add(spec.local.name); } } } } }, [AST_NODE_TYPES.VariableDeclarator](node) { if (node.id.type !== AST_NODE_TYPES.Identifier) { return; } if (!node.init || node.init.type !== AST_NODE_TYPES.CallExpression) { return; } let kind = null; const callee = node.init.callee; if (callee.type === AST_NODE_TYPES.Identifier) { if (signalCreatorLocals.has(callee.name)) { kind = "signal"; } else if (computedCreatorLocals.has(callee.name)) { kind = "computed"; } } else if (callee.type === AST_NODE_TYPES.MemberExpression && callee.object.type === AST_NODE_TYPES.Identifier && creatorNamespaces.has(callee.object.name) && callee.property.type === AST_NODE_TYPES.Identifier && (callee.property.name === "signal" || callee.property.name === "computed")) { kind = callee.property.name; } if (kind === null) { return; } if (!isValidSignalName(node.id.name, suffixRegex)) { const messageId = kind === "signal" ? "invalidSignalName" : "invalidComputedName"; if (getSeverity(messageId, option) !== "off") { context.report({ node: node.id, messageId, data: { name: node.id.name, expectedSuffix: suffix, }, fix(fixer) { const fixes = []; try { if (!("name" in node.id)) { return []; } const fixedName = getFixedName(node.id.name, suffix); if (fixedName === node.id.name) { return []; } const currentScope = context.sourceCode.getScope(node); if (currentScope.set.has(fixedName)) { return []; } fixes.push(fixer.replaceText(node.id, fixedName)); const variable = context.sourceCode .getScope(node) .set.get(node.id.name); if (variable) { for (const reference of variable.references) { const ref = reference.identifier; if (ref.range[0] === node.id.range[0] && ref.range[1] === node.id.range[1]) { continue; } if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ref.parent?.type === AST_NODE_TYPES.MemberExpression && ref.parent.property === ref && !ref.parent.computed) { continue; } const ancestors = context.sourceCode.getAncestors(ref); const isJsx = ancestors.some((a) => { return (a.type === AST_NODE_TYPES.JSXElement || a.type === AST_NODE_TYPES.JSXFragment || a.type === AST_NODE_TYPES.JSXAttribute || a.type === AST_NODE_TYPES.JSXExpressionContainer || a.type === AST_NODE_TYPES.JSXSpreadAttribute); }); // In JSX attribute context? (either directly under JSXAttribute, or inside its expression container) const inJsxAttribute = ancestors.some((a, idx) => { if (a.type === AST_NODE_TYPES.JSXAttribute) { return true; } if (a.type === AST_NODE_TYPES.JSXExpressionContainer && idx > 0 && ancestors[idx - 1]?.type === AST_NODE_TYPES.JSXAttribute) { return true; } return false; }); // Determine if inside a component/hook function let inComponentScope = false; for (let i = ancestors.length - 1; i >= 0; i--) { // eslint-disable-next-line security/detect-object-injection const anc = ancestors[i]; if (!anc) { continue; } if (anc.type === AST_NODE_TYPES.FunctionDeclaration) { if (anc.id && /^[A-Z]/.test(anc.id.name)) { inComponentScope = true; } break; } if (anc.type === AST_NODE_TYPES.FunctionExpression || anc.type === AST_NODE_TYPES.ArrowFunctionExpression) { // Look for enclosing variable declarator with Uppercase name const vd = ancestors.find((x) => { return (x.type === AST_NODE_TYPES.VariableDeclarator); }); if (typeof vd !== "undefined" && vd.id.type === AST_NODE_TYPES.Identifier && /^[A-Z]/.test(vd.id.name)) { inComponentScope = true; } break; } } // If reference is already the object of a member access (e.g., foo.value), don't add any accessor const isObjectOfMember = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ref.parent?.type === AST_NODE_TYPES.MemberExpression && ref.parent.object === ref; const accessor = isObjectOfMember ? "" : option?.renameOnly === true ? "" : inJsxAttribute || (isJsx && ancestors.some((a) => { if (a.type !== AST_NODE_TYPES.CallExpression) { return false; } // If identifier is within callee, it's not an argument if ( // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition a.callee && ref.range[0] >= a.callee.range[0] && ref.range[1] <= a.callee.range[1]) { return false; } // Identifier lies within one of the arguments' ranges return a.arguments.some((arg) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions if (!arg) { return false; } return (ref.range[0] >= arg.range[0] && ref.range[1] <= arg.range[1]); }); })) ? ".value" : isJsx ? "" : inComponentScope ? ".value" : ".peek()"; fixes.push(fixer.replaceText(ref, `${fixedName}${accessor}`)); } } return fixes; } catch (error) { if (option?.performance?.enableMetrics === true && option.performance.logMetrics === true) { console.error(`${ruleName}: Error in fixer:`, error); } return []; } }, }); } } }, [`${AST_NODE_TYPES.Program}:exit`]() { startPhase(perfKey, "programExit"); perf["Program:exit"](); endPhase(perfKey, "programExit"); }, }; }, }); //# sourceMappingURL=signal-variable-name.js.map