We benchmarked import-next/no-cycle against eslint-plugin-import/no-cycle and oxlint on next.js — 131K stars, 14,556 source files. Both ESLint plugins agreed: 0 cycles. oxlint disagreed: 17 cycles.
We trusted the consensus. Then we scoped the same rules to a 33-file subset of the same repo. Our rule found 5+ cycles immediately.
Same config. Same files. Different scope. Different answer.
We audited the rule. We found two bugs. Both are now fixed. Here's what they were, why they were hard to see, and what actually verified the fixes.
Bug 1: A depth limit of 10 that silently missed 12-hop cycles
The original default in import-next/no-cycle was maxDepth: 10. It seemed reasonable — most import chains are shallow, and a finite limit bounds worst-case traversal cost. Real codebases have opinions about reasonable.
Next.js's webpack-config.ts has a cycle approximately 12 hops deep. With maxDepth: 10, the DFS stops at hop 10, marks those files as explored, and reports no cycle. The traversal never reaches the closing import that would have revealed it.
// What happens at maxDepth: 10
// A → B → C → D → E → F → G → H → I → J → [DEPTH EXCEEDED — stop here]
// K → L → A ← never reached
// Result: 0 cycles. The A→…→L→A cycle silently disappears.
The failure is invisible. The rule runs, reports clean, exits 0. No warning that it stopped before the full graph.
The fix: Change the default to Number.MAX_SAFE_INTEGER — effectively unlimited, matching eslint-plugin-import's default (Infinity) and oxlint's (u32::MAX).
// In import-next/no-cycle source — default after the fix
maxDepth: Number.MAX_SAFE_INTEGER,
// "Lower values are a performance escape hatch — but with our nonCyclicFiles
// cache, traversal cost is amortized, and a low cap silently misses cycles
// deeper than the limit."
Verification: The fix was confirmed on next.js's webpack-config.ts. The cycle at ~12 hops that the old default missed is now caught. The 33-file subset (5+ cycles) is now detected correctly on every run.
Why it shipped with the wrong default: Unit tests use small, controlled graphs. Our test suite never exercised a 12-hop chain. CI passed for months. The benchmark against next.js was what surfaced it — and only because we had oxlint's output as a reference. Without an independent comparison, the silence would have looked like a passing grade.
Bug 2: Cache contamination that made benchmark results non-deterministic
The second bug was subtler: back-to-back runs of the same rule on the same files returned different cycle counts.
Run 1: 218 cycles.
Run 2: 277 cycles.
Run 3: 301 cycles.
The root cause traced to pendingCycleReports — a shared set that accumulates detected cycle reports across the lint run. This set was being retained across benchmark runs in our test harness (not in production ESLint usage). When we ran multiple lint passes to compare against oxlint, the cycle reports from run N carried into run N+1.
The count increased run-over-run (218 → 277 → 301) because each successive run started with the previous run's reports still in the set, then added its own discoveries on top. Since runs didn't process files in identical order, each run found a slightly different subset of cycles — and they accumulated. By run 3, the report set contained overlapping detections from three separate traversals.
// The fix: reset analysis-state caches between runs for determinism
sharedCache.nonCyclicFiles.clear();
sharedCache.pendingCycleReports.clear();
sharedCache.sccComputed = false;
// other cache resets...
// Production ESLint runs are single-process — this only mattered during
// benchmarking where multiple lint passes ran back-to-back in the same process.
Why this matters for the correctness claim: The 218/277/301 variance meant we couldn't trust our own rule's output as a benchmark reference. A tool that reports different cycle counts across runs on the same codebase can't tell you whether a discrepancy versus oxlint is a real false negative or benchmark noise. Fixing the non-determinism was a prerequisite for trusting the correctness comparison at all.
What the fixes mean — and what they don't
After both fixes, import-next/no-cycle now catches the 12-hop cycle in next.js's webpack-config.ts that the old 10-hop default silently missed. That's the verified result.
Whether the fixed rule finds all 17 cycles oxlint reports is still under measurement using our ground-truth corpus methodology — manual cycle verification, not one tool's output as oracle. That comparison is tracked in the ILB flagship benchmark.
On eslint-plugin-import: It already defaults to maxDepth: Infinity, so Bug 1 doesn't apply. Their 0-cycle result on next.js has a different root cause we haven't isolated. We're not claiming Bug 2 applies to them — that requires evidence we don't yet have.
What we can say: on the specific verified failure — the 12-hop cycle in webpack-config.ts — the fixed import-next/no-cycle catches what the unfixed version missed. That's the meaningful claim.
The pattern these bugs share
Both bugs are invisible in unit test suites with small, controlled graphs:
- A 6-file cycle test doesn't exercise 12-hop depth limits
- A single-run test doesn't expose cross-run cache contamination
The only thing that caught them was a real-world repo compared against an independent reference tool, with enough runs to observe variance. That's the ground-truth methodology — F1 measurement against real codebases, not coverage of controlled inputs.
If your lint rule reports silence on a 50K-line monorepo, ask whether it's doing the same work it does on 100 files. Sometimes the silence is correct. Sometimes it's a depth limit you didn't know you hit.
The test that exposed our bug works on any cycle detector. Scope your no-cycle rule to a small, known-complex subdirectory of your monorepo and run it in isolation. If it finds cycles that the full-repo run doesn't, your tool has a cache or depth issue affecting the larger scope. We found 5+ in 33 files that were invisible in 14,556. If your tool returns different answers at different scopes, the smaller scope is showing you something the larger one buried.
Has a lint rule ever silently failed on your codebase — returning no errors on code you later found was wrong? What was the first signal that made you look closer?
Part of the Inside our linter benchmarks series:
← What Ground Truth Caught That Unit Tests Missed | no-cycle Finds 0 Cycles in Next.js (And Other Lies Caches Tell You) →
📦 eslint-plugin-import-next · Rule docs
GitHub | X | LinkedIn | Dev.to | ofriperetz.dev









