crates/lint/docs/enumerable-loop-removal.md
Severity: High
ID: enumerable-loop-removal
Flags remove on an EnumerableSet inside a loop that also iterates the same set with at.
Reports a call to EnumerableSet.remove inside a loop that reads the same set with EnumerableSet.at at the loop's own ascending index. Calls are resolved through the type checker, so both the using for method form (set.remove(...)) and the library-qualified form (EnumerableSet.remove(set, ...)) are recognized, named arguments included. The library is identified only by being a library named exactly EnumerableSet; its origin and implementation are not verified, so a same-named user library can match while a renamed fork does not.
The report requires a shape that can be judged without a flow analysis:
i++, i += 1, i = i + 1, or i = 1 + i (a step of more than one and an unchecked step count too). A for's next-step, a while's in-body counter, and a do-while's all qualify. Every write to that index must be one of these ascending forms; a decrement, reset, or unrecognized write leaves the loop unreported;at read at that cadence: set.at(i) for the cadence variable i;remove on the same set. Calls inside short-circuit and ternary expressions count when their arm may execute; an arm that a literal boolean condition proves unreachable is skipped;if, try, break, continue, return, revert, unconditional require(false)/assert(false), inline assembly, or nested loop.Two set operands name the same set when they name the same storage path: a base variable followed by struct fields and literal mapping keys. A local storage reference is another name for what its last straight-line binding gave it, resolved where the loop runs, so set = holders iterates holders, and a reference rebound only after the loop still names what it did inside it. An operand the analysis cannot read (a varying mapping key, a call result, a reference rebound under a condition) is matched against every set, reporting a possibly-safe removal rather than missing an unsafe one.
These stay clean:
remove in a loop without at is the recommended collect-then-remove pattern;at in a loop without remove is a plain read;remove outside a loop is fine;at on a different set instance, including two struct fields or two literal mapping keys;at at an index the loop never advances (remove(at(0)), a constant, a stationary cursor, a no-op step like i += 0): the swap refills the read position;EnumerableSet library.The detector omits statement shapes that need control-flow or value reasoning to decide whether removal corrupts iteration. This leaves some genuine corruptions unreported rather than guessing about their safe lookalikes:
if (cond) set.remove(set.at(i)) under an unconditional i++). A call inside a short-circuit or ternary expression is still scanned and can report unless a literal boolean proves that expression arm unreachable;continue, which comes round on the shrunk set;try clause that falls through;idx = i; set.at(idx)) or composite arithmetic on it;This intentionally trades recall for a narrow, predictable reporting shape. The canonical ascending remove(set.at(i)) loop is covered, while real corruptions outside that shape are missed. Within the supported shape, unresolved set operands are treated as possible aliases, so the detector can also warn when two operands are distinct at runtime.
The argument passed to remove is not related back to the value returned by at. Consequently, the lint can report a safe same-set removal, such as removing the current tail while separately reading the set at the ascending cadence, because tail removal does not shift another element into the walked prefix.
EnumerableSet.remove is swap-and-pop: it moves the last element into the removed slot and shrinks the set. Iterating by an ascending index with at while removing skips the swapped-in elements or reads out-of-bounds indices, so some elements are silently never visited.
for (uint256 i = 0; i < set.length(); i++) {
set.remove(set.at(i));
}
// removing everything: EnumerableSet has a dedicated function for it,
// much cheaper than removing one element at a time
set.clear();
// removing selectively: collect during the loop, remove after it
address[] memory toRemove = new address[](set.length());
for (uint256 i = 0; i < set.length(); i++) {
if (shouldRemove(set.at(i))) toRemove[i] = set.at(i);
}
for (uint256 i = 0; i < toRemove.length; i++) {
set.remove(toRemove[i]);
}