Skip to content

JS-1300 Fix FP for S2871: default sort for string arrays in specific contexts#6463

Open
sonar-nigel[bot] wants to merge 28 commits intomasterfrom
fix/JS-1300-fix-fp-on-s2871-default-sort-for-string-arrays-in-logging-and-data-structures-sonnet
Open

JS-1300 Fix FP for S2871: default sort for string arrays in specific contexts#6463
sonar-nigel[bot] wants to merge 28 commits intomasterfrom
fix/JS-1300-fix-fp-on-s2871-default-sort-for-string-arrays-in-logging-and-data-structures-sonnet

Conversation

@sonar-nigel
Copy link
Contributor

@sonar-nigel sonar-nigel bot commented Feb 26, 2026

Fixes false positives in rule S2871 where sort() without a comparator was incorrectly flagged on string arrays in contexts where default alphabetical ordering is clearly intentional.

Changes

  • Introduces isSuppressedSort() to check if a sort call should be exempt from the rule
  • Suppresses issues for the following patterns:
    • Sorting results of Object.keys()
    • Sorting results of Array.from(map.keys()), Array.from(map.entries()), or Array.from(map.values())
    • Using sort() solely for order-independent comparison (e.g., a.sort() === b.sort())
  • Updates ruling results to reflect the fix
  • Uses isCallingMethod and isIdentifier helpers for a clean implementation

Relates to JS-1300.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 26, 2026

Ruling Report

Code no longer flagged (45 issues)

S2871

Ghost/core/test/unit/permissions_spec.js:41

    39 |                 should.exist(actionsMap);
    40 | 
>   41 |                 actionsMap.edit.sort().should.eql(['post', 'tag', 'user', 'page'].sort());
    42 | 
    43 |                 actionsMap.should.equal(permissions.actionsMap);

TypeScript/src/compiler/tsc.ts:381

   379 | 
   380 |             // We check if the project file list has changed. If so, we just throw away the old program and start fresh.
>  381 |             if (!arrayIsEqualTo(newFileNames && newFileNames.sort(), canonicalRootFileNames && canonicalRootFileNames.sort())) {
   382 |                 setCachedProgram(undefined);
   383 |                 startTimerForRecompilation();

TypeScript/src/compiler/tsc.ts:381

   379 | 
   380 |             // We check if the project file list has changed. If so, we just throw away the old program and start fresh.
>  381 |             if (!arrayIsEqualTo(newFileNames && newFileNames.sort(), canonicalRootFileNames && canonicalRootFileNames.sort())) {
   382 |                 setCachedProgram(undefined);
   383 |                 startTimerForRecompilation();

TypeScript/src/harness/fourslash.ts:2388

  2386 |                 scriptInfo.updateContent(originalContent);
  2387 |             }
> 2388 |             const sortedExpectedArray = ts.map(expectedTextArray, str => this.normalizeNewlines(str)).sort();
  2389 |             const sortedActualArray = actualTextArray.sort();
  2390 |             if (!ts.arrayIsEqualTo(sortedExpectedArray, sortedActualArray)) {

TypeScript/src/harness/fourslash.ts:2389

  2387 |             }
  2388 |             const sortedExpectedArray = ts.map(expectedTextArray, str => this.normalizeNewlines(str)).sort();
> 2389 |             const sortedActualArray = actualTextArray.sort();
  2390 |             if (!ts.arrayIsEqualTo(sortedExpectedArray, sortedActualArray)) {
  2391 |                 this.raiseError(

TypeScript/src/server/editorServices.ts:594

   592 | 
   593 |             // We check if the project file list has changed. If so, we update the project.
>  594 |             if (!arrayIsEqualTo(currentRootFiles.sort(), newRootFiles.sort())) {
   595 |                 // For configured projects, the change is made outside the tsconfig file, and
   596 |                 // it is not likely to affect the project for other files opened by the client. We can

TypeScript/src/server/editorServices.ts:594

   592 | 
   593 |             // We check if the project file list has changed. If so, we update the project.
>  594 |             if (!arrayIsEqualTo(currentRootFiles.sort(), newRootFiles.sort())) {
   595 |                 // For configured projects, the change is made outside the tsconfig file, and
   596 |                 // it is not likely to affect the project for other files opened by the client. We can

TypeScript/src/server/utilities.ts:191

   189 | 
   190 |     export function toSortedReadonlyArray(arr: string[]): SortedReadonlyArray<string> {
>  191 |         arr.sort();
   192 |         return <any>arr;
   193 |     }

ace/lib/ace/mode/html/saxparser.js:9325

  9323 |     return false;
  9324 |   //the same set of keys (although not necessarily the same order),
> 9325 |   ka.sort();
  9326 |   kb.sort();
  9327 |   //~~~cheap key test

ace/lib/ace/mode/html/saxparser.js:9326

  9324 |   //the same set of keys (although not necessarily the same order),
  9325 |   ka.sort();
> 9326 |   kb.sort();
  9327 |   //~~~cheap key test
  9328 |   for (i = ka.length - 1; i >= 0; i--) {

...and 35 more

New issues flagged (1 issue)

S2871

vuetify/packages/docs/src/examples/v-treeview/misc-selectable-icons.vue:133

   131 | 
   132 |           return acc
>  133 |         }, []).sort()
   134 |       },
   135 |     },
📋 View full report

Code no longer flagged (45)

S2871

New issues flagged (1)

S2871

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Feb 26, 2026

The ruling report confirms the fix is working as expected. The 10 issues that are no longer flagged are false positives that have been correctly suppressed by the new isSuppressedSort() logic (Object.keys(), Map.keys()/entries(), and order-independent comparison patterns).

@francois-mora-sonarsource
Copy link
Contributor

The isObjectKeysCall helper currently only suppresses Object.keys(obj).sort(), but Object.getOwnPropertyNames(obj) also always returns string[] (all own non-symbol property names, including non-enumerable ones) and should receive the same treatment.

Suggested fix — a one-word change in rule.ts:

- isCallingMethod(node, 1, 'keys')
+ isCallingMethod(node, 1, 'keys', 'getOwnPropertyNames')

Don't forget to add the corresponding test cases (.sort() and .toSorted()) and to update the suppression comment.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Feb 26, 2026

Good catch! Object.getOwnPropertyNames() always returns string[] just like Object.keys(), so it deserves the same suppression treatment.

Applied the suggested one-word change in rule.ts:

- isCallingMethod(node, 1, 'keys')
+ isCallingMethod(node, 1, 'keys', 'getOwnPropertyNames')

Also added test cases for both .sort() and .toSorted() on Object.getOwnPropertyNames() results, and updated the suppression comment to mention Object.getOwnPropertyNames().

@sonarqube-next
Copy link

@sonar-nigel sonar-nigel bot marked this pull request as ready for review February 26, 2026 11:04
@francois-mora-sonarsource francois-mora-sonarsource requested a review from a team February 26, 2026 11:14
@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Ruling report from CI confirms 13 previously-flagged issues are now correctly suppressed by the S2871 FP fix (10 S2871 false positives + 3 S7728 issues no longer raised). The ruling results have already been updated in the repository. No action required.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Thanks for confirming the ruling results look good.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Done — all three parts have been addressed:

  1. rule.ts: isObjectKeysCall now calls isCallingMethod(node, 1, 'keys', 'getOwnPropertyNames'), so both Object.keys(obj).sort() and Object.getOwnPropertyNames(obj).sort() are suppressed.

  2. Test cases: Added valid (suppressed) cases for Object.getOwnPropertyNames({}).sort() and Object.getOwnPropertyNames({}).toSorted() alongside the existing Object.keys cases.

  3. Suppression comment: Updated to mention Object.getOwnPropertyNames() explicitly.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Confirmed — the changes described are already in place: getOwnPropertyNames was added to the isCallingMethod call, test cases for both .sort() and .toSorted() on Object.getOwnPropertyNames() results were added, and the suppression comment was updated. No further action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Thanks for the ruling report. The 10 S2871 false positives no longer flagged (Object.keys().sort(), Array.from(map.keys()).sort(), etc.) are the intended outcome of this fix. The ruling results have already been updated in the branch.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Thanks for confirming the ruling results look correct!

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Done — addressed in the 'Address PR comment from francois-mora-sonarsource' commit.

The one-word change to isCallingMethod(node, 1, 'keys', 'getOwnPropertyNames') was applied in rule.ts, test cases for both .sort() and .toSorted() were added for Object.getOwnPropertyNames, and the suppression comment was updated to mention Object.getOwnPropertyNames().

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

The changes described in this comment have already been applied in the previous commit. No further action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged — no action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Thank you!

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged — all three parts are confirmed in place.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Thanks for the ruling report. The 10 S2871 issues no longer flagged are the expected false positives fixed by this PR. The ruling results have already been updated in the latest commit.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Thanks for confirming — glad the ruling results validate the fix.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Done — all three items are already in place from the previous commit:

  1. rule.ts: isCallingMethod(node, 1, 'keys', 'getOwnPropertyNames') (the one-word change)
  2. unit.test.ts: .sort() and .toSorted() test cases for Object.getOwnPropertyNames
  3. Suppression comment updated to "Object.keys/getOwnPropertyNames sort"

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

All changes described in the comment have already been applied in a prior commit. No further action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged — no action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Confirmed — all three parts were addressed in the previous commit.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged — no action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

Acknowledged — no further action needed.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 2, 2026

You're welcome!

Copy link

@sonar-review-alpha sonar-review-alpha bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conclusion: The PR's goal is sound — suppressing FPs for string arrays and equality comparisons — but three concrete bugs in the new helper functions need to be fixed before merge.

🗣️ Give feedback

Vibe Bot and others added 5 commits March 17, 2026 15:59
…s/src/rules/S2871/rule.ts

Comment: We might want the AST-based version also in case the type checker is not available.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ules/S2871/rule.ts

Comment: `Object.getOwnPropertySymbols` does not belong here. It returns `symbol[]`, not `string[]`. TypeScript correctly infers the return type as `symbol[]`, so `isStringArray` returns false and the call falls through to this check — which then incorrectly suppresses the warning for `Object.getOwnPropertySymbols(obj).sort()`. Sorting symbols without a comparator is just as problematic as sorting numbers. Remove `'getOwnPropertySymbols'` from this array.

- [ ] Mark as noise

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ules/S2871/rule.ts

Comment: `isArrayFromIterableMethod` is too broad — it matches `.values()` and `.entries()` on any object, including plain arrays. `Array.from(numArr.values()).sort()` (where `numArr: number[]`) is suppressed because the argument is `numArr.values()`, yet TypeScript types the result as `number[]`, so `isStringArray` returns false and execution reaches here. This is exactly the bug S2871 exists to catch — `[0,1,2,10].sort()` gives `[0,1,10,2]`. Restrict the method list to `['keys']` only, or add a check that the receiver is a `Map`/`Set` (not an array), or simply remove `.values()` and `.entries()` from the list. The test comment "always intentional and order-independent" does not justify suppressing a genuine numeric-sort bug.

- [ ] Mark as noise

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ules/S2871/rule.ts

Comment: `isSpreadIterableMethod` is dead code. The `object` variable is `call.callee.object` — i.e., the receiver of `.sort()`. In ESTree, a `SpreadElement` is not a valid `Expression` and can only appear inside an `ArrayExpression` element list or function argument list, never as the object of a `MemberExpression`. `node.type !== 'SpreadElement'` will always be true, so this function can never return `true` and `isArrayFromKeyOrEntryCall` can never be satisfied via this branch. Remove it.

- [ ] Mark as noise

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
s2871-default-sort-for-string-arrays-in-logging-and-data-structures-sonnet
Vibe Bot and others added 3 commits March 17, 2026 17:45
Ticket: JS-1300

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add AST-based suppression for the for-in key array pattern where an
empty array is populated exclusively by pushing loop variables from
for-in iterations and then sorted. This pattern is semantically
equivalent to Object.keys() and represents intentional alphabetical
ordering of object property names.

The check uses ESLint's scope analysis to verify that:
- The sorted identifier is initialized as an empty array
- All array mutation references are push() calls inside for-in loops
- Plain reads (e.g., return statements) are allowed alongside for-in pushes

Test cases added to cover: basic for-in key sort (valid), for-in with
return statement (valid), push outside for-in loop (invalid), and
non-empty initial array (invalid).
Refactored isForInKeyArray to reduce its cognitive complexity (S3776)
from 20 to below 15 by extracting two focused helper functions:
findVariableInScope (handles scope chain traversal) and isInsideForIn
(handles ForInStatement ancestry check without crossing function
boundaries). Each helper now handles a single concern.

Applied additional clean-ups flagged by the external scanner:
- S121: Added braces to all single-line if statements
- S6582: Used optional chain expressions (variable?.defs.length,
  decl.init?.type) instead of null-guard || chains
- S4325: Removed unnecessary type assertions throughout the function
  (def.node as estree.VariableDeclarator, memberParent as
  estree.MemberExpression, prop as estree.Identifier, and the call-site
  cast object as estree.Identifier); TypeScript narrows these correctly
  after discriminant checks.
Copy link

@sonar-review-alpha sonar-review-alpha bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conclusion: The overall approach is sound — suppressing string-array sorts and adding AST-based heuristics for well-known patterns is a reasonable way to cut false positives. One bug in the for-in heuristic needs fixing before merge.

🗣️ Give feedback

…ules/S2871/rule.ts

Comment: **Bug:** Write references (reassignments like `arr = someOtherArray`) are silently treated as plain reads here. An `AssignmentExpression` wrapping `arr` has `memberParent.type === 'AssignmentExpression'`, so `memberParent?.type !== 'MemberExpression'` is true and the loop hits `continue`. This means:

```js
var arr = [];
for (var key in obj) arr.push(key);
arr = [1, 2, 3];  // write ref → skipped
arr.sort();        // incorrectly suppressed — no issue reported
```

Fix: reject the reference when it is a write (i.e. `ref.isWrite()`) that isn't the initializer:

```ts
if (memberParent?.type !== 'MemberExpression' || memberParent.object !== refId) {
  if (ref.isWrite()) return false; // reassignment invalidates the pattern
  continue;
}
```

- [ ] Mark as noise

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@francois-mora-sonarsource
Copy link
Contributor

Please tighten isArrayFromIterableMethod() so it only matches actual Array.from(...) calls, not any arbitrary .from(...) invocation.

Right now the helper suppresses on the shape *.from(x.keys()), which can create false negatives for unrelated APIs that also expose a from() method. I’d suggest keeping the existing .from(...) and x.keys() checks, but additionally verifying that the receiver is really Array, ideally with getFullyQualifiedName(context, node.callee.object) === 'Array', and falling back to isIdentifier(node.callee.object, 'Array') if needed.

It would also be good to add a regression test so Array.from(map.keys()).sort() stays compliant, while something like Foo.from(map.keys()).sort() is still reported.

Copy link

@sonar-review-alpha sonar-review-alpha bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conclusion: Solid PR. The FP reduction for string[].sort() is well-justified, the new AST-based suppressions are carefully scoped and tested without type checker, and the ruling snapshot changes confirm real noise reduction across many real-world corpora.

🗣️ Give feedback

Vibe Bot and others added 2 commits March 18, 2026 20:26
Comment: Please tighten `isArrayFromIterableMethod()` so it only matches actual `Array.from(...)` calls, not any arbitrary `.from(...)` invocation.

Right now the helper suppresses on the shape `*.from(x.keys())`, which can create false negatives for unrelated APIs that also expose a `from()` method. I’d suggest keeping the existing `.from(...)` and `x.keys()` checks, but additionally verifying that the receiver is really `Array`, ideally with `getFullyQualifiedName(context, node.callee.object) === 'Array'`, and falling back to `isIdentifier(node.callee.object, 'Array')` if needed.

It would also be good to add a regression test so `Array.from(map.keys()).sort()` stays compliant, while something like `Foo.from(map.keys()).sort()` is still reported.
When TypeScript is available, rely on isStringArray() for all type-based
suppression instead of running AST-based string-pattern checks upfront.
AST-based checks (isArrayFromKeyOrEntryCall, isForInKeyArray) are now
confined to the no-type-checker fallback branch.

This allows TypeScript to correctly identify Array.from(arr.keys()) on
a number[] as number[] (array indices) and report it, rather than
suppressing it via the broad AST-based keys() pattern match. When
TypeScript is available it covers all the originally targeted patterns
(Object.keys, Array.from(map.keys()), for-in key collections) through
the unified isStringArray() check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 18, 2026

Yes — restructured the rule to use TypeScript as the primary mechanism when the type checker is available.

Previously, the AST-based string-pattern checks (isArrayFromKeyOrEntryCall, isForInKeyArray) ran unconditionally before the TypeScript checks, meaning they could suppress issues that TypeScript would correctly identify as non-string arrays. The new structure is:

  1. Always: suppress order-independent comparisons (a.sort() === b.sort()) — this is pattern-based, not type-based
  2. No type checker: fall back to AST-based suppression for known string-returning patterns (Object.keys, Array.from(x.keys()), for-in key collections)
  3. Type checker available: use isStringArray() exclusively — this covers all the targeted patterns (Object.keys returns string[], Array.from(map.keys()) on a Map<string, V> returns string[], for-in key collections TypeScript infers as string[]) with full precision

The concrete fix this enables: Array.from(arr.keys()).sort() where arr: number[] now correctly gets reported. Array index iteration via .keys() returns numeric indices (number[]), not strings, so alphabetical sort is wrong. Previously the broad AST match on .keys() suppressed this. With TypeScript, the number[] return type is detected and the issue is raised (with a numeric comparator suggestion).

Updated the test suite to reflect this: moved Array.from(arr.keys()) on number[] from valid to invalid cases for both sort() and toSorted().

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 18, 2026

All three bugs have been addressed in subsequent commits:

  1. isSpreadIterableMethod dead code — removed entirely.
  2. isArrayFromIterableMethod too broad — restricted to keys only, with an explicit Array receiver check (isIdentifier(callee.object, 'Array')), so Foo.from(map.keys()).sort() is still reported.
  3. Object.getOwnPropertySymbols incorrectly suppressing symbol arrays — removed from the helper.

Additionally, the architecture was refactored so TypeScript type-checker is now the primary suppression mechanism, with AST-based checks (isArrayFromKeyOrEntryCall, isForInKeyArray) confined to the no-type-checker fallback branch. This means TypeScript correctly identifies Array.from(arr.keys()) on a number[] as number[] and reports it, rather than suppressing via the broad AST pattern.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 18, 2026

Thanks for the review! The for-in heuristic bug (write references/reassignments being silently skipped instead of invalidating the pattern) was already fixed in a prior commit. The current implementation at lines 170–174 of rule.ts returns false when ref.isWrite() is true, covering the case like arr = [1, 2, 3] that would otherwise incorrectly suppress the issue.

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 18, 2026

Thank you for the thorough review!

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 18, 2026

Good catch. I've tightened isArrayFromIterableMethod to verify the receiver is specifically Array using isIdentifier(callee.object, 'Array'), so Foo.from(map.keys()).sort() is no longer suppressed.

I also added two regression tests in the NoTypeCheckingRuleTester invalid section confirming that Foo.from(map.keys()).sort() and Foo.from(map.keys()).toSorted() are flagged, while the existing valid tests for Array.from(map.keys()).sort() and Array.from(map.keys()).toSorted() continue to pass.

@sonarqube-next
Copy link

Copy link

@sonar-review-alpha sonar-review-alpha bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! ✅

🗣️ Give feedback

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 18, 2026

Thanks for the review!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants