Skip to content

Report in-place sort calls that cannot change the array behind the sortWithoutEffect bleeding-edge toggle - #6296

Merged
ondrejmirtes merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-9p12jr7
Sep 3, 2026
Merged

ondrejmirtes merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-9p12jr7

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

ksort() on a list is always a no-op: a list's keys are 0..n-1 in ascending order already, so with the default SORT_REGULAR flags there is nothing to reorder. This is the same class of dead call ArrayValuesRule already reports as arrayValues.list / arrayValues.empty, and it silently hides real bugs (the issue reports a third-party extension whose output was non-deterministic because a ksort() was meant to be a sort()).

This PR adds PHPStan\Rules\Functions\SortWithoutEffectRule, behind the sortWithoutEffect bleeding-edge toggle, and covers the whole family of in-place sort functions rather than ksort() alone.

Changes

  • src/Rules/Functions/SortWithoutEffectRule.php — new rule listening on FuncCall. It normalizes the arguments (so named arguments work), takes the PHPDoc or native type of #1 $array depending on treatPhpDocTypesAsCertain, and reports:
    • ksort.list — the array is a list and the sort flags are known to be SORT_REGULAR or SORT_NUMERIC (or omitted). The flags check is a isSuperTypeOf() against 0|1, so SORT_STRING, SORT_NATURAL, SORT_REGULAR|SORT_FLAG_CASE and non-constant int flags all bail out.
    • <fn>.empty — the array is definitely empty. Applies to every in-place sort: sort, rsort, usort, shuffle, asort, arsort, ksort, krsort, uasort, uksort, natsort, natcasesort.
    • <fn>.singleElement — the array has at most one element (int<2, max> is not a supertype of getArraySize()). Reported for the key-preserving sorts unconditionally, and for the reindexing sorts (sort, rsort, usort, shuffle) only when the array is also a list.
  • conf/bleedingEdge.neon, conf/config.neon, conf/parametersSchema.neon — new featureToggles.sortWithoutEffect.
  • conf/config.level5.neon — registers the rule (mirroring ArrayValuesRule's level) with a conditionalTags entry on the toggle.
  • tests/PHPStan/Rules/Functions/SortWithoutEffectRuleTest.php + two data files.

Analogous cases probed and deliberately not reported, because they are not no-ops:

  • krsort() on a list of two or more elements — it reverses the array.
  • sort() / rsort() / usort() / shuffle() on a single-element array that is not a list (e.g. array{foo: int}) — they renumber the key to 0.
  • ksort() on non-empty-array<string, int>, on array{foo: int}|array{bar: int, baz: int} (size 1|2), and on mixed.
  • ksort($list, SORT_STRING) / SORT_NATURAL / a non-constant int — string comparison orders "10" before "2", and unknown flags are skipped conservatively.

Root cause

This is a missing check rather than a regression. The existing "call has no effect" rules only covered array_values() and array_filter(); the in-place sort family had no equivalent. The pattern behind all of them is the same: an argument type whose shape (empty, at most one element, already a list) makes the operation provably identity.

Rather than special-casing ksort(), the rule splits the sort functions along the one axis that decides the answer — whether the function preserves keys or reindexes the array — and derives the three no-effect conditions from the argument's isIterableAtLeastOnce(), isList() and getArraySize(). That way every sibling function is covered by construction and adding a new one is a single array entry.

Test

tests/PHPStan/Rules/Functions/SortWithoutEffectRuleTest.php:

  • testRule() analyses data/sort-without-effect.php, which contains both reproducers from the issue (ksort() on a list<string> parameter, and ksort() on an array built with $tips[] = ...), plus one isolated function per sort function for the empty, single-element, single-element-list, non-empty-map, union and mixed cases, and one per interesting ksort() flags value. Each call lives in its own function so the by-reference type of the previous call cannot leak into the next assertion.
  • testNamedArguments() (#[RequiresPhp('>= 8.0.0')]) analyses data/sort-without-effect-named-args.php and covers ksort(array: $list) and ksort(flags: SORT_STRING, array: $list), exercising ArgumentsNormalizer.

Both the rule test and an end-to-end bin/phpstan analyse -l 8 run of the issue's snippet were checked to report nothing with the toggle off and the two expected ksort.list errors with conf/bleedingEdge.neon. make tests, make phpstan and make cs are green. (make name-collision fails on tests/PHPStan/Reflection/data/attribute-const-reflection.php, which reproduces on a clean checkout and is unrelated to this change.)

Fixes phpstan/phpstan#15126

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the same is true for arrays which are known to have a size of 0 or 1

Should be checked with IntegerRangeType of 0-1 isSuperTypeOf $arg->getArraySize.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Addressed the review and pushed as 82aa8bfbe.

Change (src/Rules/Functions/SortWithoutEffectRule.php:158):

- if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($arrayType->getArraySize())->no()) {
+ if (IntegerRangeType::fromInterval(0, 1)->isSuperTypeOf($arrayType->getArraySize())->yes()) {

The two forms select the same calls — an unknown int size gives maybe either way, so nothing new is reported and nothing stops being reported — but the new one states the condition directly instead of via a double negative.

I also added two data cases that exercise the range form on a size that is genuinely int<0, 1> rather than a fixed shape (the previous cases were all exactly-0 or exactly-1 sizes): array{foo?: int} with ksort() and array{0?: int} with sort(). Both are reported as singleElement.

make tests (21109 tests, 97 skipped, no failures), make phpstan (no errors) and make cs are green.

@staabm

staabm commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

worth testing: there are cases where sorting a array with a size of 1 still has a effect, because e.g. keys get re-numbered.

<?php
$array = ['key' => 42];
sort($array);

var_dump($array);
// [0 => 42]

see https://3v4l.org/XHf45#veol

-> we should not emit a PHPStan error for this cases

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

^^ add the sort test

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed c67ac56ef. Full test suite is running in the background; I'll report when it finishes.

What I changed — only the rule's test data, since the behavior staabm raised is already correct:

tests/PHPStan/Rules/Functions/data/sort-without-effect.php gained three cases, none of which is reported:

function sortSingleElementMapLiteral(): void
{
	$array = ['key' => 42];
	sort($array);
}

/** @param array{foo?: int} $optional */
function sortOptionalKeyMap(array $optional): void { sort($optional); }

/** @param array{foo?: int} $optional */
function shuffleOptionalKeyMap(array $optional): void { shuffle($optional); }

The first is the 3v4l reproducer verbatim: sort() on a one-element map renumbers 'key' to 0, so it is not a no-op. The other two pin down the int<0, 1> size path from the previous review round — array{foo?: int} is empty or a one-element map, so the isList() guard, not the size check, has to be what stops the report.

I verified these are meaningful and not vacuously passing: replacing the if (!$keyPreserving && !$isList) return null; guard with if (false) makes all three fail with Parameter #1 $array (array{key: 42}) of function sort has at most 1 element, call has no effect. etc. Restored, they pass.

make phpstan and make cs are green.

ondrejmirtes and others added 3 commits September 3, 2026 13:55
…ortWithoutEffect` bleeding-edge toggle

- New rule `PHPStan\Rules\Functions\SortWithoutEffectRule` (level 5), gated behind the
  `sortWithoutEffect` feature toggle in `conf/bleedingEdge.neon`.
- `ksort()` on a `list` is reported as `ksort.list`: the keys are already `0..n-1` ascending.
  Only reported when the flags are known to be `SORT_REGULAR` or `SORT_NUMERIC` (or absent);
  `SORT_STRING`, `SORT_NATURAL` and non-constant flags bail out.
- Swept the whole in-place sort family for the same class of dead call:
  - definitely empty array + any of `sort`, `rsort`, `usort`, `shuffle`, `asort`, `arsort`,
    `ksort`, `krsort`, `uasort`, `uksort`, `natsort`, `natcasesort` → `<fn>.empty`.
  - array with at most one element + a key-preserving sort (`asort`, `arsort`, `ksort`,
    `krsort`, `uasort`, `uksort`, `natsort`, `natcasesort`) → `<fn>.singleElement`.
  - array with at most one element that is also a list + a reindexing sort (`sort`, `rsort`,
    `usort`, `shuffle`) → `<fn>.singleElement`. Reindexing sorts are deliberately not reported
    on single-element non-lists, because they renumber the key.
- `krsort()` on a multi-element list is deliberately not reported - it reverses the array.
- Registers the toggle in `conf/config.neon`, `conf/parametersSchema.neon` and wires the rule
  up in `conf/config.level5.neon`.
Instead of asserting that int<2, max> is not a supertype of the array size,
ask directly whether int<0, 1> is a supertype of it. Covers the same cases
and states the condition the way it is meant to be read.

Co-Authored-By: Claude Opus 5 <[email protected]>
sort()/shuffle() on a one-element array with a non-zero key is not a
no-op: the key is renumbered to 0. The rule already bails out on
non-lists for the reindexing sorts; these cases pin that behaviour down,
including the maybe-a-list array{foo?: int} shape whose size is int<0, 1>.

Co-Authored-By: Claude Opus 5 <[email protected]>
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-9p12jr7 branch from c67ac56 to b9bf231 Compare September 3, 2026 11:55
@ondrejmirtes
ondrejmirtes merged commit 4901c60 into phpstan:2.2.x Sep 3, 2026
148 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-9p12jr7 branch September 3, 2026 11:55
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.

Report ksort() on a list as a call with no effect

3 participants