DEV Community

Ofri Peretz
Ofri Peretz

Posted on • Originally published at ofriperetz.dev

Our Cycle Detector Found More Cycles in 33 Files Than in 14,556. Here's the Bug.

A rule that finds more cycles on 33 files than on 14,556 has a bug.

import-next/no-cycle reported 0 cycles on next.js — 131K stars, 14,556 source files. oxlint reported 17. We scoped our rule to a 33-file subdirectory of the same repo. It found 5 cycles immediately.

Same rule. Same config. Narrower scope. More violations found.

We audited the rule and found the bug. Here's what it was, why it hid cycles in large repos while surfacing them in small ones, and what the fix changed.


The bug: a 10-hop depth limit that silenced 12-hop cycles — and poisoned the cache

The original default in import-next/no-cycle was maxDepth: 10.

Next.js's webpack-config.ts has an import cycle approximately 12 hops deep. With maxDepth: 10, the DFS reaches hop 10, stops, marks those boundary files as explored, and exits without finding the cycle. The closing import — the one that would have revealed it — is never reached.

// What happens at maxDepth: 10
// A → B → C → D → E → F → G → H → I → J → [depth exceeded — stop]
//                                                  K → L → A  ← never reached
//
// Result: 0 cycles. The A→…→L→A cycle disappears.
Enter fullscreen mode Exit fullscreen mode

The failure is invisible. The rule runs, reports no violations, exits 0. No warning that it stopped early. No indication that part of the graph wasn't examined.

Two-part fix:

First, change the default to Number.MAX_SAFE_INTEGER so no cycle is silenced by hitting the depth cap:

// Default after the fix — from the source
maxDepth: Number.MAX_SAFE_INTEGER,
Enter fullscreen mode Exit fullscreen mode

Second — and more importantly — don't write to the "explored" cache for nodes whose subtree was depth-truncated. The real bug isn't the 10-hop cap itself; it's marking a depth-truncated node as unconditionally acyclic. Raising the cap to unlimited fixes the default, but any team that sets a finite maxDepth as a performance escape hatch reintroduces the cache-poisoning behavior. The correct invariant: only cache a node as "acyclic" when its full subtree was actually traversed.

What the fix changes: After lifting the cap and correcting the cache invariant, the ~12-hop cycle in webpack-config.ts is now caught on every run. The 33-file router-reducer subset returns the same cycles whether run in isolation or as part of the full 14,556-file repo — 5 distinct cycles, consistently. The gap that produced 0 on the full run is closed.

Whether the fixed rule finds all 15 cycles oxlint reports is tracked via our ground-truth corpus — manual verification, not one tool's count as oracle. eslint-plugin-import/no-cycle already defaults to maxDepth: Infinity, so Bug 1 doesn't explain their 0. Our leading hypothesis is a difference in how they handle barrel-file re-exports — they may not traverse through them. The ground-truth corpus comparison will close this.

Why it shipped with the wrong default: Unit tests use small, controlled graphs — never 12 hops deep. CI stayed green. The benchmark against next.js was what surfaced it, and only because we had oxlint's count as a reference. Without an independent comparison, the silence would have looked like a clean result.


Why the subset found more than the full repo

This is the counterintuitive part worth understanding: the 33-file subset found 5 cycles that the 14,556-file run missed.

Bug 1 explains it. During the full-repo run, the DFS processes files outside the 33-file subset first. It hits the 10-hop boundary on some of them and marks those files as "explored." Some of those boundary files are intermediate nodes in cycles that pass through the 33-file subset. When the rule later traverses those cycles from files inside the subset, the boundary nodes are already cache-hit as explored — so the DFS exits early and the cycle disappears.

The 33-file subset run starts with a clean cache. None of the broader repo's false "explored" entries are present. The DFS traverses each path fully and finds the cycles.

Smaller scope → cleaner cache → more cycles found. That's the signature of a depth-limit bug poisoning the "explored" cache.


What this means for your own cycle detector

The test that exposed our bug works on any implementation:

  1. Run no-cycle on your full monorepo — note the count
  2. Pick a known-complex directory (dependency-heavy, many cross-imports) and run on just that subtree
  3. If the subset finds cycles the full run doesn't, you have a cache or depth interaction affecting the broader scope

We found 5 in 33 files that were invisible in 14,556. The smaller scope, because it has a cleaner traversal state, shows you what the larger scope buried. Most teams never run this check because they assume "fewer files = fewer findings." For cycle detection with caching, the opposite can be true.


What we still don't know

Our rule returned 0 on the full repo before the fix. eslint-plugin-import/no-cycle also returned 0 — and it already defaults to maxDepth: Infinity, so Bug 1 doesn't apply to it. Their 0-cycle result has a different root cause we haven't isolated yet.

We're not claiming our bugs explain their behavior. We're saying: at least one ESLint implementation is wrong on the next.js cycle question, and we know which bug we fixed in ours. The ground-truth corpus work (manual cycle verification, tool-agnostic) is what will close this comparison — tracked in the ILB flagship benchmark.


The pattern both bugs share

Unit tests on small graphs don't catch either of these:

  • A 6-file test cycle never exercises a 10-hop depth limit
  • A single-pass test doesn't expose cross-run report accumulation

The only thing that caught it: a large real-world repo measured against an independent reference tool. That's the ground-truth methodology — F1 measurement against real codebases where the correct answer is known independently, not coverage on controlled inputs.

(During post-fix validation we also found our benchmark harness was accumulating pendingCycleReports across back-to-back runs, producing 218→255→301 variance on the same files. This is a harness-tooling bug, not a rule bug — production npx eslint src/ runs once and exits. We include it for transparency: it's why our validation numbers were noisy before we identified it.)

If your cycle detector reports silence on a large monorepo, run the subset test. The silence might be correct. Or it might be a depth limit you didn't know you hit.


Has a lint rule ever returned different counts on the same codebase across back-to-back runs — or found more issues on a subset than the full repo? What was the first thing that made you look closer?


Part of the Inside our linter benchmarks series:
no-cycle Finds 0 Cycles in Next.js (And Other Lies Caches Tell You) | What Ground Truth Caught That Unit Tests Missed →


📦 eslint-plugin-import-next · Rule docs

⭐ Star on GitHub


GitHub | X | LinkedIn | Dev.to | ofriperetz.dev

Top comments (0)