Commit Graph

1865 Commits

Author SHA1 Message Date
Aiden Bai 29b7229ea1 0.2.0-beta.4 2026-05-15 07:13:11 -07:00
Aiden Bai 10d5de804f 0.2.0-beta.3 2026-05-15 07:11:24 -07:00
Aiden Bai 9783acf525 Fix no-barrel-import index false positives (#253) 2026-05-15 07:07:52 -07:00
Aiden Bai 809e38ceba pub 2026-05-15 07:01:35 -07:00
Aiden Bai 0cd93551a4 fix: narrow effect event handler detection (#256) 2026-05-15 06:47:09 -07:00
Aiden Bai bca5d30fc5 fix(oxlint): collapse duplicate diagnostics + recover diagnostics from large monorepo projects (eval-driven) (#262) 2026-05-15 06:46:36 -07:00
Aiden Bai ffbd20f3d0 fix: suppress local use hook false positives (#257) 2026-05-15 06:22:35 -07:00
Aiden Bai bfaf9c9530 fix: avoid React 19 rule false positives on React 18 (#254) 2026-05-15 05:40:34 -07:00
Aiden Bai 6bc33c8aab fix: treat return guards as render state reads (#255) 2026-05-15 05:40:07 -07:00
Aiden Bai d23137e177 test: add fixed issue regression coverage (#258) 2026-05-15 02:23:03 -07:00
Aiden Bai 92829c7123 chore: format TODOS.md to fix format:check on main 2026-05-15 02:15:22 -07:00
Aiden Bai 0a52ca3d05 fix 2026-05-15 01:47:20 -07:00
Aiden Bai 9902ea671d TODOS.md 2026-05-15 01:45:59 -07:00
Aiden Bai 6e2ee9d474 refactor(cli): reorganize cli/ into commands/utils + port from react-grab (#250) 2026-05-15 00:50:42 -07:00
Aiden Bai f0198e2f2d refactor: extract @react-doctor/{types,project-info,core} internal packages + cleanup (#249) 2026-05-14 22:22:19 -07:00
Aiden Bai 4d622970b5 Revert "pub"
This reverts commit 4d9d1a5fcf.
2026-05-14 22:05:22 -07:00
Aiden Bai 4d9d1a5fcf pub 2026-05-14 22:03:02 -07:00
Aiden Bai 12d9bf75ab chore: drop .fallowrc.json + fallow devDep (one-time sweep, not committing to fallow integration) (#247) 2026-05-14 19:34:29 -07:00
Aiden Bai 6d8ba1b993 refactor: rip out knip / dead-code-detection integration (#246)
react-doctor is dropping dead-code analysis. knip exists as a standalone
tool; users who want it can run `knip` themselves. This removes the
entire integration: subprocess runner, config loading, filter caching,
result merging, CLI flag, types, and tests.

## Source files deleted (7)

- `src/core/run-knip.ts` (224 LOC)
- `src/core/collect-unused-file-paths.ts`
- `src/core/sanitize-knip-config-patterns.ts`
- `src/core/has-knip-config.ts`
- `src/core/is-likely-build-entry.ts` (only consumer was the knip
  auto-suppression branch in merge-and-filter-diagnostics)
- `src/types/knip.ts`
- `src/knip.d.ts`

## Test files deleted (5)

- `tests/run-knip.test.ts`
- `tests/has-knip-config.test.ts`
- `tests/sanitize-knip-config-patterns.test.ts`
- `tests/collect-unused-file-paths.test.ts`
- `tests/extract-failed-plugin-name.test.ts` (was knip-specific)

## Modified files (26)

- `src/core/inspect.ts` — drop `deadCode` option, the entire
  `deadCodePromise` block, `runKnip` import, `didDeadCodeFail`
  tracking, and the "dead code" skip-list entry. `Promise.all` over
  one promise → direct `await`.
- `src/core/combine-diagnostics.ts` — drop `deadCodeDiagnostics`
  parameter
- `src/core/merge-and-filter-diagnostics.ts` — drop the
  `if (plugin === "knip" && rule === "files")` auto-suppression branch
  and its `isLikelyBuildEntry` cache
- `src/types/inspect.ts`, `types/diagnose.ts`, `types/config.ts` —
  drop `deadCode?: boolean` fields and `entryFiles?` (knip-only)
- `src/cli/cli-flags.ts`, `cli/resolve-cli-inspect-options.ts`,
  `cli/index.ts`, `cli/commands/inspect.ts` — drop `--dead-code` /
  `--no-dead-code` flags
- `src/core/validate-config-types.ts` — drop validator entries for
  `deadCode` and `entryFiles`
- `package.json` — drop `"knip"` from `keywords` and from
  `dependencies`
- Various test files — drop knip-related assertions

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm test` — 75 test files / **688 / 688 pass** (was 80/734 — 5
  test files and 46 tests removed with knip)
- `pnpm lint` — 0 warnings, 0 errors
- `pnpm format` — clean
- Diagnostic-snapshot diff vs `origin/main` — byte-identical 262 lines
  (the snapshot only covers `runOxlint` output; knip was a separate
  diagnostic source that didn't contribute on the test fixtures)

## Stats

- **39 files changed, +43 / -1700 net (1657 line reduction)**
- 13 files deleted
- One full dependency dropped (`knip` from package.json)
2026-05-14 19:29:04 -07:00
Aiden Bai 575a245179 chore(dead-code): fallow sweep — drop 2 dead constants, dedupe RuleSeverity, fix test exports (#245)
Ran fallow (https://github.com/fallow-rs/fallow) against the repo for
dead-code analysis. Acted on the genuine findings, skipped false
positives.

## Genuine dead code (deleted)

- **`USE_EFFECT_EVENT_MIN_MAJOR`** (`src/constants.ts`) — only referenced
  in a comment, never in actual code. Real dead.
- **`EM_DASH_CHARACTER`** (`src/plugin/constants/design.ts`) — was used
  by the `no-em-dash-in-jsx-text` rule that #242 deleted as an orphan;
  stale leftover.

## Test helpers (made internal)

- `tests/run-oxlint/_helpers.ts` had `FIXTURES_DIRECTORY` and
  `findDiagnosticsByRule` marked `export const`, but neither is consumed
  outside the file. Stripped the `export` keyword on both — internal use
  still works, dead-export warning gone.

## Duplicate export (renamed)

- `RuleSeverity` was declared in two places with different definitions:
  - `plugin/utils/rule.ts`: `"error" | "warn"` (plugin's defineRule
    type — never "off" because off rules aren't registered)
  - `core/runners/oxlint/types.ts`: `"error" | "warn" | "off"` (oxlint
    config entries — "off" explicitly disables a rule)

  These are related but semantically distinct. Unified by making the
  oxlint version DERIVE from the plugin type: `OxlintRuleSeverity =
  RuleSeverity | "off"`. Renamed all oxlint-layer usages to
  `OxlintRuleSeverity` to make the distinction clear in code. Plugin's
  `RuleSeverity` remains the single source of truth.

## Tool integration

- Added `fallow` as a devDependency
- Added `.fallowrc.json` with this project's entry points
  (`src/cli/index.ts`, `src/index.ts`, `src/plugin/react-doctor-plugin.ts`,
  `src/eslint-plugin.ts`, `scripts/*.mjs`) and `tests/fixtures/**`
  ignored (those are intentional broken-code fixtures)

Run `pnpm exec fallow --summary` from the package to re-audit.

## Not acted on (false positives or out-of-scope)

- 5 `name` field warnings on `Error` subclasses in `src/errors.ts` —
  the `name` property is consumed by the JS runtime when formatting
  errors, not by direct code references. False positive.
- 2 "unlisted dependency" warnings (`undici`, `vite-plus`) — both are
  available transitively. Adding them to `dependencies` would change
  install behavior, which is outside the dead-code-sweep scope.
- 4 "unresolved import" warnings in `tests/fixtures/**` — intentional
  broken-code fixtures used by suppression / resilience tests. Already
  in `.fallowrc.json` ignorePatterns; the next fallow run will skip.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm test` — 734 / 734 pass
- `pnpm lint` — 0 warnings, 0 errors
- `pnpm format` — clean
- Diagnostic-snapshot diff vs `origin/main` — byte-identical 262 lines

## Stats

- 11 files changed, +115 / -28 net (most of the +/- is the
  `OxlintRuleSeverity` rename touching 5 files)
- Net deletion of 2 dead constants + 2 unnecessary exports
2026-05-14 19:23:56 -07:00
Aiden Bai 7169551251 refactor: tier 1 cleanup — drop dead fields, flatten core/, inline single-use utils (#244)
* refactor: tier 1 cleanup — drop dead fields, flatten core/, inline single-use utils

Five structural simplifications in one PR, all zero-behavior-change
(verified: byte-identical 262-line diagnostic snapshot vs `main`).

## 1. Drop `examples` field on Rule

The `examples: RuleExample[]` slot was authored across all 178 rules in
PR #234 but nothing ever consumed it. Pure storage / maintenance burden.
Removed the field from the interface, deleted `RuleExample` type, and
stripped the field from every rule via codemod.

## 2. Derive `framework` from bucket directory (no per-rule field)

The `framework` field was always 1:1 with the bucket directory:
- `nextjs/*.ts` → `framework: "nextjs"`
- `react-native/*.ts` → `framework: "react-native"`
- `tanstack-start/*.ts` → `framework: "tanstack-start"`
- `tanstack-query/*.ts` → `framework: "tanstack-query"`
- everything else → `framework: "global"`

Pure redundancy. Updated the codegen (`scripts/generate-rule-registry.mjs`)
to compute it from the bucket directory and inject it into the registry
entry. Rule files no longer carry the field at all. 178 lines removed
across rule files; codegen gained the bucket→framework table.

## 3. Default `category` from bucket, preserve overrides

Same shape as #2 but with overrides — most rules in a bucket share a
category, but a few (e.g. `tanstack-start-no-secrets-in-loader`,
`nextjs-no-side-effect-in-get-handler`) override to `"Security"`.

Codegen now provides a bucket→default-category table, and rules only
ship the `category` field when overriding. Codemod stripped ~170 of the
178 `category:` declarations (left 4 overrides intact, verified by
diagnostic snapshot).

## 4. Inline single-use plugin utils

Helpers in `plugin/utils/` and `plugin/rules/<bucket>/utils/` with only
one importer got inlined back into their consumer. Pure file-count
reduction — utilities shared by 2+ rules stay.

- `plugin/utils/`: ~50 → 40 files
- `plugin/rules/*/utils/`: ~30 → 27 files

## 5. Flatten `core/` subdirectories

The 5 subdirectories (`core/config/`, `core/detection/`,
`core/diagnostics/`, `core/runners/`, `core/scoring/`) collapsed into a
flat `core/` directory. Subdirs weren't pulling weight — consumers always
imported by specific file name, never as a group. Now everything's one
level deep.

(Exception: `core/runners/oxlint/` stays nested — those 6 files form a
cohesive oxlint-config subcluster.)

200+ import statements updated across the codebase. `git mv` used so
history is preserved.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm test` — 80 test files / 734 / 734 pass
- `pnpm lint` — 0 warnings, 0 errors
- `pnpm format` — clean
- Diagnostic-snapshot diff vs `origin/main` — byte-identical 262 lines

## Stats

- 330 files changed, **+650 / -6312 net** (5662 line reduction)
- 18 files deleted (dead utils + the now-pointless RuleExample type)
- 12 renamed (the core/ flatten)
- 54 renamed-with-modifications (core/ flatten + import updates)

After this PR, the largest source-of-truth files are the 178 rule files
themselves; everything else is supporting infrastructure that's now
smaller and flatter.

* fix(codegen): emit multi-line registry entries so prettier doesn't re-wrap them

CI's `format:check` reformatted long single-line registry entries to
multi-line, which would have caused the codegen-overwrite-on-build to
loop forever. Pre-emit the multi-line shape so the file is byte-stable
across `pnpm gen → pnpm format → pnpm gen`.

Verified: MD5 unchanged across gen → format → gen cycle.
2026-05-14 18:50:14 -07:00
Aiden Bai 8393d9c8ee refactor: auto-register rules via codegen + split 3 kitchen-sink files (types, state-rules.test, run-oxlint.test) (#242)
* refactor: auto-register rules via codegen + split 3 kitchen-sink files (types, state-rules.test, run-oxlint.test)

Four structural cleanups in one PR. Pure file-organization — zero
behavior change, byte-identical 262-line diagnostic snapshot vs `main`.

## 1. Auto-register rules via codegen

`plugin/react-doctor-plugin.ts` shrinks from **403 → 14 lines**. Every
rule now declares its own `id: "<rule-id>"` field in its
`defineRule({...})` call; a build-time codegen script
(`scripts/generate-rule-registry.mjs`) scans every per-rule file and
emits `src/plugin/rule-registry.ts`, which is the only thing the plugin
imports.

Adding a new rule used to require editing 2 places (import line +
registry entry in `react-doctor-plugin.ts`) and silently dropped the
rule from the scan if you forgot. Now it's a single-file operation —
create `plugin/rules/<bucket>/<rule>.ts` with an `id` field, run
`pnpm gen`. The codegen fails loudly on missing-id or duplicate-id.

Wired into the package scripts so `pnpm typecheck`, `pnpm test`,
`pnpm build` all auto-run codegen. A `pnpm gen:check` script (CI-ready)
asserts the committed registry matches what codegen would produce.

Also deletes the orphan `plugin/rules/react-ui/no-em-dash-in-jsx-text.ts`
— it was authored but never registered in the plugin (dead code for 3+
PRs). Re-adding it properly is a feature commit, not a refactor.

## 2. `src/types.ts` (346 LOC) → `src/types/<domain>.ts`

Split the kitchen-sink types file into focused per-domain files
(`project-info.ts`, `diagnostic.ts`, `inspect.ts`, `diagnose.ts`,
`config.ts`, `knip.ts`, `prompts.ts`, `score.ts`, `handle-error.ts`).
Every consumer's import path updated to point at the specific domain
file. No barrel.

## 3. `tests/regressions/state-rules.test.ts` (2788 LOC) → per-rule files

Split the biggest single file in the repo into per-rule test files
under `tests/regressions/state-rules/<rule-id>.test.ts`. Shared setup
extracted to `state-rules/_helpers.ts`.

## 4. `tests/run-oxlint.test.ts` (983 LOC) → per-concern files

Split into per-concern files under `tests/run-oxlint/<concern>.test.ts`
(globals, nextjs, tanstack-start, tanstack-query, react-native,
categories, suppression, custom-rules-only, adopt-existing-lint-config,
architecture, async-performance, bundle-size, correctness,
performance, etc.). Shared helpers in `_helpers.ts`.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm test` — 80 test files (was 54) / **734 / 734 pass** (same count)
- `pnpm lint` — 0 warnings, 0 errors
- `pnpm format` — clean
- Diagnostic-snapshot diff vs `origin/main` — **byte-identical** 262 lines

## Stats

- 264 files changed, +302 / -4628 net (most of the deletion is the test
  splits collapsing duplicate imports + the 400-line plugin registry
  being replaced by a 14-line wrapper + a generated file)
- Plugin registry: 403 → 14 hand-written lines (plus 366 generated)

* fix(codegen): use POSIX separators in rule-registry import paths (Windows fix)

* fix(plugin): delete 16 dead bucket re-export barrels (rules/<bucket>.ts)

Bugbot caught these as orphaned after the auto-register refactor. The
codegen-built `rule-registry.ts` imports directly from per-rule files
(`plugin/rules/<bucket>/<rule>.ts`), so the bucket-level barrels
(`plugin/rules/architecture.ts`, `bundle-size.ts`, `client.ts`,
`correctness.ts`, `design.ts`, `js-performance.ts`, `nextjs.ts`,
`performance.ts`, `react-native.ts`, `react-ui.ts`, `security.ts`,
`server.ts`, `state-and-effects.ts`, `tanstack-query.ts`,
`tanstack-start.ts`, `view-transitions.ts`) are no longer reached by
any consumer. ~200 lines of pure dead re-exports.
2026-05-14 16:38:28 -07:00
Aiden Bai b71a492481 refactor: dead-file sweep + split 3 kitchen-sink files (constants, discover-project, cli/index) (#237)
* refactor: dead-file sweep + split 3 kitchen-sink files (constants, discover-project, cli/index)

Seven structural cleanups in one PR.

## Dead files removed (zero consumers)

- `plugin/utils/index.ts` (52-line barrel, never imported)
- `plugin/helpers.ts` (28-line shim from the original utils/ split)
- `plugin/types.ts` (9-line type-only shim, single consumer inlined)
- `core/runners/oxlint-config.ts` (11-line re-export barrel, 2 consumers
  now import directly from the underlying `oxlint/config.js` and
  `oxlint/react-doctor-rules.js`)

## `plugin/constants.ts` (831 lines) → `plugin/constants/<domain>.ts`

Split the kitchen-sink constants file into 11 domain-grouped sibling
files. Every rule now imports just the constants it actually uses, from
the specific domain file (no barrel):

- `constants/thresholds.ts` — numeric "too many" / "too big" cutoffs
- `constants/style.ts` — layout/CSS/motion property sets
- `constants/tanstack.ts` — TanStack Router / Query / Start
- `constants/react.ts` — React / React Compiler / effect hooks
- `constants/react-native.ts` — RN-specific
- `constants/nextjs.ts` — Next.js-specific
- `constants/security.ts` — secret/auth patterns
- `constants/library.ts` — heavy-library bundles, fetch callees
- `constants/dom.ts` — passive events, barrel suffixes
- `constants/js.ts` — JS-runtime constants
- `constants/design.ts` — Tailwind/design tokens

## `core/detection/discover-project.ts` (849 → ~142 lines)

Extracted each detection routine to its own focused file in
`core/detection/`. The top-level file is now a slim orchestrator that
composes the per-concern detectors.

## `cli/index.ts` (743 → ~102 lines) + `cli/commands/`

Extracted each subcommand handler to `cli/commands/<command>.ts`
(`inspect.ts`, `install.ts`). `cli/index.ts` is now just Commander setup
+ command registration + `program.parse(...)`.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm test` — 734 / 734 pass
- `pnpm lint` — 0 warnings, 0 errors
- `pnpm format` — clean
- Diagnostic-snapshot diff vs `origin/main` — **byte-identical** 262 lines

## Stats

- 150 files changed (109 modified, 5 deleted, 36 added)
- +171 / -2447 net lines deleted (mostly from the constants split where
  every rule went from one giant import to ~3 focused imports)

* fix(detection): hoist `.map().filter()` into single for...of loop in resolve-workspace-directories

Eats own dogfood on `js-combine-iterations`. The other 5 dogfood
warnings from React Review on this PR are either false positives of
`js-set-map-lookups` on `string.indexOf`/`string.includes` (not array
methods) or are intentional sequential UX in the inspect loop where
logger output between iterations must stay ordered.
2026-05-13 23:59:49 -07:00
Aiden Bai f85d980e1d refactor(oxlint): split rule-maps.ts into external-plugin-rules + react-doctor-rules (#236)
* refactor(oxlint): split rule-maps.ts into external-plugin-rules + react-doctor-rules

`oxlint/rule-maps.ts` was mixing two unrelated concerns: 4 hand-maintained
maps of rules adopted from upstream plugins (react, jsx-a11y,
react-hooks-js, you-might-not-need-effect) and 5 react-doctor-derived
constants computed from `plugin.rules`. Splits them into purpose-named
sibling files and deletes the misnamed `rule-maps.ts`:

- **`oxlint/external-plugin-rules.ts`** — the 4 upstream-plugin maps.
  Pure data, no derivation. Loaded by `createOxlintConfig` to merge into
  the generated config.

- **`oxlint/react-doctor-rules.ts`** — the 5 per-framework maps PLUS
  `ALL_REACT_DOCTOR_RULE_KEYS` and `FRAMEWORK_SPECIFIC_RULE_KEYS`. All
  derived at module load from the colocated `defineRule({...})` metadata
  on each rule. The two `*_RULE_KEYS` sets now derive **directly** from
  `plugin.rules` instead of going through the per-framework maps, which
  cuts one indirection and makes the source of truth explicit.

Public API unchanged — `oxlint-config.ts` re-exports the same names from
their new locations, so consumers (`run-oxlint.ts`,
`merge-and-filter-diagnostics.ts`, `eslint-plugin.ts`) don't move.

Verified: byte-identical 262-line diagnostic snapshot from `runOxlint`
against `basic-react`, `nextjs-app`, and `tanstack-start-app` fixtures
vs `origin/main`. 734 / 734 tests pass.

* fix(rule-maps): use for...of instead of .filter().map() to satisfy js-combine-iterations
2026-05-13 23:06:05 -07:00
Aiden Bai b2dfc018d1 refactor(types): EsTreeNode = TSESTree.Node (drop loose [key: string]: any escape hatch) (#235)
Replaces the custom loose `EsTreeNode` interface (which exposed a
`[key: string]: any` index signature so any property access typechecked
as `any`) with a direct alias for `@typescript-eslint/types`'
`TSESTree.Node` discriminated union, with `parent` relaxed via the same
`WithLooseParent` helper that's already used by `EsTreeNodeOfType<T>`.

Net effect:
- **Native discriminated-union narrowing** — `if (node.type === "CallExpression") { node.callee }` now narrows `node` automatically (no `isNodeOfType` ceremony required for inline cases).
- **Strict by default** — every property access on a non-narrowed `EsTreeNode` is a typecheck error. Catches missing narrowings at compile time the way TS catches missing imports.
- **`isNodeOfType` still works** — it's now just a runtime-safe equivalent of `node?.type === "X"` that narrows through opaque inputs (e.g. `.find` results). Most existing call sites keep it for readability.

To get to 0 errors, three classes of fixes landed:

1. **Visitor-parameter retype** (codemod): 222 visitor methods across 172
   rule files had their `(node: EsTreeNode)` annotation tightened to
   `(node: EsTreeNodeOfType<"<SelectorKey>">)`. The visitor's selector
   IS the node type the handler receives, so this is mechanical.

2. **`RuleVisitors` parameter loosened to `any`**. Function-type
   parameters are contravariant — `(node: CallExpression) => void` is
   NOT assignable to `(node: EsTreeNode) => void`. Typing the visitor's
   `any` parameter sidesteps variance while keeping `EsTreeNode` strict
   everywhere else. Scoped to the visitor entry-point only.

3. **Per-call-site narrowings** for the remaining ~282 sites where
   helper functions or nested member accesses needed an `isNodeOfType`
   guard (or further narrowing of an already-narrowed union like
   `Expression` → `ArrowFunctionExpression`). Each fix is a tightening
   — preserves the original behavior on the cases the code handled,
   silently no-ops on the cases the loose `any` previously let through.

Verified: byte-identical 262-line diagnostic snapshot from `runOxlint`
against `basic-react`, `nextjs-app`, and `tanstack-start-app` fixtures
vs `origin/main`. 734 / 734 tests pass.
2026-05-13 22:49:23 -07:00
Aiden Bai 6b357778f0 refactor(rules): final colocation pass (requires/tags + examples + tighten interface) (#234)
Three changes in one PR — the last seams in the per-rule metadata model:

**1. Colocate `RULE_METADATA` (requires + tags) into each `defineRule({...})`**

Drops the 166-line `oxlint/rule-metadata.ts` table that gated framework-
specific rules by capability (`react:19`, `nextjs`, `tailwind:3.4`, …)
and grouped them by behavioral tag (`test-noise`, `design`). Moves
every entry onto the matching rule:

```ts
export const noReact19DeprecatedApis = defineRule<Rule>({
  category: "Architecture",
  framework: "global",
  severity: "warn",
  requires: ["react:19"],
  tags: ["test-noise"],
  recommendation: "…",
  // …
  create: …
});
```

`createOxlintConfig` now reads `requires` + `tags` off the plugin
registry; `merge-and-filter-diagnostics` reads `tags` the same way for
auto-suppression of test-noise rules in test files. The
`rule-metadata.ts` file is deleted entirely.

**2. Tighten `Rule.framework` + `Rule.severity` to required**

Now that every rule ships both (via #231 + this PR's verification),
flipping them from optional to required catches "forgot to set framework
on a new rule" at compile time. The factory used by the deprecated-API
rules (`createDeprecatedReactImportRule`) is retyped to
`Pick<Rule, "create">` so the spread doesn't double-specify these
fields. The orphan `no-em-dash-in-jsx-text` rule (registered nowhere
but still typechecked) gets the required fields filled in to keep the
file valid.

**3. Add `examples` for every rule (178 hand-authored before/after pairs)**

The `Rule.examples: RuleExample[]` slot existed since the
`defineRule` introduction but was unused. Every rule now ships at
least one minimal before/after example next to its recommendation,
extracted from the rule's diagnostic message and `create` body. The
examples are deliberately short (≤4 lines each side) — they describe
the shape the rule fires on, not full real-world snippets.

**Phase 4 (strict `EsTreeNode` — dropping the `[key: string]: any`
escape hatch) was attempted in this branch and reverted: it exposed
747 type errors across ~150 rule files, which is genuinely a separate
PR's worth of work.** Filed as the natural next step.

**Verified runtime equivalence:** byte-identical 262-line diagnostic
snapshot from `runOxlint` against `basic-react`, `nextjs-app`, and
`tanstack-start-app` fixtures vs `origin/main`.

- 188 files changed, +1348/-206
- 734/734 tests pass, 0 lint warnings, 0 typecheck errors
2026-05-13 22:08:33 -07:00
Aiden Bai 2c1f7423c9 refactor(rules): colocate severity + framework with each defineRule call (#231)
Drops the 5 framework-keyed maps (`GLOBAL_REACT_DOCTOR_RULES`,
`NEXTJS_RULES`, `REACT_NATIVE_RULES`, `TANSTACK_START_RULES`,
`TANSTACK_QUERY_RULES`) in `oxlint/rule-maps.ts` and moves every entry
onto the matching rule's `defineRule({...})` call as `framework: "..."`
+ `severity: "..."` fields (next to the `category` and `recommendation`
that landed in #228 / #230). The 5 maps are now thin derived constants
computed at module load by filtering `plugin.rules` on each rule's
`framework` field, so every downstream consumer (`createOxlintConfig`,
`eslint-plugin.ts`'s flat configs) keeps the same public shape.

178 rule files updated. The single source of truth for "which framework
does this rule belong to" and "at what severity does it fire" now lives
next to the rule's `create` function — adding a new rule no longer
requires editing a separate map (and forgetting to is no longer how
rules silently miss the framework filter).

Verified runtime behavior: `runOxlint` produces a byte-identical 262-line
diagnostic snapshot against `basic-react`, `nextjs-app`, and
`tanstack-start-app` fixtures vs `main`.
2026-05-13 20:28:46 -07:00
Aiden Bai 4241e3d470 refactor(rules): colocate category with each defineRule call (#230)
Drops the 196-line `RULE_CATEGORY_MAP` table in `run-oxlint.ts` and moves
every entry onto the matching rule's `defineRule({...})` call as a
`category: "..."` field (next to the `recommendation` that landed in
#228). `run-oxlint.ts` now reads the category off the plugin registry at
diagnostic-rendering time, so output is unchanged but each rule has a
single obvious place for both its recommendation AND its category
bucket.

178 rule files updated; `run-oxlint.ts` shrinks by ~196 lines. No
behavior change — same category strings, same fallback through
`PLUGIN_CATEGORY_MAP` for non-react-doctor diagnostics, same
registration validator (now warning on any rule whose `defineRule`
call doesn't ship a `category`).
2026-05-13 19:59:58 -07:00
Aiden Bai d5bc247ea9 refactor(plugin): port inline node.type === "X" checks to isNodeOfType(node, "X") (#229)
* refactor(plugin): port inline `node.type === \"X\"` checks to `isNodeOfType(node, \"X\")`

Replaces 1127 inline `<chain>(?.|.)type ===/!== "TypeName"` checks across
the rules with calls to the existing `isNodeOfType(<chain>, "TypeName")`
type-guard. The helper narrows the chain to the real TSESTree node shape
so subsequent property access is type-checked; the inline string
comparison only checks at runtime (`EsTreeNode.type: string` doesn't
form a discriminated union, so `if (node.type === "Identifier")` did NOT
narrow `node` for TypeScript).

193 files touched. Same runtime behavior — but the new narrowing exposed
~42 latent type errors that were previously swallowed by the loose
`EsTreeNode[key: string]: any` index signature. Each was fixed by
adding the narrowing the code had implicitly assumed:

- `ImportSpecifier.imported` is `Identifier | StringLiteral` (ES2022
  module-string imports). Adds a `getImportedName(specifier)` helper
  that returns the name from either shape; 9 rule files now use it.
- `MemberExpression.property` is `PrivateIdentifier | Expression` —
  rules that wanted the property's name now narrow to `Identifier`
  first (e.g. `no-array-index-as-key`, `no-inline-prop-on-memo-component`,
  `rerender-functional-setstate`).
- `ObjectExpression.properties[]` is `(Property | SpreadElement | RestElement)[]` —
  rules that read `.key` / `.value` now narrow to `Property` first
  (`query-no-void-query-fn`, `query-no-use-query-for-mutation`,
  `walk-server-fn-chain`).
- `Literal.value` is `string | number | bigint | boolean | RegExp | null` —
  callers that fed it to a `string` API now `typeof === "string"`
  first (`client-passive-event-listeners`,
  `no-layout-property-animation`).
- A handful of other rules narrow chained `callee` / `init` /
  `expression` / destructuring-pattern elements that the loose type
  let through.

`isNodeOfType` is null-safe (delegates to `hasTypeProperty`) so all
optional-chain forms (`x?.type ===` etc.) are preserved in semantics.

* fix(rule): drop redundant `isNodeOfType` re-check on already-narrowed `node.id`

Bugbot caught a leftover narrowing check from the previous commit's fix:
the `if (!isNodeOfType(node.id, "ArrayPattern")) return;` on line 21 was
unnecessary because line 14 already narrows `node.id` to `ArrayPattern`,
and TypeScript preserves that narrowing across the intermediate
`node.init` accesses (`node.id` isn't reassigned). Tests + typecheck
remain green.

* fix(rule): preserve PrivateIdentifier name in `rerender-lazy-state-init` message

The previous narrowing fix only accepted `Identifier` as the
MemberExpression property, so `useState(obj.#method())` started
rendering as `useState(fn())` instead of `useState(method())`. The
original code reached `.name` directly off the loose `EsTreeNode` and
got the private-field name for free. Add an explicit `PrivateIdentifier`
narrowing alongside the `Identifier` one so the diagnostic message
keeps quoting the actual method name in both cases.

Cosmetic only — the rule still fires on the same code shape; only the
text inside the suggested replacement message changes.
2026-05-13 17:43:16 -07:00
Aiden Bai ca0ba6ea03 refactor(rules): colocate recommendation text with each defineRule call (#228)
* refactor(rules): colocate recommendation text with each defineRule call

Drops the 375-line `RULE_HELP_MAP` table in `run-oxlint.ts` and moves every
entry onto the matching rule's `defineRule({...})` call as a
`recommendation: "..."` field. `run-oxlint.ts` now reads the recommendation
off the plugin registry at lookup time, so the diagnostic-rendering path is
unchanged but each new rule has a single obvious place to put its
recommendation text (next to the rule logic it describes).

178 rule files updated; `run-oxlint.ts` shrinks by 365 net lines. No
behavior or output change — same recommendation strings, same registration
validator (now warning on any rule whose `defineRule` call doesn't ship a
`recommendation`).

* fix(plugin): move plugin object out of plugin/index.ts to avoid barrel-import warning

Renames `plugin/index.ts` → `plugin/react-doctor-plugin.ts` (where it's
actually constructed) and updates the 3 consumers (`eslint-plugin.ts`,
`run-oxlint.ts`, `vite.config.ts`) to import from the direct path. The
file's only export was the plugin object built in-place, so this isn't a
re-export barrel — but the `/index.js` suffix tripped react-doctor's own
`no-barrel-import` rule once a second importer landed in this PR.
Fixes the React Review warning at `run-oxlint.ts:22` introduced by the
previous commit.

* pub
2026-05-13 11:27:08 -07:00
Aiden Bai 6e9d682334 refactor(diagnose): use combineDiagnostics helper instead of inlining its body (#227)
`diagnose()` in src/index.ts manually inlined the exact same logic that
`combineDiagnostics` (the helper already used by `inspect()`) provides:

  1. Conditionally add `checkReducedMotion(directory)` diagnostics
     (skip when isDiffMode)
  2. Merge [...lint, ...deadCode, ...env]
  3. Pass through `mergeAndFilterDiagnostics` with the same options

Audit (see PR description) confirmed the two paths are functionally
identical -- `combineDiagnostics` was added later and `diagnose()`
never adopted it.

After this change:
  - Both `inspect()` and `diagnose()` go through the same merge helper
  - Future bug fixes / behavior tweaks only have to happen in one place
  - `diagnose()` no longer needs to import `checkReducedMotion` or
    `mergeAndFilterDiagnostics` directly

Net change: 1 file, +8/-8 (drop 2 imports, add 1; replace 8-line inline
with 8-line helper call). Functionally identical -- 734/734 tests pass.

This is the small, low-risk slice of the bigger inspect/diagnose
unification question. The remaining differences (subproject discovery,
nvm-Node detection, offline scoring, skipped-checks tracking) are
intentional behavioral choices that need product decisions, not
refactoring -- they stay out of scope here.
2026-05-13 10:57:22 -07:00
Aiden Bai 4e62a2c0e2 refactor: split oxlint-config.ts (722 LOC) into core/runners/oxlint/ (#226)
Same shape as the scan.ts split: the 722-line oxlint-config.ts mixed
five distinct concerns. Carved into six focused files under
src/core/runners/oxlint/, with oxlint-config.ts kept as a thin
re-export barrel so existing importers (run-oxlint.ts, eslint-plugin.ts,
merge-and-filter-diagnostics.ts, tests) keep working unchanged.

New layout:

  src/core/runners/oxlint/
    types.ts             11 LOC  RuleSeverity + OxlintConfigOptions
    rule-maps.ts        305 LOC  per-framework severity maps (NEXTJS_RULES,
                                 REACT_NATIVE_RULES, TANSTACK_START_RULES,
                                 TANSTACK_QUERY_RULES, GLOBAL_REACT_DOCTOR_RULES,
                                 REACT_COMPILER_RULES, YOU_MIGHT_NOT_NEED_EFFECT_RULES,
                                 BUILTIN_REACT_RULES, BUILTIN_A11Y_RULES,
                                 ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS)
    rule-metadata.ts    166 LOC  RULE_METADATA + tag constants + RuleMetadataEntry
    plugin-resolution.ts 110 LOC  resolveReactHooksJsPlugin / resolve-You-Might-...
                                  / readPluginRuleNames / filterRulesToAvailable
    capabilities.ts      56 LOC  buildCapabilities + shouldEnableRule
    config.ts            98 LOC  createOxlintConfig (the composer that pulls
                                 everything together)

  src/core/runners/oxlint-config.ts  12 LOC  re-export barrel

Compared to before: 722-line monolithic file -> 758 LOC total across 7
focused files, each under 305 LOC.

Internal-only types/helpers that were exported unnecessarily are now
unexported in their new homes (e.g. RuleMetadataEntry stays internal to
rule-metadata.ts; buildCapabilities/shouldEnableRule are internal to
the capabilities module).

Verification:
  - typecheck/lint/format clean
  - 734/734 tests pass
  - No public API change (barrel exposes the same surface)
  - run-oxlint.ts, eslint-plugin.ts, merge-and-filter-diagnostics.ts,
    and tests all import from the unchanged oxlint-config.js barrel
2026-05-13 10:50:24 -07:00
Aiden Bai 8bb54c9823 refactor: split src/utils/ into src/core/{config,detection,diagnostics,scoring,runners}/ + src/cli/ (#224)
* refactor: split src/utils/ (68 flat files) into src/core/{config,detection,diagnostics,scoring,runners}/ + src/cli/

Final big organizational refactor in the series (#218 plugin/, #220 cli/,
#221 scan/, #222 drop browser-poc, #223 dead-code). Carves the 68-file
src/utils/ dumping ground into purpose-built directories.

New layout:

  src/cli/             presentation (26 files)
    + prompts.ts, highlighter.ts, spinner.ts, logger.ts
    + colorize-by-score.ts, format-error-chain.ts
    + indent-multiline-text.ts, wrap-indented-text.ts
    + build-hidden-diagnostics-summary.ts
    + should-auto-select-current-choice.ts, should-select-all-choices.ts
    + detect-agents.ts, to-display-name.ts
    + (existing cli/ files: index.ts, render-*.ts, etc.)

  src/core/            core logic (7 files at root + 5 subdirs)
    build-json-report.ts                shared JSON reporting (CLI + public API)
    build-json-report-error.ts          shared JSON error envelope
    group-by.ts, is-file.ts             generic primitives
    is-plain-object.ts, to-relative-path.ts
    read-file-lines-node.ts

    config/ (11)       config loading, root-dir resolution, ignore patterns,
                       glob matching, gitattributes parsing, validation
    detection/ (6)     project discovery, react/tailwind major-version parsing,
                       package.json reading, monorepo root detection
    diagnostics/ (12)  combine/filter/suppress/merge, jsx-opener-span
                       lookup, disable-directive walks
    scoring/ (6)       local + remote score calc, score breakdown,
                       reduced-motion check, proxy fetch
    runners/ (13)      oxlint + knip + their config builders, include-path
                       computation, Node-version compat, diff-files

  src/plugin/          (unchanged from #218)
  scan.ts, index.ts, errors.ts, types.ts, constants.ts,
  oxlint-config.ts, eslint-plugin.ts, install-skill.ts    top-level

Driven by a one-shot codemod that performed the moves via `git mv` (so
all 68 file renames preserve blame/log history) and rewrote every
relative import in the package + tests (98 importer files touched) to
the new paths.

One non-mechanical fix needed after the move:
  src/core/runners/run-oxlint.ts resolved its built plugin via
  `../../dist/react-doctor-plugin.js` -- with 1 extra level of nesting
  that needs to be `../../../dist/react-doctor-plugin.js`.

One test fixed:
  tests/regressions/scan-resilience.test.ts read a source file by
  hardcoded path (`src/utils/calculate-score-locally.ts`) to assert no
  `fetch(` reference -- updated to the new location
  (`src/core/scoring/calculate-score-locally.ts`).

Verification:
  - 128 files changed, +237 / -222 (almost entirely import-path edits)
  - typecheck/lint/format clean
  - 734/734 tests pass
  - build produces identical dist/cli.js, dist/index.js, etc.
  - No public API change
  - src/utils/ no longer exists

* refactor: move orchestrators into tiers + rename scan -> inspect (#225)

* refactor: move install-skill, scan, oxlint-config out of src/ root into their tiers

Top-level src/ was still holding three orchestrator-grade files that
logically belong inside the existing tier directories. Move them so the
src/ root only contains public-API entry points and cross-cutting types.

Moves (via git mv, history preserved):
  src/install-skill.ts   -> src/cli/install-skill.ts          CLI subcommand
                                                              (already imported
                                                              6 things from cli/)
  src/scan.ts            -> src/core/scan.ts                  Orchestrator
                                                              (lives with the
                                                              logic it calls)
  src/oxlint-config.ts   -> src/core/runners/oxlint-config.ts Sibling of
                                                              run-oxlint.ts
                                                              (config + runner)

After: top-level src/ contains only the entry points and shared types:
  src/
  ├── cli/                  presentation
  ├── core/                 logic
  ├── plugin/               rules
  ├── constants.ts
  ├── errors.ts
  ├── eslint-plugin.ts      public-API entry
  ├── index.ts              public-API entry
  ├── knip.d.ts             type declaration
  └── types.ts

Imports rewritten via a small codemod (11 files):
  - 3 moved files' internal imports recomputed for new depth
  - 4 src/ consumers of the moved files updated
  - 4 test files updated

Verification:
  - 11 files changed, +39/-39 (all import path edits)
  - typecheck/lint/format clean
  - 734/734 tests pass
  - build produces identical dist/* output
  - run-oxlint.ts's relative path to dist/react-doctor-plugin.js
    (../../../) is unchanged -- run-oxlint.ts itself didn't move

Stacks on top of #224 (utils->core reorg). Once #224 merges, this PR's
base auto-rebases to main.

* refactor: rename scan -> inspect (function, file, types, test)

"scan" was a misnomer -- the function does much more than scan files: it
runs lint via oxlint, runs dead-code via knip, calculates a score,
merges/filters diagnostics, and renders CLI output. "inspect" describes
the role accurately and matches v2's chosen name.

Renames:
  src/core/scan.ts            -> src/core/inspect.ts             (git mv)
  tests/scan.test.ts          -> tests/inspect.test.ts           (git mv)
  scan() function             -> inspect() function
  runScan()                   -> runInspect()
  mergeScanOptions()          -> mergeInspectOptions()
  ResolvedScanOptions         -> ResolvedInspectOptions
  ScanResult                  -> InspectResult                   (src/types.ts)
  ScanOptions                 -> InspectOptions                  (src/types.ts)
  describe("scan", ...)       -> describe("inspect", ...)        (test)

Importers updated:
  src/cli/index.ts                                4 sites
  src/core/build-json-report.ts                   3 sites
  tests/inspect.test.ts                           5 sites
  tests/regressions/cli-and-output.test.ts        4 sites
  tests/build-json-report.test.ts                 2 sites

Kept as English prose (not identifiers):
  - "full scan", "scan output", "scan only files", "scan banner" in
    comments, CLI option descriptions, and prompt text -- these describe
    the action of scanning the codebase, not references to the function.
  - constants.ts JSX_OPENER_SCAN_MAX_LINES (about lexical JSX scanning,
    unrelated to inspect()).

Verification:
  - 7 files changed, +54/-54 (all renames)
  - typecheck/lint/format clean
  - 734/734 tests pass
  - No public API change (scan/ScanResult/ScanOptions weren't exported
    from src/index.ts -- the public API still calls the underlying
    function diagnose() which is unchanged)

Stacks on #225 (which moved scan.ts to src/core/). Once #225 merges,
this PR's base auto-rebases to main.

* fix: restore help-text example paths corrupted by the import codemod

The utils->core import-path codemod's regex matched `from '...'`
substrings inside string literals -- specifically the EXAMPLE code
shown to users in oxlint diagnostic help text. Three messages in
HELP_TEXT_MAP had their example paths mangled into the codemod's
target form (`../../utils/...`) even though they were supposed to
illustrate paths in the USER'S code, not paths internal to this
package.

Restored to the original example paths:

  no-barrel-import:
    `import { Button } from '../../utils/components/Button.js'`
    -> `import { Button } from './components/Button'`

  no-dynamic-import-path:
    `import('../../utils/feature/heavy.js')`
    -> `import('./feature/heavy.js')`

  nextjs-no-css-link:
    `import styles from '../../utils/Button.module.css.js'`
    -> `import styles from './Button.module.css'`

Verified no other string literals were corrupted (grepped for
`["'`]../../(utils|core|cli)/` across src/ -- all remaining hits are
legitimate plugin/utils imports from the per-rule split in #218, which
is its own unrelated `utils/` directory).

Closes the Bugbot finding on PR #224.

* refactor: move format-error-chain / logger / highlighter to core/ (fix layering)

Addresses Bugbot's "Core modules depend backwards on CLI layer" finding
on PR #224. Several core/ files were importing from cli/, undermining
the layered structure the refactor was introducing:

  core/build-json-report-error.ts        -> cli/format-error-chain
  core/runners/extract-failed-plugin-name -> cli/format-error-chain
  core/config/load-config                -> cli/logger
  core/config/read-ignore-file           -> cli/logger
  core/config/resolve-config-root-dir    -> cli/logger

The 3 utilities in question are all pure abstractions (no CLI-specific
behavior beyond using console.* for output), so they belong in core/
where both core AND cli consumers can import them without inversion.

Moves (git mv, history preserved):
  cli/format-error-chain.ts -> core/format-error-chain.ts  (Error.cause walker)
  cli/logger.ts             -> core/logger.ts              (silent-mode logger)
  cli/highlighter.ts        -> core/highlighter.ts         (picocolors wrapper)

22 files changed, +29/-29 -- all relative-import path rewrites driven
by a small codemod with a strict `^import ... from "..."` regex
(line-anchored to avoid the help-text-literal corruption bug from the
previous codemod, which triggered the bigger Bugbot follow-up).

After this:
  - core/ no longer imports from cli/ for utilities -- only
    core/inspect.ts still does (legitimately: it's the CLI orchestrator
    that calls render-*.ts + resolve-oxlint-node prompt + spinner)
  - cli/ files import the moved utilities via `../core/<name>.js`

Verification:
  - typecheck/lint/format clean
  - 734/734 tests pass
  - `rg "from .+cli/" src/core` shows only inspect.ts's legitimate
    imports of render-*, spinner, resolve-oxlint-node (all of which
    are CLI presentation/interaction, not utilities)
2026-05-13 10:39:21 -07:00
Aiden Bai ef2b7fe9f4 chore: dead-code sweep (16 -> 1 unused exports) (#223)
Audit after the refactor wave (#218 plugin/, #220 cli/, #221 scan/,
#222 drop browser-poc) found 16 zero-reference exports across src/. 15
of them were genuinely dead; 1 was kept (clearCaches is public API).

Removals:
  src/utils/is-rule-suppressed-at.ts                ENTIRELY ORPHANED:
                                                    no file imports it
                                                    (it just wraps
                                                    evaluateSuppression)

  src/constants.ts:
    REACT_19_DEPRECATION_MIN_MAJOR                  Defined for rule
                                                    version-gating that
                                                    inlined the numbers
                                                    in oxlint-config.ts
                                                    instead.
    REACT_DOM_LEGACY_API_MIN_MAJOR                  Same -- never wired up
    TAILWIND_SIZE_SHORTHAND_MIN_MAJOR               Same
    TAILWIND_SIZE_SHORTHAND_MIN_MINOR               Same
    (plus the orphaned HACK comment that described them)

Un-exported (interface/helpers only used inside own file):
  src/cli/parse-file-line-argument.ts ParsedFileLineArgument
  src/oxlint-config.ts                RuleMetadataEntry
                                      buildCapabilities
                                      shouldEnableRule
  src/utils/apply-ignore-overrides.ts CompiledIgnoreOverride
  src/utils/build-category-breakdown.ts CategoryBreakdownEntry
  src/utils/calculate-score-locally.ts ScoreBreakdown
  src/utils/evaluate-suppression.ts   SuppressionEvaluation
  src/utils/load-config.ts            LoadedReactDoctorConfig
  src/utils/parse-tailwind-major-minor.ts TailwindMajorMinor

Kept:
  src/index.ts clearCaches            Public-API entry, intentionally
                                      exposed even if no internal
                                      caller uses it.

10 files changed, +10 / -36. typecheck/lint/format/test all clean,
734/734 tests still pass.
2026-05-13 10:08:07 -07:00
Aiden Bai 4fc784eb33 refactor(scan): extract CLI rendering from scan.ts into src/cli/render-*.ts (#221)
Splits the 899-line src/scan.ts god-orchestrator: keeps the actual scan
flow in scan.ts (313 lines) and lifts the CLI rendering helpers into
6 focused files under src/cli/.

scan.ts before: project detection prints, diagnostic group formatters,
score bar + face renderer, summary + share URL builder, tmpdir
diagnostics dump, Node-for-oxlint resolution prompt -- all interleaved
with the scan orchestrator.

scan.ts after: just the orchestrator -- option merge, runScan flow,
lint/dead-code Promise.all, score calculation, calls to the renderers.

Files extracted:
  src/cli/render-diagnostics.ts        317 LOC  printDiagnostics +
                                                category/rule group
                                                formatters + verbose
                                                map + helpers
  src/cli/render-score-header.ts        74 LOC  printScoreHeader /
                                                printBrandingOnlyHeader /
                                                printNoScoreHeader (+
                                                doctor face + score bar)
  src/cli/render-summary.ts             82 LOC  printSummary +
                                                printCountsSummaryLine +
                                                buildShareUrl
  src/cli/render-project-detection.ts   50 LOC  printProjectDetection
                                                (spinner-driven "Found
                                                React 19" lines)
  src/cli/write-diagnostics-directory.ts 27 LOC writeDiagnosticsDirectory
                                                (the /tmp dump)
  src/cli/resolve-oxlint-node.ts        66 LOC  resolveOxlintNode (Node
                                                version detection +
                                                "install via nvm?"
                                                prompt)

No behavior change. Same orchestration, same output, same scan() signature.
Each extracted file only imports what it actually uses (no barrel re-exports).

Tests: 734 passing, typecheck/lint/format clean, build produces identical
dist/cli.js.
2026-05-13 10:02:31 -07:00
Aiden Bai e2c925e172 refactor: drop browser-poc (src + fixtures + bippy/@oxc-parser/wasm deps) (#222)
Removes the browser-poc surface entirely (matches v2's decision). Frees
~1300 LOC of source + two runtime deps that nothing else in src/ uses.

Deletions:
  src/browser-poc.ts                                1083 LOC
  src/wasm.d.ts                                        4 LOC (only typed
                                                            .wasm imports
                                                            for browser-poc)
  tests/fixtures/browser-poc/                       app.tsx + index.html
  src/constants.ts                                  drop 2 unused consts
                                                    (BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS,
                                                     BROWSER_POC_HOST_SELECTOR_MAX_COUNT)

package.json:
  - exports./browser-poc                            removed
  - scripts.build                                   drop trailing esbuild
                                                    step that bundled
                                                    dist/browser-poc.global.js
  - scripts.poc:browser                             removed (vite dev
                                                    server for the demo)
  - dependencies.@oxc-parser/wasm ^0.60.0           removed (only used
                                                    by browser-poc)
  - dependencies.bippy ^0.5.39                      removed (only used
                                                    by browser-poc)

vite.config.ts:
  - drop react-doctor-wasm-binary-loader plugin     (only converted .wasm
                                                    to base64 for browser-poc)
  - drop WASM_FILE_SUFFIX constant
  - drop the { entry: { "browser-poc": ... } } pack

Verified after deletion:
  - typecheck/format/lint/test all clean
  - 734 tests still pass (no tests targeted browser-poc directly --
    only the fixture html/tsx that were html-loaded by the vite dev
    server in `poc:browser`)
  - dist size drops from ~1.4 MB to ~1.19 MB
  - 3 packages removed from node_modules (bippy + @oxc-parser/wasm +
    one transitive)

Net diff: 8 files changed, +1 / -1267.
2026-05-13 10:01:34 -07:00
Aiden Bai 6ca7c1eb64 refactor(cli): split src/cli.ts and CLI-only utils into src/cli/ (#220)
Same shape as the plugin/ split (#218) but for the CLI tier: carves the
743-line src/cli.ts orchestrator and its 6 CLI-only utility files out of
src/utils/ and into a dedicated src/cli/ directory.

Files moved (via `git mv` so history is preserved):
  src/cli.ts                            -> src/cli/index.ts
  src/utils/annotation-encoding.ts      -> src/cli/annotation-encoding.ts
  src/utils/find-owning-project.ts      -> src/cli/find-owning-project.ts
  src/utils/get-staged-files.ts         -> src/cli/get-staged-files.ts
  src/utils/handle-error.ts             -> src/cli/handle-error.ts
  src/utils/parse-file-line-argument.ts -> src/cli/parse-file-line-argument.ts
  src/utils/select-projects.ts          -> src/cli/select-projects.ts

Picked these specifically because they are the only utils with cli.ts as
their sole non-test importer (per a classifier audit). Utils still used by
scan.ts, index.ts (public API), eslint-plugin.ts, or install-skill.ts
stay in src/utils/ since they aren't CLI-only.

Import path rewrites:
  - Inside the moved files: ./<other-cli-util>.js stays (same dir now),
    ./<shared-util>.js becomes ../utils/<util>.js
  - Inside cli/index.ts: ./utils/<x>.js -> ../utils/<x>.js for shared utils,
    -> ./<x>.js for the 6 moved CLI utils; ./scan.js, ./install-skill.js,
    ./constants.js, ./types.js -> ../<file>.js
  - 4 test files updated to import the moved utils from src/cli/ instead
    of src/utils/
  - vite.config.ts: cli entry "./src/cli.ts" -> "./src/cli/index.ts"

No behavior change. 734 tests pass, lint clean, typecheck clean, build
produces identical dist/cli.js output.
2026-05-13 09:53:35 -07:00
Aiden Bai 4eab5ab7ae refactor(plugin): split rules into per-file modules with TSESTree-narrowed type layer (#218)
* refactor(plugin): split helpers/types into single-purpose files with TSESTree-narrowed isNodeOfType

Replaces the monolithic plugin/types.ts and plugin/helpers.ts (~600 lines combined)
with 50 single-purpose files under plugin/utils/, and wires up TSESTree as the
source of truth for AST node shapes so isNodeOfType<T> narrows to the real
typescript-eslint node interfaces (e.g. MemberExpression.computed: boolean,
CallExpression.arguments.length: number) instead of `any`.

The old plugin/types.ts and plugin/helpers.ts now re-export from plugin/utils
so no other file needs to change to import them. Adds a defineRule helper
in plugin/utils/define-rule.ts ready for the per-rule split in a follow-up.

- adds @typescript-eslint/types as a devDependency for type narrowing
- EsTreeNode keeps its loose `[key: string]: any` escape hatch so existing
  rule bodies continue to compile unchanged
- EsTreeNodeOfType<T> resolves to Extract<TSESTree.Node, { type: T }> with
  a relaxed `parent: EsTreeNode | null` (our walker assigns parent freely)
- 2 narrow null-tolerance fixes in plugin/rules/react-native.ts (parent param)

Tests: 734 passing, typecheck/lint/format clean.

* refactor(plugin): split each rule into its own file with defineRule wrapper

Splits the 16 monolithic plugin/rules/<bucket>.ts files (10,368 LOC,
248 rules) into 179 per-rule files under plugin/rules/<bucket>/<rule>.ts.
Each rule now lives in its own file, wraps its definition with
defineRule<Rule>({...}) from plugin/utils, and only imports the helpers
+ constants it actually uses. The bucket files become thin re-export
barrels (12-24 lines each) so plugin/index.ts and external consumers
keep working unchanged.

Private helpers (consts, functions, interfaces, type aliases) that were
only used by a single rule are inlined into that rule's file; helpers
shared across multiple rules are duplicated into each consumer (matches
the v2 plan: keep the file self-contained, lift to plugin/utils later
if it grows beyond a handful of copies).

Generated by a one-shot codemod (parsed each bucket with oxc-parser,
computed each rule's transitive helper closure, emitted per-rule files
with only the imports they need, replaced the bucket with a barrel).

- 16 bucket files: 10,368 LOC -> 179 LOC (barrels)
- 179 new per-rule files under plugin/rules/<bucket>/
- Each rule's `recommendation`/`examples` metadata is now ready to be
  added per-file in a follow-up
- 3 react-native split files: re-apply parent param null-tolerance
  (these helpers got copied into each consumer file by the codemod)

Tests: 734 passing, typecheck/lint/format clean.

* refactor(plugin): direct-import per-rule files + lift duplicated helpers

Addresses two reviewer comments on the per-rule split:

1. Bugbot found that the codemod inlined some helpers up to 13x across files
   in the same category (e.g. getInlineStyleExpression x13 in design/,
   resolveJsxElementName x8 in react-native/, collectUseStateBindings x6 in
   state-and-effects/). A bug fix in any one copy risks missing the others.

2. React Review fired the package's own no-barrel-import rule 192 times
   on the new per-rule files, which all imported from "../../utils/index.js".

Fixes:

- Per-rule files now import directly from individual util source files
  (e.g. ../../utils/define-rule.js) instead of the utils/index.js barrel.
- Lifted 33 multi-use helpers from per-rule files into shared per-category
  utils dirs at plugin/rules/<category>/utils/<helper>.ts:
    architecture/utils/       2 helpers (deprecated-react-import factory + options)
    design/utils/             8 helpers (style/color extraction)
    react-native/utils/       2 helpers (resolveJsxElementName, SCROLLVIEW_NAMES)
    react-ui/utils/           5 helpers (className/jsx parsing)
    state-and-effects/utils/  12 helpers (subscribe-shape, cleanup, dep-graph)
    tanstack-start/utils/     4 helpers (route options, server fn chain walker)
- Each lifted helper picks up only the imports it actually needs from
  constants and plugin/utils.
- Pruned 53 unused-import warnings in rule files that no longer reference
  utilities used only by the lifted helpers.

Tests: 734 passing, typecheck/lint/format clean, zero duplicates remaining.

* chore(plugin): unexport DefineRule interface (used only internally)

The DefineRule interface in plugin/utils/define-rule.ts is only needed
to type the defineRule const inside the same file -- no external file
imports it. Removing `export` from the interface keeps the file's public
surface minimal (just defineRule itself).

Found via dead-export audit before merge: this was the only truly dead
export in the new plugin/utils/ + plugin/rules/<bucket>/utils/ layout.
The other low-reference helpers (DeprecatedReactImportRuleOptions,
ServerFnChainInfo, isReleaseLikeCall, containsReleaseLikeCall) are all
legitimate transitive dependencies within their per-category utils dir.
2026-05-13 09:44:21 -07:00
Aiden Bai 8556b31d8e feat: address user feedback — reduce false positives, improve scoring transparency, and add config options (#208)
* feat(browser-poc): add AST normalizers for production React analysis

Add 12 AST normalizers that transform minified production code into
patterns the react-doctor plugin rules can analyze. Enables 30+ rules
to fire on production bundles with 0 parse errors and 0 rule failures
across 6 tested sites (vercel, notion, linear, discord, shopify, ami).

Normalizers: SequenceExpression callee unwrap, OXC literal type
normalization, boolean/void recovery, return/expression sequence
splitting, JSX reconstruction (jsx/jsxs/createElement → JSXElement
tree with Fragment, ExpressionContainer, key extraction), setter
binding + reference rename, and component name uppercase recovery.

Also adds parent reference tracking in visitAst, "use client"
directive injection, WASM failure caching for CSP-blocked sites,
truncated source skip, score calculation, and a null-safety fix
for prefer-useReducer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: address user feedback — reduce false positives, improve scoring transparency, and add config options

- Add `offline`, `designRules`, and `entryFiles` config options
- Suppress React 19 deprecation rules on React 18 (migration-hint gate)
- Skip `rn-no-raw-text` for `.web.*` files (RN platform convention)
- Add sleep/delay and paginated-fetch heuristics to `asyncAwaitInLoop`
- Remove `noEmDashInJsxText` rule (em dashes are standard punctuation)
- Add `designRules` toggle to disable opinionated design rules
- Thread `entryFiles` to knip for dead-code false positive reduction
- Export `calculateScoreBreakdown` and show formula in `--verbose`
- Document scoring formula, diff/staged modes, and agent integration
- Switch to `@changesets/changelog-github` for richer changelogs
- Add GitHub Releases workflow

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: capabilities-based rule gating system

Replace bespoke flags and filter functions with a unified
capabilities + tags system:

- Add buildCapabilities(project) that derives a flat Set<string>
  from ProjectInfo (react:19, nextjs, tanstack-query, etc.)
- Add RULE_METADATA map with requires[] (capability gates) and
  tags (static classification like "design", "test-noise")
- Replace filterRulesByReactMajor, filterDesignRules, VERSION_GATED_RULE_IDS,
  VersionGateMode, and conditional spreads with one shouldEnableRule predicate
- Replace 9 individual fields on RunOxlintOptions/OxlintConfigOptions
  with project: ProjectInfo
- Add ignore.tags to user config (replaces designRules boolean)
- Cherry-pick from cursor/library-aware-deprecation-rules-ec3b:
  peerRangeSupportsLegacyReact, isTestFilePath, isLikelyBuildEntry,
  parseTailwindMajorMinor, isLikelyStringReceiver (js-set-map-lookups fix)
- Compute effective React version from min(installed, peerRangeFloor)
  so library-targeting-legacy is handled by version gating alone

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: library peer-range detection + README designRules cleanup

- Fix Bugbot: peerRangeMinMajor computes the floor major from the peer
  range so effective version is min(installed, peerFloor) instead of null
- Fix Bugbot: replace designRules config key with ignore.tags in README
- Add peerRangeMinMajor tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: wire isLikelyBuildEntry + isTestFilePath into post-scan suppression

- Add auto-suppression in mergeAndFilterDiagnostics: suppress knip/files
  diagnostics when a matching build artifact exists, suppress test-noise
  tagged rules in test/fixture files
- Tag deprecation and design rules with "test-noise" in RULE_METADATA
- Fixes Bugbot: isLikelyBuildEntry is no longer dead code

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: remove dead DESIGN_TAGS constant, clear auto-suppression caches

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: remove dead peerRangeSupportsLegacyReact, unused ruleKey param, new Promise false negative

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: forward ignoredTags and entryFiles in diagnose() programmatic API

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: handle destructuring in loop-carried dependency detection

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: apply sleep/dependency heuristics to callback-based iteration

The loopBodyHasOnlySleepLikeAwaits and hasLoopCarriedDependency checks
were only applied in inspectLoopBody (for/while/do-while) but skipped
for callback-based iteration (.forEach, .map, etc.), causing false
positives on patterns like arr.forEach(async item => { await sleep(500) }).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: guard framework-specific rules against missing RULE_METADATA

Rules from framework-specific maps (NEXTJS_RULES, REACT_NATIVE_RULES,
etc.) without a RULE_METADATA entry were unconditionally enabled for
all projects. Now they are skipped at runtime, and
validateRuleRegistration warns about the gap at dev time.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: scope RULE_METADATA validation to framework-specific rules only

Global rules intentionally omit RULE_METADATA entries since they're
unconditionally enabled. Extracted FRAMEWORK_SPECIFIC_RULE_KEYS to
share the set between the runtime guard and the validation check.


---------
2026-05-11 21:29:34 -07:00
github-actions[bot] 052cd20aa4 chore(readme): refresh leaderboard top 10 2026-05-11 08:27:16 +00:00
Aiden Bai 53fa4dffe8 feat(react-doctor): detect Tailwind version and gate size-N rule on it (#202)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 23:23:57 -07:00
Aiden Bai 30d851a287 0.1.6 2026-05-09 22:40:50 -07:00
Aiden Bai e9e4217711 fix 2026-05-09 22:40:22 -07:00
Aiden Bai d52e9a8b0f 0.1.,5 2026-05-09 21:46:43 -07:00
Aiden Bai 5f0f9f9acc feat(react-doctor): typed errors + config rootDir for diagnose() targeting (#200)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 21:45:14 -07:00
Aiden Bai ad850cb6ca feat(plugin): integrate eslint-plugin-react-you-might-not-need-an-effect (#201)
* feat(plugin): integrate eslint-plugin-react-you-might-not-need-an-effect (#187)

Adds the upstream effects-anti-pattern plugin as an optional peer
dependency alongside the existing eslint-plugin-react-hooks
integration. When installed in the scanned project (or hoisted in a
monorepo), its rules fire alongside react-doctor's native
state-and-effects rules and count toward the score.

Wiring follows the same pattern as react-hooks-js:

- Optional peer (`peerDependenciesMeta.optional: true`), so users
  who don't need the extra surface aren't forced to install it.
- Namespaced as `effect/` in oxlint's `jsPlugins` to keep the
  plugin's long upstream name out of rule keys
  (`effect/no-derived-state`, etc.).
- Categorized under "State & Effects" in PLUGIN_CATEGORY_MAP so
  diagnostics show in the right output bucket.
- Filtered through `filterRulesToAvailable` against the loaded
  plugin's `rules` export, so a future plugin upgrade that drops a
  rule silently skips just that rule instead of crashing the scan.
- Suppressed when `customRulesOnly` is set, identical to the
  react-hooks-js gating.

Adds three regression tests in `scan-resilience.test.ts`:

- The plugin entry shows up in jsPlugins with the configured
  namespace, and its rules are emitted at "warn" severity.
- `customRulesOnly: true` strips both the entry and its rules.
- Every configured `effect/` rule key still exists in the loaded
  plugin's `rules` map.

Verified end-to-end against a sample project — `effect/no-derived-state`
fires four times alongside react-doctor's own rules in `--verbose`
output.

Documents the integration in the README under a new "Optional
companion plugins" subsection so users discover the surface without
reading the source.

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

* chore(docs): apply formatter to companion-plugins table

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 21:44:57 -07:00
Aiden Bai 71a1043882 docs(actions): full workflow example and inputs reference (#188) (#199)
The composite action shipped at `action.yml` since v0.1.x already
implements every option from the issue (PR annotations, score output,
diff scans, fail-on levels, etc.). The README only showed a
job-fragment, which can read as "this is one of three options" rather
than "this is the supported integration."

Replace the fragment with a complete copy-pasteable workflow file:
documents the trigger, required `fetch-depth: 0` for diff scans,
`pull-requests: write` permission for PR comments, and links the
action.yml input list. The npx fallback is kept as a one-line escape
hatch for projects that prefer no Marketplace dependency.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 21:44:54 -07:00
Aiden Bai b501c3bc20 docs(config): clarify ignore.overrides covers per-file rule ignore (#197)
* docs(config): clarify ignore.overrides covers per-file rule ignore (#160)

The `ignore.overrides` config field already supports per-file rule
ignores with the same shape requested in #160 (`{ files, rules }[]`)
landing in #165 alongside multi-line JSX suppressions. This rewrites
the configuration section so the three nested keys' relative scope is
unambiguous and the example covers two of the scenarios from the
issue (a glob with multiple rules, a specific file with a
single-rule override). No code change needed — the feature is fully
implemented and unit-tested in tests/filter-diagnostics.test.ts.

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

* chore(docs): apply formatter to README example

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 21:44:51 -07:00
Aiden Bai b2927fdc60 fix(suppressions): match disable comments with descriptive trailing text (#159) (#196)
Two small but related fixes for inline-suppression robustness:

1. The disable-line / disable-next-line regexes used a narrow rule-list
   character class ([\\w/\\-.,\\s]) that excluded common comment
   punctuation (`;` `:` `(` `'` `?` `!` …). Whenever a user
   appended an explanatory tail like

       // react-doctor-disable-next-line react-doctor/foo -- searchQuery initial; user can type

   the regex stopped matching entirely and the suppression was silently
   ignored. Broaden the capture to `[^\\r\\n]*?` (anything on the
   same line) and split the rule section vs. description in a follow-up
   step, so any prose after the rule ids is fine.

2. `isRuleListedInComment` now strips text after ` -- ` before
   tokenizing the rule list, mirroring ESLint's convention that anything
   after ` -- ` on a disable comment is a description rather than a
   rule id. Without this, descriptive words could be tokenized
   alongside the rule list, and any trailing punctuation kept polluting
   equality checks.

Stacked single-rule comments (#159) already worked end-to-end after #165
landed multi-line / chain handling — the failing case in the report was
purely the regex rejecting comments with descriptive tails. Adds two
regression tests using the issue's exact reproduction (`useState(searchQuery)`
with stacked descriptive disables, plus the equivalent comma-separated
form), and documents both forms (and the chain-breaks-on-code rule) in
the README so the comma-separated syntax stops being undocumented.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 21:44:48 -07:00
Aiden Bai d95c939cab feat(cli): expose --why as documented public alias for --explain (#161) (#198)
The MVP requested by #161 — a single-site suppression diagnosis at
`react-doctor --why <file:line>` — was already implemented as a
hidden alias for `--explain`. Promote it to a documented option so
users can reach it without reading the source.

Updates the README's CLI reference and suppressions section to make
the discovery story explicit:

- `--explain <file:line>` (and its `--why` alias) classify what the
  scanner sees at a single location, matching every status in the
  issue's classifier table (MISSING, WRONG_RULE, GAP_CODE, etc.).
- `--verbose` mode already surfaces the same suppression hint
  inline next to every flagged site, and `--json` mode includes it
  on each diagnostic as `suppressionHint`. A single scan therefore
  doubles as a suppression audit without a separate flag, which
  covers the "Bonus" and most of the "Maximum" goals from the
  request.

Removes the unused `Option` import that the previous
`addOption(...).hideHelp()` wiring left behind.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-09 21:44:45 -07:00