Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 1 addition & 4 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
"eslint-plugin-react-doctor",
"oxlint-plugin-react-doctor",
"@react-doctor/core",
"@react-doctor/api",
"@react-doctor/language-server",
"deslop-js",
"deslop-cli"
"@react-doctor/api"
]
],
"linked": [],
Expand Down
10 changes: 10 additions & 0 deletions .changeset/few-berries-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"oxlint-plugin-react-doctor": patch
"react-doctor": patch
---

Keep the interactive score header intact in narrow split views and invalidate locally stale scan results when rule implementations change.

Report standalone Three.js render loops that use `requestAnimationFrame` instead of the renderer-managed `setAnimationLoop` API.

Include standalone Three.js, supported React framework, Remotion, and React Three Fiber ecosystem packages in automatic workspace project discovery.
5 changes: 5 additions & 0 deletions .changeset/quiet-agent-stop-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Run installed Claude Code and Cursor hooks once at the end of an agent turn, include untracked files in the changed-file scan, and migrate existing per-tool React Doctor hooks automatically.
6 changes: 6 additions & 0 deletions .changeset/react-cleanup-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"react-doctor": patch
"oxlint-plugin-react-doctor": patch
---

Make React cleanup a first-class part of React Doctor with diagnostics for complex React functions and repeated JSX composition. Keep whole-project unused file, export, type, dependency, and import-cycle analysis as explicit opt-in rules while removing the separate Deslop packages, experimental language server, and IDE extensions.
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,6 @@ jobs:
if: ${{ matrix.os == 'windows-latest' }}
run: pnpm test --concurrency=1

# deslop-js/-cli assert on POSIX-separator paths, so their suites are
# build-only on Windows (mirrors the deslop upstream matrix).
- run: pnpm test:deslop
if: ${{ matrix.os != 'windows-latest' }}

# lint + typecheck run as dedicated jobs in code-quality.yml; don't pay for
# them again here.
- name: Check formatting
Expand Down
17 changes: 8 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ packages/
refs.ts Context.Reference for ambient env config
run-inspect.ts streaming orchestrator (the heart)
build-diagnostic-pipeline per-element filter pipeline (single source of truth)
services/ 10 Context.Service classes (Files, Git, Project,
Config, Linter, DeadCode, Score, Reporter, Progress,
NodeResolver, StagedFiles) + LintPartialFailures
services/ Context.Service implementations (Files, Git, Project,
Config, Linter, Maintainability, Score, Reporter,
Progress, NodeResolver, StagedFiles, SupplyChain)
+ LintPartialFailures
... rest of the lint / score / suppression engine
api/ PRIVATE programmatic diagnose() (Effect.runPromise shell)
react-doctor/ PUBLISHED CLI + public inspect() + bin
Expand Down Expand Up @@ -157,7 +158,7 @@ for this codebase) for canonical examples.
- `layerCapture` for the test layer that records calls into a `Ref` exposed via a
sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`).
- `layerNoop` for the production layer that has void-return / discard semantics
(Reporter, Progress). Analyzers (Linter, DeadCode) use `layerOf([])` instead.
(Reporter, Progress). Analyzers (Linter, Maintainability) use `layerOf([])` instead.
- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`.

### Schemas
Expand Down Expand Up @@ -228,9 +229,7 @@ for spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span.
metrics exporter passes `maxBatchSize: "disabled"` internally, which skips
Effect's empty-buffer short-circuit — it POSTs on every scope close whether or
not anything was recorded, so on a firewalled machine that request cannot fail
fast. The language server overrides `exportIntervalMs` (see
`LSP_TELEMETRY_EXPORT_INTERVAL_MS`) because an editor session may never shut
down cleanly.
fast.
- **Anonymization.** Telemetry must stay anonymized, and OTLP has **no**
`beforeSend`-style hook — the safety net Sentry gave us for free had to be
rebuilt. Two mechanisms now carry it:
Expand Down Expand Up @@ -292,7 +291,7 @@ for spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span.
(`outcome.wouldBlock`/`outcome.blocking`/`outcome.clean`/`outcome.skippedChecks`),
findings (`diag.total`, `diag.errors`/`diag.warnings`, `diag.affectedFiles`,
`diag.distinctRules`, `diag.topRule`, per-category `diag.category.*`),
`score.value`/`score.label`/`score.available`, the `lint.*`/`deadCode.*`/
`score.value`/`score.label`/`score.available`, the `lint.*`/`maintainability.*`/
`supplyChain.*` pass outcomes, `timing.*` durations, and the CI/PR specifics
(`action.actorAssociation`, `action.runnerOs`, and the forwarded action knobs
`action.comment`/`action.reviewComments`/`action.versionPin`). Typing matters
Expand All @@ -305,7 +304,7 @@ for spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span.
outcome dimensions on the wide event (wrapped in `withNamespace`), **not** new
counters — the `scan.completed`/`scan.duration`/`rule.fired` counters stay as
the cheap floor alongside `cli.invoked`/`cli.error`. Score reachability is
derivable (`!score.available && !lint.failed && !deadCode.failed && !scan.noScore`)
derivable (`!score.available && !lint.failed && !maintainability.failed && !scan.noScore`)
and score latency is the `Score.compute` child span's duration, so neither
needs a dedicated field. CI detection + the official-action marker and
forwarded inputs live in `cli/utils/is-ci-environment.ts`; `action.yml` sets the
Expand Down
5 changes: 2 additions & 3 deletions docs/HOW_TO_WRITE_A_RULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ Resource-informed implementation rules:
- From Babel: nested structures are common; explicitly prune or model nested functions, classes, and blocks.
- From OXC/Babex: expect Babel-compatible vocabulary, but verify parser-specific node shapes for TypeScript, JSX, optional chaining, and computed members.
- From React Compiler: document unsupported JavaScript/control-flow cases instead of pretending every pattern is modeled soundly.
- From Deslop: think in confidence tiers; strong diagnostics should be high-confidence findings.
- Think in confidence tiers; strong diagnostics should be high-confidence findings.

## AST Vocabulary

Expand Down Expand Up @@ -924,7 +924,6 @@ Spend 1-2 hours asking an agent Q/A about these resources. Use them to build AST
| Babel handbook | https://github.com/jamiebuilds/babel-handbook | ASTs, visitors, paths, scopes, bindings, traversal state, nested structures, and plugin testing patterns. |
| Babel plugin handbook | https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md | Practical plugin authoring guidance: visitors, path APIs, scope/binding lookups, traversal performance, and unit testing. |
| OXC | https://github.com/oxc-project/oxc | Oxlint/parser context, high-performance AST tooling, and rule implementation patterns to compare against React Doctor rules. |
| Deslop | https://github.com/millionco/deslop-js/ | Confidence tiers, syntactic vs semantic findings, structured analysis errors, and CI-gating strategy for high-signal findings. |
| React Compiler | https://github.com/facebook/react/tree/main/compiler | React rule semantics, conservative modeling, control-flow needs, React rules validation, and why unsupported JavaScript features should be explicit non-goals. |
| React Doctor | https://github.com/millionco/react-doctor | Product context: deterministic React scans across state/effects, performance, architecture, security, and accessibility. |
| Babex | https://github.com/millionco/babex | Babel-compatible APIs backed by OXC; useful for understanding parser/traverse compatibility and the shape of fast AST tooling. |
Expand All @@ -947,5 +946,5 @@ Resource takeaways:
- Traversal is expensive. Prefer a single visitor or direct child lookup when that is enough.
- Nested structures are easy to mishandle. Explicitly skip nested functions unless the rule intends to inspect them.
- React Compiler is conservative about unsupported or hard-to-model JavaScript. React Doctor rules should also document v1 unsupported cases.
- Deslop-style confidence tiers are a useful mental model: only high-confidence findings should block or produce strong diagnostics.
- Confidence tiers are a useful mental model: only high-confidence findings should block or produce strong diagnostics.
- Babex shows the compatibility target: Babel-style parse/traverse APIs can sit on top of OXC, so rule authors should understand both Babel vocabulary and OXC-powered parsing.
File renamed without changes.
File renamed without changes.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@
"performance:stress": "tsx scripts/performance/run-stress-performance.ts",
"performance:profile": "tsx scripts/performance/analyze-cpu-profile.ts",
"performance:memory": "tsx scripts/performance/analyze-heap-profile.ts",
"test": "turbo run test --filter=react-doctor --filter=@react-doctor/core --filter=@react-doctor/api --filter=@react-doctor/language-server --filter=oxlint-plugin-react-doctor --filter=eslint-plugin-react-doctor --filter=@react-doctor/fuzz --filter=@react-doctor/evals",
"test": "turbo run test --filter=react-doctor --filter=@react-doctor/core --filter=@react-doctor/api --filter=oxlint-plugin-react-doctor --filter=eslint-plugin-react-doctor --filter=@react-doctor/fuzz --filter=@react-doctor/evals",
"fuzz": "pnpm --filter @react-doctor/fuzz fuzz",
"test:deslop": "turbo run test --filter=deslop-js --filter=deslop-cli",
"test:public-react-repos": "REACT_DOCTOR_PUBLIC_REPOS=1 vp test run packages/react-doctor/tests/public-react-repos.test.ts",
"typecheck": "turbo run typecheck",
"lint": "vp lint",
Expand Down
10 changes: 8 additions & 2 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import {
createOxlintSpawnSlots,
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
DeadCode,
detectAiTrainingEnvironment,
Files,
Git,
hasReactRuntime,
layerUserOtlp,
Linter,
LintPartialFailures,
Maintainability,
mapWithConcurrency,
mergeReactDoctorConfigs,
OxlintConcurrency,
Expand All @@ -26,6 +26,7 @@ import {
restoreLegacyThrow,
runInspect,
Score,
shouldUseMaintainabilityLayer,
SupplyChain,
type InspectOutput,
type ResolvedScanTarget,
Expand Down Expand Up @@ -90,7 +91,12 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => {
return Layer.mergeAll(
Project.layerNode,
configLayer,
input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]),
shouldUseMaintainabilityLayer({
shouldRunDuplicateJsx: input.shouldRunDeadCode,
userConfig: input.config,
})
? Maintainability.layerNode
: Maintainability.layerOf([]),
Files.layerNode,
Git.layerNode,
input.shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]),
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/project-analysis-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { startProjectAnalysisWorker } from "../../core/src/start-project-analysis-worker.js";

startProjectAnalysisWorker();
49 changes: 49 additions & 0 deletions packages/api/tests/diagnose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,55 @@ describe("diagnose({ projects })", () => {
expect(result.projects[0].ok).toBe(true);
});

it("runs explicitly enabled project rules through the public API", async () => {
const projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-project-analysis-"));
const sourceDirectory = path.join(projectDirectory, "src");
fs.mkdirSync(sourceDirectory);
fs.writeFileSync(
path.join(projectDirectory, "package.json"),
JSON.stringify({
name: "project-analysis-api-test",
main: "src/index.ts",
dependencies: { react: "19.2.5" },
}),
);
fs.writeFileSync(
path.join(sourceDirectory, "index.ts"),
'import { usedValue } from "./library.js";\nconsole.log(usedValue);\n',
);
fs.writeFileSync(
path.join(sourceDirectory, "library.ts"),
"export const usedValue = 1;\nexport const unusedValue = 2;\n",
);
try {
const result = await diagnose({
projects: [
{
directory: projectDirectory,
config: { rules: { "react-doctor/unused-export": "warn" } },
},
],
deadCode: false,
lint: false,
});

const projectResult = result.projects[0];
expect(projectResult.ok).toBe(true);
if (!projectResult.ok) return;
expect(projectResult.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
plugin: "react-doctor",
rule: "unused-export",
message: expect.stringContaining("unusedValue"),
}),
]),
);
} finally {
fs.rmSync(projectDirectory, { recursive: true, force: true });
}
});

it("respects batch config lint: false", async () => {
const result = await diagnose({
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
Expand Down
6 changes: 4 additions & 2 deletions packages/api/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
pack: [
{
entry: { index: "./src/index.ts" },
entry: {
index: "./src/index.ts",
"project-analysis-worker": "./src/project-analysis-worker.ts",
},
deps: {
neverBundle: [
"deslop-js",
"effect",
"oxc-parser",
"oxc-resolver",
Expand Down
13 changes: 12 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,26 @@
"@astrojs/compiler": "^4.0.0",
"@effect/platform-node-shared": "4.0.0-beta.102",
"@jridgewell/trace-mapping": "^0.3.31",
"acorn": "^8.18.0",
"acorn-jsx": "^5.3.2",
"browserslist": "^4.28.1",
"confbox": "^0.2.4",
"deslop-js": "workspace:*",
"effect": "4.0.0-beta.102",
"eslint-plugin-react-hooks": "^7.1.1",
"fast-glob": "^3.3.3",
"jiti": "^2.7.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-mdx-expression": "^2.0.1",
"mdast-util-mdx-jsx": "^3.2.0",
"mdast-util-mdxjs-esm": "^2.0.1",
"micromark-extension-mdx-expression": "^3.0.1",
"micromark-extension-mdx-jsx": "^3.0.2",
"micromark-extension-mdxjs-esm": "^3.0.0",
"oxc-parser": "^0.143.0",
"oxc-resolver": "^11.24.2",
"oxlint": ">=1.77.0 <1.78.0",
"oxlint-plugin-react-doctor": "workspace:*",
"parse5": "^8.0.1",
"picomatch": "^4.0.4",
"semver": "^7.7.4",
"typescript": ">=5.0.4 <7"
Expand Down
Loading
Loading