-
Notifications
You must be signed in to change notification settings - Fork 473
Add ESLint rule for duplicate constant values #48657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { RuleTester } from "eslint"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { noDuplicateConstantValuesRule } from "./no-duplicate-constant-values"; | ||
|
|
||
| const ruleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: "commonjs", | ||
| }, | ||
| }); | ||
|
|
||
| describe("no-duplicate-constant-values", () => { | ||
| it("uses the correct docs URL", () => { | ||
| expect(noDuplicateConstantValuesRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#no-duplicate-constant-values"); | ||
| }); | ||
|
|
||
| it("accepts unique and dynamic constant values", () => { | ||
| ruleTester.run("no-duplicate-constant-values", noDuplicateConstantValuesRule, { | ||
| valid: [ | ||
| `const FIRST = "first"; const SECOND = "second";`, | ||
| `const FIRST = makeValue(); const SECOND = makeValue();`, | ||
| `const FIRST = { value: 1 }; const SECOND = { value: 1 };`, | ||
| `let first = "same"; let second = "same";`, | ||
| `const { first, second } = value;`, | ||
| `function first() { const VALUE = "same"; } function second() { const VALUE = "same"; }`, | ||
| ], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("reports duplicate strings, numbers, templates, and regular expressions", () => { | ||
| ruleTester.run("no-duplicate-constant-values", noDuplicateConstantValuesRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `const FIRST = "same"; const SECOND = "same";`, | ||
| errors: [{ messageId: "duplicateConstantValue", data: { name: "SECOND", originalName: "FIRST", value: `"same"` } }], | ||
| }, | ||
| { | ||
| code: 'const FIRST = `same`; const SECOND = "same";', | ||
| errors: [{ messageId: "duplicateConstantValue", data: { name: "SECOND", originalName: "FIRST", value: `"same"` } }], | ||
| }, | ||
| { | ||
| code: `const FIRST = -42; const SECOND = -42; const THIRD = 42;`, | ||
| errors: [{ messageId: "duplicateConstantValue", data: { name: "SECOND", originalName: "FIRST", value: "-42" } }], | ||
| }, | ||
| { | ||
| code: `const FIRST = /value/gi; const SECOND = /value/gi;`, | ||
| errors: [{ messageId: "duplicateConstantValue", data: { name: "SECOND", originalName: "FIRST", value: "/value/gi" } }], | ||
| }, | ||
| { | ||
| code: `const FIRST = /value/gi; const SECOND = /value/ig;`, | ||
| errors: [{ messageId: "duplicateConstantValue", data: { name: "SECOND", originalName: "FIRST", value: "/value/ig" } }], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("reports every duplicate after the first declaration", () => { | ||
| ruleTester.run("no-duplicate-constant-values", noDuplicateConstantValuesRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `const FIRST = "same"; const SECOND = "same"; const THIRD = "same";`, | ||
| errors: [ | ||
| { messageId: "duplicateConstantValue", data: { name: "SECOND", originalName: "FIRST", value: `"same"` } }, | ||
| { messageId: "duplicateConstantValue", data: { name: "THIRD", originalName: "FIRST", value: `"same"` } }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; | ||
|
|
||
| const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); | ||
|
|
||
| interface ConstantDeclaration { | ||
| name: string; | ||
| } | ||
|
|
||
| function getStaticValueKey(node: TSESTree.Expression): string | null { | ||
| if (node.type === AST_NODE_TYPES.Literal) { | ||
| if ("regex" in node && node.regex) { | ||
| return `regexp:${node.regex.pattern}/${[...node.regex.flags].sort().join("")}`; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Regex flag-order normalization is missing — 💡 Suggested fixSort flags before building the key: return `regexp:${node.regex.pattern}/${[...node.regex.flags].sort().join('')}`;Add a test case: @copilot please address this. |
||
| return `${typeof node.value}:${String(node.value)}`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BigInt literals aren't handled correctly and can collide with unrelated 💡 DetailsFor a BigInt literal (e.g. |
||
| } | ||
|
|
||
| if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) { | ||
| return `string:${node.quasis[0].value.cooked ?? node.quasis[0].value.raw}`; | ||
| } | ||
|
|
||
| if (node.type === AST_NODE_TYPES.UnaryExpression && (node.operator === "+" || node.operator === "-") && node.argument.type === AST_NODE_TYPES.Literal && typeof node.argument.value === "number") { | ||
| return `number:${String(node.operator === "-" ? -node.argument.value : node.argument.value)}`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] A no-expressions template literal @copilot please address this. |
||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The unary @copilot please address this. |
||
| export const noDuplicateConstantValuesRule = createRule({ | ||
| name: "no-duplicate-constant-values", | ||
| meta: { | ||
| type: "suggestion", | ||
| docs: { | ||
| description: "List module-level constant declarations by their static primitive values and report later declarations that duplicate a value in the same file.", | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| duplicateConstantValue: "Constant '{{name}}' duplicates the value of constant '{{originalName}}' ({{value}}).", | ||
| }, | ||
| }, | ||
| defaultOptions: [], | ||
| create(context) { | ||
| const constantsByValue = new Map<string, ConstantDeclaration>(); | ||
|
|
||
| return { | ||
| VariableDeclaration(node) { | ||
| if (node.kind !== "const" || node.parent.type !== AST_NODE_TYPES.Program) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This guard silently skips every 💡 DetailsFor Fix: also allow the export wrapper: const isTopLevel =
node.parent.type === AST_NODE_TYPES.Program ||
(node.parent.type === AST_NODE_TYPES.ExportNamedDeclaration && node.parent.parent.type === AST_NODE_TYPES.Program);
if (node.kind !== "const" || !isTopLevel) return;No test in this PR exercises |
||
| return; | ||
| } | ||
|
|
||
| for (const declaration of node.declarations) { | ||
| if (declaration.id.type !== AST_NODE_TYPES.Identifier || !declaration.init) { | ||
| continue; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] No test covers a @copilot please address this. |
||
|
|
||
| const valueKey = getStaticValueKey(declaration.init); | ||
| if (valueKey === null) { | ||
| continue; | ||
| } | ||
|
|
||
| const original = constantsByValue.get(valueKey); | ||
| if (!original) { | ||
| constantsByValue.set(valueKey, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any two unrelated constants that coincidentally share a common trivial value (0, 1, "", true, null) will be flagged, which will likely be extremely noisy in real production 💡 DetailsThe rule's premise (matching by structural equality of the initializer value alone, with no relation to identifier name/semantics) means any file with e.g. |
||
| name: declaration.id.name, | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| context.report({ | ||
| node: declaration, | ||
| messageId: "duplicateConstantValue", | ||
| data: { | ||
| name: declaration.id.name, | ||
| originalName: original.name, | ||
| value: context.sourceCode.getText(declaration.init), | ||
| }, | ||
| }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/codebase-design] The
ConstantDeclarationinterface only holdsnamebut is used just to carry the string — it adds indirection with no benefit. Use a plainMap<string, string>(value key → name) instead.💡 Simplified approach
Removing the wrapper type makes the intent clearer.
@copilot please address this.