@@ -34,6 +34,95 @@ function getExecLoopRegexName(test: TSESTree.Expression): string | null {
3434 return callee . object . name ;
3535}
3636
37+ const LOOP_NODE_TYPES = new Set < string > ( [ AST_NODE_TYPES . ForStatement , AST_NODE_TYPES . ForOfStatement , AST_NODE_TYPES . ForInStatement , AST_NODE_TYPES . WhileStatement , AST_NODE_TYPES . DoWhileStatement ] ) ;
38+
39+ /**
40+ * Returns true when `node` is nested inside another loop within the same enclosing
41+ * function/program (i.e. the exec loop could run again on a later sibling iteration,
42+ * reusing the same stateful regex across those iterations).
43+ */
44+ function hasEnclosingLoop ( node : TSESTree . Node ) : boolean {
45+ let current : TSESTree . Node | undefined = node . parent ;
46+ while (
47+ current &&
48+ current . type !== AST_NODE_TYPES . FunctionDeclaration &&
49+ current . type !== AST_NODE_TYPES . FunctionExpression &&
50+ current . type !== AST_NODE_TYPES . ArrowFunctionExpression &&
51+ current . type !== AST_NODE_TYPES . Program
52+ ) {
53+ if ( LOOP_NODE_TYPES . has ( current . type ) ) return true ;
54+ current = current . parent ;
55+ }
56+ return false ;
57+ }
58+
59+ /**
60+ * Returns the set of labels that directly label `node` (e.g. `outer: while (...) {}`).
61+ */
62+ function getOwnLabels ( node : TSESTree . Node ) : Set < string > {
63+ const labels = new Set < string > ( ) ;
64+ let current : TSESTree . Node = node ;
65+ while ( current . parent ?. type === AST_NODE_TYPES . LabeledStatement && current . parent . body === current ) {
66+ labels . add ( current . parent . label . name ) ;
67+ current = current . parent ;
68+ }
69+ return labels ;
70+ }
71+
72+ /**
73+ * Walks `body` (without crossing into nested function scopes) looking for a
74+ * `break`/`return`/`throw`, or a labeled `continue` targeting a loop other than the one
75+ * labeled by `ownLabels`, any of which would let the loop stop before `.exec()` has a
76+ * chance to run out of matches and reset `lastIndex` to 0 naturally.
77+ */
78+ function loopBodyCanExitEarly ( body : TSESTree . Node , ownLabels : Set < string > ) : boolean {
79+ let exitsEarly = false ;
80+
81+ function visit ( node : unknown , inNestedLoopOrSwitch : boolean ) : void {
82+ if ( exitsEarly || ! node || typeof node !== "object" || typeof ( node as TSESTree . Node ) . type !== "string" ) return ;
83+ const current = node as TSESTree . Node ;
84+
85+ switch ( current . type ) {
86+ case AST_NODE_TYPES . FunctionDeclaration :
87+ case AST_NODE_TYPES . FunctionExpression :
88+ case AST_NODE_TYPES . ArrowFunctionExpression :
89+ return ; // Don't cross into nested function scopes; their control flow is independent.
90+ case AST_NODE_TYPES . ReturnStatement :
91+ case AST_NODE_TYPES . ThrowStatement :
92+ exitsEarly = true ;
93+ return ;
94+ case AST_NODE_TYPES . BreakStatement :
95+ // Unlike continue, break always terminates the loop rather than moving on to
96+ // the next iteration, so it can happen before .exec() naturally returns null,
97+ // regardless of whether it's labeled with this loop's own label. An unlabeled
98+ // break inside a nested loop/switch only exits that nested construct though.
99+ if ( current . label || ! inNestedLoopOrSwitch ) exitsEarly = true ;
100+ return ;
101+ case AST_NODE_TYPES . ContinueStatement :
102+ // A labeled continue whose label isn't this loop's own label targets some
103+ // outer loop, ending this loop's iteration early.
104+ if ( current . label && ! ownLabels . has ( current . label . name ) ) exitsEarly = true ;
105+ return ;
106+ default :
107+ break ;
108+ }
109+
110+ const isLoopOrSwitch = LOOP_NODE_TYPES . has ( current . type ) || current . type === AST_NODE_TYPES . SwitchStatement ;
111+ for ( const key of Object . keys ( current ) ) {
112+ if ( key === "parent" ) continue ;
113+ const value = ( current as unknown as Record < string , unknown > ) [ key ] ;
114+ if ( Array . isArray ( value ) ) {
115+ for ( const item of value ) visit ( item , inNestedLoopOrSwitch || isLoopOrSwitch ) ;
116+ } else {
117+ visit ( value , inNestedLoopOrSwitch || isLoopOrSwitch ) ;
118+ }
119+ }
120+ }
121+
122+ visit ( body , false ) ;
123+ return exitsEarly ;
124+ }
125+
37126export const requireLastIndexResetBeforeGlobalExecLoopRule = createRule ( {
38127 name : "require-lastindex-reset-before-global-exec-loop" ,
39128 meta : {
@@ -92,6 +181,15 @@ export const requireLastIndexResetBeforeGlobalExecLoopRule = createRule({
92181 const relevantTextBefore = textBefore . slice ( scanStart ) ;
93182
94183 if ( ! resetPattern . test ( relevantTextBefore ) ) {
184+ // If this exec loop is nested inside another loop (reusing the same regex
185+ // across sibling iterations, e.g. one `while` per field of an outer `for`)
186+ // and its body has no `break`/`return`/`throw`/outer-`continue`, it is
187+ // guaranteed to run to natural exhaustion every time, at which point `.exec()`
188+ // resets `lastIndex` to 0 on its own. There's nothing to fix in that case.
189+ if ( hasEnclosingLoop ( node ) && ! loopBodyCanExitEarly ( node . body , getOwnLabels ( node ) ) ) {
190+ return ;
191+ }
192+
95193 context . report ( {
96194 node,
97195 messageId : "requireLastIndexReset" ,
0 commit comments