Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/cfg-beefup-and-rule-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"oxlint-plugin-react-doctor": patch
---

Beef up the control-flow graph and fix the false positives it exposed.

The internal CFG now exposes reachability, dominance, post-dominance, loop-membership, and unreachable-code primitives, models loop back-edges and infinite loops, and gives `try`/`catch`/`finally` proper finalize/join semantics (a `finally` body stays reachable even when the `try` returns; code after the try is unreachable when no path completes normally). Several rules adopt it:

- `nextjs-no-redirect-in-try-catch` no longer mis-flags `redirect()` / `notFound()` in a `catch` block, in a `finally` block, or in a `try` that has only a `finally` (no `catch`) — none of those swallow the navigation control-flow error.
- `no-mutating-reducer-state` no longer reports a loop that mutates and then `return`s a fresh object (`for (…) { state.items.push(x); return { ...state } }`) when a trailing `return state` only runs on the no-match path.
- `js-hoist-regexp`, `js-index-maps`, and `js-set-map-lookups` no longer mis-flag work inside a callback that merely escapes a loop (the loop-aware check now uses real CFG loop membership instead of lexical nesting depth).
17 changes: 17 additions & 0 deletions .changeset/feat-cfg-formal-verification-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"oxlint-plugin-react-doctor": patch
---

Add a formal-verification stack to the control-flow graph and three path-sensitive rules.

`@react-doctor/cfg` gains four layers on top of its CFG/SSA engine, all pure-TS, bundled at build time, lazy (a rule that never reads a layer pays nothing), and run once per scan:

- **Dataflow framework** — `solveDataflow`, a generic monotone worklist fixpoint over a `Lattice<Fact>` (one solver subsumes many analyses), and `analyzeDefiniteAssignment` built on it: a forward must-analysis over the SSA occurrence stream answering _is this read reached unassigned on some path?_ (a `declare` like `let x;` is neither read nor write, so a bare declaration never counts as an assignment).
- **Typestate engine** — `verifyTypestate(cfg, { automaton, classifier })` generalizes resource-protocol checking into a reusable automaton verified over the CFG, reporting error transitions (an illegal event) and leaked resources (a resource left non-accepting on a normal-completion path). Events are attributed to their real block and deduplicated, so the whole-body implicit-return never double-counts a call.
- **Path feasibility** — a bounded, dependency-free checker (`isPathFeasible` + `lowerGuard` / `pathConditionFacts`) that lowers a path's branch guards into facts over SSA values and refutes correlated-branch counterexamples via union-find congruence closure. It only ever _suppresses_ a diagnostic when the path search is complete and every counterexample is provably infeasible (e.g. `if (x) open(); … if (x) close();`), so it strictly removes false positives and is never unsound for bug-finding.

Three new rules consume them:

- `correctness/no-use-before-define` — a block-scoped binding (`let` / `const` / `class` / `using`) used lexically before its declaration runs, in the same synchronous execution, which always throws a `ReferenceError` from the Temporal Dead Zone. Sound by construction: quiet for hoisted `var` / function declarations, params, globals, and any access nested in a closure or class body that may run after the declaration. A declared-but-unassigned `let` read (`let x; if (c) x = 1; use(x)`) is `undefined`, not a TDZ crash, so it is deliberately not reported.
- `state-and-effects/no-stale-closure-capture` — a `useMemo` / `useCallback` closure that captures a `let` binding reassigned later in the same render, so the memoised value/function sees a stale value (the deps array signals the author intended the value at creation time). Quiet for `const` and bindings never reassigned after capture, and for the deferred effect hooks (`useEffect` / `useLayoutEffect`), whose callbacks run after render where reading the binding's final value is the intended pattern, not a stale capture.
- `state-and-effects/no-unreleased-resource` — a resource opened inside a React effect callback (timer, subscription, event listener, `AbortController`) and released INLINE on some paths but leaked on an early return. Scoped to `useEffect` / `useLayoutEffect` / `useInsertionEffect` (including the namespaced `React.useEffect` form): the returned-cleanup contract stays owned by `effect-cleanup-not-on-every-path`, a `finally`-based release counts as run-on-every-path, and non-effect functions (class lifecycle methods, non-React frameworks like Solid's `createEffect`/`onCleanup`) are left alone.
9 changes: 9 additions & 0 deletions .changeset/feat-cfg-native-ssa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"oxlint-plugin-react-doctor": patch
---

Add native SSA to the control-flow graph and a path-sensitive dead-assignment rule.

`@react-doctor/cfg` now builds variable-level **static single assignment** form over its oxc-native CFG via the Braun, Buchwald, Hack et al. (2013) on-the-fly sealed-block algorithm — the same algorithm the React Compiler's `EnterSSA` implements — followed by their redundant-φ elimination pass. It is a clean-room port (no Babel, MIT attribution): a minimal value model (`SsaIdentifier` / `Place` / `Phi`), per-instruction read/write extraction, a self-contained lexical binding resolver with an injectable seam (the plugin feeds in its own scope analyzer's binding identities), and an `analyzeSsa` query API (`versionAt`, `reachingDefinition`, `isLiveValue`, `isRedefinedBetween`, `bindingOf`, per-function φ + def blocks). The parity suite asserts the Braun φ placement equals the iterated dominance frontier of each binding's definitions (Cytron et al.), and `toDot` renders φ-functions.

New `no-dead-assignment` rule uses it: it flags a write to a reassignable local whose value is never read because every path overwrites it first (`let total = expensive(); total = cheap(); return total;`). This is a value-flow question pure control flow can't answer — it complements `no-unused-vars` (which only sees wholly-unused bindings) and stays quiet for `const`, compound assignments, closure-captured bindings, and any write whose value is read on some path.
12 changes: 12 additions & 0 deletions .changeset/feat-cfg-react-verifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"oxlint-plugin-react-doctor": minor
---

Turn the control-flow graph into a React verifier: model expression-level control flow and add a path-sensitive effect-leak rule.

The CFG now lowers expression-level control flow the way the React Compiler's HIR does — a ternary's arms, a `&&` / `||` / `??` (and `&&=` / `||=` / `??=`) right operand, and an optional chain's links past each `?.` all get their own basic blocks. A hook / `setState` / effect short-circuited inside any of those is now correctly seen as conditional, which statement-level lowering alone could not see.

Two rules use it as a verifier:

- New `effect-cleanup-not-on-every-path`: flags a subscription/timer acquired in an effect whose cleanup is skipped on an early-return path (`const id = setInterval(…); if (skip) return; return () => clearInterval(id)` leaks on the `skip` path). This is a reachability question no AST shape can answer — it complements `effect-needs-cleanup` (which only checks a cleanup exists at all) and stays quiet when the guard runs before the acquisition or when every return path cleans up.
- `no-set-state-in-render` now flags any setter the CFG proves runs on every render path (`isUnconditionalFromEntry`), not just a bare top-level statement — so `const x = setCount(c + 1)` and unconditional blocks are caught, while the guarded store-previous-render fixed-point pattern (`if (prev !== count) setPrev(count)`) stays quiet.
7 changes: 7 additions & 0 deletions .changeset/feat-cfg-structural-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
---

Upgrade `@react-doctor/cfg` to a full structural control-flow graph.

Each basic block is now a typed instruction list ending in a first-class `Terminal` modeled on the React Compiler HIR taxonomy (`goto` / `if` / `switch` / loops / `logical` / `ternary` / `optional` / `try` / `return` / `throw`), with `fallthrough` join blocks and explicit `goto` lowering of `break` / `continue`. Dominance now uses the Cooper–Harvey–Kennedy immediate-dominator tree over reverse-postorder (plus the Cytron dominance frontier as the SSA seam). New analysis surface: `dominanceFrontier`, `isInfiniteLoopStart` (oxc-parity constant folding), and a Graphviz `toDot` export. The builder is split into `ir/` + `build/` + `analysis/` modules, and curated parity corpora from oxc (`no-fallthrough`, `no-unsafe-finally`, `getter-return`), ESLint code-path analysis, and React Compiler `BuildHIR` are ported as tests. The published plugin behavior is unchanged (all rule tests pass); this is an internal engine upgrade bundled at build time.
8 changes: 8 additions & 0 deletions .changeset/feat-cfg-verifier-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"oxlint-plugin-react-doctor": patch
---

Add 2 new rules that use the structural control-flow graph as a verifier:

- `no-unreachable-code` (Bugs): flags code that never runs because every path above it returns, throws, breaks, continues, or loops forever (via the CFG's `isUnreachable`). Hoisted function declarations, type-only TS declarations, and a bare `var x;` are left alone, matching ESLint's `no-unreachable` carve-outs. Global rule (runs on all JS/TS), so the defensive trailing `throw` after a switch whose every case returns is reported as dead code, consistent with `no-unreachable`.
- `no-set-state-in-render-loop` (Bugs): flags a `useState` setter called inside a render-phase loop (via the CFG's `isInsideLoop`), which fires every iteration and restarts rendering ("Too many re-renders"). Complements `no-set-state-in-render`, which only catches setters that run unconditionally; the two partition cleanly on `isUnconditionalFromEntry`, so an unconditional `for (;;)` / `while (true)` setter is owned by `no-set-state-in-render` and never double-reported. Setters in `.map()` / event-handler / effect callbacks (separate functions) stay quiet.
10 changes: 10 additions & 0 deletions .changeset/fix-no-dead-assignment-cfg-false-positives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"oxlint-plugin-react-doctor": patch
---

Fix two `no-dead-assignment` false positives surfaced by running the rule across the OSS corpus.

- **Loop-carried state machines.** An unlabeled `break` inside a `switch` nested in a loop was routed to the loop's exit instead of the switch's merge, so a value written in a `case` and read at the top of the next iteration looked dead. The CFG builder now resolves an unlabeled `break` to the innermost enclosing loop **or** switch (matching JS semantics), keeping the loop's back-edge intact. Real repro: tldraw's `reorderShapes` state machine flagged `state = …` as dead on every iteration.
- **Values read only on an exceptional path.** A value assigned inside a `try` body — including a nested `catch` within it — and read only in the enclosing `catch`/`finally` was reported as dead, because the CFG models the exception path coarsely. `no-dead-assignment` now treats writes inside a `try` body as live. Real repro: bippy's `owner-stack` `control = caughtError`, read in the outer `catch`.

The `break`/`switch` control-flow fix also makes loop-membership and reachability correct for any code after a `switch` `break` inside a loop, which the other control-flow-graph-backed rules consume.
11 changes: 11 additions & 0 deletions .changeset/perf-cfg-shared-lazy-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"oxlint-plugin-react-doctor": patch
---

Make the CFG-backed rules' shared analysis layer roughly twice as fast, with no change in diagnostics.

Every rule is wrapped with a per-file semantic context, but each wrapper rebuilt the scope tree, control-flow graph, SSA, and definite-assignment analysis on its own — so a single file rebuilt the CFG once per CFG-reading rule (and SSA/definite-assignment each rebuilt it again internally). The build is now shared across all rules over the same file via a `Program`-keyed `WeakMap` (the pattern already used for the effect rules' scope analysis), and the one CFG is threaded into SSA and definite-assignment instead of each constructing its own.

The CFG's per-function derived structures (dominator and post-dominator trees, loop membership, reachability, the unconditional-from-entry set, source-order index) are now computed lazily on first query and memoized, so a rule that only asks `isInsideLoop` no longer pays for two dominator trees. Loop-membership detection drops from an O(V^3) per-block self-reachability scan to a single O(V+E) strongly-connected-components pass, the reachability and unconditional traversals stop re-shifting their BFS queues, `forEachChildNode` no longer allocates a key array per AST node, and the simple-path enumerator gained a global visit budget so a branch-heavy file can't blow up.

Behavior is unchanged: the loop and unconditional rewrites are pinned by parity tests against the original implementations, and the whole change was differential-tested — running every CFG/SSA/typestate-backed rule over a corpus of adversarial edge cases plus the fixtures, in both rule orders, produced byte-identical diagnostics before and after.
7 changes: 7 additions & 0 deletions .changeset/refactor-extract-cfg-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
---

Extract the control-flow graph into a dedicated internal `@react-doctor/cfg` package.

The per-function CFG builder and its dominance / reachability analyses now live in their own self-contained package (bundled into the plugin at build time, so the published surface is unchanged). The package ships a typed `analyzeControlFlow` API, a README documenting the modeled terminal taxonomy, and a full port of oxc's `eslint/no-unreachable` `pass` / `fail` corpus asserted directly against the graph's `isUnreachable`.
Loading
Loading