Skip to content

feat: add dead-code analysis via deslop-js#291

Merged
aidenybai merged 12 commits into
mainfrom
cursor/add-deslop-dead-code-rules-cd02
May 20, 2026
Merged

feat: add dead-code analysis via deslop-js#291
aidenybai merged 12 commits into
mainfrom
cursor/add-deslop-dead-code-rules-cd02

Conversation

@aidenybai
Copy link
Copy Markdown
Member

Summary

Re-adds dead-code detection to react-doctor, this time powered by deslop-js (oxc-based, fast) instead of the previously-removed knip integration.

Unused files, unused exports, unused dependencies, and circular import cycles surface alongside lint findings under a new Dead Code category and flow through the same suppression / severity / surface pipeline as every other rule.

New rule ids

  • deslop/unused-file — a source file not reachable from any detected entry point.
  • deslop/unused-export / deslop/unused-type — exported symbol (or type-only symbol) that no other module imports.
  • deslop/unused-dependency / deslop/unused-dev-dependency — a package.json dep that is never imported.
  • deslop/circular-dependency — an import cycle between two or more files.

Controls

  • CLI: --no-dead-code skips the pass entirely; --dead-code re-enables it.
  • Config: "deadCode": false mirrors --no-dead-code.
  • Severity: "rules": { "deslop/unused-export": "off" } or "categories": { "Dead Code": "off" } works out of the box.
  • Surfaces, ignore.files, ignore.overrides all apply just like any other diagnostic.

Dead-code reachability is a whole-project property, so the pass is automatically skipped in --diff / --staged modes — a diff scan can't tell whether a deleted importer just stopped using a file or whether the file became unreachable. This matches how checkReducedMotion is gated in combineDiagnostics.

Changes by package

  • @react-doctor/core
    • New checkDeadCode() (packages/core/src/check-dead-code.ts) wraps deslop's analyze() and maps each ScanResult entry to a Diagnostic (plugin "deslop", category "Dead Code", severity "warning").
    • combineDiagnostics gained an extraDiagnostics channel so the async pass feeds the same suppression / severity pipeline as lint.
    • validateConfigTypes knows about the new boolean deadCode field.
    • PLUGIN_CATEGORY_MAP maps deslop"Dead Code" for the rare case an external tool emits a raw deslop/* diagnostic without a category.
    • Added deslop-js@^0.0.2 as a dependency.
  • @react-doctor/types
    • New deadCode?: boolean on ReactDoctorConfig, InspectOptions, and DiagnoseOptions.
  • react-doctor
    • inspect() and diagnose() run the dead-code pass in parallel with runOxlint; failures degrade gracefully and add a "dead-code" entry to skippedChecks / skippedCheckReasons instead of failing the whole scan.
    • CLI exposes --dead-code / --no-dead-code.
    • README updated with a dedicated dead-code section, the new CLI flag, and the new config key.

Tests

  • New packages/react-doctor/tests/check-dead-code.test.ts regression suite covering empty projects, unused files, unused exports, and unused dependencies.
  • Added an extraDiagnostics case to combine-diagnostics.test.ts.
  • All 1175 existing tests still pass; pnpm lint and pnpm typecheck both clean.

Smoke test

Running react-doctor against the basic-react fixture now shows entries like:

deslop/unused-file        ×22  Unused file — not reachable from any entry point
deslop/unused-dependency       Unused dependency: `@tanstack/react-query`

and --no-dead-code zeroes them out.

Open in Web Open in Cursor 

Wires deslop-js into the react-doctor scan so unused files, unused
exports, unused dependencies, and circular import cycles surface
alongside lint findings under a new "Dead Code" category.

- packages/core: new checkDeadCode() wraps deslop's analyze() and maps
  ScanResult entries to Diagnostic objects (plugin: "deslop",
  category: "Dead Code"). combineDiagnostics gained an
  `extraDiagnostics` channel so the async dead-code pass can feed
  results through the same suppression/severity pipeline as lint.
- packages/types: new `deadCode?: boolean` field on
  ReactDoctorConfig, InspectOptions, and DiagnoseOptions.
- packages/react-doctor: inspect() and diagnose() run the dead-code
  pass in parallel with oxlint; CLI exposes `--dead-code` /
  `--no-dead-code`. The pass is skipped automatically in diff /
  staged mode because reachability is a whole-project property.
- Severity & surface controls work out of the box via the standard
  `<plugin>/<rule>` ids (e.g. "deslop/unused-export", "Dead Code"
  category).

Tests: new check-dead-code regression suite plus an extraDiagnostics
case in combine-diagnostics. All 1175 existing tests still pass.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@reactreview
Copy link
Copy Markdown

reactreview Bot commented May 17, 2026

0 score

Copy as prompt
Check if these React Review issues are valid. If so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.

Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff

Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issues instead of changing or suppressing the rules.

React Review found 0 errors and 1 warning. This PR leaves the React health score unchanged.

<file name="packages/react-doctor/tests/check-dead-code.test.ts">

<violation number="1" location="packages/react-doctor/tests/check-dead-code.test.ts:70">
Severity: Warning

.filter().map() iterates the array twice — combine into a single loop with .reduce() or for...of

Combine `.map().filter()` (or similar chains) into a single pass with `.reduce()` or a `for...of` loop to avoid iterating the array twice

Rule: `js-combine-iterations`
</violation>

</file>

Reviewed by reactreview for commit b382d61. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 20, 2026 9:21pm

@aidenybai aidenybai marked this pull request as ready for review May 17, 2026 10:52
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Comment thread packages/react-doctor/src/index.ts Outdated
Both callers of `checkDeadCode` previously passed only
`rootDirectory`, so deslop ran without any exclusion patterns. That
meant files the user had told react-doctor (or any of the standard
ignore files) to skip still entered deslop's reachability graph,
producing false-positive `unused-file` warnings and distorting
which exports were considered reachable.

`checkDeadCode` now resolves the effective ignore set itself by
unioning:

- `.gitignore` (collectIgnorePatterns intentionally omits this since
  oxlint reads it automatically — deslop does not)
- `.eslintignore` / `.oxlintignore` / `.prettierignore`
- `.gitattributes` `linguist-vendored` / `linguist-generated`
- `userConfig.ignore.files`
- any caller-supplied `ignorePatterns`

The two callers (`inspect()` and `diagnose()`) now just forward
`userConfig` and let `checkDeadCode` figure out the rest.

Reported by Cursor Bugbot on #291.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Comment thread packages/core/src/check-dead-code.ts
`Diagnostic.filePath` is consumed downstream by picomatch-based
ignore-pattern matching (e.g. `isFileIgnoredByPatterns`,
`isDiagnosticIgnoredByOverrides`), which only understands POSIX
separators. On Windows, `path.relative` returns backslash-separated
paths like `src\\foo.ts` — those would silently fail to match
`src/**` overrides.

Route the rewrite through the existing `toRelativePath` helper
(which already does the `\\` → `/` normalization) so deslop
diagnostics behave identically to oxlint diagnostics on every
platform. Added a POSIX-separator regression test.

Reported by Cursor Bugbot on #291.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Dead-code analysis is additive — the user can still get a clean lint
scan, the score, and the full diagnostic list even when deslop
crashes. A scary red error block on top of an otherwise-fine scan is
the worst possible outcome. Failure modes are dominated by
user-environment issues the scanner cannot recover from but the user
can (missing `node_modules`, malformed `tsconfig.json`, parser
crash on an exotic source file, ENOENT race against an editor delete,
unsupported workspace layout, …).

Move all of that behind a new `check-dead-code-errors.ts` module
that owns the silent-fallback contract:

- A `DeadCodeAnalysisError` tag for call sites that need to
  distinguish a deslop failure from another rejection in a
  `Promise.all` chain.
- `formatDeadCodeFailureReason(error)` returns the string that
  belongs in `InspectResult.skippedCheckReasons["dead-code"]` so
  the format stays consistent across call sites and a fallback
  message kicks in if the error has no useful text.

Behavior changes at the call sites:

- `inspect()`: stop printing the red `logger.error` block and the
  `Dead-code analysis failed` fail-spinner. Finalize the spinner
  with the same "Analyzing dead code." text the success path uses
  so the failure is invisible.
- `inspect()`: stop pushing `"dead-code"` into `skippedChecks`
  even on failure — that array drives the user-visible
  "Note: <check> failed — score may be incomplete" banner and the
  top-of-output warning. The reason still flows into
  `skippedCheckReasons["dead-code"]` for JSON consumers and bug
  reports.
- `diagnose()`: drop the `console.error` so programmatic API
  consumers don't get unexpected stderr noise.

No tests added for the new errors module per the silent-fallback
contract being a pure documentation / single-source-of-truth move.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Comment thread packages/core/src/check-dead-code-errors.ts Outdated
The class was exported with JSDoc promising an `instanceof
DeadCodeAnalysisError` check, but no call site ever instantiated it
— `checkDeadCode()` lets errors propagate unwrapped and both
`inspect()` and `diagnose()` swallow raw errors per the
silent-fallback contract. Removing the class so the module stays
honest; `formatDeadCodeFailureReason()` is the only public surface
we actually need.

Reported by Cursor Bugbot on #291.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Picks up the cumulative improvements from 0.0.3..0.0.8 (six patch
releases) on top of the same public API — `defineConfig` and
`analyze` still take and return identical shapes, so no react-doctor
code changes are needed.

Full test suite (1178 tests), lint, and typecheck remain green
against the new binding.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Comment thread packages/react-doctor/src/inspect.ts Outdated
PR cleanup pass — minimal interface, no over-explanatory JSDoc, no
unused indirection. Net effect: 1061 → 810 insertions, one fewer
file, identical behavior. Full test suite (1174 tests) still passes.

- Delete `check-dead-code-errors.ts`. The whole module existed to
  wrap one call to `formatErrorChain` with a fallback message; the
  failure reason is no longer plumbed anywhere user-visible, so the
  helper has no consumer either.
- Drop `tsConfigPath` and `ignorePatterns` options on
  `CheckDeadCodeOptions`. Neither caller (`inspect()` or
  `diagnose()`) passes them — tsconfig auto-resolves and ignore
  patterns are auto-collected from userConfig.
- Drop `didDeadCodeFail` / `deadCodeFailureReason` tracking and
  the `skippedCheckReasons["dead-code"]` entry. Since the
  failure isn't surfaced anywhere user-facing per the silent-fallback
  contract, the plumbing was dead weight. Catch → return [] is the
  whole story.
- Drop the unused `deslop` entry from `PLUGIN_CATEGORY_MAP`.
  Every deslop Diagnostic sets `category` directly so the
  fallback lookup never runs.
- Inline the four single-use recommendation constants, the helper
  for `label` text on dependency / export diagnostics, and the
  spinner success-text repetition (moved into a `finally` block).
- Replace multi-paragraph JSDoc on `DiagnoseOptions.deadCode`,
  `InspectOptions.deadCode`, `ReactDoctorConfig.deadCode`,
  `extraDiagnostics`, and several internal helpers with one-line
  summaries. The rationale lives in commit messages.
- Slim `check-dead-code.test.ts` from 7 cases (175 lines) to 3
  focused smoke cases (76 lines): no-package-json, single
  unused-file + plugin/category/POSIX path assertions, and combined
  `.gitignore` + `userConfig.ignore.files` honoring.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Comment thread packages/core/src/check-dead-code.ts Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c63b169. Configure here.

Comment thread packages/react-doctor/src/inspect.ts Outdated
@NisargIO NisargIO closed this May 20, 2026
@aidenybai aidenybai reopened this May 20, 2026
aidenybai and others added 2 commits May 20, 2026 14:15
- Lazy-load `deslop-js` inside `checkDeadCode` (P1, reactreview).
  A missing/incompatible native oxc binding inside `deslop-js`
  used to crash module evaluation of `@react-doctor/core` (which the
  entire CLI imports) before the `--no-dead-code`, diff-mode skip,
  or the per-call try/catch fallback had a chance to run. Switched
  the static import to `await import("deslop-js")` so the caller's
  try/catch now catches the binding failure and degrades gracefully.

- Sequence the lint and dead-code spinners in `inspect()`.
  `checkDeadCode` and `runOxlint` still run in parallel under the
  hood, but the dead-code spinner now starts only after the lint
  spinner finalizes. Each `spinner()` call returns its own `ora`
  instance, so two concurrent starts would both animate on stderr
  and produce garbled output. Sync no-op `.catch` on the kicked-off
  promise prevents Node's unhandled-rejection warning if dead-code
  rejects before lint completes.

- Track dead-code failures in `skippedChecks` / `skippedCheckReasons`
  in both `inspect()` and `diagnose()`. The earlier silent swallow
  left programmatic consumers no way to discover that the pass
  didn't run to completion, and `inspect()` even rendered a green
  spinner checkmark on failure. Added `skippedChecks` (and the
  optional `skippedCheckReasons`) to `DiagnoseResult` to mirror
  `InspectResult`, forwarded both through `toJsonReport`, and
  flipped the dead-code spinner to `fail()` on error with a
  "non-fatal, skipping" message.

Co-authored-by: Cursor <cursoragent@cursor.com>
@aidenybai aidenybai merged commit aae328c into main May 20, 2026
7 checks passed
@aidenybai aidenybai deleted the cursor/add-deslop-dead-code-rules-cd02 branch May 20, 2026 21:29
cursor Bot pushed a commit that referenced this pull request May 20, 2026
main grew a `--dead-code` feature in commit aae328c that wires a
deslop-js-based reachability pass into the legacy `inspect()` /
`diagnose()` flow. After rebasing onto main, the rebase replayed
that feature on top of the runtime-rewired entry points, but the
new orchestration didn't yet know about dead-code. This commit
brings the feature back through the runtime so the streaming
pipeline picks up dead-code diagnostics with the same Layer-driven
contract every other axis already follows.

DeadCode Context.Service (runtime/dead-code.ts):

- compute(rootDirectory, userConfig): Effect<readonly Diagnostic[], Error>
- layerNode wraps @react-doctor/core's checkDeadCode (which
  delegates to deslop-js); failures are surfaced as Error so the
  orchestrator can fold them into didDeadCodeFail.
- layerNoop returns []; used when --no-dead-code or
  config.deadCode = false.
- layerOf(diagnostics) is the test surface; mirrors Linter.layerOf.

runInspect orchestration:

- New RunInspectInput.runDeadCode flag (the orchestrator
  additionally gates on !isDiffMode since reachability is
  whole-project, matching how checkReducedMotion is gated).
- RunInspectOutput.didDeadCodeFail / .deadCodeFailureReason mirror
  the lint failure pair, so callers distinguish 'skipped on
  purpose' from 'tried and failed'.
- Dead-code diagnostics flow through the same Stream.filterMap
  pipeline as lint and environment diagnostics: prepend env, then
  the lint stream, then the dead-code stream, then the per-element
  transform, then Reporter.emit.
- DeadCode failures are caught with Effect.matchEffect at the
  service boundary and folded into the deadCodeFailure Ref —
  never sinking the whole scan.

Public API:

- inspect.ts threads options.deadCode through ResolvedInspectOptions,
  passes runDeadCode to the runtime, and renders a separate
  'Analyzing dead code...' spinner line after lint finalizes (so
  two ora frame loops don't compete for stderr). didDeadCodeFail
  surfaces as skippedChecks: ['dead-code'] with
  skippedCheckReasons['dead-code'] in the InspectResult.
- index.ts (diagnose) wires the same runDeadCode flag and provides
  DeadCode.layerNode / layerNoop based on options.deadCode.
  DiagnoseResult now carries skippedChecks + skippedCheckReasons
  to match the new public type — programmatic consumers see exactly
  the same shape the legacy diagnose() returned post-#291.

Tests (3 new in runtime, 31 total; 4 new in react-doctor for the
dead-code feature itself, 1,202 total there):

- runtime/tests/dead-code.test.ts: layerOf returns the supplied
  diagnostics; layerNoop returns []; service shape supports
  Error-typed failure channel.
- runtime/tests/run-inspect.test.ts: every test layer stack now
  includes DeadCode.layerOf([]) so the runtime's expanded service
  graph is satisfied; baseInput sets runDeadCode: false to match
  the test scenarios that don't exercise dead-code.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants