@@ -10,7 +10,7 @@ use oxc_ast::{
1010use oxc_ast_visit:: { Visit , walk} ;
1111use oxc_diagnostics:: OxcDiagnostic ;
1212use oxc_macros:: declare_oxc_lint;
13- use oxc_semantic:: { AstNode , NodeId , SymbolId } ;
13+ use oxc_semantic:: { AstNode , NodeId , ScopeId , SymbolId } ;
1414use oxc_span:: { GetSpan , Span } ;
1515use oxc_syntax:: { scope:: ScopeFlags , symbol:: SymbolFlags } ;
1616
@@ -326,11 +326,11 @@ impl NoLoopFunc {
326326 }
327327
328328 /// Check if a let-declared variable is unsafe in a loop
329- fn is_let_unsafe_in_loop (
329+ fn is_let_unsafe_in_loop < ' a > (
330330 symbol_id : SymbolId ,
331- symbol_decl_node : & AstNode ,
332- loop_node : & AstNode ,
333- ctx : & LintContext ,
331+ symbol_decl_node : & AstNode < ' a > ,
332+ loop_node : & AstNode < ' a > ,
333+ ctx : & LintContext < ' a > ,
334334 ) -> bool {
335335 let loop_body_span = Self :: get_loop_body_span ( loop_node) ;
336336 let decl_span = symbol_decl_node. span ( ) ;
@@ -340,23 +340,136 @@ impl NoLoopFunc {
340340 return false ;
341341 }
342342
343- // For `for` loops, check if let is declared in the loop header (init expression)
344- // `for (let i = 0; ...)` creates fresh bindings per iteration - safe
345- if Self :: is_in_for_loop_header ( symbol_decl_node, loop_node) {
343+ // For `for` loops, check if let is declared in this loop header or in a containing
344+ // loop header. `for (let i = 0; ...)` creates fresh bindings per iteration, and that
345+ // binding remains safe for functions in nested loop bodies within the same iteration.
346+ if Self :: is_in_containing_for_loop_header ( symbol_decl_node, loop_node, ctx) {
346347 return false ;
347348 }
348349
349- // If declared outside the loop body, check for modifications
350350 let scoping = ctx. scoping ( ) ;
351+ let border = Self :: get_top_loop_node ( loop_node, Some ( symbol_decl_node) , ctx) . span ( ) . start ;
352+ let symbol_variable_scope_id =
353+ Self :: get_enclosing_variable_scope_id ( scoping. symbol_scope_id ( symbol_id) , ctx) ;
354+
355+ // If declared outside the loop body, writes are safe only when they happen before the
356+ // relevant loop and in the same variable scope as the declaration.
351357 for reference in scoping. get_resolved_references ( symbol_id) {
352358 if reference. is_write ( ) {
353- return true ;
359+ let ref_span = ctx. semantic ( ) . reference_span ( reference) ;
360+ let ref_variable_scope_id =
361+ Self :: get_enclosing_variable_scope_id ( reference. scope_id ( ) , ctx) ;
362+ if ref_span. start >= border || ref_variable_scope_id != symbol_variable_scope_id {
363+ return true ;
364+ }
354365 }
355366 }
356367
357368 false
358369 }
359370
371+ /// Get the outermost loop that starts after an excluded node.
372+ fn get_top_loop_node < ' a , ' ctx > (
373+ loop_node : & ' ctx AstNode < ' a > ,
374+ excluded_node : Option < & AstNode > ,
375+ ctx : & ' ctx LintContext < ' a > ,
376+ ) -> & ' ctx AstNode < ' a > {
377+ let border = excluded_node. map_or ( 0 , |node| node. span ( ) . end ) ;
378+ let mut top_loop_node = loop_node;
379+ let mut containing_loop_node = Some ( loop_node) ;
380+
381+ while let Some ( current_loop_node) = containing_loop_node {
382+ if current_loop_node. span ( ) . start < border {
383+ break ;
384+ }
385+ top_loop_node = current_loop_node;
386+ containing_loop_node = Self :: get_containing_loop_node ( current_loop_node, ctx) ;
387+ }
388+
389+ top_loop_node
390+ }
391+
392+ /// Gets the containing loop node of a node without crossing function boundaries.
393+ fn get_containing_loop_node < ' a , ' ctx > (
394+ node : & ' ctx AstNode < ' a > ,
395+ ctx : & ' ctx LintContext < ' a > ,
396+ ) -> Option < & ' ctx AstNode < ' a > > {
397+ let nodes = ctx. nodes ( ) ;
398+ let mut current = node;
399+
400+ loop {
401+ let parent = nodes. parent_node ( current. id ( ) ) ;
402+ match parent. kind ( ) {
403+ AstKind :: WhileStatement ( _) | AstKind :: DoWhileStatement ( _) => return Some ( parent) ,
404+ AstKind :: ForStatement ( stmt) => {
405+ if stmt
406+ . init
407+ . as_ref ( )
408+ . is_none_or ( |init| !init. span ( ) . contains_inclusive ( current. span ( ) ) )
409+ {
410+ return Some ( parent) ;
411+ }
412+ }
413+ AstKind :: ForInStatement ( stmt) => {
414+ if !stmt. right . span ( ) . contains_inclusive ( current. span ( ) ) {
415+ return Some ( parent) ;
416+ }
417+ }
418+ AstKind :: ForOfStatement ( stmt) => {
419+ if !stmt. right . span ( ) . contains_inclusive ( current. span ( ) ) {
420+ return Some ( parent) ;
421+ }
422+ }
423+ AstKind :: Function ( _)
424+ | AstKind :: ArrowFunctionExpression ( _)
425+ | AstKind :: Program ( _) => {
426+ return None ;
427+ }
428+ _ => { }
429+ }
430+ current = parent;
431+ }
432+ }
433+
434+ fn get_enclosing_variable_scope_id ( scope_id : ScopeId , ctx : & LintContext ) -> ScopeId {
435+ let scoping = ctx. scoping ( ) ;
436+ let mut current_scope_id = scope_id;
437+ loop {
438+ if scoping. scope_flags ( current_scope_id) . is_var ( ) {
439+ return current_scope_id;
440+ }
441+ let Some ( parent_scope_id) = scoping. scope_parent_id ( current_scope_id) else {
442+ return current_scope_id;
443+ } ;
444+ current_scope_id = parent_scope_id;
445+ }
446+ }
447+
448+ fn is_in_containing_for_loop_header < ' a > (
449+ symbol_decl_node : & AstNode < ' a > ,
450+ loop_node : & AstNode < ' a > ,
451+ ctx : & LintContext < ' a > ,
452+ ) -> bool {
453+ let nodes = ctx. nodes ( ) ;
454+ let mut current = loop_node;
455+
456+ loop {
457+ if Self :: is_in_for_loop_header ( symbol_decl_node, current) {
458+ return true ;
459+ }
460+
461+ let parent = nodes. parent_node ( current. id ( ) ) ;
462+ match parent. kind ( ) {
463+ AstKind :: Function ( _)
464+ | AstKind :: ArrowFunctionExpression ( _)
465+ | AstKind :: Program ( _) => {
466+ return false ;
467+ }
468+ _ => current = parent,
469+ }
470+ }
471+ }
472+
360473 /// Check if a variable declaration is in a for loop's header (init expression)
361474 fn is_in_for_loop_header ( symbol_decl_node : & AstNode , loop_node : & AstNode ) -> bool {
362475 let decl_span = symbol_decl_node. span ( ) ;
@@ -698,6 +811,34 @@ fn test() {
698811 " ,
699812 // Function in the for-update slot is not in the loop body.
700813 "for (var i = 0; i < l; i++, (function () { i; })()) { }" ,
814+ r"
815+ const callbacks = [];
816+ for (let row = 0; row < 5; row++) {
817+ for (let col = 0; col < 5; col++) {
818+ callbacks.push(function () {
819+ return row + col;
820+ });
821+ }
822+ }
823+
824+ let factor = 0;
825+ if (factor < 5) {
826+ factor++;
827+ } else {
828+ for (let index = 0; index < 5; index++) {
829+ const compute = (value, index) => factor * value + index;
830+ }
831+ }
832+
833+ let isOn = false;
834+ if (isOn) {
835+ isOn = true;
836+ }
837+
838+ for (let imageId = 0; imageId < 5; imageId++) {
839+ image.onload = () => (isOn ? 'enabled' : 'disabled');
840+ }
841+ " ,
701842 ] ;
702843
703844 let fail = vec ! [
0 commit comments