Skip to content

Commit 59fbf76

Browse files
test(lint): no-polling-loop exempts counter-bounded loops of any kind and flags length predicates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 31d8bae commit 59fbf76

3 files changed

Lines changed: 114 additions & 11 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4357,6 +4357,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
43574357
offer: (request) =>
43584358
Effect.sync(() => void offers.push(request)).pipe(
43594359
Effect.andThen(Deferred.succeed(offered, undefined)),
4360+
Effect.asVoid,
43604361
),
43614362
take: Effect.never,
43624363
}),
@@ -4875,6 +4876,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
48754876
offer: (request) =>
48764877
Effect.sync(() => void offers.push(request)).pipe(
48774878
Effect.andThen(Deferred.succeed(offered, undefined)),
4879+
Effect.asVoid,
48784880
),
48794881
take: Effect.never,
48804882
}),

‎oxlint-plugin-t3code/rules/no-polling-loop-in-tests.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,37 @@ describe("t3code/no-polling-loop-in-tests", () => {
1818
`,
1919
);
2020

21+
rule.valid(
22+
"allows a fixed-N while flush with a counter bound",
23+
`
24+
import * as Effect from "effect/Effect";
25+
26+
export const flush = Effect.gen(function* () {
27+
let i = 0;
28+
while (i < 3) {
29+
yield* Effect.yieldNow;
30+
i++;
31+
}
32+
});
33+
`,
34+
);
35+
36+
rule.valid(
37+
"allows a fixed-N do-while flush against a named bound",
38+
`
39+
import * as Effect from "effect/Effect";
40+
41+
export const flush = (turns: number) =>
42+
Effect.gen(function* () {
43+
let i = 0;
44+
do {
45+
yield* Effect.yieldNow;
46+
i += 1;
47+
} while (i < turns);
48+
});
49+
`,
50+
);
51+
2152
rule.valid(
2253
"allows a loop that takes from a queue",
2354
`
@@ -82,6 +113,46 @@ describe("t3code/no-polling-loop-in-tests", () => {
82113
`,
83114
);
84115

116+
rule.invalid(
117+
"reports a while loop bounded by a length predicate",
118+
`
119+
import * as Effect from "effect/Effect";
120+
121+
export const wait = (events: Array<number>) =>
122+
Effect.gen(function* () {
123+
while (events.length < 1) yield* Effect.yieldNow;
124+
});
125+
`,
126+
);
127+
128+
rule.invalid(
129+
"reports a comparison against an identifier the loop never advances",
130+
`
131+
import * as Effect from "effect/Effect";
132+
133+
export const wait = (state: { count: number }) =>
134+
Effect.gen(function* () {
135+
let seen = state.count;
136+
while (seen < 3) {
137+
yield* Effect.yieldNow;
138+
seen = state.count;
139+
}
140+
});
141+
`,
142+
);
143+
144+
rule.invalid(
145+
"reports a for loop bounded by a length predicate",
146+
`
147+
import * as Effect from "effect/Effect";
148+
149+
export const wait = (events: Array<number>) =>
150+
Effect.gen(function* () {
151+
for (; events.length < 1; ) yield* Effect.yieldNow;
152+
});
153+
`,
154+
);
155+
85156
rule.invalid(
86157
"reports a sleep poll with a counter bound and an early return",
87158
`

‎oxlint-plugin-t3code/rules/no-polling-loop-in-tests.ts‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,47 @@ const hasConditionalExit = (body: ESTree.Node): boolean =>
113113
),
114114
);
115115

116-
// A bare counter (`i < 3`, `attempt < max`) bounds the loop without asking
117-
// anything about the system under test.
118-
const isCounterTest = (test: ESTree.Expression | null | undefined): boolean => {
119-
if (test == null) return false;
120-
const expression = unwrapExpression(test);
116+
const isNumericBound = (node: unknown): boolean => {
117+
const expression = unwrapExpression(node);
118+
if (Option.isNone(expression)) return false;
119+
const value = expression.value;
120+
return (
121+
value.type === "Identifier" || (value.type === "Literal" && typeof value.value === "number")
122+
);
123+
};
124+
125+
const updatesCounter = (node: unknown, name: string): boolean =>
126+
someDescendant(
127+
node,
128+
(child) =>
129+
(child.type === "UpdateExpression" && isIdentifier(unwrapExpression(child.argument), name)) ||
130+
(child.type === "AssignmentExpression" &&
131+
(child.operator === "+=" || child.operator === "-=") &&
132+
isIdentifier(unwrapExpression(child.left), name)),
133+
);
134+
135+
// A bare counter (`i < 3`, `attempt < max`) that the loop itself advances
136+
// bounds the loop without asking anything about the system under test.
137+
// `events.length < 1` or a counter nobody increments is a predicate.
138+
const isCounterTest = (loop: LoopNode): boolean => {
139+
if (loop.test == null) return false;
140+
const expression = unwrapExpression(loop.test);
121141
if (Option.isNone(expression) || expression.value.type !== "BinaryExpression") return false;
122142
if (!COUNTER_OPERATORS.has(expression.value.operator)) return false;
123-
return !someDescendant(expression.value, (node) => node.type === "CallExpression");
143+
const { left, right } = expression.value;
144+
const advances = [loop.body, loop.type === "ForStatement" ? loop.update : null];
145+
return [
146+
[left, right],
147+
[right, left],
148+
].some(([counter, bound]) => {
149+
const name = unwrapExpression(counter);
150+
return (
151+
Option.isSome(name) &&
152+
name.value.type === "Identifier" &&
153+
isNumericBound(bound) &&
154+
updatesCounter(advances, name.value.name)
155+
);
156+
});
124157
};
125158

126159
const isTrueLiteral = (test: ESTree.Expression): boolean => {
@@ -144,11 +177,8 @@ const failsAfterLoop = (loop: LoopNode): boolean => {
144177

145178
const hasPredicateExit = (loop: LoopNode): boolean => {
146179
if (hasConditionalExit(loop.body)) return true;
147-
if (loop.type === "ForStatement") {
148-
if (loop.test != null && !isCounterTest(loop.test)) return true;
149-
return failsAfterLoop(loop);
150-
}
151-
return !isTrueLiteral(loop.test);
180+
if (loop.test != null && !isTrueLiteral(loop.test) && !isCounterTest(loop)) return true;
181+
return failsAfterLoop(loop);
152182
};
153183

154184
export default defineRule({

0 commit comments

Comments
 (0)