feat(lint): add enumerable loop removal#15542
Conversation
Flags `remove` on an EnumerableSet inside a loop whose subtree also calls `at` on an EnumerableSet. Removal is swap-and-pop, so removing while iterating by index skips the swapped-in elements or reads out-of-bounds indices. Neither half fires alone: `remove` without `at` is the recommended collect-then-remove pattern, `at` without `remove` is a plain read, and the same method names on a type that is not an EnumerableSet are out of scope. Part of foundry-rs#14381.
…calls through the type checker Two review findings: - tracking any `at` on any EnumerableSet flagged safe patterns: a drain reading at the literal index 0, and a removal from a set distinct from the iterated one. `remove` and `at` calls now carry the identity of their set (the variable it is stored in) and only the same-set combination reports; `at` at a literal index does not count as iteration, and an operand too complex to name a single variable conservatively matches either set. - only the `using for` method form was recognized: the library-qualified `EnumerableSet.remove(set, EnumerableSet.at(set, i))` has the same swap-and-pop bug but kept the library as receiver. Calls are now resolved through the type checker (gcx.type_of_expr on the callee) and matched on functions declared in a library named EnumerableSet, which also covers import aliases and keeps same-name functions of user libraries attached to the set type out.
mattsse
left a comment
There was a problem hiding this comment.
No actionable findings from my pass.
…oop-removal A removal was reported as soon as a loop held an `at` on any set. Four judgements now stand between the two calls and the diagnostic, each with a fixture on both sides of its boundary. Ownership: an `at` follows the loop whose cadence its index paces, wherever it is written. A loop's cadence is a variable it advances from its old value (`i++`, `i += n`, `i = i + 1`) and names in a termination condition or a direct step; a reset each turn does not progress and is no cadence. A nested loop's own cursor, advanced and tested inside it, stays the nested loop's even when it is a function parameter or a hoisted local. A loop whose condition hides its counter behind an opaque call, the counter stepped only in a nested loop, is beyond a syntactic pass and stays a documented limit. Continuation: a removal the loop never comes back from corrupts nothing. `break`, `return`, a revert and a `try` whose every clause leaves end the iteration; `continue`, a `break` ending only a nested loop, and a `catch` that falls through do not. Identity: two operands name the same set when they name the same storage path, a base variable then struct fields and literal mapping keys. A `storage` reference resolves to what it was bound to, and to nothing readable once rebound. Dispatch: named arguments are matched to parameters by name, and the method form shifts the argument positions by one. A fixed index (a literal, a cast of one, a `constant`) is a drain and not iteration. The documented parity with Aderyn is corrected. Aderyn filters `remove` on a type string containing `EnumerableSet` and matches any `at` by name under the closest ancestor of each loop kind, so this detector is narrower on what earns a report and wider on which loop the two calls may belong to.
…in enumerable-loop-removal An at read walks a loop only when its index names the loop's own moving cadence or a straight-line copy of it, a cadence only stepped downward drains without skipping, a removal deciding an exiting branch mutates only on the path that leaves, try clauses carry their own trailing exit state, and local storage references resolve to their last straight-line binding where the loop runs, a reference bound no way being read as any set. Helpers handed the same set through two storage parameters are a documented interprocedural limit.
The prior rounds grew a cadence-direction analysis, a copy propagation, and an exit-and-branch exemption, each layer trading a false positive for a false negative the next review found. Replace them with a single rule the detector can judge without a flow analysis: an unconditional ascending cadence (`i++`, `i += 1`, `i = i + 1`), an `at` read at that cadence, a `remove` on the same set, and a straight-line body (no `if`, `try`, `break`, `continue`, `return`, or nested loop). Anything a control-flow construct could change is left clean rather than guessed at, which removes the reported false positives (swap-and-pop filters, short-circuit exits, killed copies, no-op steps, nested-then-exit removals) at the cost of not reporting the genuine corruptions those same constructs hide. Those become documented limitations; the canonical ascending remove-as-you-go is still reported, and the safe swap-and-pop and drain-and-exit forms are no longer warned on. Over-reporting on a statically-unreadable set operand is unchanged from the earlier rounds. The type-checker call resolution and the storage-path set identity from the earlier rounds are kept. The synthetic condition guard the lowering wraps every loop in is peeled so the guard's `break` and the `for`'s next-step are read for what they are.
4f18aea to
a611819
Compare
0xMars42
left a comment
There was a problem hiding this comment.
Thanks for the thorough passes. Each round I added a layer (cadence direction, copy propagation, an exit-and-branch exemption) to keep a report while dodging the last false positive, and each layer grew the next false negative you then found. Rather than add a fourth, I reworked the detector around a single rule it can judge without a flow analysis, pushed in 9db3decca.
Reported now: an unconditional ascending cadence (i++, i += 1, i = i + 1), an at read at that cadence, a remove on the same set, and a straight-line body (no if, try, break, continue, return, or nested loop). The type-checker call resolution and the storage-path set identity from the earlier rounds are kept. The synthetic condition guard the lowering wraps every loop in is peeled so its break and the for's next-step read for what they are.
This drops every false positive you raised, by construction:
- swap-and-pop filter (
i++only in the no-removal branch): the cadence is under a branch, so it is not an unconditional ascending cadence. - short-circuit exit (
remove(t) && flagthenbreak) and negated exit (!remove(v)thenbreak): the body has abreak. - no-op step (
i += 0): not a positive step, so no ascending cadence. - killed copy (
idx = ireassigned before the read): copy propagation is gone; the index must be the cadence variable. - nested removal then outer exit: a nested loop is not straight-line.
The false negatives you flagged become documented limitations, in the docs and the fixture: the descending drain of a different value, an eventual exit reading a shifted slot, composite index arithmetic, and a member/opaque cursor are all cases where telling the corruption from a safe lookalike needs the flow analysis this pass does not run, so it stays silent instead of guessing. The canonical ascending remove-as-you-go is still reported.
This is the conservative-but-precise version on the control-flow axis: it never warns on the safe swap-and-pop and drain-and-exit forms it can read from the loop's structure, and in exchange it misses the corruptions that hide behind control flow. It can still over-report on the set-identity axis, unchanged from the earlier rounds: an operand it cannot read statically (a varying mapping key, a conditionally-rebound alias) is matched against every set, reporting a possibly-safe removal rather than missing an unsafe one.
If you would rather have the control-flow corruptions caught and accept the flow analysis it takes (reaching definitions for the cadence, correlating the mutating path with the exiting branch, polarity, short-circuit), I am happy to build that out instead. Your call on which trade you want.
Require every write to a cadence to be a supported ascending step and only accept a bare cadence identifier as the EnumerableSet at index. Treat builtin and custom-error reverts as non-fallthrough loop bodies. Document descending traversal as an unreported limit and describe the detector's conservative recall and aliasing tradeoffs.
Treat declaration initializers, delete expressions, and tuple lvalues as cadence writes, and reject inline assembly from straight-line loop bodies. Document the lint's syntactic matching and value-analysis limits.
Ignore set operations in literal-proven dead expression arms and accept commuted ascending loop cadences. Keep the public lint documentation and UI fixtures aligned with both behaviors.
Reuse shared terminal-flow analysis so require(false), assert(false), and parenthesized exit calls prevent impossible next-iteration warnings. Keep warning controls for nonterminal builtin calls.
This adds the remaining High-severity
enumerable-loop-removallint from #14381. It reports a semantically resolvedEnumerableSet.removewhen the same storage set is read withatat a bare ascending cadence and the loop body matches the conservative flow-free shape: the cadence has no unsupported writes and the body has no branches, jumps, reverts, inline assembly,try, or nested loops. Both method and library-qualified calls are recognized, while storage aliases, struct fields, literal mapping keys, and unknown operands are handled conservatively.Review feedback tightened cadence invalidation across declarations, tuple assignments, resets, decrements, and deletes; restricted
atto the bare cadence identifier; excluded every unmodeled exit shape; and documented descending, control-flow, expression-arm, and remove-argument precision limits. Permanent positive and negative fixtures cover each corrected case.The user documentation is in foundry-rs/book#1987, with the review-aligned follow-up in foundry-rs/book#1992.
Prepared with assistance from OpenAI Codex.