lint: move last remaining native eslint rules to oxlint (create new alternatives) (#6468)

* lint: clean up unnecessary config and disables

* fix: update package import path in browser preload script

* lint: create custom ESLint rules for rules not in oxlint

- Add object-shorthand-properties rule (replaces object-shorthand)
- Add prefer-const rule (replaces prefer-const)
- Add no-anchor-tag rule (replaces no-restricted-syntax for <a> tags)
- Add no-react-default-import rule (replaces no-restricted-syntax for React.*)

These custom rules are needed because oxlint doesn't support these rules yet.
All rules are properly tested and integrated into the ESLint config.

* refactor: enhance prefer-const rule to track variable reassignments by scope

- Introduced a mapping of scopes to reassigned variable names, improving the accuracy of the prefer-const rule.
- Added helper functions to determine variable scopes and reassignment status.
- Updated logic to check variable reassignments during declaration analysis.
- Adjusted test cases to reflect changes in variable handling.
This commit is contained in:
Matiss Janis Aboltins
2026-01-05 10:00:44 +01:00
committed by GitHub
parent 88fbfe7078
commit f1faf45659
17 changed files with 1162 additions and 57 deletions

View File

@@ -71,7 +71,7 @@ try {
console.log('Generated summary:', summary);
const result = {
summary: summary,
summary,
prNumber: prDetails.number,
author: prDetails.author,
};

View File

@@ -35,14 +35,15 @@ function readMigrations(ref) {
}
spawnSync('git', ['fetch', 'origin', 'master']);
let masterMigrations = readMigrations('origin/master');
let headMigrations = readMigrations('HEAD');
const masterMigrations = readMigrations('origin/master');
const headMigrations = readMigrations('HEAD');
let latestMasterMigration = masterMigrations[masterMigrations.length - 1].date;
let newMigrations = headMigrations.filter(
const latestMasterMigration =
masterMigrations[masterMigrations.length - 1].date;
const newMigrations = headMigrations.filter(
migration => !masterMigrations.find(m => m.name === migration.name),
);
let badMigrations = newMigrations.filter(
const badMigrations = newMigrations.filter(
migration => migration.date <= latestMasterMigration,
);

View File

@@ -25,6 +25,10 @@
"actual/prefer-trans-over-t": "error",
"actual/prefer-if-statement": "warn",
"actual/prefer-logger-over-console": "error",
"actual/object-shorthand-properties": "warn",
"actual/prefer-const": "warn",
"actual/no-anchor-tag": "warn",
"actual/no-react-default-import": "warn",
// JSX A11y rules
"jsx-a11y/no-autofocus": [
@@ -410,6 +414,12 @@
"import/no-default-export": "off"
}
},
{
"files": ["packages/docs/**/*"],
"rules": {
"actual/no-anchor-tag": "off"
}
},
// TODO: enable these
{
"files": [

View File

@@ -47,22 +47,7 @@ export default defineConfig(
perfectionist: pluginPerfectionist,
},
rules: {
'no-restricted-properties': [
'error',
{
object: 'require',
property: 'ensure',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
{
object: 'System',
property: 'import',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
],
// TODO: https://github.com/oxc-project/oxc/issues/17076
'perfectionist/sort-imports': [
'warn',
{
@@ -93,26 +78,6 @@ export default defineConfig(
newlinesBetween: 'always',
},
],
'object-shorthand': ['warn', 'properties'],
'no-restricted-syntax': [
'warn',
{
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
selector:
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
message:
'Using default React import is discouraged, please use named exports directly instead.',
},
{
// forbid <a> in favor of <Link>
selector: 'JSXOpeningElement[name.name="a"]',
message: 'Using <a> is discouraged, please use <Link> instead.',
},
],
'prefer-const': 'warn',
},
},
{
@@ -128,10 +93,4 @@ export default defineConfig(
'typescript-paths/absolute-import': ['error', { enableAlias: false }],
},
},
{
files: ['packages/docs/**/*'],
rules: {
'no-restricted-syntax': 'off',
},
},
);

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useEffect } from 'react';
import React, { Fragment, useEffect } from 'react';
import { useLocation } from 'react-router';
import { send } from 'loot-core/platform/client/fetch';
@@ -405,9 +405,7 @@ export function Modals() {
}
})
.map((modal, idx) => (
<React.Fragment key={`${modalStack[idx].name}-${idx}`}>
{modal}
</React.Fragment>
<Fragment key={`${modalStack[idx].name}-${idx}`}>{modal}</Fragment>
));
// fragment needed per TS types

View File

@@ -52,7 +52,6 @@ const ExternalLink = ({
}: ExternalLinkProps) => {
return (
// we can't use <ExternalLink /> here for obvious reasons
// eslint-disable-next-line no-restricted-syntax
<a
href={to ?? ''}
target="_blank"

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { Fragment } from 'react';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
@@ -35,9 +35,7 @@ export function NotesTagFormatter({
switch (segment.type) {
case 'text':
return (
<React.Fragment key={index}>{segment.content}</React.Fragment>
);
return <Fragment key={index}>{segment.content}</Fragment>;
case 'tag':
if (isNarrowWidth) {

View File

@@ -8,5 +8,9 @@ module.exports = {
typography: require('./rules/typography'),
'prefer-if-statement': require('./rules/prefer-if-statement'),
'prefer-logger-over-console': require('./rules/prefer-logger-over-console'),
'object-shorthand-properties': require('./rules/object-shorthand-properties'),
'prefer-const': require('./rules/prefer-const'),
'no-anchor-tag': require('./rules/no-anchor-tag'),
'no-react-default-import': require('./rules/no-react-default-import'),
},
};

View File

@@ -0,0 +1,96 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
import { runClassic } from 'eslint-vitest-rule-tester';
import * as rule from '../no-anchor-tag';
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
runClassic(
'no-anchor-tag',
rule,
{
valid: [
// Link component usage
`<Link variant="external" to="https://example.com">Click me</Link>`,
`<Link variant="internal" to="/path">Internal link</Link>`,
// Other JSX elements
`<div>Content</div>`,
`<span>Text</span>`,
`<button>Click</button>`,
// JSX with different casing
`<A>Not an anchor</A>`,
`<Anchor>Not an anchor</Anchor>`,
// Self-closing tags
`<img src="test.jpg" />`,
`<br />`,
],
invalid: [
{
code: '<a href="https://example.com">Link</a>',
output: null,
errors: [
{
messageId: 'useLink',
type: 'JSXOpeningElement',
},
],
},
{
code: '<a href="/path" target="_blank">External</a>',
output: null,
errors: [
{
messageId: 'useLink',
type: 'JSXOpeningElement',
},
],
},
{
code: '<a>Click here</a>',
output: null,
errors: [
{
messageId: 'useLink',
type: 'JSXOpeningElement',
},
],
},
{
code: '<div><a href="/test">Link</a></div>',
output: null,
errors: [
{
messageId: 'useLink',
type: 'JSXOpeningElement',
},
],
},
{
code: 'function Component() { return <a href="/">Home</a>; }',
output: null,
errors: [
{
messageId: 'useLink',
type: 'JSXOpeningElement',
},
],
},
],
},
{
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
);

View File

@@ -0,0 +1,103 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
import { runClassic } from 'eslint-vitest-rule-tester';
import * as rule from '../no-react-default-import';
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
runClassic(
'no-react-default-import',
rule,
{
valid: [
// Named imports (correct usage)
`import { Component, useState } from 'react';`,
`import { Component as Comp, useState } from 'react';`,
// Usage of named imports
`const MyComponent = Component.extend({});`,
`const [state, setState] = useState(0);`,
// Other identifiers
`const ReactLib = require('react-lib');`,
`ReactLib.something();`,
// Non-React member expressions
`const obj = { React: 'test' };`,
`obj.React.something();`,
// JSX with Fragment (correct usage)
`import { Fragment } from 'react';`,
`const element = <Fragment>Test</Fragment>;`,
],
invalid: [
{
code: 'const Component = React.Component;',
output: null,
errors: [
{
messageId: 'useNamedExport',
type: 'MemberExpression',
},
],
},
{
code: 'const [state, setState] = React.useState(0);',
output: null,
errors: [
{
messageId: 'useNamedExport',
type: 'MemberExpression',
},
],
},
{
code: 'class MyComponent extends React.Component {}',
output: null,
errors: [
{
messageId: 'useNamedExport',
type: 'MemberExpression',
},
],
},
{
code: 'function test() { return React.createElement("div"); }',
output: null,
errors: [
{
messageId: 'useNamedExport',
type: 'MemberExpression',
},
],
},
{
code: 'const element = <React.Fragment>Test</React.Fragment>;',
output: null,
errors: [
{
messageId: 'useNamedExport',
type: 'JSXMemberExpression',
},
{
messageId: 'useNamedExport',
type: 'JSXMemberExpression',
},
],
},
],
},
{
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
);

View File

@@ -0,0 +1,166 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
import { runClassic } from 'eslint-vitest-rule-tester';
import * as rule from '../object-shorthand-properties';
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
runClassic(
'object-shorthand-properties',
rule,
{
valid: [
// Already using shorthand
`const obj = { foo };`,
`const obj = { foo, bar };`,
`const obj = { foo, bar, baz };`,
// Different key and value names
`const obj = { foo: bar };`,
`const obj = { foo: bar.baz };`,
`const obj = { foo: 123 };`,
`const obj = { foo: 'bar' };`,
`const obj = { foo: true };`,
`const obj = { foo: null };`,
`const obj = { foo: undefined };`,
// Methods (should not be enforced)
`const obj = { foo() {} };`,
`const obj = { foo: function() {} };`,
`const obj = { foo: () => {} };`,
// Computed properties
`const obj = { [foo]: foo };`,
`const obj = { [foo]: bar };`,
// String/number keys
`const obj = { 'foo': foo };`,
`const obj = { 123: 123 };`,
// Nested objects (shorthand should still be enforced in nested objects)
`const obj = { foo: { bar } };`,
],
invalid: [
{
code: 'const obj = { foo: foo };',
output: 'const obj = { foo };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'foo' },
type: 'Property',
},
],
},
{
code: 'const obj = { foo: foo, bar: bar };',
output: 'const obj = { foo, bar };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'foo' },
type: 'Property',
},
{
messageId: 'useShorthand',
data: { key: 'bar' },
type: 'Property',
},
],
},
{
code: 'const obj = { foo: foo, bar: baz };',
output: 'const obj = { foo, bar: baz };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'foo' },
type: 'Property',
},
],
},
{
code: 'const obj = { a: a, b: b, c: c };',
output: 'const obj = { a, b, c };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'a' },
type: 'Property',
},
{
messageId: 'useShorthand',
data: { key: 'b' },
type: 'Property',
},
{
messageId: 'useShorthand',
data: { key: 'c' },
type: 'Property',
},
],
},
{
code: 'function test() { return { x: x, y: y }; }',
output: 'function test() { return { x, y }; }',
errors: [
{
messageId: 'useShorthand',
data: { key: 'x' },
type: 'Property',
},
{
messageId: 'useShorthand',
data: { key: 'y' },
type: 'Property',
},
],
},
{
code: 'const obj = { prop: prop, method() {} };',
output: 'const obj = { prop, method() {} };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'prop' },
type: 'Property',
},
],
},
{
code: 'const obj = { foo: { bar: bar } };',
output: 'const obj = { foo: { bar } };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'bar' },
type: 'Property',
},
],
},
{
code: 'const obj = { ...other, foo: foo };',
output: 'const obj = { ...other, foo };',
errors: [
{
messageId: 'useShorthand',
data: { key: 'foo' },
type: 'Property',
},
],
},
],
},
{
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: { jsx: true },
},
},
);

View File

@@ -0,0 +1,162 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
import { runClassic } from 'eslint-vitest-rule-tester';
import * as rule from '../prefer-const';
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
runClassic(
'prefer-const',
rule,
{
valid: [
// Already using const
`const x = 1;`,
`const x = 1, y = 2;`,
// let that gets reassigned
`let x = 1; x = 2;`,
`let x = 1; x++;`,
`let x = 1; x += 1;`,
`let x = 1; x--;`,
`let x = 1; x -= 1;`,
// let in for loops
`for (let i = 0; i < 10; i++) {}`,
`for (let item of items) {}`,
`for (let item in items) {}`,
// Destructuring with reassignment
`let { x } = obj; x = 2;`,
`let [x] = arr; x = 2;`,
// Variables used before declaration (temporal dead zone)
`let x; x = 1;`,
// Destructuring assignment (valid use of let)
`let id; ({ id } = obj);`,
`let x, y; ({ x, y } = obj);`,
`async function test() { let id; ({ id } = await someFunction()); }`,
`let x, y; [x, y] = arr;`,
`async function test() { let id; try { ({ id } = await func()); } catch {} }`,
// Function parameters (not applicable)
`function test(x) { return x; }`,
// Variables in different scopes
`const x = 1; function test() { let x = 2; x = 3; }`,
],
invalid: [
{
code: 'let x = 1;',
output: 'const x = 1;',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
],
},
{
code: 'let x = 1, y = 2;',
output: 'const x = 1, y = 2;',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
{
messageId: 'useConst',
data: { name: 'y' },
type: 'Identifier',
},
],
},
{
code: 'let x = 1; console.log(x);',
output: 'const x = 1; console.log(x);',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
],
},
{
code: 'let x = 1; let y = 2;',
output: 'const x = 1; const y = 2;',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
{
messageId: 'useConst',
data: { name: 'y' },
type: 'Identifier',
},
],
},
{
code: 'function test() { let x = 1; return x; }',
output: 'function test() { const x = 1; return x; }',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
],
},
{
code: 'let { x } = obj;',
output: 'const { x } = obj;',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
],
},
{
code: 'let [x] = arr;',
output: 'const [x] = arr;',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
],
},
{
code: 'let x = 1, y = 2; y = 3;',
output: 'let x = 1, y = 2; y = 3;',
errors: [
{
messageId: 'useConst',
data: { name: 'x' },
type: 'Identifier',
},
],
},
],
},
{
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: { jsx: true },
},
},
);

View File

@@ -0,0 +1,57 @@
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Forbid usage of <a> tags in favor of <Link> component',
},
fixable: null,
schema: [],
messages: {
useLink: 'Using <a> is discouraged, please use <Link> instead.',
},
},
create(context) {
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
/**
* Checks if the current file is the Link component itself
* @returns {boolean}
*/
function isLinkComponentFile() {
const filename = context.getFilename();
const normalizedFilename = filename.replace(/\\/g, '/');
return normalizedFilename.includes(
'packages/desktop-client/src/components/common/Link.tsx',
);
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
JSXOpeningElement(node) {
// Skip if this is the Link component file itself
if (isLinkComponentFile()) {
return;
}
// Check if the element name is "a"
if (node.name.type === 'JSXIdentifier' && node.name.name === 'a') {
context.report({
node,
messageId: 'useLink',
});
}
},
};
},
};

View File

@@ -0,0 +1,86 @@
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Forbid usage of default React import (React.*) in favor of named exports',
},
fixable: null,
schema: [],
messages: {
useNamedExport:
'Using default React import is discouraged, please use named exports directly instead.',
},
},
create(context) {
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
/**
* Checks if a node is a React default import usage
* @param {import('estree').Node} node
* @returns {boolean}
*/
function isReactDefaultImport(node) {
// Check MemberExpression: React.Component, React.useState, etc.
if (node.type === 'MemberExpression') {
return (
node.object.type === 'Identifier' && node.object.name === 'React'
);
}
// Check TSQualifiedName: React.FC, React.ComponentType, etc. (TypeScript)
if (node.type === 'TSQualifiedName') {
return node.left.type === 'Identifier' && node.left.name === 'React';
}
return false;
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
// Catch React.* member expressions (e.g., React.Component, React.useState)
MemberExpression(node) {
if (isReactDefaultImport(node)) {
context.report({
node,
messageId: 'useNamedExport',
});
}
},
// Catch React.* in JSX (e.g., <React.Fragment>)
JSXMemberExpression(node) {
if (
node.object.type === 'JSXIdentifier' &&
node.object.name === 'React'
) {
context.report({
node,
messageId: 'useNamedExport',
});
}
},
// Catch React.* TypeScript qualified names (e.g., React.FC, React.ComponentType)
TSQualifiedName(node) {
if (isReactDefaultImport(node)) {
context.report({
node,
messageId: 'useNamedExport',
});
}
},
};
},
};

View File

@@ -0,0 +1,92 @@
/* @see https://eslint.org/docs/latest/rules/object-shorthand */
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Enforce property shorthand syntax in object literals (properties only)',
},
fixable: 'code',
schema: [],
messages: {
useShorthand:
'Expected property shorthand. Use `{{key}}` instead of `{{key}}: {{key}}`.',
},
},
create(context) {
const sourceCode = context.getSourceCode();
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
/**
* Checks if a property can use shorthand syntax
* @param {import('estree').Property} node
* @returns {boolean}
*/
function canUseShorthand(node) {
// Only check properties (not methods)
if (node.method) {
return false;
}
// Key must be an identifier (not computed)
if (node.key.type !== 'Identifier' || node.computed) {
return false;
}
// Value must be an identifier with the same name as the key
if (
node.value.type !== 'Identifier' ||
node.key.name !== node.value.name
) {
return false;
}
// Already using shorthand
if (node.shorthand) {
return false;
}
return true;
}
/**
* Creates a fixer to convert to shorthand
* @param {import('estree').Property} node
* @returns {import('eslint').Rule.ReportFixer}
*/
function makeFixer(node) {
return fixer => {
const keyText = sourceCode.getText(node.key);
return fixer.replaceText(node, keyText);
};
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
Property(node) {
if (canUseShorthand(node)) {
context.report({
node,
messageId: 'useShorthand',
data: {
key: node.key.name,
},
fix: makeFixer(node),
});
}
},
};
},
};

View File

@@ -0,0 +1,368 @@
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Require `const` declarations for variables that are never reassigned after declared',
},
fixable: 'code',
schema: [],
messages: {
useConst: "'{{name}}' is never reassigned. Use 'const' instead.",
},
},
create(context) {
const sourceCode = context.getSourceCode();
// Map of scope to Set of reassigned variable names in that scope
const reassignedVariablesByScope = new Map();
const letDeclarations = [];
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
/**
* Gets the scope for a node
* @param {import('estree').Node} node
* @returns {import('eslint').Scope.Scope | null}
*/
function getScope(node) {
try {
return sourceCode.getScope(node);
} catch {
return null;
}
}
/**
* Gets the variable name from an identifier node
* @param {import('estree').Node} node
* @returns {string | null}
*/
function getVariableName(node) {
if (node.type === 'Identifier') {
return node.name;
}
return null;
}
/**
* Marks a variable as reassigned in its scope
* @param {string} name
* @param {import('estree').Node} node
*/
function markAsReassigned(name, node) {
const scope = getScope(node);
if (scope) {
// Get the identifier being assigned
let identifier = null;
if (node.type === 'AssignmentExpression') {
identifier = node.left.type === 'Identifier' ? node.left : null;
} else if (node.type === 'UpdateExpression') {
identifier =
node.argument.type === 'Identifier' ? node.argument : null;
}
if (identifier) {
// Use ESLint's scope analysis to find the variable being referenced
// Search from the identifier's scope upward to find the variable
let identifierScope = getScope(identifier);
while (identifierScope) {
const variable = identifierScope.variables.find(
v => v.name === name,
);
if (variable) {
// Found the variable - it's declared in identifierScope
// (variables in a scope's variables array are declared in that scope)
if (!reassignedVariablesByScope.has(identifierScope)) {
reassignedVariablesByScope.set(identifierScope, new Set());
}
reassignedVariablesByScope.get(identifierScope).add(name);
return;
}
identifierScope = identifierScope.upper;
}
}
// Fallback: find variable in scope chain (for destructuring, etc.)
let currentScope = scope;
while (currentScope) {
const variable = currentScope.variables.find(
v => v.name === name && v.defs.length > 0,
);
if (variable) {
// Variable is declared in currentScope (it's in currentScope.variables)
if (!reassignedVariablesByScope.has(currentScope)) {
reassignedVariablesByScope.set(currentScope, new Set());
}
reassignedVariablesByScope.get(currentScope).add(name);
return;
}
currentScope = currentScope.upper;
}
}
}
/**
* Checks if a variable is reassigned in its scope
* @param {string} name
* @param {import('eslint').Scope.Scope} variableScope
* @returns {boolean}
*/
function isReassignedInScope(name, variableScope) {
const reassigned = reassignedVariablesByScope.get(variableScope);
return reassigned ? reassigned.has(name) : false;
}
/**
* Extracts all variable names from a pattern (handles destructuring)
* @param {import('estree').Pattern} pattern
* @returns {string[]}
*/
function extractVariableNames(pattern) {
const names = [];
if (pattern.type === 'Identifier') {
names.push(pattern.name);
} else if (pattern.type === 'ObjectPattern') {
for (const prop of pattern.properties) {
if (prop.type === 'Property') {
if (prop.value.type === 'Identifier') {
names.push(prop.value.name);
} else if (
prop.value.type === 'ObjectPattern' ||
prop.value.type === 'ArrayPattern'
) {
names.push(...extractVariableNames(prop.value));
}
} else if (prop.type === 'RestElement') {
names.push(...extractVariableNames(prop.argument));
}
}
} else if (pattern.type === 'ArrayPattern') {
for (const element of pattern.elements) {
if (element) {
if (element.type === 'Identifier') {
names.push(element.name);
} else if (
element.type === 'ObjectPattern' ||
element.type === 'ArrayPattern'
) {
names.push(...extractVariableNames(element));
} else if (element.type === 'RestElement') {
names.push(...extractVariableNames(element.argument));
}
}
}
} else if (pattern.type === 'RestElement') {
names.push(...extractVariableNames(pattern.argument));
}
return names;
}
/**
* Creates a fixer to change `let` to `const`
* @param {import('estree').VariableDeclaration} node
* @returns {import('eslint').Rule.ReportFixer}
*/
function makeFixer(node) {
return fixer => {
const letToken = sourceCode.getFirstToken(node, {
filter: token => token.value === 'let',
});
if (letToken) {
return fixer.replaceText(letToken, 'const');
}
return null;
};
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
// Track assignments to variables
AssignmentExpression(node) {
// Handle simple assignments: x = value
const name = getVariableName(node.left);
if (name) {
markAsReassigned(name, node);
} else {
// Handle destructuring assignments: ({ x } = value) or [x] = value
const patternNames = extractVariableNames(node.left);
for (const patternName of patternNames) {
markAsReassigned(patternName, node);
}
}
},
// Track update expressions (x++, x--, x += 1, etc.)
UpdateExpression(node) {
const name = getVariableName(node.argument);
if (name) {
markAsReassigned(name, node);
}
},
// Track for loop initializers (let i = 0; i < 10; i++)
'ForStatement:exit'(node) {
if (node.init && node.init.type === 'VariableDeclaration') {
for (const declarator of node.init.declarations) {
const name = getVariableName(declarator.id);
if (name) {
markAsReassigned(name, node);
}
}
}
if (node.update) {
const name = getVariableName(node.update);
if (name) {
markAsReassigned(name, node);
}
}
},
'ForInStatement:exit'(node) {
if (node.left.type === 'VariableDeclaration') {
for (const declarator of node.left.declarations) {
const name = getVariableName(declarator.id);
if (name) {
markAsReassigned(name, node);
}
}
}
},
'ForOfStatement:exit'(node) {
if (node.left.type === 'VariableDeclaration') {
for (const declarator of node.left.declarations) {
const name = getVariableName(declarator.id);
if (name) {
markAsReassigned(name, node);
}
}
}
},
// Collect all let declarations
VariableDeclaration(node) {
if (node.kind === 'let') {
letDeclarations.push(node);
}
},
// Check all let declarations at the end
'Program:exit'() {
for (const node of letDeclarations) {
const nodeScope = getScope(node);
if (!nodeScope) continue;
// Collect all variables in this declaration with their scopes
// Variables declared in a VariableDeclaration are in the scope that contains that declaration
const allVariablesInDeclaration = new Map();
for (const declarator of node.declarations) {
const variableNames = extractVariableNames(declarator.id);
for (const name of variableNames) {
// Find the variable in nodeScope (where it should be declared)
// Use the scope where we actually find the variable
let variableScope = nodeScope;
const variable = nodeScope.variables.find(v => v.name === name);
if (variable) {
// Variable found in nodeScope - use nodeScope
variableScope = nodeScope;
} else {
// Variable not found in nodeScope, search upward (shouldn't happen, but be safe)
let currentScope = nodeScope.upper;
while (currentScope) {
const foundVar = currentScope.variables.find(
v => v.name === name,
);
if (foundVar) {
variableScope = currentScope;
break;
}
currentScope = currentScope.upper;
}
}
allVariablesInDeclaration.set(name, variableScope);
}
}
// Check if all variables in this declaration can be const
const allCanBeConst = Array.from(
allVariablesInDeclaration.entries(),
).every(([name, varScope]) => !isReassignedInScope(name, varScope));
// Check each declarator
for (const declarator of node.declarations) {
const variableNames = extractVariableNames(declarator.id);
for (const variableName of variableNames) {
// Get the scope for this variable
const variableScope = allVariablesInDeclaration.get(variableName);
if (!variableScope) continue;
// Skip if the variable is reassigned in its scope
if (isReassignedInScope(variableName, variableScope)) {
continue;
}
// Find the identifier node for this variable name
let targetNode = declarator.id;
if (declarator.id.type !== 'Identifier') {
// For destructuring, find the specific identifier
const findIdentifier = (pattern, name) => {
if (pattern.type === 'Identifier' && pattern.name === name) {
return pattern;
}
if (pattern.type === 'ObjectPattern') {
for (const prop of pattern.properties) {
if (prop.type === 'Property') {
const found = findIdentifier(prop.value, name);
if (found) return found;
} else if (prop.type === 'RestElement') {
const found = findIdentifier(prop.argument, name);
if (found) return found;
}
}
}
if (pattern.type === 'ArrayPattern') {
for (const element of pattern.elements) {
if (element) {
const found = findIdentifier(element, name);
if (found) return found;
}
}
}
if (pattern.type === 'RestElement') {
return findIdentifier(pattern.argument, name);
}
return null;
};
targetNode =
findIdentifier(declarator.id, variableName) || declarator.id;
}
// Report for this variable
// Only provide a fix if all variables in the declaration can be const
context.report({
node: targetNode,
messageId: 'useConst',
data: {
name: variableName,
},
fix: allCanBeConst ? makeFixer(node) : null,
});
}
}
}
},
};
},
};

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
lint: create new custom rules for eslint rules that are not in oxlint