Files
Skosh ff7dd679e8 fix(rule): recognize owned effect cleanup paths (#1762)
* test: add failing tests for issue #1756 false positives

Covers three patterns:
- Chained timer cleared by helper (timer reassigned, helper clears current value)
- Timer allocated in nested function after await with guard
- AbortController cleanup via abort event handler

Co-authored-by: Skosh <skoshx@users.noreply.github.com>

* docs: document issue #1756 root causes with failing regression tests

Three false positive patterns in effect-needs-cleanup:

1. **Chained timer**: Timer reassigned inside its own callback, cleared by
   helper. Rule doesn't recognize mutable handle semantics - clearTimeout(timer)
   clears whatever is currently in the variable.

2. **Async guarded allocation**: Guard in async caller protects sync callee
   allocation. Rule recognizes guards inside promise callbacks (#1241) but not
   guards protecting function calls that contain allocations.

3. **AbortController delegation**: signal.addEventListener('abort', ...) removes
   listener, cleanup calls controller.abort(). Rule recognizes direct {signal}
   but not event-based delegation pattern.

Tests currently fail as expected. Implementations need to extend:
- Timer cleanup to understand mutable handles
- Guard tracking to follow call boundaries
- AbortController detection to recognize abort event delegation

Related: #306, #1241, #1594, #1736

Co-authored-by: Skosh <skoshx@users.noreply.github.com>

* wip: partial AbortController delegation fix (needs debugging)

Added `hasAbortSignalDelegatedCleanup` helper to recognize the pattern:
- signal.addEventListener('abort', () => removeEventListener(...))
- cleanup calls controller.abort()

Logic handles both direct (controller.signal) and destructured
(const { signal } = controller) signal references.

Current status: Helper added and integrated into effectHasCleanupForUsage,
but tests still fail. Needs debugging of AST traversal logic to correctly
identify and match the abort listener with the cleanup abort() call.

The other two patterns (chained timer, async guard) still need implementation.

Co-authored-by: Skosh <skoshx@users.noreply.github.com>

* fix(rule): recognize owned effect cleanup paths

* fix(rule): preserve unsafe timer diagnostics

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
Co-authored-by: Aiden Bai <aiden@million.dev>
2026-09-05 14:37:32 -07:00
..
2026-09-01 21:34:33 -07:00
2026-09-01 21:34:33 -07:00

@react-doctor/fuzz

Adversarial fuzzing harness for React Doctor rules. Private — never published.

Each iteration builds a program from one of three sources, mutates it, and runs every rule in the registry against it with three oracles:

Program sources:

  • grammar-generated — snippet pools distilled from two real-world corpora (React/TSX that coding agents actually wrote, mined from Claude Code session traces, and 13 production repos from the react-bench corpus). Pools share a common lexicon (state/setState, items, url, handle, …) and include multi-statement scenario builders (alias → guard → deref, listener register → cleanup, cancellation flags) so statements form the dataflow relationships rules key on. Filenames rotate per iteration to exercise path gating (test-noise skips, Next.js app//pages/, .client.tsx, e2e suffixes).
  • pathological shapes (~12% of iterations) — deep JSX nesting, long optional/binary chains, hundreds of siblings, nested ternaries, very wide component bodies: probes recursive walkers for stack overflows and quadratic scans.
  • corpus files (opt-in via FUZZ_CORPUS_DIR) — real .tsx/.jsx files loaded round-robin across the directory's top-level repos, optionally crossed over (AFL-style line-chunk transplant) with a generated program.

Mutations: deleted/duplicated/swapped character slices and whole lines, unicode/token injection, and a dictionary of rule-trigger tokens (useEffect(() => {}, .removeEventListener(, dangerouslySetInnerHTML, …) spliced into random positions to produce half-formed trigger contexts.

Oracles:

  • crash — the rule threw while visiting a parseable program
  • slow — a single file took pathologically long (default 2s)
  • invariant-violation — a semantics-preserving rewrite (leading/trailing comments, trailing unused declaration, comments/blank lines spliced between top-level sections) changed the diagnostics, meaning the rule keys off incidental source shape (AST rules only)

When a program makes a rule FIRE, extra mutated descendants of it are fuzzed too — a cheap feedback loop that keeps inputs near reporting paths. The run prints a fire-coverage summary (rules that produced ≥1 diagnostic): that number, not the iteration count, is the harness's health metric — a rule that never fires is only having its early bails fuzzed.

Each compatible canonical liveness fixture runs first, so the harness reaches a known reporting path before exploring generated and corpus-derived programs. Corpus seeds with a // verdict: pass or // verdict: fail header run deterministically in both the smoke suite and the targeted fuzz run against the rule named in their // rule: header. Use pass for false-positive regressions and fail for confirmed true positives.

Every case is reproducible from its seed; reproducers for findings are written to tmp/fuzz-findings/.

Usage

pnpm fuzz                                  # fuzz all rules (from repo root)
FUZZ_RULE=no-array-index-as-key pnpm fuzz  # one rule (substring match)
FUZZ_TAG=design pnpm fuzz                  # every rule with one registry tag
FUZZ_ITERATIONS=200 FUZZ_SEED=42 pnpm fuzz # more cases, fixed seed
FUZZ_INVARIANTS=1 pnpm fuzz                # warn on invariant violations
FUZZ_STRICT=1 pnpm fuzz                    # fail on invariant violations too
FUZZ_SLOW_THRESHOLD_MS=100 pnpm fuzz       # verify and report lower-latency outliers
FUZZ_CORPUS_DIR=~/corpus-repos pnpm fuzz   # also fuzz real files + crossover
FUZZ_PRINT_SILENT=1 pnpm fuzz              # list rules that never fired
FUZZ_PRINT_STATS=1 pnpm fuzz               # print executed, fired, and parse-skip counts

scripts/measure-coverage.ts and scripts/measure-corpus-coverage.ts (run with bun) report generator/corpus fire-coverage when tuning snippet pools.

Canonical corpora

Ready-made FUZZ_CORPUS_DIR targets — each script works on any machine (no pre-existing cache required; the RDE repo cache at ~/.cache/rde/repos is used as a symlink fast path when present):

  • Pinned corpus sample — bun scripts/sync-fuzz-corpus.ts materializes tmp/corpus-repos/ from scripts/corpus-repos.json, a deterministic 48-repo sample of the react-doctor-evals corpus (blob-filtered clones at pinned refs; incremental re-runs). The full 8.4k-repo manifest lives in react-doctor-evals/repos.json if the sample ever needs regenerating.
  • react-bench RD-health targets — bun scripts/build-bench-corpus.ts (env: REACT_BENCH, optional RDE_REPO_CACHE) copies the benchmark's target files — each selected for having ≥6 severe React Doctor diagnostics — into tmp/bench-corpus/, cloning any missing repo at its pinned SHA. Densest seeds available: ~60 files fire ~90+ distinct rules.
  • Any directory of cloned repos (e.g. the RDE cache itself when present: 200 of its files fire ~110 distinct rules).

pnpm test in this package runs only the always-on harness smoke tests (including a generator-health check: 100 seeded programs must parse cleanly); the full fuzz run is opt-in via REACT_DOCTOR_FUZZ=1 (what pnpm fuzz sets).