From eb9d84100a646cbb5e1651ceec3a41dd9935feb3 Mon Sep 17 00:00:00 2001 From: Lars Kappert Date: Tue, 19 May 2026 19:58:24 +0200 Subject: [PATCH 1/3] fix(prefer-array-to-sorted): bail on non-array iterable spread without type info Avoid unsafe untyped autofixes for spread-copy sorts whose source is known not to be an array. These rewrites can otherwise turn Set/Map/iterator sources into invalid `.toSorted()` calls. Bail on known collection constructors, local identifiers initialized from those constructors, iterator-returning `.entries()`/`.keys()`/`.values()` calls, and TS syntax-only iterable annotations when type services are unavailable. Keep `Object.entries()` and known array sources fixable. Verified with the focused rule test, lint, build, and a consumer probe across representative checked-out projects. --- src/rules/prefer-array-to-sorted.test.ts | 46 +++++++- src/rules/prefer-array-to-sorted.ts | 129 ++++++++++++++++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/rules/prefer-array-to-sorted.test.ts b/src/rules/prefer-array-to-sorted.test.ts index b7a4d4e..72a568d 100644 --- a/src/rules/prefer-array-to-sorted.test.ts +++ b/src/rules/prefer-array-to-sorted.test.ts @@ -25,6 +25,14 @@ const ruleTester = new RuleTester({ sourceType: 'module' } }); +const tsSyntaxRuleTester = new TSRuleTester({ + languageOptions: { + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module' + } + } +}); ruleTester.run( 'prefer-array-to-sorted (untyped)', @@ -40,7 +48,17 @@ ruleTester.run( // slice with different arguments 'arr.slice(1).sort();', - 'arr.slice(0, 5).sort();' + 'arr.slice(0, 5).sort();', + + 'const sorted = [...new Set([1, 2, 3])].sort();', + 'const sorted = [...new Set(arr)].sort();', + 'const sorted = [...new Map()].sort();', + 'const foundVersions = new Set(); const sorted = [...foundVersions].sort();', + 'let foundVersions = new Set(); const sorted = [...foundVersions].sort();', + 'const entries = new Map(); const sorted = [...entries].sort();', + 'const sorted = [...items.entries()].sort();', + 'const sorted = [...items.keys()].sort();', + 'const sorted = [...items.values()].sort();' ], invalid: [ @@ -174,6 +192,12 @@ ruleTester.run( } ] }, + { + code: 'const sorted = [...Object.entries(counts)].sort(([a], [b]) => a.localeCompare(b));', + output: + 'const sorted = Object.entries(counts).toSorted(([a], [b]) => a.localeCompare(b));', + errors: [{messageId: 'preferToSorted'}] + }, // Multiple arguments (edge case - sort() typically takes 0 or 1 arg) { @@ -244,6 +268,26 @@ ruleTester.run( } ); +tsSyntaxRuleTester.run( + 'prefer-array-to-sorted (typescript syntax)', + preferArrayToSorted, + { + valid: [ + 'function sortItems(items: Iterable) { return [...items].sort(); }', + 'function sortItems(items: Map) { return [...items.entries()].sort(); }', + 'const items: Set = new Set(); const sorted = [...items].sort();', + 'let items: Iterable; const sorted = [...items].sort();' + ], + invalid: [ + { + code: 'const items: string[] = []; const sorted = [...items].sort();', + output: 'const items: string[] = []; const sorted = items.toSorted();', + errors: [{messageId: 'preferToSorted'}] + } + ] + } +); + typedRuleTester.run('prefer-array-to-sorted (typed)', preferArrayToSorted, { valid: [ // Set spread - Set doesn't have toSorted() diff --git a/src/rules/prefer-array-to-sorted.ts b/src/rules/prefer-array-to-sorted.ts index dc29d3e..863e129 100644 --- a/src/rules/prefer-array-to-sorted.ts +++ b/src/rules/prefer-array-to-sorted.ts @@ -9,6 +9,125 @@ import {isArrayType} from '../utils/typescript.js'; type MessageIds = 'preferToSorted'; +const NON_ARRAY_COPY_SOURCE_CTORS = new Set(['Set', 'Map']); +const NON_ARRAY_COPY_SOURCE_TYPES = new Set([ + 'Iterable', + 'IterableIterator', + 'Iterator', + 'Map', + 'ReadonlyMap', + 'ReadonlySet', + 'Set', + 'URLSearchParams' +]); +const ITERATOR_RETURNING_METHODS = new Set(['entries', 'keys', 'values']); + +type NodeWithTypeAnnotation = TSESTree.Node & { + typeAnnotation?: TSESTree.TSTypeAnnotation; +}; + +function getTypeReferenceName(node: TSESTree.Node): string | null { + if (node.type !== 'TSTypeReference') { + return null; + } + + const {typeName} = node; + if (typeName.type === 'Identifier') { + return typeName.name; + } + if (typeName.type === 'TSQualifiedName') { + return typeName.right.name; + } + return null; +} + +function hasKnownNonArrayTypeAnnotation(node: TSESTree.Node): boolean { + const typeAnnotation = (node as NodeWithTypeAnnotation).typeAnnotation; + if (typeAnnotation?.type !== 'TSTypeAnnotation') { + return false; + } + + const annotation = typeAnnotation.typeAnnotation; + if (annotation.type === 'TSUnionType') { + return annotation.types.some((typeNode) => + hasKnownNonArrayType(typeNode as TSESTree.Node) + ); + } + return hasKnownNonArrayType(annotation as TSESTree.Node); +} + +function hasKnownNonArrayType(node: TSESTree.Node): boolean { + const typeName = getTypeReferenceName(node); + return typeName !== null && NON_ARRAY_COPY_SOURCE_TYPES.has(typeName); +} + +function isIteratorReturningCall(node: TSESTree.Node): boolean { + return ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + ITERATOR_RETURNING_METHODS.has(node.callee.property.name) && + !( + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Object' + ) + ); +} + +function isKnownNonArrayCopySource(node: TSESTree.Node): boolean { + return ( + isIteratorReturningCall(node) || + (node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + NON_ARRAY_COPY_SOURCE_CTORS.has(node.callee.name)) + ); +} + +function resolvesToKnownNonArrayCopySource( + node: TSESTree.Node, + context: TSESLint.RuleContext +): boolean { + if (isKnownNonArrayCopySource(node)) { + return true; + } + + if (node.type !== 'Identifier') { + return false; + } + + const variable = context.sourceCode + .getScope(node) + .references.find((ref) => ref.identifier === node)?.resolved; + + if (!variable || variable.defs.length !== 1) { + return false; + } + + const def = variable.defs[0]; + if (!def || (def.type !== 'Variable' && def.type !== 'Parameter')) { + return false; + } + + if (hasKnownNonArrayTypeAnnotation(def.name as TSESTree.Node)) { + return true; + } + + if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') { + return false; + } + + const init = def.node.init; + if (hasKnownNonArrayTypeAnnotation(def.node.id)) { + return true; + } + + return ( + init !== null && + isKnownNonArrayCopySource(init) && + variable.references.every((ref) => !ref.isWrite() || ref.writeExpr === init) + ); +} + export const preferArrayToSorted: TSESLint.RuleModule = { meta: { type: 'suggestion', @@ -40,7 +159,15 @@ export const preferArrayToSorted: TSESLint.RuleModule = { const arrayNode = getArrayFromCopyPattern(sortCallee); if (arrayNode) { - if (isArrayType(arrayNode, context) === false) { + const arrayType = isArrayType(arrayNode, context); + if (arrayType === false) { + return; + } + + if ( + arrayType !== true && + resolvesToKnownNonArrayCopySource(arrayNode, context) + ) { return; } From c3a3c2f39eb823dee2957adfe8fc1c033dc4a843 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:39:01 +0100 Subject: [PATCH 2/3] chore: drop casts --- src/rules/prefer-array-to-sorted.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/rules/prefer-array-to-sorted.ts b/src/rules/prefer-array-to-sorted.ts index 863e129..5568566 100644 --- a/src/rules/prefer-array-to-sorted.ts +++ b/src/rules/prefer-array-to-sorted.ts @@ -22,10 +22,6 @@ const NON_ARRAY_COPY_SOURCE_TYPES = new Set([ ]); const ITERATOR_RETURNING_METHODS = new Set(['entries', 'keys', 'values']); -type NodeWithTypeAnnotation = TSESTree.Node & { - typeAnnotation?: TSESTree.TSTypeAnnotation; -}; - function getTypeReferenceName(node: TSESTree.Node): string | null { if (node.type !== 'TSTypeReference') { return null; @@ -41,19 +37,17 @@ function getTypeReferenceName(node: TSESTree.Node): string | null { return null; } -function hasKnownNonArrayTypeAnnotation(node: TSESTree.Node): boolean { - const typeAnnotation = (node as NodeWithTypeAnnotation).typeAnnotation; - if (typeAnnotation?.type !== 'TSTypeAnnotation') { +function hasKnownNonArrayTypeAnnotation(node: TSESTree.BindingName): boolean { + const typeAnnotation = node.typeAnnotation; + if (typeAnnotation === undefined) { return false; } const annotation = typeAnnotation.typeAnnotation; if (annotation.type === 'TSUnionType') { - return annotation.types.some((typeNode) => - hasKnownNonArrayType(typeNode as TSESTree.Node) - ); + return annotation.types.some((typeNode) => hasKnownNonArrayType(typeNode)); } - return hasKnownNonArrayType(annotation as TSESTree.Node); + return hasKnownNonArrayType(annotation); } function hasKnownNonArrayType(node: TSESTree.Node): boolean { @@ -108,7 +102,7 @@ function resolvesToKnownNonArrayCopySource( return false; } - if (hasKnownNonArrayTypeAnnotation(def.name as TSESTree.Node)) { + if (hasKnownNonArrayTypeAnnotation(def.name)) { return true; } From aa481a599d457d810e4e52f55d167a180cd89c9f Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:51:43 +0100 Subject: [PATCH 3/3] chore: remove redundant check Variable IDs are the same as their declarator's name, so no need checking it again. --- src/rules/prefer-array-to-sorted.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/rules/prefer-array-to-sorted.ts b/src/rules/prefer-array-to-sorted.ts index 5568566..65ee0c6 100644 --- a/src/rules/prefer-array-to-sorted.ts +++ b/src/rules/prefer-array-to-sorted.ts @@ -111,9 +111,6 @@ function resolvesToKnownNonArrayCopySource( } const init = def.node.init; - if (hasKnownNonArrayTypeAnnotation(def.node.id)) { - return true; - } return ( init !== null &&