From 41ad940adf3da52ad444da61d2a3350eb66e63a0 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 1 Sep 2026 10:16:01 -0400 Subject: [PATCH 1/9] fix: missed where opporunity false positive --- csharp/ql/lib/Linq/Helpers.qll | 20 +++++++- .../ql/src/Linq/MissedWhereOpportunity.qhelp | 27 ++++++---- .../ql/src/Linq/MissedWhereOpportunityGood.cs | 33 ++++++++++++ .../MissedWhereOpportunity.cs | 50 +++++++++++++++++++ .../MissedWhereOpportunity.expected | 1 + 5 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 csharp/ql/src/Linq/MissedWhereOpportunityGood.cs diff --git a/csharp/ql/lib/Linq/Helpers.qll b/csharp/ql/lib/Linq/Helpers.qll index 2a4d5c8c27a2..4d0c30e55f1a 100644 --- a/csharp/ql/lib/Linq/Helpers.qll +++ b/csharp/ql/lib/Linq/Helpers.qll @@ -20,6 +20,23 @@ private int numStmts(ForeachStmt fes) { else result = 1 } +private predicate terminatesCallable(Stmt s) { + s.stripSingletonBlocks() instanceof ReturnStmt + or + s.stripSingletonBlocks() instanceof YieldBreakStmt + or + s.stripSingletonBlocks() instanceof ThrowStmt + or + exists(BlockStmt b | b = s.stripSingletonBlocks() | terminatesCallable(b.getLastStmt())) + or + exists(IfStmt nested | + nested = s.stripSingletonBlocks() and + exists(nested.getElse()) and + terminatesCallable(nested.getThen()) and + terminatesCallable(nested.getElse()) + ) +} + /** Holds if the type's qualified name is "System.Linq.Enumerable" */ predicate isEnumerableType(ValueOrRefType t) { t.hasFullyQualifiedName("System.Linq", "Enumerable") @@ -152,7 +169,8 @@ predicate missedWhereOpportunity(ForeachStmtGenericEnumerable fes, IfStmt is) { is.getThen() instanceof ContinueStmt or not exists(is.getElse()) and - numStmts(fes) = 1 + numStmts(fes) = 1 and + not terminatesCallable(is.getThen()) ) } diff --git a/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp b/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp index 6b22d1a14edc..a1f98a76e6d1 100644 --- a/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp +++ b/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp @@ -3,29 +3,38 @@ "qhelp.dtd"> -

Programmers sometimes need to iterative over a filtered version of a sequence, rather than the -sequence itself. For example, you might want to print out only the numbers in the range [1,10] that -are even. One standard way of doing this is to write a loop that iterates over the whole sequence, -testing the variable each iteration to determine whether or not it is even. This is often written -using either if(!condition(var)) continue; as the initial statement in the loop, or by +

Programmers sometimes need to iterate over a filtered version of a sequence, rather than the +sequence itself. For example, you might want to print out only the numbers in the range [1,10] that +are even. One standard way of doing this is to write a loop that iterates over the whole sequence, +testing the variable each iteration to determine whether or not it is even. This is often written +using either if(!condition(var)) continue; as the initial statement in the loop, or by enclosing the entire loop body with if(condition(var)).

+

This recommendation does not apply when the matching branch exits the loop by exiting the current +method or iterator, such as with a full return, yield break, or +throw. In those cases the loop is searching for a terminal condition rather than +filtering the remaining loop body.

+
-

This pattern works well and is also available as the Where method in LINQ in C# 3.5 -and above. It is better to use a library method in preference to writing your own pattern unless you -have a specific need for a custom version. In particular, this makes the code easier to read by +

This pattern works well and is also available as the Where method in LINQ in C# 3.5 +and above. It is better to use a library method in preference to writing your own pattern unless you +have a specific need for a custom version. In particular, this makes the code easier to read by expressing the intent better and by reducing the nesting depth of the code.

-

This example shows two ways of iterating over a series of integers and only performing an action +

This example shows two ways of iterating over a series of integers and only performing an action on the even ones.

This is far better expressed using the Where method.

+

The following examples should not use Where, because the matching branch exits the +method or iterator instead of continuing with filtered loop work.

+ +
diff --git a/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs b/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs new file mode 100644 index 000000000000..da28fd6e79f1 --- /dev/null +++ b/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs @@ -0,0 +1,33 @@ +class MissedWhereOpportunityGood +{ + public int? FindFirstEven(System.Collections.Generic.IEnumerable values) + { + foreach (int value in values) + { + if (value % 2 == 0) + return value; + } + + return null; + } + + public System.Collections.Generic.IEnumerable ValuesUntilFirstEven(System.Collections.Generic.IEnumerable values) + { + foreach (int value in values) + { + if (value % 2 == 0) + yield break; + + yield return value; + } + } + + public void ThrowOnFirstEven(System.Collections.Generic.IEnumerable values) + { + foreach (int value in values) + { + if (value % 2 == 0) + throw new System.InvalidOperationException("Unexpected even value."); + } + } +} diff --git a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs index 0fee1e9c48ff..abee5da38f9d 100644 --- a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs +++ b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs @@ -76,6 +76,56 @@ public void M5(IEnumerable elements) } // $ Alert } + public int M6(IEnumerable elements) + { + // GOOD: The filtered case returns from the method instead of continuing the loop. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + return element; + } + } + + return 0; + } + + public IEnumerable M7(IEnumerable elements) + { + // GOOD: The filtered case exits the iterator instead of continuing the loop. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + yield break; + } + } + } + + public void M8(IEnumerable elements) + { + // GOOD: The filtered case throws instead of continuing the loop. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + throw new InvalidOperationException(); + } + } + } + + public IEnumerable M9(IEnumerable elements) + { + // BAD: A yield return does not exit the iterator, so the loop still filters yielded values. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + yield return element; + } + } // $ Alert + } + public class NonEnumerableClass { public IEnumerator GetEnumerator() => throw null; diff --git a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.expected b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.expected index 5efde9aebedc..8b9398292ef2 100644 --- a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.expected +++ b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.expected @@ -2,3 +2,4 @@ | MissedWhereOpportunity.cs:19:9:26:9 | foreach (... ... in ...) ... | This foreach loop $@ - consider filtering the sequence explicitly using '.Where(...)'. | MissedWhereOpportunity.cs:21:17:21:26 | ... == ... | implicitly filters its target sequence | | MissedWhereOpportunity.cs:45:9:52:9 | foreach (... ... in ...) ... | This foreach loop $@ - consider filtering the sequence explicitly using '.Where(...)'. | MissedWhereOpportunity.cs:47:17:47:26 | ... == ... | implicitly filters its target sequence | | MissedWhereOpportunity.cs:70:9:76:9 | foreach (... ... in ...) ... | This foreach loop $@ - consider filtering the sequence explicitly using '.Where(...)'. | MissedWhereOpportunity.cs:72:17:72:46 | ... == ... | implicitly filters its target sequence | +| MissedWhereOpportunity.cs:120:9:126:9 | foreach (... ... in ...) ... | This foreach loop $@ - consider filtering the sequence explicitly using '.Where(...)'. | MissedWhereOpportunity.cs:122:17:122:46 | ... == ... | implicitly filters its target sequence | From dc42a4c645b97d20bc8ee5c53727068a5a40ecb9 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 1 Sep 2026 10:23:54 -0400 Subject: [PATCH 2/9] docs: rewording Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- csharp/ql/src/Linq/MissedWhereOpportunity.qhelp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp b/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp index a1f98a76e6d1..f8f2769b0812 100644 --- a/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp +++ b/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp @@ -10,10 +10,10 @@ testing the variable each iteration to determine whether or not it is even. This using either if(!condition(var)) continue; as the initial statement in the loop, or by enclosing the entire loop body with if(condition(var)).

-

This recommendation does not apply when the matching branch exits the loop by exiting the current -method or iterator, such as with a full return, yield break, or -throw. In those cases the loop is searching for a terminal condition rather than -filtering the remaining loop body.

+

This recommendation does not apply when the matching branch exits the loop without continuing to +later iterations, such as with return, yield break, or throw. +In those cases the loop is searching for a terminal condition rather than filtering the remaining +loop body.

From 17412f33db75e9688b9977d80bafa119846291ee Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 1 Sep 2026 10:50:59 -0400 Subject: [PATCH 3/9] tests: adds a unit test to prevent regression on else cases --- .../MissedWhereOpportunity.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs index abee5da38f9d..192e9893c955 100644 --- a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs +++ b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs @@ -126,6 +126,42 @@ public IEnumerable M9(IEnumerable elements) } // $ Alert } + public int M10(IEnumerable elements) + { + // GOOD: The filtered case ends with a return from the method instead of continuing the loop. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + Console.WriteLine(element); + return element; + } + } + + return 0; + } + + public int M11(IEnumerable elements) + { + // GOOD: Both nested filtered cases return from the method instead of continuing the loop. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + if (element > 10) + { + return element; + } + else + { + return 10; + } + } + } + + return 0; + } + public class NonEnumerableClass { public IEnumerator GetEnumerator() => throw null; From 327158734400bdfb1a1387787d88c560122c2519 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 1 Sep 2026 10:58:11 -0400 Subject: [PATCH 4/9] chore: fixes missing break case Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- csharp/ql/lib/Linq/Helpers.qll | 2 ++ 1 file changed, 2 insertions(+) diff --git a/csharp/ql/lib/Linq/Helpers.qll b/csharp/ql/lib/Linq/Helpers.qll index 4d0c30e55f1a..2909c6696a73 100644 --- a/csharp/ql/lib/Linq/Helpers.qll +++ b/csharp/ql/lib/Linq/Helpers.qll @@ -27,6 +27,8 @@ private predicate terminatesCallable(Stmt s) { or s.stripSingletonBlocks() instanceof ThrowStmt or + s.stripSingletonBlocks() instanceof BreakStmt + or exists(BlockStmt b | b = s.stripSingletonBlocks() | terminatesCallable(b.getLastStmt())) or exists(IfStmt nested | From 5e11c9aeb65fbba2432107ad9603c525d1bd90be Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 1 Sep 2026 13:09:33 -0400 Subject: [PATCH 5/9] tests: adds a negative test for break --- .../MissedWhereOpportunity/MissedWhereOpportunity.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs index 192e9893c955..7b9d35821299 100644 --- a/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs +++ b/csharp/ql/test/query-tests/Linq/MissedWhereOpportunity/MissedWhereOpportunity.cs @@ -162,6 +162,18 @@ public int M11(IEnumerable elements) return 0; } + public void M12(IEnumerable elements) + { + // GOOD: The filtered case exits the loop instead of continuing with filtered loop work. + foreach (var element in elements) + { + if (element.GetHashCode() % 2 == 0) + { + break; + } + } + } + public class NonEnumerableClass { public IEnumerator GetEnumerator() => throw null; From dd005e51f3027230dc5611dd982b3fc0c39261d3 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 2 Sep 2026 08:15:06 -0400 Subject: [PATCH 6/9] chore: applies review suggestion Co-authored-by: Michael Nebel --- csharp/ql/lib/Linq/Helpers.qll | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/csharp/ql/lib/Linq/Helpers.qll b/csharp/ql/lib/Linq/Helpers.qll index 2909c6696a73..ad4e83e91ba6 100644 --- a/csharp/ql/lib/Linq/Helpers.qll +++ b/csharp/ql/lib/Linq/Helpers.qll @@ -21,21 +21,23 @@ private int numStmts(ForeachStmt fes) { } private predicate terminatesCallable(Stmt s) { - s.stripSingletonBlocks() instanceof ReturnStmt - or - s.stripSingletonBlocks() instanceof YieldBreakStmt - or - s.stripSingletonBlocks() instanceof ThrowStmt - or - s.stripSingletonBlocks() instanceof BreakStmt - or - exists(BlockStmt b | b = s.stripSingletonBlocks() | terminatesCallable(b.getLastStmt())) - or - exists(IfStmt nested | - nested = s.stripSingletonBlocks() and - exists(nested.getElse()) and - terminatesCallable(nested.getThen()) and - terminatesCallable(nested.getElse()) + exists(Stmt stripped | stripped = s.stripSingletonBlocks() | + stripped instanceof ReturnStmt + or + stripped instanceof YieldBreakStmt + or + stripped instanceof ThrowStmt + or + stripped instanceof BreakStmt + or + stripped = any(BlockStmt b | terminatesCallable(b.getLastStmt())) + or + stripped = + any(IfStmt nested | + exists(nested.getElse()) and + terminatesCallable(nested.getThen()) and + terminatesCallable(nested.getElse()) + ) ) } From d6ffc18f790e52831a546c0b1bafc91284be7da2 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 2 Sep 2026 09:12:32 -0400 Subject: [PATCH 7/9] docs: removes additional cases to avoid the page being too long Signed-off-by: Vincent Biret --- .../ql/src/Linq/MissedWhereOpportunity.qhelp | 2 +- .../ql/src/Linq/MissedWhereOpportunityGood.cs | 20 ------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp b/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp index f8f2769b0812..53ac540441c4 100644 --- a/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp +++ b/csharp/ql/src/Linq/MissedWhereOpportunity.qhelp @@ -31,7 +31,7 @@ on the even ones.

This is far better expressed using the Where method.

-

The following examples should not use Where, because the matching branch exits the +

The following example should not use Where, because the matching branch exits the method or iterator instead of continuing with filtered loop work.

diff --git a/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs b/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs index da28fd6e79f1..b96db876583a 100644 --- a/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs +++ b/csharp/ql/src/Linq/MissedWhereOpportunityGood.cs @@ -10,24 +10,4 @@ class MissedWhereOpportunityGood return null; } - - public System.Collections.Generic.IEnumerable ValuesUntilFirstEven(System.Collections.Generic.IEnumerable values) - { - foreach (int value in values) - { - if (value % 2 == 0) - yield break; - - yield return value; - } - } - - public void ThrowOnFirstEven(System.Collections.Generic.IEnumerable values) - { - foreach (int value in values) - { - if (value % 2 == 0) - throw new System.InvalidOperationException("Unexpected even value."); - } - } } From 2a7faa5e781cf9694e573a5e386e7bad028dcfc3 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 3 Sep 2026 07:05:11 -0400 Subject: [PATCH 8/9] chore: applies review suggestion Co-authored-by: Michael Nebel --- csharp/ql/lib/Linq/Helpers.qll | 1 - 1 file changed, 1 deletion(-) diff --git a/csharp/ql/lib/Linq/Helpers.qll b/csharp/ql/lib/Linq/Helpers.qll index ad4e83e91ba6..fcbc01c5e35d 100644 --- a/csharp/ql/lib/Linq/Helpers.qll +++ b/csharp/ql/lib/Linq/Helpers.qll @@ -34,7 +34,6 @@ private predicate terminatesCallable(Stmt s) { or stripped = any(IfStmt nested | - exists(nested.getElse()) and terminatesCallable(nested.getThen()) and terminatesCallable(nested.getElse()) ) From dd84d7f14473cb1f025259f8c4c935b7e86b8697 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 3 Sep 2026 07:22:40 -0400 Subject: [PATCH 9/9] docs: adds change note for linq where fixes --- .../change-notes/2026-09-03-missed-where-terminal-branches.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 csharp/ql/src/change-notes/2026-09-03-missed-where-terminal-branches.md diff --git a/csharp/ql/src/change-notes/2026-09-03-missed-where-terminal-branches.md b/csharp/ql/src/change-notes/2026-09-03-missed-where-terminal-branches.md new file mode 100644 index 000000000000..33515e4c90cd --- /dev/null +++ b/csharp/ql/src/change-notes/2026-09-03-missed-where-terminal-branches.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `cs/linq/missed-where` query no longer flags `foreach` loops where the matching branch terminates the method, iterator, or loop instead of continuing with filtered loop work.