`--diff <base>` silently fell back to a full repo scan on GitHub Actions
`pull_request` runs because `actions/checkout@v4` leaves HEAD detached
at `refs/pull/N/merge`. `getCurrentBranch` returned null, `getDiffInfo`
bailed early, and a one-file PR produced hundreds of unrelated findings.
Only require a named branch when no explicit base was given — `git
merge-base <base> HEAD` resolves on a bare commit hash. Surface
`currentBranch: null` to callers (typed as `string | null`) and render
it as "(detached HEAD)" in the CLI. When `--diff <base>` was explicit
and still failed, the warning now names the base instead of the
misleading "no feature branch detected" message so silent CI scope
drops surface immediately.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(project-info): swallow EPERM/EACCES from filesystem walks
Recursive directory walks in discoverReactSubprojects, listSourceFiles,
countSourceFiles, neutralize-disable-directives, resolveWorkspaceDirectories
and getNxWorkspaceDirectories called fs.readdirSync without guarding
against permission errors. On macOS, TCC-protected directories like
~/Library/Accounts throw EPERM on scandir even for the owning user.
Running react-doctor from a parent directory (/home/ubuntu) crashed the entire
scan with:
EPERM: operation not permitted, scandir '/Users/<user>/Library/Accounts'
Introduce readDirectoryEntries() that wraps fs.readdirSync({withFileTypes})
and returns [] for ignorable errors (EPERM, EACCES, ENOENT, ENOTDIR),
rethrowing anything else. Route every recursive walk through it so a
single unreadable directory can't take down the whole run.
Closes#275, #290.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* fix(project-info): close adjacent EPERM gaps (manifest reads, stat guards)
Follow-up to the readdir EPERM fix — the same TCC-protected directory
that crashed scandir would also throw on:
1. Reading any package.json inside it. readPackageJsonUncached caught
EISDIR and EACCES but not EPERM (macOS TCC) or ENOENT (race during
long walks), so a manifest under ~/Library/<protected>/package.json
would still take down the scan one step after our readdir fix.
2. The 'fs.existsSync(p) && fs.statSync(p).isDirectory()' guard used
in discoverReactSubprojects, resolveWorkspaceDirectories,
getNxWorkspaceDirectories, and resolveConfigRootDir. existsSync
returns false on most denials, but if it ever returns true while
statSync is denied (narrow race / TCC interaction) the whole call
throws.
Introduce an isDirectory() helper that swallows stat errors and returns
false, paralleling the existing isFile() helper, and route every guard
through it. Add EPERM + ENOENT to the readPackageJsonUncached recovery
list.
Adds three regression tests under issue #275 + #290:
- readPackageJson returns {} for a chmod-0 manifest (EPERM/EACCES)
- readPackageJson returns {} for a missing manifest (ENOENT)
- isDirectory returns false rather than throwing for an inaccessible
path (parent chmod 0)
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>
The rule flagged every `indigo-*` / `gray-*` / `slate-*` token in a
className as "the Tailwind template default" and pushed users toward
zinc / neutral / stone (or a brand color). In practice the choice
between those neutral scales is a deliberate brand decision rather
than a code-health signal, and the Radix-Colors carve-out it shipped
with (anchoring the regex to the canonical 50…950 stops) didn't help
teams that customize the default Tailwind palette to a different
numeric scale.
Removes the rule file, the now-unused TAILWIND_DEFAULT_PALETTE_*
/ TAILWIND_PALETTE_UTILITY_PREFIXES constants, the entry in the
generated rule-registry, and the dedicated regression-test describe
block. Also adds an AGENTS.md rule forbidding agents from authoring
changesets or editing CHANGELOG.md — those belong to humans at
release time.
Closes MIL-1543.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(core): add severityOverrides config with per-rule / per-category / per-tag channels
Introduces a unified severity-override resolver that lets users
promote, demote, or fully disable rules across every diagnostic
surface (CLI, PR comment, score, --fail-on) without editing source.
- New `severityOverrides` field on `ReactDoctorConfig` with `rules`,
`categories`, and `tags` channels. Values: `error` | `warn` | `off`.
Precedence: `rules` > `categories` > `tags`; within the tag channel
the most permissive override wins (`off` > `warn` > `error`).
- `resolveRuleSeverityOverride` is a pure resolver shared between the
oxlint config builder (next commit) and the post-lint diagnostic
filter, so external-plugin rules (`react/*`, `jsx-a11y/*`) honor
`off` too even though their severities live outside our control.
- `applySeverityOverrides` drops `off` diagnostics, re-stamps
`warn` / `error` severities, and short-circuits to the input
array when no overrides are configured so the common path stays
allocation-free.
- `validateConfigTypes` coerces stringy mistakes and warns on
invalid severities / channel shapes; `mergeAndFilterDiagnostics`
runs the override before the existing ignore pipeline.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* feat(core): apply severityOverrides at oxlint config time
Threads `severityOverrides` from the user config through `runOxlint`
into `createOxlintConfig`, where it short-circuits `off` rules
before registration (so they never run, never emit, and never reach
any surface) and re-stamps the registered severity for `warn` /
`error` overrides. The retry-without-extends fallback honors the
same overrides so a crashed adopt-extends path doesn't quietly
reactivate a silenced rule family.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* feat(plugin): bucket-derived auto-tags and migration-hint family
Cross-cutting controls (`severityOverrides.tags`,
`surfaces.*.excludeTags`, `config.ignore.tags`) need a way to target
whole rule families without each rule file repeating the same tag.
The codegen now merges bucket-derived tags into every entry:
- `react-native/` bucket rules get `"react-native"`
- `server/` bucket rules get `"server-action"`
Tags authored on the rule (e.g. `test-noise` on
`no-react19-deprecated-apis`) are preserved and deduped at registry
build time. The four migration-prompt rules now carry an explicit
`migration-hint` tag too:
- `no-react19-deprecated-apis`
- `no-react-dom-deprecated-apis`
- `no-legacy-class-lifecycles`
- `no-legacy-context-api`
`formatAutoTagsLine` mirrors prettier's wrap-at-100-cols decision at
codegen time so the regen-equals-formatted contract enforced by
`gen:check` stays stable.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* test: cover severityOverrides resolution, post-lint apply, and tag registration
- `resolve-rule-severity-override.test.ts` checks precedence,
empty-overrides short-circuit, the multi-tag most-permissive-wins
rule, and category / tag fallback.
- `apply-severity-overrides.test.ts` verifies `off` drops both
react-doctor and external-plugin diagnostics; `warn` / `error`
re-stamps the severity; and the no-overrides path returns the
input array by identity.
- `rule-tag-registration.test.ts` guards the bucket-derived auto-tags
(`react-native` on every RN rule, `server-action` on every server
rule) and the explicit `migration-hint` tags on the four migration
rules, plus that authored tags survive the bucket merge.
- `validate-config-types.test.ts` extends the existing surfaces
coverage with severityOverrides happy / invalid / partial-channel
paths.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* docs: document severityOverrides and the five rule-family tags
Adds a README section right after the surface controls explaining
when to use `severityOverrides` (single value applied across every
channel) vs `surfaces` (visibility-only, per channel), with a
copy-pasteable JSON example covering the five tag families called
out in the original TODO — `design`, `test-noise`, `react-native`,
`server-action`, `migration-hint`. Updates the TODOs entry to
landed status and adds a minor-bump changeset.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* refactor: tighten severity-override surface for elegance
Net -58 lines while making each path read more directly:
- New `get-diagnostic-rule-identity.ts` projects a diagnostic onto
the three axes every rule-targeted control reasons about
(`ruleKey`, `category`, `tags`). `filter-for-surface` and
`apply-severity-overrides` both consume it instead of repeating
the plugin-aware tag lookup inline.
- `resolve-rule-severity-override` drops the two-level
`pickMostPermissive` / `collectTagOverride` helpers in favor of a
single linear scan with a small `isMorePermissive` predicate;
reads top-to-bottom in rule > category > tags precedence order.
- `apply-severity-overrides` factors a `restampSeverity` helper
driven by a `SEVERITY_FOR_OVERRIDE` map, keeping the main loop to
three lines of decision logic.
- `createOxlintConfig` collapses the override-then-fallback into one
`?? rule.severity` expression so the "\"off\" skips registration"
check is the only branch left.
- Codegen drops the manual prettier-width math entirely — the
shorter `[...new Set(...)]` form always fits under 100 cols, so
`gen:check` stays idempotent without the wrap-aware fork.
- `validate-config-types` extracts `applyFieldValidator`,
`isPlainObject`, and `formatType` so the four "if undefined skip;
validate; store or delete" blocks at the bottom collapse into one
expression each, and the three "must be plain object" guards stop
duplicating their type check.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* test: dogfood js-combine-iterations in rule-tag-registration test
React Review flagged two .filter().map() chains in the test file as
`js-combine-iterations` warnings (we eat our own dogfood). Replaces
both chains with a shared `collectRuleIdsMatching` helper that walks
`Object.entries(plugin.rules)` once and pushes ids into a single
result array — same behavior, single pass.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* fix(core): warn on unknown severityOverrides channel keys
Bugbot caught an asymmetry: `validateSurfacesField` iterates the
user-provided keys via `Object.entries` and warns on unknown surface
names, but `validateSeverityOverridesField` only iterated the
allowlist — so a typo like `"rule"` instead of `"rules"` or
`"category"` instead of `"categories"` silently dropped the
override with no signal to the user.
Switches the iteration to walk the user's keys (matching surfaces)
and emits `config field "severityOverrides.<key>" is not a known
channel (expected one of: rules, categories, tags); ignoring.` when
the key isn't recognized. Adds a regression test for the typo path.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* refactor: rename severityOverrides config to severity for ESLint/oxlint idiomacy
The wrapper key `severityOverrides` was the only non-idiomatic part of
the design — ESLint exposes severity under top-level `rules`, oxlint
exposes it under top-level `rules` + `categories`, and Biome under
`linter.rules`. None wrap it in a multi-word `*Overrides` namespace,
and our own project's existing wrapper namespaces (`ignore`,
`surfaces`) are single-word.
Renames:
- Config field `severityOverrides` → `severity`. The triple
`ignore` / `surfaces` / `severity` reads as a coherent set of
single-word namespaces parallel to the rest of the project.
- Type `SeverityOverrideControls` → `RuleSeverityControls`. The value
type stays `RuleSeverityOverride` so it doesn't collide with the
plugin's existing `RuleSeverity = "error" | "warn"` (the
registrable subset).
- File `apply-severity-overrides.ts` → `apply-severity-controls.ts`,
function `applySeverityOverrides` → `applySeverityControls`. Matches
the config field's noun form.
- Validation error messages updated from
`config field "severityOverrides.<x>"` to
`config field "severity.<x>"`.
- README, changeset, and TODOs.md updated to draw the explicit
parallel to ESLint's `rules: { ... }` and oxlint's
`rules: { ... } + categories: { ... }`, plus the extra `tags`
channel for behavioral families.
All 1158 tests pass.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* refactor: promote severity controls to top-level rules / categories / tags (ESLint shape)
The wrapper namespace was the last non-idiomatic piece. ESLint exposes
severity under top-level `rules` (its hallmark field name and shape).
oxlint exposes `rules` + `categories` at the top level. Promoting our
three channels to the config root gives users the exact field they
already know from ESLint, plus oxlint-shaped `categories` and a
`tags` extension with the same Record-of-severity shape.
Before:
{
"severity": {
"rules": { "react-doctor/no-array-index-as-key": "error" },
"categories": { "React Native": "warn" },
"tags": { "design": "off" }
}
}
After (matches ESLint's flat-config / .eslintrc shape exactly, plus
oxlint's `categories`):
{
"rules": { "react-doctor/no-array-index-as-key": "error" },
"categories": { "React Native": "warn" },
"tags": { "design": "off" }
}
Changes:
- `ReactDoctorConfig` drops `severity` and gains three top-level
fields `rules`, `categories`, `tags`. Each is a
`Record<string, RuleSeverityOverride>`.
- New `buildRuleSeverityControls(config)` adapter assembles the
internal `RuleSeverityControls` shape from the three top-level
fields so the resolver and the oxlint config builder don't need
to know about the public API shape.
- `applySeverityControls` and `runOxlint` switch to the adapter;
`resolveRuleSeverityOverride` is unchanged (still takes
`RuleSeverityControls` directly).
- Validation: drops the `severity` wrapper validator; adds a
shared `validateSeverityMap(fieldName, value)` invoked once per
top-level field. Error messages now read `config field "rules"` /
`config field "tags.design"` — matching what the user typed.
- Tests, README, changeset, and TODOs.md updated. README now opens
the section with "The exact ESLint / oxlint shape" so the parity
is the first thing a reader sees.
All 1161 tests pass (gained 3 from build-rule-severity-controls.test.ts).
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* ci: retrigger workflow after path filter miss
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* ci: nudge actions queue
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* chore: drop changeset for category-level rule controls
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* docs(readme): tighten severity-controls section
Previous version was 32 lines of intro + 5-bullet behavior reference
+ full tag/category enumeration. Cuts to one intro sentence, a
compact JSON example, and a single paragraph covering precedence and
where the value applies. The full tag/category list is implicit from
the example; the precedence and most-permissive-wins rule are now
one sentence each.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* refactor: drop user-facing severity-by-tag (tags are internal metadata)
Per-tag severity at the top level exposed an internal grouping
concept that doesn't exist in ESLint or oxlint. Tags are
behavioral labels rule authors invent; surfacing them as a
first-class user config field made `react-doctor.config.json`
depend on internal labeling decisions.
Drops the top-level `tags` field. The severity surface is now
strictly ESLint + oxlint shaped:
- `rules` — ESLint's exact field
- `categories` — oxlint's exact field
Recovers the dropped functionality via existing fields:
- whole-family suppression: `ignore.tags: ["design", ...]`
(existing field, unchanged)
- per-channel visibility: `surfaces.*.{include,exclude}Tags`
(existing field, unchanged)
- per-rule severity: list rules explicitly in `rules`
Code falls out:
- `RuleSeverityControls.tags` removed
- `resolveRuleSeverityOverride` collapses to a two-line lookup
(`rules` -> `categories`); drops the most-permissive-wins
machinery and the `SEVERITY_RANK` map entirely
- `buildRuleSeverityControls` drops the tag merge
- `validate-config-types` SEVERITY_FIELD_NAMES = ["rules", "categories"]
- `applySeverityControls` destructures only the two channels it
needs from `getDiagnosticRuleIdentity` (which keeps returning
tags for surface filters / ignore.tags consumers)
Tests, README, and TODOs updated to reflect the smaller surface.
1169 tests still passing.
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>
* feat(action): expose --annotations through action.yml
Adds an opt-in 'annotations' input to the composite action so users
can surface findings as inline GitHub Actions annotations on the PR's
Files changed view. The flag is forwarded to the CLI as --annotations.
When 'github-token' is also set, the captured PR-comment body now
filters out '::error' / '::warning' workflow-command lines so the
sticky summary stays human-readable while the runner still parses
annotations from the live step log.
README documents the three resulting modes: sticky-comment-only
(default), annotations-only, and both.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* docs: tighten action annotations docs, remove em dashes
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* test(action): update contract test for split tee/sed and annotations
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>
* docs: remove stale dead-code claims after Knip removal (#246)
PR #246 removed the bundled knip integration, but several user-facing
docs still advertised dead-code coverage and exposed the removed
`--dead-code` / `--no-dead-code` CLI flags:
- packages/react-doctor/README.md ("and dead code" in the categories list)
- skills/react-doctor/SKILL.md (skill description)
- packages/website/public/llms.txt (CLI options block)
Strips those claims and adds a short migration note pointing users to
`npx knip` directly for dead-code analysis.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* chore(todos): mark stale dead-code doc cleanup as resolved
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>
Builds the AST-only foundation needed to port the 8 rules from
eslint-plugin-react-you-might-not-need-an-effect (NickvanDyke,
v0.10.1, upstream SHA 4c71faaa7623d2d5feb33983dc2ebcc08206bcc5).
Upstream is ESLint-native and uses context.sourceCode.getScope()
references / resolved.defs[].node.init recursively to chase the
ultimate source of every value. Oxlint plugins have no scope
manager. This commit ports the impedance-bridging analyzer:
- get-effect-deps-array.ts: typed accessor for useEffect(..., deps)
- has-effect-cleanup.ts: matches upstream's loose hasCleanup shape
- is-custom-hook-name.ts: `use[A-Z]` predicate
- is-synchronous.ts: parent-pointer walk bailing on await / void /
nested function-likes
- get-containing-component-or-hook.ts: classifies the surrounding
function as component / HOC-wrapped component / custom hook
(mirrors upstream's pure-HOC short-circuit for memo/forwardRef)
- collect-use-ref-bindings.ts: top-level useRef binding collector
- collect-local-function-bindings.ts: FunctionDeclaration / arrow /
useCallback bindings with param names + body for indirect-call
reasoning
- analyze-component-bindings.ts: the cornerstone — classifies every
component-scope name as state / setter / ref / prop / constant /
local-function and precomputes localFunctionCalls{StateSetter,
PropFunction,RefMethod} flags so callees like setX(...) inside an
intermediateSetter() still register as state-setter calls
- classify-effect-callee-argument.ts: leaf / transitive upstream
classification used by every rule for argument and deps analysis
- stringify-expression-snippet.ts: bounded AST → text formatter for
the no-initialize-state "set to ..." message
- create-effect-analyzer-tracker.ts: function-entry / -exit visitor
pair that maintains the ComponentBindingTable stack
Plus rules/state-and-effects/effect/SOURCE.md with upstream commit
SHA, license attribution, and known divergences.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
* fix: scope client secret diagnostics
Avoid reporting weak secret-name heuristics in config and tooling files, and render framework-specific public env guidance so non-Next.js projects get actionable output.
* fix: broaden secret false-positive guards
Extend the weak client-secret heuristic boundary to common server and tooling paths while keeping exact secret-shape detection active, and move recommendation helpers into focused utilities.
* fix: tighten rc file secret heuristic
Require rc config filenames to be explicit so normal source files like src.ts still use the weak secret-name heuristic.
* test: cover explicit rc secret heuristic
Add regression coverage that explicit rc config files remain outside the weak client-secret name heuristic while regular src.ts files still report.
* fix: refine secret heuristic boundaries
Keep client API helpers covered while making the server-only heuristic exclusions easier to audit.
* chore: format TODO tracker
Format the inherited TODO tracker so PR merge-commit checks can pass.
* fix: classify client secret exposure
Gate the weak secret-name heuristic on explicit client exposure while keeping high-confidence secret values active everywhere.
* test: cover secret exposure boundaries
Lock down client, server, ambiguous-source, and App Router cases for the client-secret heuristic.
* fix: pass framework to secret classifier
Use detected framework settings to avoid path-only client exposure guesses for app directories and server-owned source roots.
* fix: respect use server in secret classifier
Classify use-server modules as server code so client-path heuristics do not report server action constants.
* fix: harden secret exposure edges
Cover mixed server-only scopes, server-owned source roots, and additional client entry shapes so the weak secret heuristic stays scoped to bundled client code.
* fix: refine secret classifier precedence
Classify secret exposure from project-relative paths and gate Next-only route heuristics so explicit client signals win over broad server path names.
* fix: suppress secret label false positives
Avoid weak secret-name diagnostics for UI copy and storage-key prefixes found during evals while preserving client secret fallback coverage.
* fix: preserve api key secret detection
Keep camel-case apiKey variables eligible for the weak secret heuristic while retaining UI text and storage prefix suppressions.
* chore: format changeset prerelease metadata
Keep the new prerelease metadata from main aligned with the repository formatter so CI can pass on the PR merge commit.