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

530 lines 24.5 kB
/* eslint-disable react-signals-hooks/no-non-signal-with-signal-suffix */ /** 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, stopTracking, startTracking, trackOperation, createPerformanceTracker, DEFAULT_PERFORMANCE_BUDGET, } from './utils/performance.js'; import { hasSignalSuffix } from './utils/suffix.js'; import { getRuleDocUrl } from './utils/urls.js'; const ruleName = 'forbid-signal-destructuring'; function getSeverity(messageId, options) { if (!options?.severity) { return 'error'; } switch (messageId) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition case 'destructureSignal': { return options.severity.destructureSignal ?? 'error'; } default: { return 'error'; } } } // Unwrap optional chaining containers to their inner expression function unwrapChainExpression(expr) { return expr.type === AST_NODE_TYPES.ChainExpression ? expr.expression : expr; } function getCallCalleeIfAny(expr) { // In typescript-estree, optional calls are represented as CallExpression // wrapped by ChainExpression with expr.optional === true. We already unwrap // ChainExpression before calling this helper, so a simple check is sufficient. return expr.type === AST_NODE_TYPES.CallExpression ? expr.callee : null; } function isCreatorCallee(node, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt) { trackOperation(perfKey, PerformanceOperations.signalCheck); if (!node) { return false; } if (node.type === AST_NODE_TYPES.Identifier) { // Only consider identifiers that were imported as creators return creatorIdentifiers.has(node.name) || creatorNamesOpt.has(node.name); } if (node.type === AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES.Identifier && node.object.type === AST_NODE_TYPES.Identifier) { return (creatorNamespaces.has(node.object.name) && (['signal', 'computed', 'effect'].includes(node.property.name) || creatorNamesOpt.has(node.property.name))); } return false; } function reportDestructure(node, name, context) { if (getSeverity('destructureSignal', context.options[0]) === 'off') { return; } context.report({ node, messageId: 'destructureSignal', data: { name }, suggest: [ { messageId: 'destructureSignal', data: { name }, fix(fixer) { return fixer.insertTextBefore(node, `/* react-signals-hooks: avoid destructuring from signal ${name}; read from .value or access members on the value. */\n`); }, }, ], }); } function resolveBaseName(expr) { // Walk to the left-most Identifier base of a MemberExpression chain let cur = expr; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { if (cur.type === AST_NODE_TYPES.Identifier) { return cur.name; } if (cur.type === AST_NODE_TYPES.MemberExpression && (cur.object.type === AST_NODE_TYPES.Identifier || cur.object.type === AST_NODE_TYPES.MemberExpression)) { if (cur.object.type === AST_NODE_TYPES.Identifier) { return cur.object.name; } // continue walking if MemberExpression cur = cur.object; continue; } return null; } } // directly references a signal creator call or a known signal/container identifier. function hasTopLevelSignalRef(node, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt, knownSignalVars, knownSignalContainers) { if (node.type === AST_NODE_TYPES.ObjectExpression) { for (const prop of node.properties) { if (prop.type !== AST_NODE_TYPES.Property) { continue; } const v = prop.value; if ((v.type === AST_NODE_TYPES.CallExpression && isCreatorCallee(v.callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) || (v.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(v.name) || knownSignalContainers.has(v.name)))) { return true; } } return false; } // ArrayExpression for (const el of node.elements) { if (!el) continue; if ((el.type === AST_NODE_TYPES.CallExpression && isCreatorCallee(el.callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) || (el.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(el.name) || knownSignalContainers.has(el.name)))) { return true; } } return false; } // Return the set of top-level object keys or array indices that directly reference a signal. function getTopLevelSignalKeys(node, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt, knownSignalVars, knownSignalContainers) { const result = new Set(); if (node.type === AST_NODE_TYPES.ObjectExpression) { for (const prop of node.properties) { if (prop.type !== AST_NODE_TYPES.Property) continue; const v = prop.value; const k = prop.key; let keyName = null; if (k.type === AST_NODE_TYPES.Identifier) keyName = k.name; else if (k.type === AST_NODE_TYPES.Literal && typeof k.value === 'string') keyName = k.value; if (keyName === null) continue; if ((v.type === AST_NODE_TYPES.CallExpression && isCreatorCallee(v.callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) || (v.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(v.name) || knownSignalContainers.has(v.name)))) { result.add(keyName); } } return result; } // ArrayExpression indices node.elements.forEach((el, idx) => { if (el === null) { return; } if ((el.type === AST_NODE_TYPES.CallExpression && isCreatorCallee(el.callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) || (el.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(el.name) || knownSignalContainers.has(el.name)))) { result.add(String(idx)); } }); return result; } function patternOverlapsSignalKeys(pattern, signalKeys) { if (pattern.type === AST_NODE_TYPES.ObjectPattern) { const picked = new Set(); let hasRest = false; for (const p of pattern.properties) { if (p.type === AST_NODE_TYPES.RestElement) { hasRest = true; continue; } if (p.key.type === AST_NODE_TYPES.Identifier) { picked.add(p.key.name); } else if (p.key.type === AST_NODE_TYPES.Literal && typeof p.key.value === 'string') { picked.add(p.key.value); } } // Any directly picked signal key is an overlap for (const k of picked) { if (signalKeys.has(k)) { return true; } } // If there is a rest element and not all signal keys are explicitly picked, // the rest binding will capture remaining signal-bearing keys. if (hasRest) { for (const k of signalKeys) { if (!picked.has(k)) { return true; } } } return false; } // ArrayPattern let restAt = null; if (!('elements' in pattern)) { return false; } for (let i = 0; i < pattern.elements.length; i++) { // eslint-disable-next-line security/detect-object-injection const el = pattern.elements[i]; if (el === null || typeof el === 'undefined') { continue; } if (el.type === AST_NODE_TYPES.RestElement) { restAt = i; break; } if (signalKeys.has(String(i))) { return true; } } if (restAt !== null) { // Any signal-bearing index at or after rest position overlaps for (const idx of signalKeys) { const n = Number(idx); if (!Number.isNaN(n) && n >= restAt) { return true; } } } return false; } // Known modules exporting signal creators const KNOWN_SIGNAL_MODULES = new Set(['@preact/signals-react', '@preact/signals-core']); export const forbidSignalDestructuringRule = ESLintUtils.RuleCreator((name) => getRuleDocUrl(name))({ name: ruleName, meta: { type: 'problem', docs: { description: 'Forbid destructuring that creates new bindings from a signal reference. Prefer explicit `.value` access or passing the signal directly.', url: getRuleDocUrl(ruleName), }, hasSuggestions: true, schema: [ { type: 'object', properties: { suffix: { type: 'string', minLength: 1 }, modules: { type: 'array', items: { type: 'string', minLength: 1 }, }, creatorNames: { type: 'array', items: { type: 'string', minLength: 1 }, }, enableSuffixHeuristic: { type: 'boolean' }, 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(([k]) => [ k, { type: 'number', minimum: 1 }, ])), }, }, additionalProperties: false, }, severity: { type: 'object', properties: { destructureSignal: { type: 'string', enum: ['error', 'warn', 'off'], }, }, additionalProperties: false, }, }, additionalProperties: false, }, ], messages: { destructureSignal: "Avoid destructuring from signal '{{name}}'. Read from '.value' or use direct member access instead.", }, }, defaultOptions: [ { suffix: 'Signal', modules: [], creatorNames: [], enableSuffixHeuristic: false, performance: DEFAULT_PERFORMANCE_BUDGET, severity: { destructureSignal: 'error', }, }, ], create(context, [option]) { const perfKey = `${ruleName}:${context.filename}:${Date.now()}`; const perf = createPerformanceTracker(perfKey, option?.performance); // Enable metrics if specified 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, }, }); const suffixRegex = typeof option?.suffix === 'string' ? // eslint-disable-next-line security/detect-non-literal-regexp, optimize-regex/optimize-regex new RegExp(`${option.suffix.replace(/[-/\\^$*+?.()|[\]{}]/g, '')}$`) : /Signal$/; // Track imported creator identifiers and namespaces const creatorIdentifiers = new Set(); const creatorNamespaces = new Set(); const creatorNamesOpt = new Set(option?.creatorNames ?? []); // Track variables definitely holding a signal or containers that include a signal const knownSignalVars = new Set(); const knownSignalContainers = new Set(); // Track locally declared identifiers to narrow suffix heuristic usage const declaredLocals = new Set(); let nodeCount = 0; function shouldContinue() { nodeCount++; // Check if we've exceeded the node budget if (nodeCount > (option?.performance?.maxNodes ?? 2000)) { trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded); return false; } return true; } return { '*': (node) => { if (!shouldContinue()) { endPhase(perfKey, 'recordMetrics'); stopTracking(perfKey); return; } perf.trackNode(node); trackOperation(perfKey, PerformanceOperations[`${node.type}Processing`]); }, [AST_NODE_TYPES.ImportDeclaration](node) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (node.source.type !== AST_NODE_TYPES.Literal) { return; } const allowedModules = new Set([...KNOWN_SIGNAL_MODULES, ...(option?.modules ?? [])]); if (!allowedModules.has(String(node.source.value))) { return; } for (const spec of node.specifiers) { if (spec.type === AST_NODE_TYPES.ImportSpecifier) { const importedName = spec.imported.type === AST_NODE_TYPES.Identifier ? spec.imported.name : null; if (importedName !== null && (['signal', 'computed', 'effect'].includes(importedName) || creatorNamesOpt.has(importedName))) { creatorIdentifiers.add(spec.local.name); } } else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) { creatorNamespaces.add(spec.local.name); } } }, // Track simple identifier initializations to populate known sets 'VariableDeclarator[id.type="Identifier"]': (node) => { if (!node.init || node.id.type !== AST_NODE_TYPES.Identifier) { return; } const initExpr = unwrapChainExpression(node.init); // Track declared local declaredLocals.add(node.id.name); { const callee = getCallCalleeIfAny(initExpr); if (callee && isCreatorCallee(callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) { knownSignalVars.add(node.id.name); return; } } if ((initExpr.type === AST_NODE_TYPES.ObjectExpression || initExpr.type === AST_NODE_TYPES.ArrayExpression) && hasTopLevelSignalRef(initExpr, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt, knownSignalVars, knownSignalContainers)) { knownSignalContainers.add(node.id.name); return; } if (initExpr.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(initExpr.name) || knownSignalContainers.has(initExpr.name))) { // Propagate known-ness through simple reassignments if (knownSignalVars.has(initExpr.name)) { knownSignalVars.add(node.id.name); } else { knownSignalContainers.add(node.id.name); } } }, // Destructuring variable declarations 'VariableDeclarator[id.type="ObjectPattern"], VariableDeclarator[id.type="ArrayPattern"]': (node) => { if (node.init === null) { return; } const initExpr = unwrapChainExpression(node.init); const stack = [node.id]; while (stack.length) { const cur = stack.pop(); if (typeof cur === 'undefined') { continue; } if (cur.type === AST_NODE_TYPES.Identifier) { declaredLocals.add(cur.name); } else if (cur.type === AST_NODE_TYPES.Property) { stack.push(cur.value); } else if (cur.type === AST_NODE_TYPES.ObjectPattern || cur.type === AST_NODE_TYPES.ArrayPattern || cur.type === AST_NODE_TYPES.RestElement || cur.type === AST_NODE_TYPES.AssignmentPattern) { for (const key in cur) { if (key === 'parent') { continue; } const val = cur[key]; if (Array.isArray(val)) { for (const item of val) { if (typeof item === 'object' && 'type' in item) { stack.push(item); } } } else if (typeof val !== 'undefined' && typeof val === 'object' && 'type' in val) { stack.push(val); } } } } // If RHS is direct signal() or namespaced creator call { const callee = getCallCalleeIfAny(initExpr); if (callee && isCreatorCallee(callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) { reportDestructure(node.id, context.sourceCode.getText(initExpr), context); return; } } // If RHS is identifier previously marked as signal or container-with-signal if (initExpr.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(initExpr.name) || knownSignalContainers.has(initExpr.name) || (option?.enableSuffixHeuristic === true && (creatorIdentifiers.size > 0 || creatorNamespaces.size > 0) && declaredLocals.has(initExpr.name) && hasSignalSuffix(initExpr.name, suffixRegex)))) { reportDestructure(node.id, initExpr.name, context); return; } // If RHS is literal object/array containing a signal call, report only if pattern overlaps signal-bearing keys if (initExpr.type === AST_NODE_TYPES.ObjectExpression || initExpr.type === AST_NODE_TYPES.ArrayExpression) { const keys = getTopLevelSignalKeys(initExpr, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt, knownSignalVars, knownSignalContainers); if (keys.size > 0 && patternOverlapsSignalKeys(node.id, keys)) { reportDestructure(node.id, context.sourceCode.getText(initExpr), context); } } }, // Destructuring assignment patterns [AST_NODE_TYPES.AssignmentExpression](node) { if (node.left.type !== AST_NODE_TYPES.ObjectPattern && node.left.type !== AST_NODE_TYPES.ArrayPattern) { return; } const rightExpr = unwrapChainExpression(node.right); // If RHS is direct signal() or namespaced creator call { const callee = getCallCalleeIfAny(rightExpr); if (callee && isCreatorCallee(callee, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt)) { reportDestructure(node.left, context.sourceCode.getText(rightExpr), context); return; } } // If RHS is identifier previously marked as signal or container-with-signal if (rightExpr.type === AST_NODE_TYPES.Identifier && (knownSignalVars.has(rightExpr.name) || knownSignalContainers.has(rightExpr.name) || (option?.enableSuffixHeuristic === true && (creatorIdentifiers.size > 0 || creatorNamespaces.size > 0) && declaredLocals.has(rightExpr.name) && hasSignalSuffix(rightExpr.name, suffixRegex)))) { reportDestructure(node.left, rightExpr.name, context); return; } // If RHS is literal object/array containing a signal call, report only if pattern overlaps signal-bearing keys if (rightExpr.type === AST_NODE_TYPES.ObjectExpression || rightExpr.type === AST_NODE_TYPES.ArrayExpression) { const keys = getTopLevelSignalKeys(rightExpr, perfKey, creatorIdentifiers, creatorNamespaces, creatorNamesOpt, knownSignalVars, knownSignalContainers); if (keys.size > 0 && patternOverlapsSignalKeys(node.left, keys)) { reportDestructure(node.left, context.sourceCode.getText(rightExpr), context); return; } } // Conservative heuristic: if MemberExpression base looks like variable we know if (rightExpr.type === AST_NODE_TYPES.MemberExpression) { const base = resolveBaseName(rightExpr); if (base !== null && (knownSignalVars.has(base) || knownSignalContainers.has(base) || (option?.enableSuffixHeuristic === true && (creatorIdentifiers.size > 0 || creatorNamespaces.size > 0) && declaredLocals.has(base) && hasSignalSuffix(base, suffixRegex)))) { reportDestructure(node.left, base, context); } } }, [`${AST_NODE_TYPES.Program}:exit`]() { startPhase(perfKey, 'programExit'); perf['Program:exit'](); endPhase(perfKey, 'programExit'); }, }; }, }); //# sourceMappingURL=forbid-signal-destructuring.js.map