@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
430 lines • 21.1 kB
JavaScript
/** 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-re-assignment';
// Cache for subtree scans in containsSignalRef
const signalRefCache = new WeakMap();
// Unwrap optional chaining wrapper to access the underlying callee/node
function unwrapChainExpression(node) {
if (!node) {
return node;
}
if ('type' in node && node.type === AST_NODE_TYPES.ChainExpression) {
return node.expression;
}
return node;
}
function getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
case 'reassignSignal': {
return options.severity.reassignSignal ?? 'error';
}
default: {
return 'error';
}
}
}
function isCreatorCallee(node, perfKey, creatorIdentifiers, creatorNamespaces, allowBareNames, creatorBaseNames) {
trackOperation(perfKey, PerformanceOperations.signalCheck);
if (!node) {
return false;
}
const unwrapped = unwrapChainExpression(node);
if (!unwrapped) {
return false;
}
if (unwrapped.type === AST_NODE_TYPES.Identifier) {
return (creatorIdentifiers.has(unwrapped.name) ||
(allowBareNames === true && creatorBaseNames.has(unwrapped.name)));
}
if (unwrapped.type === AST_NODE_TYPES.MemberExpression &&
!unwrapped.computed &&
unwrapped.property.type === AST_NODE_TYPES.Identifier &&
unwrapped.object.type === AST_NODE_TYPES.Identifier) {
return (creatorNamespaces.has(unwrapped.object.name) && creatorBaseNames.has(unwrapped.property.name));
}
return false;
}
// Runtime type guard for ESTree nodes
function isESTreeNode(value) {
return (value !== null &&
typeof value === 'object' &&
// eslint-disable-next-line n/no-unsupported-features/es-builtins, n/no-unsupported-features/es-syntax
Object.hasOwn(value, 'type'));
}
function containsSignalRef(expr, perfKey, creatorIdentifiers, creatorNamespaces, allowBareNames, creatorBaseNames) {
const cached = signalRefCache.get(expr);
if (typeof cached === 'boolean') {
return cached;
}
const stack = [expr];
const visited = new WeakSet();
while (stack.length) {
const cur = stack.pop();
if (!cur) {
continue;
}
// Avoid infinite loops by not revisiting nodes
if (visited.has(cur)) {
continue;
}
visited.add(cur);
if ('type' in cur &&
cur.type === AST_NODE_TYPES.CallExpression &&
isCreatorCallee(cur.callee, perfKey, creatorIdentifiers, creatorNamespaces, allowBareNames, creatorBaseNames)) {
signalRefCache.set(expr, true);
return true;
}
for (const k in cur) {
// Skip cyclical parent link commonly added by parsers
if (k === 'parent') {
continue;
}
const v = cur[k];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (v !== null && typeof v === 'object') {
if (Array.isArray(v)) {
for (const it of v) {
if (isESTreeNode(it) && !visited.has(it)) {
stack.push(it);
}
}
}
else if (isESTreeNode(v) && !visited.has(v)) {
stack.push(v);
}
}
}
}
return false;
}
function rhsIsSignalLike(rhs, knownSignalVars, knownSignalContainers, suffixRegex, context, perfKey, creatorIdentifiers, creatorNamespaces, allowBareNames, creatorBaseNames, suffixHeuristicActive, hasCreatorImport) {
const node = unwrapChainExpression(rhs) ?? rhs;
if (node.type === AST_NODE_TYPES.Identifier &&
(knownSignalVars.has(node.name) ||
knownSignalContainers.has(node.name) ||
(suffixHeuristicActive && hasSignalSuffix(node.name, suffixRegex)))) {
return { match: true, name: node.name };
}
if (node.type === AST_NODE_TYPES.CallExpression &&
isCreatorCallee(node.callee, perfKey, creatorIdentifiers, creatorNamespaces, hasCreatorImport && allowBareNames, creatorBaseNames)) {
return { match: true, name: context.sourceCode.getText(node) };
}
if ((node.type === AST_NODE_TYPES.ObjectExpression ||
node.type === AST_NODE_TYPES.ArrayExpression) &&
containsSignalRef(node, perfKey, creatorIdentifiers, creatorNamespaces, hasCreatorImport && allowBareNames, creatorBaseNames)) {
return { match: true, name: context.sourceCode.getText(node) };
}
if (node.type === AST_NODE_TYPES.MemberExpression &&
node.object.type === AST_NODE_TYPES.Identifier &&
// Ignore simple value reads like `fooSignal.value`
!(node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'value' &&
!node.computed) &&
(knownSignalContainers.has(node.object.name) ||
knownSignalVars.has(node.object.name) ||
(suffixHeuristicActive && hasSignalSuffix(node.object.name, suffixRegex)))) {
return { match: true, name: context.sourceCode.getText(node) };
}
return { match: false, name: '' };
}
function reportWithSuggestions(node, name, context) {
if (getSeverity('reassignSignal', context.options[0]) === 'off') {
return;
}
context.report({
node,
messageId: 'reassignSignal',
data: { name },
suggest: [
{
messageId: 'reassignSignal',
fix(fixer) {
return fixer.insertTextBefore(node, `/* Prefer using the original signal or its .value instead of aliasing: ${name} */\n`);
},
},
],
});
}
// Known modules exporting signal creators
const KNOWN_SIGNAL_MODULES = new Set(['@preact/signals-react', '@preact/signals-core']);
export const forbidSignalReAssignmentRule = ESLintUtils.RuleCreator((name) => getRuleDocUrl(name))({
name: ruleName,
meta: {
type: 'problem',
docs: {
description: "Forbid aliasing or re-assigning variables that hold a signal. Prefer reading '.value' or using the original reference.",
url: getRuleDocUrl(ruleName),
},
hasSuggestions: true,
schema: [
{
type: 'object',
properties: {
suffix: { type: 'string', minLength: 1 },
creatorNames: {
type: 'array',
items: { type: 'string', minLength: 1 },
},
enableSuffixHeuristic: { type: 'boolean' },
modules: {
type: 'array',
items: { type: 'string', minLength: 1 },
},
allowBareNames: { 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: {
reassignSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
],
messages: {
reassignSignal: "Avoid re-assigning or aliasing signal '{{name}}'. Access its '.value' or pass it directly instead.",
},
},
defaultOptions: [
{
suffix: 'Signal',
performance: DEFAULT_PERFORMANCE_BUDGET,
allowBareNames: false,
creatorNames: [],
enableSuffixHeuristic: true,
severity: {
reassignSignal: 'error',
},
},
],
create(context, [option]) {
const perfKey = `${ruleName}:${context.filename}:${Date.now()}`;
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,
},
});
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$/;
// Effective modules set
const effectiveModules = new Set([...KNOWN_SIGNAL_MODULES, ...(option?.modules ?? [])]);
// Detection state
const creatorIdentifiers = new Set();
const creatorNamespaces = new Set();
const knownSignalVars = new Set(); // variables that hold a signal
const knownSignalContainers = new Set(); // variables that are arrays/objects containing signals
const creatorBaseNames = new Set([
'signal',
'computed',
'effect',
...(option?.creatorNames ?? []),
]);
let hasCreatorImport = false;
let nodeCount = 0;
function shouldContinue() {
nodeCount++;
if (typeof option?.performance?.maxNodes === 'number' &&
nodeCount > option.performance.maxNodes) {
trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded);
return false;
}
return true;
}
return {
'*': (node) => {
if (!shouldContinue()) {
endPhase(perfKey, 'recordMetrics');
stopTracking(perfKey);
return;
}
perf.trackNode(node);
trackOperation(perfKey, PerformanceOperations.nodeProcessing);
},
[AST_NODE_TYPES.ImportDeclaration](node) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (node.source.type !== AST_NODE_TYPES.Literal) {
return;
}
if (!effectiveModules.has(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 && creatorBaseNames.has(importedName)) {
creatorIdentifiers.add(spec.local.name);
hasCreatorImport = true;
}
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
creatorNamespaces.add(spec.local.name);
hasCreatorImport = true;
}
}
},
[AST_NODE_TYPES.VariableDeclarator](node) {
// Track known signal vars and containers
if (node.id.type === AST_NODE_TYPES.Identifier && node.init) {
if (node.init.type === AST_NODE_TYPES.CallExpression &&
isCreatorCallee(node.init.callee, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true && hasCreatorImport, creatorBaseNames)) {
knownSignalVars.add(node.id.name);
}
else if (node.init.type === AST_NODE_TYPES.ObjectExpression ||
node.init.type === AST_NODE_TYPES.ArrayExpression) {
if (containsSignalRef(node.init, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true && hasCreatorImport, creatorBaseNames)) {
knownSignalContainers.add(node.id.name);
}
}
else if (node.init.type === AST_NODE_TYPES.Identifier) {
const isVar = knownSignalVars.has(node.init.name);
const isContainer = knownSignalContainers.has(node.init.name);
const suffixOnly = !isVar &&
!isContainer &&
option?.enableSuffixHeuristic === true &&
hasCreatorImport &&
hasSignalSuffix(node.init.name, suffixRegex);
if (isVar || isContainer || suffixOnly) {
reportWithSuggestions(node, node.init.name, context);
// propagate only when we know the source kind
if (isVar) {
knownSignalVars.add(node.id.name);
}
else if (isContainer) {
knownSignalContainers.add(node.id.name);
}
}
}
else if (node.init.type === AST_NODE_TYPES.MemberExpression) {
// alias from container access
const base = node.init.object.type === AST_NODE_TYPES.Identifier ? node.init.object.name : null;
if (base !== null &&
!(node.init.property.type === AST_NODE_TYPES.Identifier &&
node.init.property.name === 'value' &&
node.init.computed === false) &&
(knownSignalContainers.has(base) || knownSignalVars.has(base))) {
reportWithSuggestions(node, context.sourceCode.getText(node.init), context);
// propagate alias so subsequent uses are tracked
knownSignalVars.add(node.id.name);
}
}
}
// Destructuring that aliases signal itself from container literal or identifier
if (!((node.id.type === AST_NODE_TYPES.ObjectPattern ||
node.id.type === AST_NODE_TYPES.ArrayPattern) &&
node.init !== null)) {
return;
}
const initUnwrapped = unwrapChainExpression(node.init);
if (initUnwrapped &&
initUnwrapped.type === AST_NODE_TYPES.CallExpression &&
isCreatorCallee(initUnwrapped.callee, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true && hasCreatorImport, creatorBaseNames)) {
// const [s] = signal() -- unlikely but catch
reportWithSuggestions(node.id, context.sourceCode.getText(initUnwrapped), context);
return;
}
if (node.init.type === AST_NODE_TYPES.Identifier &&
(knownSignalVars.has(node.init.name) ||
knownSignalContainers.has(node.init.name) ||
(option?.enableSuffixHeuristic === true &&
hasCreatorImport &&
hasSignalSuffix(node.init.name, suffixRegex)))) {
reportWithSuggestions(node.id, node.init.name, context);
return;
}
if ((node.init.type === AST_NODE_TYPES.ObjectExpression ||
node.init.type === AST_NODE_TYPES.ArrayExpression) &&
containsSignalRef(node.init, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true && hasCreatorImport, creatorBaseNames)) {
reportWithSuggestions(node.id, context.sourceCode.getText(node.init), context);
}
},
[AST_NODE_TYPES.AssignmentExpression](node) {
if (node.operator !== '=') {
return;
}
// Simple alias assignment like `a = countSignal` or destructuring handled below
if (node.left.type === AST_NODE_TYPES.Identifier) {
const { match, name } = rhsIsSignalLike(node.right, knownSignalVars, knownSignalContainers, suffixRegex, context, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true, creatorBaseNames, option?.enableSuffixHeuristic === true && hasCreatorImport, hasCreatorImport);
if (match) {
reportWithSuggestions(node, name, context);
// propagate alias; if rhs is a known container identifier, track as container
if (node.right.type === AST_NODE_TYPES.Identifier &&
knownSignalContainers.has(node.right.name)) {
knownSignalContainers.add(node.left.name);
}
else {
knownSignalVars.add(node.left.name);
}
}
return;
}
if (node.left.type === AST_NODE_TYPES.ObjectPattern ||
node.left.type === AST_NODE_TYPES.ArrayPattern) {
const { match, name } = rhsIsSignalLike(node.right, knownSignalVars, knownSignalContainers, suffixRegex, context, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true, creatorBaseNames, option?.enableSuffixHeuristic === true && hasCreatorImport, hasCreatorImport);
if (match) {
reportWithSuggestions(node.left, name, context);
}
}
},
[AST_NODE_TYPES.FunctionDeclaration](node) {
for (const param of node.params) {
if (param.type === AST_NODE_TYPES.AssignmentPattern &&
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
param.right &&
param.left.type === AST_NODE_TYPES.Identifier) {
const { match, name } = rhsIsSignalLike(param.right, knownSignalVars, knownSignalContainers, suffixRegex, context, perfKey, creatorIdentifiers, creatorNamespaces, option?.allowBareNames === true, creatorBaseNames, option?.enableSuffixHeuristic === true && hasCreatorImport, hasCreatorImport);
if (match) {
reportWithSuggestions(param, name, context);
}
}
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=forbid-signal-re-assignment.js.map