Skip to content

Commit c613ac3

Browse files
authored
Fix false positive in require-lastindex-reset-before-global-exec-loop for naturally-exhausting nested exec loops (#53602)
1 parent 5bf1d0e commit c613ac3

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

eslint-factory/src/rules/require-lastindex-reset-before-global-exec-loop.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,24 @@ describe("require-lastindex-reset-before-global-exec-loop", () => {
5454
}`,
5555
// Unrelated while loop.
5656
`while (x < 10) { x++; }`,
57+
// Mirrors extractTemporaryIdReferences: module-scope 'g' regex reused across an
58+
// outer for-loop's iterations, but the inner while loop's body has no
59+
// break/return/throw, so it always drains to natural exhaustion and resets
60+
// lastIndex to 0 on its own before the next field is scanned.
61+
`const TEMPORARY_ID_PATTERN = /#(aw_[A-Za-z0-9_]{3,12})\\b/gi;
62+
function extractTemporaryIdReferences(message) {
63+
const tempIds = new Set();
64+
const textFields = ["body", "title", "description"];
65+
for (const field of textFields) {
66+
if (typeof message[field] === "string") {
67+
let match;
68+
while ((match = TEMPORARY_ID_PATTERN.exec(message[field])) !== null) {
69+
tempIds.add(match[1]);
70+
}
71+
}
72+
}
73+
return tempIds;
74+
}`,
5775
],
5876
invalid: [
5977
{
@@ -86,6 +104,44 @@ describe("require-lastindex-reset-before-global-exec-loop", () => {
86104
}`,
87105
errors: [{ messageId: "requireLastIndexReset" }],
88106
},
107+
{
108+
// Reused across an outer for-loop's iterations like the valid case above, but
109+
// the loop body can return early, so it is not guaranteed to drain the regex
110+
// to natural exhaustion each time - the reset is still required.
111+
code: `const TEMPORARY_ID_PATTERN = /#(aw_[A-Za-z0-9_]{3,12})\\b/gi;
112+
function extractTemporaryIdReferences(message, stopField) {
113+
const tempIds = new Set();
114+
const textFields = ["body", "title", "description"];
115+
for (const field of textFields) {
116+
if (typeof message[field] === "string") {
117+
let match;
118+
while ((match = TEMPORARY_ID_PATTERN.exec(message[field])) !== null) {
119+
if (field === stopField) return tempIds;
120+
tempIds.add(match[1]);
121+
}
122+
}
123+
}
124+
return tempIds;
125+
}`,
126+
errors: [{ messageId: "requireLastIndexReset" }],
127+
},
128+
{
129+
// A `break` targeting the exec loop's own label still stops the loop before
130+
// `.exec()` naturally returns null (unlike a `continue` to the same label,
131+
// which just moves on to the next iteration), so it can leave `lastIndex`
132+
// dirty and must not be exempted just because it's nested in an outer loop.
133+
code: `const RE = /foo/g;
134+
function scan(items) {
135+
for (const item of items) {
136+
let m;
137+
inner: while ((m = RE.exec(item)) !== null) {
138+
if (skip(m)) break inner;
139+
use(m);
140+
}
141+
}
142+
}`,
143+
errors: [{ messageId: "requireLastIndexReset" }],
144+
},
89145
],
90146
});
91147
});

eslint-factory/src/rules/require-lastindex-reset-before-global-exec-loop.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
37126
export 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

Comments
 (0)