feat: add dead-code analysis via deslop-js#291
Merged
Conversation
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>
Copy as promptReviewed by reactreview for commit b382d61. Configure here. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
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>
`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>
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>
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
…d-code-rules-cd02
- 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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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— apackage.jsondep that is never imported.deslop/circular-dependency— an import cycle between two or more files.Controls
--no-dead-codeskips the pass entirely;--dead-codere-enables it."deadCode": falsemirrors--no-dead-code."rules": { "deslop/unused-export": "off" }or"categories": { "Dead Code": "off" }works out of the box.ignore.files,ignore.overridesall apply just like any other diagnostic.Dead-code reachability is a whole-project property, so the pass is automatically skipped in
--diff/--stagedmodes — a diff scan can't tell whether a deleted importer just stopped using a file or whether the file became unreachable. This matches howcheckReducedMotionis gated incombineDiagnostics.Changes by package
@react-doctor/corecheckDeadCode()(packages/core/src/check-dead-code.ts) wrapsdeslop'sanalyze()and maps eachScanResultentry to aDiagnostic(plugin"deslop", category"Dead Code", severity"warning").combineDiagnosticsgained anextraDiagnosticschannel so the async pass feeds the same suppression / severity pipeline as lint.validateConfigTypesknows about the new booleandeadCodefield.PLUGIN_CATEGORY_MAPmapsdeslop→"Dead Code"for the rare case an external tool emits a rawdeslop/*diagnostic without a category.deslop-js@^0.0.2as a dependency.@react-doctor/typesdeadCode?: booleanonReactDoctorConfig,InspectOptions, andDiagnoseOptions.react-doctorinspect()anddiagnose()run the dead-code pass in parallel withrunOxlint; failures degrade gracefully and add a"dead-code"entry toskippedChecks/skippedCheckReasonsinstead of failing the whole scan.--dead-code/--no-dead-code.Tests
packages/react-doctor/tests/check-dead-code.test.tsregression suite covering empty projects, unused files, unused exports, and unused dependencies.extraDiagnosticscase tocombine-diagnostics.test.ts.pnpm lintandpnpm typecheckboth clean.Smoke test
Running
react-doctoragainst thebasic-reactfixture now shows entries like:and
--no-dead-codezeroes them out.