From a86ddb7e0604490a12225a21991a4e68fa7eb18d Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 22 Sep 2025 01:05:19 -0400 Subject: [PATCH 1/4] fix-15716 --- .../test/fixtures/flake8_bugbear/B023.py | 38 +++++++++++++++++++ .../rules/function_uses_loop_variable.rs | 17 ++++++++- ...__flake8_bugbear__tests__B023_B023.py.snap | 31 +++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py index 339c972452..2406aaa8db 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py @@ -183,3 +183,41 @@ for val in range(3): funcs.append(make_func()) + + +# Test cases for issue #15716 - false positives with lambda parameters +for _ in range(3): + [x for x in []] + def func(): + lambda x: x # Should not trigger B023 - x is a lambda parameter + + +# Test case from issue comment - pandas apply (should still trigger B023 for legitimate cases) +import pandas as pd + +data = pd.DataFrame() +data.loc[0, "hex"] = "72756666" +data.loc[1, "hex"] = "75767576" + +def modifier(value): + return chr(int(value, 16)) + + +for _i in range(0, 4): + data[f"v{_i}"] = data["hex"].apply( + lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should trigger B023 - _i is not bound + ) + + +# Test case from issue comment - nested function (should still trigger B023 for legitimate cases) +for _ in range(2): + + def add_one(): + def _add_one_inner(value): + return value + 1 # Should trigger B023 - value is not bound to loop variable + + return _add_one_inner + + for value in range(5): + result = add_one()(value) + print(result) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs index a4cbba7cc7..2c71a187c0 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs @@ -58,6 +58,7 @@ impl Violation for FunctionUsesLoopVariable { struct LoadedNamesVisitor<'a> { loaded: Vec<&'a ast::ExprName>, stored: Vec<&'a ast::ExprName>, + lambda_parameters: Vec<&'a str>, } /// `Visitor` to collect all used identifiers in a statement. @@ -69,6 +70,15 @@ impl<'a> Visitor<'a> for LoadedNamesVisitor<'a> { ExprContext::Store => self.stored.push(name), _ => {} }, + Expr::Lambda(ast::ExprLambda { parameters, .. }) => { + if let Some(parameters) = parameters { + for param in parameters { + self.lambda_parameters.push(param.name().as_str()); + } + } + // Still visit the lambda body to collect any loaded variables + visitor::walk_expr(self, expr); + } _ => visitor::walk_expr(self, expr), } } @@ -88,7 +98,7 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { Stmt::FunctionDef(ast::StmtFunctionDef { parameters, body, .. }) => { - // Collect all loaded variable names. + // Collect all loaded variable names and lambda parameters. let mut visitor = LoadedNamesVisitor::default(); visitor.visit_body(body); @@ -103,6 +113,11 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { return false; } + // Check if the variable is a lambda parameter + if visitor.lambda_parameters.contains(&loaded.id.as_str()) { + return false; + } + true })); diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap index 034c5f4f03..72b0982036 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap @@ -244,3 +244,34 @@ B023 Function definition does not bind loop variable `i` 174 | return [lambda: i for i in range(3)] # error | ^ | + +B023 Function definition does not bind loop variable `_i` + --> B023.py:208:34 + | +206 | for _i in range(0, 4): +207 | data[f"v{_i}"] = data["hex"].apply( +208 | lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should trigger B023 - _i is not bound + | ^^ +209 | ) + | + +B023 Function definition does not bind loop variable `_i` + --> B023.py:208:43 + | +206 | for _i in range(0, 4): +207 | data[f"v{_i}"] = data["hex"].apply( +208 | lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should trigger B023 - _i is not bound + | ^^ +209 | ) + | + +B023 Function definition does not bind loop variable `value` + --> B023.py:217:20 + | +215 | def add_one(): +216 | def _add_one_inner(value): +217 | return value + 1 # Should trigger B023 - value is not bound to loop variable + | ^^^^^ +218 | +219 | return _add_one_inner + | From 18fc7bee289291b41875cfbdda2e7c9a428e3b3a Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 1 Oct 2025 17:19:19 -0400 Subject: [PATCH 2/4] Fix false positives with apply and function params --- .../test/fixtures/flake8_bugbear/B023.py | 8 ++--- .../rules/function_uses_loop_variable.rs | 25 +++++++++++-- ...__flake8_bugbear__tests__B023_B023.py.snap | 36 +++++-------------- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py index 2406aaa8db..8b43503b29 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py @@ -192,7 +192,7 @@ for _ in range(3): lambda x: x # Should not trigger B023 - x is a lambda parameter -# Test case from issue comment - pandas apply (should still trigger B023 for legitimate cases) +# Test case from issue comment - pandas apply (should NOT trigger B023 - apply is safe like map) import pandas as pd data = pd.DataFrame() @@ -205,16 +205,16 @@ def modifier(value): for _i in range(0, 4): data[f"v{_i}"] = data["hex"].apply( - lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should trigger B023 - _i is not bound + lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should NOT trigger B023 - apply is safe ) -# Test case from issue comment - nested function (should still trigger B023 for legitimate cases) +# Test case from issue comment - nested function (should NOT trigger B023 - value is function parameter) for _ in range(2): def add_one(): def _add_one_inner(value): - return value + 1 # Should trigger B023 - value is not bound to loop variable + return value + 1 # Should NOT trigger B023 - value is function parameter return _add_one_inner diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs index 2c71a187c0..0d9214f044 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs @@ -63,6 +63,14 @@ struct LoadedNamesVisitor<'a> { /// `Visitor` to collect all used identifiers in a statement. impl<'a> Visitor<'a> for LoadedNamesVisitor<'a> { + fn visit_stmt(&mut self, stmt: &'a Stmt) { + // Don't visit nested function definitions + if stmt.is_function_def_stmt() { + return; + } + visitor::walk_stmt(self, stmt); + } + fn visit_expr(&mut self, expr: &'a Expr) { match expr { Expr::Name(name) => match &name.ctx { @@ -121,7 +129,8 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { true })); - return; + // Continue visiting nested functions + visitor::walk_stmt(self, stmt); } Stmt::Return(ast::StmtReturn { value: Some(value), @@ -167,6 +176,13 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { } } } + } else if attr == "apply" { + // pandas apply is safe like map/filter + for arg in &*arguments.args { + if arg.is_lambda_expr() { + self.safe_functions.push(arg); + } + } } } _ => {} @@ -187,7 +203,7 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { node_index: _, }) => { if !self.safe_functions.contains(&expr) { - // Collect all loaded variable names. + // Collect all loaded variable names and lambda parameters. let mut visitor = LoadedNamesVisitor::default(); visitor.visit_expr(body); @@ -205,6 +221,11 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { return false; } + // Check if the variable is a lambda parameter + if visitor.lambda_parameters.contains(&loaded.id.as_str()) { + return false; + } + true })); diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap index 72b0982036..b1ccbfa743 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap @@ -245,33 +245,13 @@ B023 Function definition does not bind loop variable `i` | ^ | -B023 Function definition does not bind loop variable `_i` - --> B023.py:208:34 +B023 Function definition does not bind loop variable `val` + --> B023.py:180:26 | -206 | for _i in range(0, 4): -207 | data[f"v{_i}"] = data["hex"].apply( -208 | lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should trigger B023 - _i is not bound - | ^^ -209 | ) - | - -B023 Function definition does not bind loop variable `_i` - --> B023.py:208:43 - | -206 | for _i in range(0, 4): -207 | data[f"v{_i}"] = data["hex"].apply( -208 | lambda x: modifier(x[2 * _i : 2 * _i + 2]), # Should trigger B023 - _i is not bound - | ^^ -209 | ) - | - -B023 Function definition does not bind loop variable `value` - --> B023.py:217:20 - | -215 | def add_one(): -216 | def _add_one_inner(value): -217 | return value + 1 # Should trigger B023 - value is not bound to loop variable - | ^^^^^ -218 | -219 | return _add_one_inner +178 | def make_func(val=val): +179 | def tmp(): +180 | return print(val) + | ^^^ +181 | +182 | return tmp | From c608106626cdd1a8584dd4bf75534bf7f4a5a372 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 8 Oct 2025 22:26:29 -0400 Subject: [PATCH 3/4] Improve detection of safe pandas apply calls in B023 --- .../rules/function_uses_loop_variable.rs | 69 ++++++++++--------- ...__flake8_bugbear__tests__B023_B023.py.snap | 11 --- 2 files changed, 38 insertions(+), 42 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs index 0d9214f044..5d902caf44 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs @@ -3,6 +3,7 @@ use ruff_python_ast::types::Node; use ruff_python_ast::visitor; use ruff_python_ast::visitor::Visitor; use ruff_python_ast::{self as ast, Comprehension, Expr, ExprContext, Stmt}; +use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::Violation; @@ -58,7 +59,6 @@ impl Violation for FunctionUsesLoopVariable { struct LoadedNamesVisitor<'a> { loaded: Vec<&'a ast::ExprName>, stored: Vec<&'a ast::ExprName>, - lambda_parameters: Vec<&'a str>, } /// `Visitor` to collect all used identifiers in a statement. @@ -78,24 +78,16 @@ impl<'a> Visitor<'a> for LoadedNamesVisitor<'a> { ExprContext::Store => self.stored.push(name), _ => {} }, - Expr::Lambda(ast::ExprLambda { parameters, .. }) => { - if let Some(parameters) = parameters { - for param in parameters { - self.lambda_parameters.push(param.name().as_str()); - } - } - // Still visit the lambda body to collect any loaded variables - visitor::walk_expr(self, expr); - } + Expr::Lambda(ast::ExprLambda { parameters: _, .. }) => {} _ => visitor::walk_expr(self, expr), } } } -#[derive(Default)] struct SuspiciousVariablesVisitor<'a> { names: Vec<&'a ast::ExprName>, safe_functions: Vec<&'a Expr>, + apply_calls: Vec<&'a Expr>, } /// `Visitor` to collect all suspicious variables (those referenced in @@ -120,17 +112,10 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { if parameters.includes(&loaded.id) { return false; } - - // Check if the variable is a lambda parameter - if visitor.lambda_parameters.contains(&loaded.id.as_str()) { - return false; - } - true })); - // Continue visiting nested functions - visitor::walk_stmt(self, stmt); + return; } Stmt::Return(ast::StmtReturn { value: Some(value), @@ -177,10 +162,10 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { } } } else if attr == "apply" { - // pandas apply is safe like map/filter + // Collect apply calls to check later if pandas is imported for arg in &*arguments.args { if arg.is_lambda_expr() { - self.safe_functions.push(arg); + self.apply_calls.push(arg); } } } @@ -203,10 +188,16 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { node_index: _, }) => { if !self.safe_functions.contains(&expr) { - // Collect all loaded variable names and lambda parameters. + // Collect all loaded variable names from the lambda body. let mut visitor = LoadedNamesVisitor::default(); visitor.visit_expr(body); + // Collect lambda parameter names + let lambda_param_names: Vec<&str> = parameters + .as_ref() + .map(|params| params.iter().map(|param| param.name().as_str()).collect()) + .unwrap_or_default(); + // Treat any non-arguments as "suspicious". self.names .extend(visitor.loaded.into_iter().filter(|loaded| { @@ -214,15 +205,8 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { return false; } - if parameters - .as_ref() - .is_some_and(|parameters| parameters.includes(&loaded.id)) - { - return false; - } - // Check if the variable is a lambda parameter - if visitor.lambda_parameters.contains(&loaded.id.as_str()) { + if lambda_param_names.contains(&loaded.id.as_str()) { return false; } @@ -319,8 +303,31 @@ impl<'a> Visitor<'a> for AssignedNamesVisitor<'a> { pub(crate) fn function_uses_loop_variable(checker: &Checker, node: &Node) { // Identify any "suspicious" variables. These are defined as variables that are // referenced in a function or lambda body, but aren't bound as arguments. + let (_suspicious_variables, mut safe_functions, apply_calls) = { + let mut visitor = SuspiciousVariablesVisitor { + names: Vec::new(), + safe_functions: Vec::new(), + apply_calls: Vec::new(), + }; + match node { + Node::Stmt(stmt) => visitor.visit_stmt(stmt), + Node::Expr(expr) => visitor.visit_expr(expr), + } + (visitor.names, visitor.safe_functions, visitor.apply_calls) + }; + + // If pandas is imported, add apply calls to safe functions + if checker.semantic().seen_module(Modules::PANDAS) { + safe_functions.extend(apply_calls); + } + + // Collect suspicious variables let suspicious_variables = { - let mut visitor = SuspiciousVariablesVisitor::default(); + let mut visitor = SuspiciousVariablesVisitor { + names: Vec::new(), + safe_functions: safe_functions.clone(), + apply_calls: Vec::new(), + }; match node { Node::Stmt(stmt) => visitor.visit_stmt(stmt), Node::Expr(expr) => visitor.visit_expr(expr), diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap index b1ccbfa743..034c5f4f03 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap @@ -244,14 +244,3 @@ B023 Function definition does not bind loop variable `i` 174 | return [lambda: i for i in range(3)] # error | ^ | - -B023 Function definition does not bind loop variable `val` - --> B023.py:180:26 - | -178 | def make_func(val=val): -179 | def tmp(): -180 | return print(val) - | ^^^ -181 | -182 | return tmp - | From a95fe58d8fca09cb8eb1e53b67f050ef701c144b Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 31 Oct 2025 16:47:56 -0400 Subject: [PATCH 4/4] Handle loop variable capture in nested functions for B023 Improves detection of loop variable capture in nested functions for the flake8-bugbear B023 rule. Adds a test case and updates logic to track outer function parameters, ensuring variables bound in outer scopes are not incorrectly flagged. --- .../test/fixtures/flake8_bugbear/B023.py | 12 +++++ .../rules/function_uses_loop_variable.rs | 54 +++++++++---------- ...__flake8_bugbear__tests__B023_B023.py.snap | 11 ++++ 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py index 8b43503b29..6bf9633701 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B023.py @@ -221,3 +221,15 @@ for _ in range(2): for value in range(5): result = add_one()(value) print(result) + + +# nested function that captures loop variable (SHOULD trigger B023) +lst = [] +for value in range(2): + def add_one(): + def _add_one_inner(): + return value + 1 # Should trigger B023 - value is loop variable, not bound + + return _add_one_inner + + lst.append(add_one()) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs index 5d902caf44..f89eac5233 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs @@ -64,7 +64,7 @@ struct LoadedNamesVisitor<'a> { /// `Visitor` to collect all used identifiers in a statement. impl<'a> Visitor<'a> for LoadedNamesVisitor<'a> { fn visit_stmt(&mut self, stmt: &'a Stmt) { - // Don't visit nested function definitions + // Skip nested function definitions - they are handled separately by `SuspiciousVariablesVisitor` if stmt.is_function_def_stmt() { return; } @@ -87,7 +87,8 @@ impl<'a> Visitor<'a> for LoadedNamesVisitor<'a> { struct SuspiciousVariablesVisitor<'a> { names: Vec<&'a ast::ExprName>, safe_functions: Vec<&'a Expr>, - apply_calls: Vec<&'a Expr>, + pandas_imported: bool, + outer_parameters: Vec<&'a ast::Parameters>, } /// `Visitor` to collect all suspicious variables (those referenced in @@ -109,12 +110,27 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { return false; } + // Check if variable is bound in current function parameters if parameters.includes(&loaded.id) { return false; } + + // Check if variable is bound in outer function parameters + if self + .outer_parameters + .iter() + .any(|params| params.includes(&loaded.id)) + { + return false; + } true })); + // Recursively visit nested functions with updated parameter stack + self.outer_parameters.push(parameters); + visitor::walk_body(self, body); + self.outer_parameters.pop(); + return; } Stmt::Return(ast::StmtReturn { @@ -162,10 +178,12 @@ impl<'a> Visitor<'a> for SuspiciousVariablesVisitor<'a> { } } } else if attr == "apply" { - // Collect apply calls to check later if pandas is imported - for arg in &*arguments.args { - if arg.is_lambda_expr() { - self.apply_calls.push(arg); + // If pandas is imported, apply is safe like map + if self.pandas_imported { + for arg in &*arguments.args { + if arg.is_lambda_expr() { + self.safe_functions.push(arg); + } } } } @@ -303,30 +321,12 @@ impl<'a> Visitor<'a> for AssignedNamesVisitor<'a> { pub(crate) fn function_uses_loop_variable(checker: &Checker, node: &Node) { // Identify any "suspicious" variables. These are defined as variables that are // referenced in a function or lambda body, but aren't bound as arguments. - let (_suspicious_variables, mut safe_functions, apply_calls) = { - let mut visitor = SuspiciousVariablesVisitor { - names: Vec::new(), - safe_functions: Vec::new(), - apply_calls: Vec::new(), - }; - match node { - Node::Stmt(stmt) => visitor.visit_stmt(stmt), - Node::Expr(expr) => visitor.visit_expr(expr), - } - (visitor.names, visitor.safe_functions, visitor.apply_calls) - }; - - // If pandas is imported, add apply calls to safe functions - if checker.semantic().seen_module(Modules::PANDAS) { - safe_functions.extend(apply_calls); - } - - // Collect suspicious variables let suspicious_variables = { let mut visitor = SuspiciousVariablesVisitor { names: Vec::new(), - safe_functions: safe_functions.clone(), - apply_calls: Vec::new(), + safe_functions: Vec::new(), + pandas_imported: checker.semantic().seen_module(Modules::PANDAS), + outer_parameters: Vec::new(), }; match node { Node::Stmt(stmt) => visitor.visit_stmt(stmt), diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap index 034c5f4f03..0bfaf57dc4 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap @@ -244,3 +244,14 @@ B023 Function definition does not bind loop variable `i` 174 | return [lambda: i for i in range(3)] # error | ^ | + +B023 Function definition does not bind loop variable `value` + --> B023.py:231:20 + | +229 | def add_one(): +230 | def _add_one_inner(): +231 | return value + 1 # Should trigger B023 - value is loop variable, not bound + | ^^^^^ +232 | +233 | return _add_one_inner + |