Commit Graph

1865 Commits

Author SHA1 Message Date
Aiden Bai 31141f03eb fix 2026-05-24 00:01:40 -07:00
Aiden Bai eecd90cc3d feat(cli): add native agent hook installer (#452) 2026-05-23 23:01:25 -07:00
Nisarg Patel 241444d352 fix: format 2026-05-23 02:18:53 -07:00
Aiden Bai fd6f46f631 fix 2026-05-23 02:15:11 -07:00
Aiden Bai e606493ab7 chore(docs): remove CONTEXT.md (#449)
Same reasoning as #448 — the architecture context belongs next to the
code, not in a parallel document that ages out within a week of being
written. AGENTS.md keeps the conventions that actually need to live at
the repo root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 01:45:32 -07:00
Aiden Bai 3213c9434a chore(docs): remove .specs/ directory (#448)
Aged out within a week of being introduced (#437) and the pattern doesn't
fit. CONTEXT.md already covers the orchestrator at the right altitude;
deeper detail belongs next to the code (`packages/core/src/run-inspect.ts`),
not in a parallel spec tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 01:45:00 -07:00
Aiden Bai 99eba624a5 feat(core): wire opt-in OpenTelemetry export via REACT_DOCTOR_OTLP_* (#444)
Adds `layerOtlp` in `core/src/observability.ts` and provides it to
both `inspect()` (CLI) and `diagnose()` (programmatic API). The
layer is a strict no-op unless BOTH env vars are set:

  REACT_DOCTOR_OTLP_ENDPOINT     e.g. https://api.axiom.co
  REACT_DOCTOR_OTLP_AUTH_HEADER  e.g. Bearer <token>

When configured, it provides `Otlp.layerJson({...})` from
`effect/unstable/observability/Otlp` with
`@effect/platform-node/NodeHttpClient.layerUndici` as the
transport. Every existing `Effect.fn("Service.method")` span and
the top-level `Effect.withSpan("runInspect", ...)` ship to the
configured backend without any further wiring.

Mirrors the canonical eval pattern from
`react-doctor-evals/src/Observability.ts → layerAxiom`. Closes the
"Bigger bet #7" item from the prioritized-improvements list.

**No behavior change for default users** — the layer drops to
`Layer.empty` when env vars are absent, so the unconfigured path
has zero network traffic and zero new dependencies at runtime.

Tests cover the no-op path. AGENTS.md and the CLI README document
the opt-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 01:44:17 -07:00
Aiden Bai cd46ac1810 chore(docs): prune accreted docs, fix stale refs from #307 + #440 (#447)
README (594 → 394 lines, -34%):
- collapse 3 redundant YAML examples in `PR blocking` to one explanation
- shrink Husky/lint-staged walkthrough to one config block + one warning
- compress `Custom rules` from 60 lines of numbered tutorial to a single example
- merge `Agent and CI integration` bullet list (duplicated CLI ref + PR blocking + Node API)
- collapse 5 prose paragraphs after the config table into a 5-bullet list
- cut implementation-detail bullet list of `rn-no-raw-text` Platform.OS forms
- drop NickvanDyke "we ported these rules" trivia paragraph
- replace duplicated install block in `Resources & Contributing Back` with one line
- inline single-row `Optional companion plugins` table

CONTEXT.md + .specs/inspect-pipeline.md:
- drop `actions/review` / `@react-doctor/review-action` section (reverted in #443)
- replace `@react-doctor/project-info` / `@react-doctor/types` refs with
  `core/src/project-info` / `core/src/types` (folded into core in #440)
- fix "13 leaf reasons today" miscount → 12
- update service count 9 → 11

Deletes:
- TODOS.md (442-line working backlog — belongs in issues, not the repo)
- .agents/skills/thermo-nuclear-code-quality-review/ (200-line skill)
- packages/react-doctor/CHANGELOG.md.bak (68KB tracked backup file)

Validated: `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm test` (1449 pass, 3 skip).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 01:42:38 -07:00
Aiden Bai 26239055c9 Revert "docs: README integration coverage + TODOS bookkeeping (#425)" (#445)
This reverts commit de45e6c22f.
2026-05-23 01:35:27 -07:00
Aiden Bai 523696940c Revert "feat(action): React Doctor Review composite action (#307)" (#443)
This reverts commit 30f05ef532.
v0.5.1
2026-05-23 01:35:12 -07:00
Aiden Bai 6047362e14 docs: remove README blockquotes (vs comparison + knip migration note) (#442)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-23 01:33:42 -07:00
Aiden Bai aa61efae28 refactor: collapse @react-doctor/types and @react-doctor/project-info into core (#440)
* refactor: collapse @react-doctor/types and @react-doctor/project-info into core

Eliminates two private workspace packages whose only role was to
namespace shared TS types and project-discovery helpers — neither
was a real consumer boundary, and the indirection cost a `workspace:*`
edge per leaf package without saving any code.

**Layout**

- `packages/types/` → `packages/core/src/types/` (10 files, pure TS types)
- `packages/project-info/` → `packages/core/src/project-info/`
  (27 files, discoverProject + framework detection + monorepo helpers)
- `packages/types/src/react-native-dependency-names.ts` →
  `packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts`
  (the canonical RN-aware-package list lives next to its heaviest
  consumer — the rule gate — and is re-exported from core to avoid
  the rule-plugin ↔ core dependency cycle the previous types package
  was specifically designed to break).

The two packages are deleted from the workspace; every consumer
(`react-doctor`, `api`, `oxlint-plugin-react-doctor`, `actions/review`,
+130 source/test files) now imports from `@react-doctor/core` directly.

**Errors cleanup**

Dropped the legacy plain-class `ReactDoctorError` and `isReactDoctorError`
from `project-info`. The four narrow errors (`ProjectNotFoundError`,
`NoReactDependencyError`, `PackageJsonNotFoundError`,
`AmbiguousProjectError`) now extend `Error` directly — the only valid
hierarchy is the tagged `Schema.TaggedErrorClass` `ReactDoctorError`
from `core/errors.ts`, which has been the canonical one since the
Effect v4 refactor. Added `isProjectDiscoveryError` as a narrow type
guard for the four leaves.

The discovery errors are thrown synchronously BEFORE the Effect
runtime takes over (at `diagnose()` / CLI entry points); inside the
runtime, the Project service's `translateProjectInfoError` converts
each one to its tagged-reason equivalent.

**Schema vs type name collisions**

`schemas.ts` exports `Diagnostic` / `JsonReport` / `JsonReportMode` /
… as Schema.Class values, while `types/` exports the same names as
TS types. The core barrel re-exports the TS-type versions
(broad-strokes API) and exposes the Schema classes via a new
`@react-doctor/core/schemas` subpath export for the two callers
that need them (`Reporter` service, smoke validator, schema tests).

**Validation**

- `pnpm typecheck` ✓
- `pnpm lint` ✓
- `pnpm format` ✓
- `pnpm test` ✓ (1449 passing, 3 skipped — no regressions)
- `pnpm smoke:json-report` ✓

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

* fix(core): keep RN-dependency constants out of types barrel to avoid pulling oxlint plugin into discovery

Bugbot caught (medium severity): re-exporting `isReactNativeDependencyName` from
`oxlint-plugin-react-doctor` via the core types barrel meant every consumer of
`@react-doctor/core` (including `discoverProject` / `discoverReactSubprojects`)
would load the entire 286-rule plugin at module-init.

Adds a tiny standalone leaf at `core/src/project-info/internal-rn-dependency-names.ts`
(zero imports, just the constants the workspace-discovery gate needs) and switches
`is-package-json-react-native-aware.ts` to use it. The types barrel no longer
re-exports the plugin-side constants — rule consumers import them directly from
`oxlint-plugin-react-doctor`.

The duplication is intentional and documented at both files; the two lists are
short enough that a future regression test in the plugin can observe the union.

Validated: typecheck + test (1449 pass) + lint + format all green.
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 01:32:44 -07:00
Aiden Bai 1ab01c8cae refactor: decompose run-oxlint.ts + inspect.ts, adopt Layer.mock in tests (#439)
Three behavior-preserving structural wins, all validated by the
existing 1448-test suite.

**run-oxlint.ts: 673 → 244 lines (-64%)**

Extracted five focused modules under `runners/oxlint/`:

- `spawn-oxlint.ts` — one subprocess invocation, hard ceilings on
  wall time and output bytes, tagged-error reject contract.
- `parse-output.ts` — oxlint stdout → `Diagnostic[]`, including the
  leading-notice-line tolerance and category/recommendation lookups.
- `resolve-paths.ts` — oxlint binary, plugin path, tsconfig path.
- `validate-rule-registration.ts` — one-time metadata-drift check.
- `spawn-batches.ts` — the batching loop with binary-split retry and
  per-batch dropped-file tracking.

`run-oxlint.ts` is now a slim orchestrator: it resolves
plugins/extends/ignore patterns, builds the oxlintrc.json, neutralizes
inline disable directives in audit mode, runs the batches, and on
extends-related crashes retries once with extends stripped (still
carrying `userPlugins` through to the fallback config).

**inspect.ts: 551 → 520 lines, layer wiring extracted**

`cli/utils/build-runtime-layers.ts` (82 lines) owns the production
`Layer.mergeAll(...)` stack. `inspect.ts` keeps its post-scan
rendering responsibilities; layer composition is no longer mixed in
with `Console.log` calls and spinner finalization.

**Tests: Layer.mock for one-off service overrides**

Replaced `Layer.succeed(Service, Service.of({...all methods...}))`
with `Layer.mock(Service, { ...only methods this test exercises... })`
in:

- `tests/run-inspect.test.ts` (Project, Linter, DeadCode overrides)
- `tests/services/staged-files.test.ts` (Git override × 2)

Dropped the `Git.of({...})` ceremony and the `Context.Tag.Service`
casts — `Layer.mock` raises an unimplemented defect if a test
exercises a method the mock didn't provide, which is the failure
shape we want anyway.

No behavior change. Full validation pass: typecheck + lint + format +
smoke + 1448 tests all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 01:25:05 -07:00
Aiden Bai 93f65a52d1 chore(website): dedupe RUN_COMMAND, drop dead score-route log
- Hoist "npx react-doctor@latest" into RUN_COMMAND in constants (deslop
  flagged 3 copies across leaderboard, share, terminal).
- Drop redundant `?? null` on share/page.tsx (clampProjectName already
  accepts undefined).
- Remove console.log + orphaned ip capture from /api/score (the
  aspirational rate-limit comment had no consumer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 00:34:31 -07:00
Aiden Bai 30f05ef532 feat(action): React Doctor Review composite action (#307)
* refactor(action): move legacy composite action to actions/inspect/

The new root action.yml will host the PR Review pipeline.

The existing 'scan + sticky comment' composite action now lives at
actions/inspect/action.yml so consumers can opt into either action
explicitly:

- millionco/react-doctor@v1            -> PR Review (new)
- millionco/react-doctor/actions/inspect@v1 -> Legacy scan flow

Updates the github-action.test.ts contract test to read from the
new path.

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

* feat(action): add React Doctor Review composite action

Self-hosted GitHub Action that mirrors the hosted react.review bot's
doctor-only review pipeline. No hosted service required — everything
runs in-process on the GitHub Actions runner.

What the action does on every pull_request (opened/synchronize/reopened):

- Reads the event payload from GITHUB_EVENT_PATH and extracts the
  owner/repo/PR number/head SHA/base ref. Detects fork PRs via head.repo
  vs base.repo full_name mismatch.
- Resolves the merge base via octokit.repos.compareCommitsWithBasehead,
  using ${headOwner}:${headRef} as the head identifier for fork PRs.
- Materializes the base commit alongside the runner's checkout of head
  via 'git fetch --depth=1 <remote> <baseSha>' + 'git worktree add
  --detach <baseDir> <baseSha>'. The base remote URL embeds
  https://x-access-token:<token>@github.com/... so private repos work.
- Lists changed files via octokit.pulls.listFiles (paginated) and
  derives addedLineContents: Map<lineNumber, content> per file from the
  unified-diff patch — required for inline-comment line resolution.
- Runs react-doctor's diagnose() API in-process on both the head
  checkout and the base worktree. Pre-discovers React subprojects via
  workspaces in package.json + pnpm-workspace.yaml (using
  discoverReactSubprojects from @react-doctor/project-info), fans out
  per project, combines diagnostics with paths remapped to be relative
  to the repo root, and weight-averages scores by source file count.
- Diffs head diagnostics against base diagnostics using a
  (relativePath, rule, message) key with count deltas (not boolean
  presence) so duplicate diagnostics are counted correctly.
- Inline comment candidates are net-new diagnostics with
  severity === 'error' whose line landed on a + line in the diff.
- Reconciles existing inline threads via GraphQL
  repository.pullRequest.reviewThreads. Threads whose body contains the
  INLINE_COMMENT_MARKER and whose threadKey matches a current candidate
  are skipped. Threads whose key is no longer in the candidate set get
  PATCHed with '✅ Addressed in <headSha>' and resolved via the
  resolveReviewThread GraphQL mutation.
- Posts remaining candidates via octokit.pulls.createReview({ event:
  'COMMENT' }), capped at MAX_INLINE_COMMENTS_COUNT (25) per run.
- Upserts the sticky summary comment identified by the
  STICKY_COMMENT_MARKER HTML marker. Posts a pending-review comment
  before analysis starts, swaps it for the final regression/no-issues
  body once diagnostics are computed, and falls back to an analysis-
  failure body in the catch path.
- Creates a check run on the head SHA before analysis, then completes
  it with the assessment body (score line, new-diagnostics list,
  project metadata table).

Edge cases handled:

- Fork PRs: warns when head.repo differs from base.repo and gracefully
  catches write failures on inline / sticky posts when the workflow
  token lacks write access. Consumers can pass a PAT or App
  installation token via the 'token' input to enable fork-PR review.
- NoReactDependencyError / PackageJsonNotFoundError /
  ProjectNotFoundError: concludes the check run as 'skipped' with a
  'not a React project' summary and deletes the pending comment
  instead of loud-failing.
- Diagnostic path resolution tries: absolute under root, absolute
  under project root, relative + project-prefixed, and 'already
  prefixed' in that order.
- Cleans up the base worktree via 'git worktree remove --force' in a
  finally block.

Token strategy (dual-mode):

- Default: workflow GITHUB_TOKEN (action.yml default). Comments author
  as github-actions[bot]. Same-repo PRs work out of the box; fork PRs
  get warnings and best-effort posts.
- Override: pass a PAT or App installation token via the 'token' input
  for fork-PR support or branded identity.

Not in scope (intentionally out):

- No LLM / agent layer — doctor-only review.
- No Vercel Sandbox dependency — diagnose() runs in-process.
- No Upstash rate limit / no-react cache — single-repo CI doesn't need
  multi-tenant guardrails.
- No OIDC token-broker mode — that flow lives in the hosted react.review
  service; this action is standalone.

File layout:

- action.yml at the repo root (composite, runs tsx against the
  entrypoint). Installs only the @react-doctor/review-action and
  react-doctor workspace closures via pnpm --filter to keep cold-start
  install footprint minimal.
- actions/review/ is a new workspace package
  (@react-doctor/review-action) with three TypeScript modules:
  - src/pipeline.ts — pure helpers (diff parser, diagnose-across-
    workspace, computeDiagnosticsDelta, formatters, inline-candidate
    builder, check-run assessment).
  - src/github.ts — Octokit IO (compareCommitsWithBasehead, listFiles,
    sticky upsert/delete, inline review, GraphQL thread reconciliation,
    check-run create/complete).
  - src/index.ts — entrypoint orchestration.
- src/constants.ts holds magic numbers (MAX_INLINE_COMMENTS_COUNT,
  sticky/inline markers, check-run name, GraphQL page size).

Dogfood:

- .github/workflows/doctor-action.yml runs the action against this
  repo's own PRs via 'uses: ./'.

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

* docs(readme): document Review and inspect actions

Adds a 'PR Review (recommended)' section showing the new root action
and keeps the previous scan + sticky comment flow under
'Scan + sticky comment (legacy)' pointing at
millionco/react-doctor/actions/inspect@main.

Existing references to 'millionco/react-doctor@main' for the legacy
inputs (directory/verbose/project/diff/github-token/fail-on/offline/
annotations/node-version) are repointed to the new
millionco/react-doctor/actions/inspect@main path so existing recipes
keep working without breaking on the new Review action's inputs.

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

* fix(action): disambiguate workspace filter to skip website build

Both the workspace root and packages/react-doctor are named 'react-doctor'.
'pnpm --filter react-doctor build' matched the root, whose build script
is 'turbo run build' — which fans out to every workspace, including
website. website#build then failed in CI because the filtered install
(by design) did not pull in Next.js.

Switch both pnpm invocations to path-style filters / direct cwd:

- Install: --filter './packages/react-doctor...' (instead of bare name).
- Build:   cd packages/react-doctor && pnpm build (run the package's
  vp-pack script directly, bypassing the name collision).

Verified locally:
  cd packages/react-doctor && rm -rf dist && pnpm build  # OK
  pnpm install --frozen-lockfile \
    --filter '@react-doctor/review-action...' \
    --filter './packages/react-doctor...'                 # Scope: 6 of 9

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

* fix(action): build workspace deps via turbo so dist/ resolves at runtime

The previous fix ran 'pnpm build' directly in packages/react-doctor,
which only bundled that one package. vp pack treats workspace deps as
external imports (Could not resolve '@react-doctor/core' ... treating
it as an external dependency), so the resulting dist/index.js still
'import { ... } from "@react-doctor/core"' at runtime. The runtime
import resolved to packages/core/dist/index.js — which was never
built — and the action crashed:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '.../packages/core/dist/index.js' imported from
  '.../packages/react-doctor/dist/index.js'

Use 'turbo run build --filter=./packages/react-doctor' instead. The
path filter avoids the workspace name collision (root + packages/
react-doctor share the name 'react-doctor'), and turbo's
'dependsOn: ["^build"]' in turbo.json triggers a topological build
of the four upstream workspace deps (@react-doctor/types, /project-
info, /core, oxlint-plugin-react-doctor) before react-doctor itself.

Verified locally: 'rm -rf packages/*/dist && pnpm exec turbo run build
--filter=./packages/react-doctor' produces dist/ in all five packages.
Turbo is a root devDependency and gets installed even with the
filtered install scope, so no install-step change required.

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

* style: drop em-dashes from new action source and README additions

Replace em-dashes (—) with plain ASCII alternatives (':', '(' ')',
or '-') in the new action.yml, actions/review/src/index.ts,
actions/review/src/pipeline.ts, and the two README lines added for
the PR Review / inspect-action documentation.

Pre-existing em-dashes elsewhere in the README are not touched —
they live in sections this PR does not modify.

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

* fix(action): drop unsafe +++/--- skips from diff parser

Bugbot caught a medium-severity bug in parseAddedLineContents: the
'rawLine.startsWith("+++ ")' / 'rawLine.startsWith("--- ")' guards
were copy-pasted from full unified-diff parsing but are wrong for
GitHub's pulls.listFiles patches.

GitHub's patch field begins at the first '@@ ...' hunk header and
never includes the '+++ b/file' / '--- a/file' file headers. So the
guards do not fire on any legitimate header, but they DO fire on
legitimate added/removed lines whose payload begins with '++ ' or
'-- ' (e.g. C++ '++ i', '++ x = 1', shell '-- arg'). When they fire
on a '+' line, currentNewLine is not incremented, off-by-oneing every
subsequent line number in the hunk and silently mis-placing inline
review comments.

Removed both guards; the '@@' hunk parser is the only thing that
should set/reset currentNewLine. Also removed the unused '\\' (no
newline at end of file marker) branch since it is a no-op and not
emitted by GitHub patches anyway.

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

* fix(action): move @react-doctor/project-info to dependencies

Bugbot flagged that pipeline.ts imports 'discoverReactSubprojects'
from '@react-doctor/project-info' at runtime, but the manifest listed
the package under devDependencies. It worked in CI only because the
filtered install does not pass --prod and so dev deps get installed
too. Move it to dependencies so a future --prod install does not
silently break the action.

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

* refactor(action): dedupe isMissingReactProjectError into pipeline.ts

Bugbot flagged that isMissingReactProjectError was identically defined
in both pipeline.ts and index.ts with the same imports from
react-doctor/api. Export it once from pipeline.ts and import in
index.ts so the two copies cannot diverge. Also lets index.ts drop
the NoReactDependencyError / PackageJsonNotFoundError /
ProjectNotFoundError imports from react-doctor/api since the helper
encapsulates the instanceof checks.

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

* fix(action): diff diagnostics at (line, column) granularity

Bugbot caught a medium-severity bug in computeDiagnosticsDelta. The
prior implementation grouped by (relativePath, rule, message) and
sliced 'headOccurrences.slice(baseCount)' to pick the 'new'
occurrences. Linters emit diagnostics in line order, so slicing the
tail systematically picks the highest-line occurrences as new. When
a genuinely new violation appeared at a *lower* line than an
existing one with the same key, the old (higher-line) occurrence was
labelled new instead, then buildInlineCommentCandidates rejected it
(its line was not on a '+' line in the diff) and the inline comment
was silently dropped.

Diff at the (line, column) sub-key. For each (relativePath, rule,
message) key we now build a multiset of base positions, walk the
head occurrences, and only flag head positions not present in base
as new. Same path for fixed (base positions not present in head).
Line-shifted occurrences naturally fall out as one 'fixed' + one
'new' but the +/- line filter in buildInlineCommentCandidates
discards the shifted-context noise.

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

* chore(action): drop unused SCORE_GAUGE_DOMAIN / GITHUB_ACTIONS_BOT_LOGIN

Bugbot flagged these as dead exports — they were not referenced from
anywhere in the codebase. Removing them per the AGENTS.md 'Remove
unused code' rule.

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

* fix(action): tolerate spaces in thread-key file paths

Bugbot flagged that THREAD_KEY_HEADER_PATTERN matched the key as
'([^\s]+)' but buildThreadKey concatenates the raw relativePath. A
file path containing a space (say, 'src/My Component.tsx') would
yield a thread key like 'src/My Component.tsx:5|rule-name' that the
regex truncated to 'src/My' at extraction time. The truncated key
never matches any candidate so the next run silently resolves the
thread as 'addressed' instead of leaving it active.

Loosen to '([^\n]+?)' (non-greedy, single-line). Equivalent for the
99% case (paths without whitespace) and correct for the path-with-
spaces case.

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

* refactor(action): single inline marker for ownership + thread key

Bugbot flagged duplicated marker logic: formatInlineCommentBody
prepended a plain <!-- react-doctor-review-inline --> line, then
buildInlineCommentBodyWithKey prepended a separate keyed
<!-- react-doctor-review-inline:KEY --> line, and isOwnedReviewThread
relied on the plain marker. If the plain prefix ever drifted, thread
reconciliation would silently fail because owned threads would no
longer match.

Collapse to one marker source. INLINE_COMMENT_MARKER becomes
INLINE_COMMENT_MARKER_PREFIX (with the trailing ':'); the keyed body
builder builds the marker from that constant; ownership detection
substring-matches on the same prefix; the threadKey regex extracts
everything between the ':' and ' -->'. formatInlineCommentBody no
longer needs to seed the body with a marker because the keyed
prefix is always prepended at post time.

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

* fix(action): null head repo + don't conflate AmbiguousProjectError with skip (bugbot)

Two Bugbot findings on the rebased PR:

### 1. Null head repo crashes context

\`pullRequest.head.repo\` is nullable (deleted forks, restricted
fork metadata). Earlier code did
\`pullRequest.head.repo?.full_name.split("/")[1]\` — \`?.\` only
guards \`repo\`, so \`full_name\` would be undefined and \`.split\`
crashes before the review runs. The same null case also set
\`headRepoFullName\` to the base repo name, so \`isFork\` became
\`false\` and fork-specific compare / permission behavior was wrong.

Funnel every field through a single \`headRepoMeta\` guard, and
make \`isFork\` only true when \`head.repo\` is present AND its
\`full_name\` differs from the base.

### 2. ReactDoctorError conflated with skip

\`runDiagnoseAcrossWorkspace\` and the orchestrator's catch block
were collapsing any \`isReactDoctorError(error)\` to "not a React
project" / "skip this workspace entry". \`AmbiguousProjectError\`
is also a \`ReactDoctorError\` but means "multiple React roots
were found here" — silently treating it as "skip" hides a real
misconfiguration and contradicts the PR's stated handling for
\`NoReactDependencyError\` / \`PackageJsonNotFoundError\` /
\`ProjectNotFoundError\` only.

Dropped \`|| isReactDoctorError(error)\` from both catch blocks so
only \`isMissingReactProjectError\` suppresses; ambiguous /
invalid-config / scan-failure ReactDoctorError instances now
propagate as hard errors. Cleaned up the now-unused
\`isReactDoctorError\` import in \`index.ts\` and \`pipeline.ts\`.

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

* fix(action): scope base worktree scan to same INPUT_DIRECTORY as head (bugbot)

Bugbot caught: when the action runs against a non-\`.\` \`directory\`
input (the monorepo / per-package scoping case), head analysis
correctly scoped to that subdirectory (\`resolveHeadDirectory\`
resolved \`workspace\` + \`INPUT_DIRECTORY\`) but base analysis ran
against the full worktree root. Diagnostic paths and counts then
disagreed across the two snapshots, so regressions, sticky
summaries, and inline comments on added lines could be wrong or
missing.

Extracted \`resolveInputDirectory()\` to centralize the
\`INPUT_DIRECTORY || "."\` lookup, and added
\`resolveBaseScanDirectory(worktreeDirectory)\` that joins
\`INPUT_DIRECTORY\` onto the worktree the same way
\`resolveHeadDirectory\` joins it onto \`GITHUB_WORKSPACE\`. The
base \`runDiagnoseAcrossWorkspace\` call now sees the same subtree
as the head call.

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

* fix(action): anchor diagnostic paths to workspace root so PR matching works under INPUT_DIRECTORY (bugbot)

The previous fix corrected the scan directory for the base
worktree but exposed a path-key mismatch: \`pulls.listFiles\`
returns changed-file keys relative to the **repository root**
(e.g. \`packages/my-app/src/App.tsx\`), but \`runDiagnoseAcrossWorkspace\`
was emitting \`relativePath\`s relative to the SCAN root (e.g.
\`src/App.tsx\` when the scan was scoped to \`packages/my-app\`).
\`buildInlineCommentCandidates\`'s \`changedFilesByPath.get(diagnostic.relativePath)\`
then always missed for monorepo / scoped-directory configs, so
no inline comments got posted and \`reconcileInlineThreads\` saw
an empty active set — silently resolving real regressions while
the sticky summary still listed them.

\`runDiagnoseAcrossWorkspace(rootDirectory, pathBaseDirectory?)\`
now accepts a second arg that overrides the directory the output
\`relativePath\`s are computed against. Defaults to \`rootDirectory\`
for the standalone-CLI case; the action passes
\`resolveWorkspaceRoot()\` for HEAD and the worktree root for BASE,
so emitted paths line up with the GitHub API's repo-root keys
regardless of \`INPUT_DIRECTORY\` scoping.

Renamed \`resolveHeadDirectory\` → uses new \`resolveWorkspaceRoot()\`
helper for DRY.

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

* fix(action): keep threads alive for regressions that can't be posted inline (bugbot)

\`reconcileInlineThreads\` was being passed only the threadKeys that
PASSED the inline-comment filter (errors with non-null \`patch\` on
an added line). A real regression that survives the head-vs-base
diff but can't be inline-posted — e.g. \`pulls.listFiles\` returned
a null \`patch\` (binary / rename-only / large file), the violation
line landed on a context line within the hunk instead of a \`+\`
line, or the file was modified outside the diff window — would be
omitted from \`activeThreadKeys\`. The existing review thread then
got the "Addressed" footer and resolved on the next push, even
though the regression is unchanged and the sticky summary still
lists it.

Build \`activeThreadKeysFromRegressions\` from every error-severity
entry in \`newDiagnostics\` (using the same \`buildThreadKey\` shape
as \`buildInlineCommentCandidates\`), then union with the
inline-candidate keys. The set passed to \`reconcileInlineThreads\`
now reflects "still a regression" rather than "still inline-postable",
so threads stay alive while the underlying violation persists.

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

* fix(action): scan PR head SHA, not merge commit; prune stale worktrees (bugbot)

Two bugbot findings:

### 1. Scans merge commit not head (high severity)

\`actions/checkout@v5\` on a \`pull_request\` event checks out the
synthetic merge commit (\`refs/pull/N/merge\`) by default, not the
PR head. So workspace HEAD is the merge result while inline
reviews + the check run annotate \`pull_request.head.sha\` —
diagnostic line numbers can disagree with what GitHub renders
in the PR diff.

Fixes:
- README example workflow + repo fixture workflow now pass
  \`ref: \${{ github.event.pull_request.head.sha }}\` to
  \`actions/checkout\` so HEAD matches the SHA we annotate.
- Action source verifies workspace HEAD == \`pull_request.head.sha\`
  at startup and logs a loud \`::warning::\` with the exact
  \`ref: ...\` snippet to add when they disagree. The action
  still runs (so existing workflows don't break overnight),
  but the user sees the actionable diagnostic in the log.

### 2. Stale worktree breaks reruns (medium severity)

\`materializeBaseWorktree\` was \`fs.rmSync\`-ing the worktree
directory before \`git worktree add\`. If a prior run left the
worktree registered in \`.git/worktrees/\` (interrupted job,
cleanup failed), \`worktree add\` rejects with "already registered"
even though the directory is gone.

Added \`tryRunGit(["worktree", "prune"], headDirectory)\` between
the \`rmSync\` and the \`add\` — drops stale registrations
defensively. \`tryRunGit\` because a fresh repo has nothing to
prune and we don't want to abort on success.

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

* fix(action): include message-hash in thread key so same-rule same-line distinct messages stay distinct (bugbot)

\`buildThreadKey\` was \`(relativePath, line, rule)\` but the
regression-diffing identity in \`computeDiagnosticsDelta\` is
\`(relativePath, rule, message)\`. So two net-new errors with the
same rule firing on one line but different messages (e.g. a
multi-arg rule that complains about each invalid prop separately)
would collapse to a single thread key — only one inline comment
gets posted, and reconciliation can't tell the second violation
ever existed when it gets fixed.

Added a compact FNV-1a 32-bit hash of the message (base36-encoded
so the key stays short, ~7 chars) and appended it to the thread
key as a fourth segment. The hash is deterministic across runs
so reconciliation continues to match the same thread on later
pushes; multiple distinct messages on the same \`(path, line, rule)\`
now keep their own threads.

Updated both call sites (\`buildInlineCommentCandidates\` in
\`pipeline.ts\` + the active-threadKeys union in \`index.ts\`) to
pass \`diagnostic.message\` through to \`buildThreadKey\`.

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

* fix(action): dedupe inline candidates by threadKey + document line-shift limitation (bugbot)

Two related bugbot findings:

### 1. Duplicate inline keys in review (fixed)

\`buildInlineCommentCandidates\` could emit multiple candidates
with the same \`threadKey\` (the same \`(path, line, rule, message)\`
identity firing twice in one scan — e.g. a rule that double-fires
on identical source). \`createReview\` would either duplicate-post
the comment or reject the whole batch.

Switched the candidate collector from an array push to a
\`Map<threadKey, candidate>\` keyed by the resolved thread key.
First-seen wins; subsequent duplicates are dropped before posting.

### 2. Line shift resolves threads (documented limitation)

When a push moves a surviving violation to a different line, the
old thread key no longer matches and the previous thread gets
the "Addressed" footer + resolution even though the regression
persists. This is a pre-existing limitation of the
\`(path, line, rule, message)\` thread-key design — removing
\`line\` would conflate distinct violations of the same rule
across multiple lines, so the fix requires tracking original-line
anchors per thread (server-side state), which is out of scope
for an in-repo action.

Added a long-form \`KNOWN LIMITATION\` comment on \`buildThreadKey\`
documenting the tradeoff and what a proper fix would need so
future readers don't re-discover the issue from scratch.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
v0.5.0
2026-05-22 20:34:08 -07:00
Aiden Bai 0a406389d4 feat(core): user-plugin extension — config.plugins: [...] for custom rules (#438)
* feat(core): user-plugin extension — \`config.plugins: [...]\` for custom rules

Users can now drop their own oxlint-shaped plugin into
\`react-doctor.config.json\`'s new \`plugins: [...]\` field and
react-doctor runs the rules alongside the built-in ones. Closes
the long-standing gap where the only way to ship a custom rule
was to fork the repo.

## Config shape

\`\`\`json
{
  "plugins": [
    "./lint/team-conventions.cjs",
    "react-doctor-plugin-shopify-conventions"
  ],
  "rules": {
    "team-conventions/no-bare-fetch": "error",
    "shopify-conventions/use-polaris-tokens": "warn"
  }
}
\`\`\`

Each \`plugins\` entry is either:
- A **relative path** (\`./\`, \`../\`, or absolute) resolved relative
  to the directory of the config file that declared it — same
  resolution shape as \`rootDir\`. NOT relative to CWD.
- An **npm package name** resolved via Node module resolution
  from the config source dir's \`node_modules\`.

The module must default-export an oxlint-plugin-shaped object:
\`{ meta: { name }, rules: Record<ruleName, HostRule> }\`. The
existing \`RulePlugin\` types from \`oxlint-plugin-react-doctor\`
are the contract — third-party plugins can import \`defineRule\`
+ \`Rule\` / \`RulePlugin\` types for a typed authoring surface.

## Behavior

- **Opt-in by default**: a user-plugin rule never runs unless
  \`rules: { "<plugin-name>/<rule>": "warn" | "error" }\` explicitly
  enables it. Mirrors \`defaultEnabled: false\` for built-in rules
  so installing a third-party plugin doesn't surprise the user
  with a flood of new diagnostics.
- **Namespace**: rule keys are \`<plugin.meta.name>/<rule-name>\`.
  If the plugin omits \`meta.name\`, react-doctor slugifies the
  filename (\`./lint/team-conventions.cjs\` → \`team-conventions\`).
- **Failure tolerance**: unresolvable entries / non-plugin-shaped
  modules / duplicate plugin names log a warning to stderr and
  are skipped. The scan continues — a typo in one entry doesn't
  block the rest.
- **Full surface integration**: user-plugin rules flow through
  every existing severity / ignore / surface knob (\`rules\`,
  \`categories\`, \`ignore.{paths, rules, tags}\`,
  \`surfaces.{cli, prComment, score, ciFailure}\`) the same as
  built-in rules. Demote out of CI without touching the source.

## Implementation

- \`config.plugins?: string[]\` added to \`ReactDoctorConfig\` schema
  (\`packages/types/src/config.ts\`) with full inline docs.
- \`validateConfigTypes\` now coerces / strips the new field via
  the existing \`validateStringArrayField\` helper — typos / wrong
  types warn instead of crashing.
- \`resolveUserPlugin(spec, configSourceDir)\` + \`resolveUserPlugins\`
  in \`packages/core/src/runners/oxlint/plugin-resolution.ts\` —
  Node-module-resolution + introspection that returns
  \`{ entry: { name, specifier }, availableRuleNames }\` ready for
  inclusion in oxlint's \`jsPlugins\`. Slug fallback for plugins
  without \`meta.name\`.
- \`createOxlintConfig\` accepts \`userPlugins\` and folds each
  plugin's enabled rules + its specifier into the generated
  oxlintrc.json. Rules are opt-in via the existing
  \`severityControls\` lookup path so the existing severity /
  category / surface controls all keep working.
- \`runOxlint\` calls \`resolveUserPlugins(userConfig?.plugins, rootDirectory)\`
  and passes the result through. Zero call-site changes for
  consumers that don't opt in.

## Tests

\`packages/react-doctor/tests/user-plugins.test.ts\` (5 cases):

1. Relative-path plugin loads + surfaces diagnostics when enabled
2. User-plugin rules are opt-in by default (no severity → no fire)
3. \`rules: { "<plugin>/<rule>": "warn" }\` honored on output severity
4. Unresolvable plugin entry warns + scan continues
5. \`meta.name\` omission falls back to slugified filename

## Docs

New \`Custom rules (user plugins)\` section in
\`packages/react-doctor/README.md\` with the full author → register →
run flow + the behavioral notes above.

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

* refactor(user-plugins): thermo-nuclear cleanup — canonical helpers + required meta.name

Self-review against the thermo-nuclear-code-quality-review skill
caught four real code-quality issues. Fixed each.

### 1. Canonical helper duplication — `warnConfigIssue`

The codebase already exports `warnConfigIssue` from
`core/src/utils/warn-config-issue.ts`. The PR introduced four new
`process.stderr.write("[react-doctor] ...")` call sites in
`plugin-resolution.ts`, AND `validate-config-types.ts` had a
pre-existing duplicate `warnConfigField` doing the same thing.
Three copies of the same one-liner.

Replaced everything with `warnConfigIssue` imports. Net: removed
the `warnConfigField` constant + 4 inline writes, single canonical
warning helper across the config-validation surface.

### 2. Slug-from-filename fallback for `meta.name` — magical, fragile

Original PR let users omit `meta.name` and slugged the filename
(`./lint/team-rules.cjs` → namespace `team-rules`). This is
exactly the "ad-hoc magic that hides simple data-shape assumptions"
the skill flags: rename the file and your rule keys in
`config.rules` silently stop matching.

Switched to a hard contract: `meta.name` is required. A plugin
without it logs an actionable warning ("add
`module.exports = { meta: { name: '...' }, rules: {...} }`")
and is skipped. Deletes `slugifyUserPluginSpec` (the whole
function), one branch in `readPluginShape`, one bullet from the
README, and one test case (replaced with a regression test that
asserts the skip behavior).

### 3. Plugin-shape introspection duplicated

The existing `readPluginRuleNames` + `resolveReactHooksJsPlugin`
and the new `readPluginModule` + `readPluginShape` +
`resolveUserPlugin` were doing the same introspection twice.

Unified into a single `readPluginShape(specifier, loadModule)`
helper that returns `{ name, ruleNames }`. The `loadModule`
parameter lets each caller use its own `require` instance —
`resolveReactHooksJsPlugin` uses the file-local `esmRequire`,
`resolveUserPlugin` uses a `candidateRequire` rooted at the
config source directory so user-installed npm packages resolve
through the user's `node_modules`. `readPluginRuleNames` is
gone; both call sites now go through `readPluginShape`.

### 4. `MaybePluginModule` defined twice in same file

The pre-existing module-shape type (used by `readPluginRuleNames`)
and my new one (with `meta?`) were near-duplicates in
`plugin-resolution.ts`. Unified into a single permissive shape
with `meta?: { name?: unknown }` + `default?: MaybePluginModule`
that both code paths use.

### File sizes (skill's 1k rule)

No file crosses the 1000-line boundary. Touched files after this
cleanup:
  packages/core/src/runners/oxlint/plugin-resolution.ts   200
  packages/core/src/runners/oxlint/config.ts              169
  packages/core/src/validate-config-types.ts              208
  packages/core/src/run-oxlint.ts                         651
  packages/types/src/config.ts                            287
  packages/react-doctor/tests/user-plugins.test.ts        168

### Net diff

The cleanup removes more than it adds:
  - `slugifyUserPluginSpec` (entire function)
  - `warnConfigField` (entire constant + 11 call-site renames)
  - `readPluginRuleNames` (subsumed by `readPluginShape`)
  - 4 inline `process.stderr.write(...)` calls
  - Duplicate `MaybePluginModule` type

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

* fix(user-plugins): resolve from config source dir, not scan root (bugbot)

Bugbot found a real bug introduced by the original PR. \`config.plugins\`
docs and intent say "relative paths resolve against the directory of
the config file that declared them, same shape as \`rootDir\`," but
the implementation was passing \`rootDirectory\` (the post-\`rootDir\`
SCAN root) into \`resolveUserPlugins\`. The two diverge whenever
\`userConfig.rootDir\` redirects the scan to a subtree — the
canonical monorepo case.

Layout that broke:
  workspace/
    react-doctor.config.json  ← { rootDir: "apps/web", plugins: ["./lint/team.cjs"] }
    lint/team.cjs             ← plugin sits next to CONFIG
    apps/web/                 ← scan root after rootDir redirect

Before this fix: \`./lint/team.cjs\` resolved from \`apps/web/\` and
silently 404'd — every team setting up monorepo scoping would lose
their plugin without an actionable error.

### Fix: thread \`configSourceDirectory\` end-to-end

\`ResolvedConfig\` (from \`Config.resolve\`) now carries
\`configSourceDirectory: string | null\` alongside \`config\` +
\`resolvedDirectory\`. The Config layer populates it from
\`loadConfigWithSource\` (which already returns the source dir).
\`LintInput\` + \`RunOxlintOptions\` thread it through; the runner
defaults to \`rootDirectory\` for direct callers that don't load
a config file. Plugin resolution uses \`configSourceDirectory\`
so relative paths resolve against the config's location no
matter how aggressively \`rootDir\` redirects the scan.

### Regression test

New case in \`tests/user-plugins.test.ts\`:
"resolves a plugin spec from the config's source directory, not
 the scan root (rootDir redirect)" — constructs the exact
monorepo layout above (plugin at workspace root, scan target at
\`apps/web\`) and asserts the plugin's diagnostic fires.

### Test layer call-site updates

\`ResolvedConfig\` is now \`{ config, resolvedDirectory, configSourceDirectory }\`
— callers that constructed it explicitly (the 5 \`Config.layerOf({...})\`
test fixtures + \`inspect.ts\`'s \`configOverride\` path) now pass
\`configSourceDirectory: null\` to indicate "no canonical config
file source." When null, plugin resolution falls back to
\`rootDirectory\`, matching the prior behavior for the no-config-
file path.

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

* fix(user-plugins): keep userPlugins on extends-retry fallback (bugbot)

Bugbot regression on #438: when oxlint crashes on the user's
adopted \`.oxlintrc.json\`, react-doctor retries once without
\`extends\`. The fallback \`createOxlintConfig({...})\` call was
omitting \`userPlugins\`, so custom rules from \`config.plugins\`
ran on the first attempt and silently disappeared from the
successful retry. No warning, just missing diagnostics.

Pass \`userPlugins\` through the fallback alongside every other
field. Added a regression test that points
\`adoptExistingLintConfig: true\` at a project with a deliberately
broken \`.oxlintrc.json\` (one oxlint can't parse), forcing the
retry to fire, then asserts the user-plugin diagnostic still
surfaces.

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

* fix(user-plugins): thread configSourceDirectory through inspect() (bugbot HIGH)

Bugbot HIGH on #438: even after threading \`configSourceDirectory\`
into the runtime/Linter/runOxlint layers, \`inspect.ts\` was
hard-coding \`configSourceDirectory: null\` on its
\`Config.layerOf(...)\` call — and the CLI ALWAYS goes through the
\`configOverride\` path (it loads the config itself then passes it
in via the option). So the CLI path lost the source-directory
information on every invocation, and \`config.plugins\` relative
paths silently failed under \`rootDir\` redirect, ancestor-loaded
configs, and \`--staged\` (which scans a temp tree).

The fix: \`inspect()\` already calls \`loadConfigWithSource(directory)\`
on the non-override path; capture its \`sourceDirectory\` and thread
it through \`runInspectWithRuntime\` → \`Config.layerOf({ ..., configSourceDirectory })\`
→ the runner. For the override-only path (programmatic API caller
that synthesizes a config), \`configSourceDirectory\` stays \`null\`
and the runner falls back to the scan root — the expected behavior
when no canonical config file exists.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:27:00 -07:00
Aiden Bai 5759fd4699 feat(skills): add thermo-nuclear-code-quality-review skill (#436) 2026-05-22 19:59:14 -07:00
Aiden Bai 1f9ec2b1f8 docs+refactor: adopt eval-canonical Effect v4 patterns; .specs + CONTEXT.md (#437)
Deep study of ~/Developer/react-doctor-evals against our current
architecture. The eval pioneered the Effect v4 patterns this repo
is modeled on; this PR brings over the lasting structural and
observability wins.

### Lasting docs (the main deliverable)

- **`.specs/inspect-pipeline.md`** — full spec for `runInspect`, the
  streaming orchestrator. Documents the 9-service wiring, the Stream
  shape, the failure-folding pattern (Ref + Stream.catchTag), the
  layer-stack override knobs (`--offline` / `--no-lint` / etc.), the
  per-element pipeline, surface filtering, hooks vs services, the
  three-layer call story (`inspect()` → `diagnose()` → `runInspect`),
  and the playbook for adding a new diagnostic source or lint backend.
  Mirrors the eval's `.specs/github-app.md` format — design constraints
  → module map → numbered sections → locked decisions → open follow-ups.
- **`CONTEXT.md`** — domain glossary. Diagnostic, InspectOutput, Service,
  Layer stack, per-element pipeline, surface filter, mode, score,
  composite action, ReactDoctorError vs legacy thrown errors, Context.Reference,
  branded paths. Pairs with AGENTS.md (which is conventions) and
  `.specs/inspect-pipeline.md` (which is the deepest dive).

### `Effect.fn("Service.method")` for free observability

Every non-trivial service-method body is now wrapped in
`Effect.fn("Service.method")` — the canonical eval pattern from
`react-doctor-evals/src/Runner.ts` and `WorkerPool.ts`. Production
cost is zero when no tracer layer is provided. With
`Otlp.layerJson(...)` from `effect/unstable/observability/Otlp`
(eval ref: `Observability.ts → layerAxiom`), users see one named
span per service call.

Wrapped today:
  - `Project.discover` (was `Effect.try`)
  - `Config.resolve` (was direct `Cache.get`)
  - `DeadCode.run` (was `Stream.unwrap(Effect.tryPromise)`)
  - `Linter.run` (was `Stream.unwrap(Effect.gen)`)
  - `Score.compute` (was `Effect.promise`)
  - `runInspect` itself, via `Effect.withSpan("runInspect", { attributes })`
    so the orchestrator is the parent of the per-service-method spans.

### AGENTS.md updates

- New `Effect.fn("Service.method")` rule in the Services section.
- New `Config.redacted` guidance for secret-shaped env vars (will land
  with the first secret-shaped config; the pattern is documented now
  so it lands correctly).
- New `Observability` subsection documenting `Effect.withSpan` +
  `Effect.fn` composition, attribute-key naming convention, and the
  `Otlp.layerJson` opt-in path.

### Not in this PR (tracked for follow-up)

- **Migrate services to canonical `make:` pattern**: the eval uses
  `Context.Service<Self>()("name", { make: Effect.gen(...) }) {
    static readonly layer = Layer.effect(this, this.make)
  }` while we use the shape-in-type-param form
  `Context.Service<Self, Shape>()("name")` + Layer.effect outside.
  Both are valid v4; the eval's form is more idiomatic for services
  that have init work. Defer the wholesale conversion — the current
  shape works and the refactor would churn every service file.
- **Adopt `Layer.mock(Service, { method })` in tests**: cleaner test
  layers than hand-rolled `Layer.succeed(Service, Service.of({ ... }))`.
  Eval pattern from `react-doctor-evals/src/Runner.ts → layerTest`.
- **`Effect.Command` from `effect/unstable/cli`** to replace commander.
  Bigger refactor; tracked for the v0.3 CLI overhaul.
- **`Otlp.layerJson` end-to-end**: the spans are now in place, but
  no actual exporter layer is wired in production. End users that
  want traces would add `Otlp.layerJson({ baseUrl, resource, headers })`
  + `NodeHttpClient.layerUndici` themselves. Document the snippet
  in the README when there's demand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 19:57:23 -07:00
Aiden Bai 0a231000bb fix(security): four pre-existing findings from a deep audit (rebased onto Effect v4) (#435)
* fix(scripts): escape leaderboard entries before writing them into README

`update-leaderboard.ts` interpolated `entry.name` and
`entry.githubUrl` directly into a markdown table that the workflow
later commits to the canonical README. A compromise of the upstream
benchmarks repo could land arbitrary markdown (or HTML that GitHub's
renderer accepts, like `<img>`) into the README and from there
onto npm via the published `react-doctor` package's README field.

Escape repo names against the markdown table grammar (backslash, pipe,
brackets, backtick, angle brackets, newlines), reject entries whose
`githubUrl` doesn't parse as an `https://github.com/...` URL, and
`encodeURI` the URL before substituting so a stray space or paren
can't terminate the markdown link target.

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

* fix(website): validate leaderboard github URLs to prevent XSS via href

The hosted leaderboard rendered `<a href={entry.githubUrl}>` with
no protocol check. `entry.githubUrl` is read from
`react-doctor-benchmarks` (a separate repo), so a compromise of
that repo could land a `javascript:` href on react.doctor. React
18+ no longer blocks `javascript:` URLs in href, so this was a
live XSS path even without a same-origin cookie story.

Parse each URL through the WHATWG URL constructor and only keep
entries whose protocol is `https:` and whose host is exactly
`github.com`. Filter happens at fetch time so every downstream
render uses the sanitized list.

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

* fix(security): zip-slip + git-flag-injection defenses for the Effect-based services

Re-applies the two source-side security fixes from #428 onto the
current Effect v4 services (the original commits were authored
against the pre-refactor sync helpers in get-staged-files.ts /
get-diff-files.ts that no longer exist).

## 1. Zip-Slip: staged path traversal

`StagedFiles.materialize` was resolving each staged path with
`path.join(tempDirectory, relativePath)`, which happily expands
`..` segments outside the temp dir. A deliberately crafted index
entry (via `git update-index --add`, a malicious pack, or a
symlinked working tree) could write attacker-controlled files
anywhere the running user can write.

Added `isPathInsideDirectory(child, parent)` helper, resolved
`tempDirectory` up front, and the per-file loop now resolves the
candidate against the resolved temp dir and `continue`s when the
result falls outside. Same shape as the standard Zip-Slip defense.

## 2. CVE-2018-17456: git-flag injection via --diff <base>

`Git.diffSelection` was forwarding `explicitBaseBranch` to
`git rev-parse --verify <branch>` and `git merge-base <branch> HEAD`
unchanged. A value starting with `-` (e.g. `--upload-pack=evil`)
gets parsed by git as an option instead of a refname — the
CVE-2018-17456 shape.

Added `isSafeGitRevision(candidate)` at the top of the Git service
and a check at the start of `diffSelection`. The validation runs
BEFORE any subprocess spawn, so a malicious ref never reaches
`ChildProcess`. Rejects: empty, leading `-`, leading/trailing `.`,
embedded `..`, `@{` reflog suffix, or any character outside
`[A-Za-z0-9_./-]`.

In the canonical CI path the GitHub composite action already
guards with `case "$DIFF_BASE" in -* )` before the CLI is invoked,
so today's exposure is limited to local CLI runs where the user
is attacking themselves. Hardening the library boundary anyway
so other callers don't have to re-implement the check.

## Regression tests

- `Git.diffSelection — git-flag injection` (6 cases): leading dash,
  `..` rev-range, `@{` reflog, shell metacharacters, leading dot,
  empty. Uses `Git.layerNode` because validation runs before any
  spawn, so the suite never reaches `git`.
- `StagedFiles.layerNode — Zip-Slip defense` (1 case): a staged
  index containing `../escaped.ts` writes only the inside-the-temp
  file and never touches the parent dir.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-22 19:11:34 -07:00
Aiden Bai de45e6c22f docs: README integration coverage + TODOS bookkeeping (#425)
README (keeps @main as the recommended action ref):
- New "Release versioning" section with the artifact mapping table
  (CLI npm, lint-plugin npm, composite-action git refs, hosted
  Review). Versioned tags (`@v0` / `@v0.2` / `@v0.2.3`) are
  documented as an opt-in alternative; `@main` remains the default.
- New "Privacy and data" section that lists the score-API and
  share-URL calls, what each payload carries (no source, no file
  paths), and what `--offline` / `share: false` disable.
- New "React Native support matrix" table above the existing
  mixed-monorepo subsection.
- New "Non-GitHub CI (GitLab, Bitbucket, CircleCI, Jenkins, ...)"
  subsection that pins `react-doctor/api`'s `JsonReport` as the
  stable integration contract.
- Opens with an explicit "React Doctor vs React Review" clarifier.
- Scoring section expanded with concrete guidance for diagnosing
  score drops (changelog, unique-rules diff, --explain / --why).
- Caveat near pin guidance: the action ref pins the workflow shape,
  not the CLI version (action invokes `npx react-doctor@latest`).

TODOS.md:
- Bumps the `last refreshed` pointer to commit 2cc0a603.
- Marks the items addressed by this docs pass as complete or
  partial; leaves remaining feature work untouched.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-22 19:03:18 -07:00
Aiden Bai 2a02c04203 refactor: adopt v4-native Effect.catchReasons + Effect.try; ban try/catch in Effect.gen (#434)
Deep audit against the Effect v4 patterns documented in
\`tmp/effect/.patterns/\` and \`tmp/effect/migration/\` (the cloned
reference repo). Three real v3-style holdovers fixed:

### 1. Manual instanceof reason dispatch → Effect.catchReasons

\`get-diff-files.ts\`, \`api/diagnose.ts\`, and \`inspect.ts\`'s
\`restoreLegacyThrow\` all had imperative \`switch (reason._tag)\`
ladders behind an \`if (cause instanceof ReactDoctorError)\` JS
catch block. v4 ships \`Effect.catchReasons(errorTag, cases, orElse?)\`
exactly for this — exhaustive, type-safe narrowing of a
\`Schema.TaggedErrorClass\` reason union, no \`instanceof\` checks.

  - \`getDiffInfo\` →
    \`.pipe(Effect.catchReasons("ReactDoctorError", { GitBaseBranchInvalid, GitBaseBranchMissing }))\`
  - \`diagnose\` + \`inspect\`'s legacy translation →
    \`.pipe(Effect.catchReasons("ReactDoctorError", { NoReactDependency, ProjectNotFound, AmbiguousProject }, orElse))\`
    The \`orElse\` branch preserves the legacy "any other tagged
    reason throws as a plain Error with the tagged-class message
    string" contract that grep-stderr callers depend on.

The recovered branches use \`Effect.die(new LegacyClass(...))\` so
the thrown class lands on \`Effect.runPromise\`'s reject channel
unchanged.

### 2. try/catch inside Effect.gen → Effect.try + Effect.orElseSucceed

\`render-summary.ts\`'s "write the dump, swallow if it fails"
block had a literal \`try { ... yield* Console.log(...) } catch {}\`
inside an \`Effect.gen\` — a hard v4 anti-pattern (the catch only
runs for sync throws, never for Effect failures, so the shape is
misleading and the v4 patterns doc forbids it).

Replaced with:

  Effect.try({ try: () => writeDiagnosticsDirectory(...), catch: (e) => e })
    .pipe(Effect.orElseSucceed(() => null))

### 3. AGENTS.md updates

New \`Error dispatch / recovery — v4 idioms\` and \`Generator hygiene\`
subsections in the Effect v4 Conventions block. Documents:
  - When to use \`catchReasons\` / \`catchTag\` / \`catch\` / \`die\`
  - Hard rule: NEVER \`try/catch\` inside \`Effect.gen\` — use
    \`Effect.try\` and \`Effect.orElseSucceed\` / \`Effect.catch\` instead
  - \`return yield* Effect.fail(...)\` for terminal effects
  - When \`Effect.fnUntraced\` is justified (hot paths only; not
    currently needed in this codebase)

### Misc

- Removed now-unused \`isReactDoctorError\` + \`InspectOutput\`
  imports from \`inspect.ts\`.
- Removed unused \`ReactDoctorError\` import from \`api/diagnose.ts\`
  (catchReasons does the narrowing).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 19:02:18 -07:00
Aiden Bai 3260381203 ci(security): pin permissions and add fork guards on existing workflows (#424) 2026-05-22 18:59:34 -07:00
Aiden Bai 3b210b6a0d refactor(core+cli): drop custom Logger service; adopt Effect.Console throughout [13/?] (#432)
* refactor: drop custom Logger service; adopt Effect.Console throughout

Pivot to Effect v4's built-in `Console` (`effect/Console`) as the
canonical logging primitive. The custom `Logger` Context.Service and
`LoggerWriter` interface (introduced in PRs #421 / #427) are removed
in favor of Effect's `Console` Context.Reference (default sink:
`globalThis.console`).

### Renderers → Effect-typed

All 6 renderer files now return `Effect.Effect<void>` and use
`yield* Console.log(...)` / `Console.warn(...)` / `Console.error(...)`
instead of taking a `logger: LoggerWriter` parameter:

  - `print-branded-header.ts`        Effect.Effect<void>
  - `render-score-header.ts`         printScoreHeader, printNoScoreHeader,
                                     printBrandingOnlyHeader
  - `render-summary.ts`              printSummary
  - `render-diagnostics.ts`          printDiagnostics (helpers now build
                                     pure string arrays; IO happens at
                                     a single Console.log boundary)
  - `render-project-detection.ts`    printProjectDetection
  - `handle-error.ts`                handleErrorEffect (+ sync façade)

`inspect.ts` yield*s the renderers from inside its existing Effect.gen
program. Silent mode swaps the Console reference for a no-op:
`Effect.provideService(Console.Console, silentConsole)`.

### Imperative CLI helpers → `cliLogger` shim

The non-Effect-typed CLI helpers (resolve-diff-mode, select-projects,
run-explain, install-skill, prompts, exit-gracefully, resolve-fail-on-level,
json-mode, the legacy paths in cli/commands/inspect.ts) import
`cliLogger as logger` from `cli/utils/cli-logger.ts`. The shim is a
thin sync façade that calls `Effect.runSync(Console.X(...))` so every
log line drains into the same Console pipeline as the Effect-typed
renderers.

### JSON-mode silencing

`enableJsonMode` now monkey-patches `globalThis.console.log` /
`error` / `warn` / `info` / `debug` to no-ops at startup. Effect's
Console reference resolves to `globalThis.console` by default, so
this silences every `yield* Console.*` from the orchestrator and
every `cliLogger.*` from the CLI helpers without any conditional
checks at the call sites.

### Files deleted

  - packages/core/src/services/logger.ts
  - packages/core/src/logger.ts
  - packages/core/tests/services/logger.test.ts

### Core: imperative warn calls

`load-config.ts`, `read-ignore-file.ts`, and `resolve-config-root-dir.ts`
were the three core helpers still importing the deprecated `logger`
shim. They now call `Effect.runSync(Console.warn(...))` directly.

### Tests

Two tests previously did `setLoggerSilent(true)` in `beforeEach`.
They now use `silenceConsoleForTest()` from a new test helper
(`tests/helpers/silence-console.ts`) that monkey-patches the global
console for the duration of the test and returns a restore fn.

### AGENTS.md

Updated the Effect v4 Conventions section with a new `Console / logging`
subsection documenting the always-use-Effect.Console rule, the
`cliLogger` bridge for imperative callers, and the silent-mode pattern.

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

* fix(cli-logger): restore highlighter coloring for warn/error/info (bugbot)

Bugbot regression on #432: the new `cliLogger` shim and the
Effect-typed `handleErrorEffect` / `inspect.ts` finalize block all
dropped the `highlighter.warn` / `.error` / `.info` calls that the
legacy `consoleLogger` applied to every message. Result: warnings,
errors, and info lines rendered as plain uncolored text.

Restore the color contract:
  - cliLogger.warn  → highlighter.warn (yellow)
  - cliLogger.error → highlighter.error (red)
  - cliLogger.info  → highlighter.info (cyan)
  - handleErrorEffect block → red across every line
  - inspect.ts `Lint checks failed` + `score may be incomplete`
    + `No issues detected, but X failed` → wrapped in highlighter
  - resolve-oxlint-node.ts consoleWarn → highlighter.warn

Effect's `Console.warn` / `Console.error` / `Console.info` route to
the matching `console.X` method but don't add color of their own
(unlike browser DevTools), so the wrapping has to happen at the
caller side.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:49:42 -07:00
Aiden Bai ff15802f04 refactor(core): replace spawnSync in Git service with Effect's ChildProcess (#433)
Drops the last imperative `node:child_process.spawnSync` call out
of the Git service path. The `Git.layerNode` service now spawns
through Effect v4's native subprocess primitives:

  - `ChildProcess.make("git", args, { cwd, extendEnv: true })`
    from `effect/unstable/process` (builds the Command AST)
  - `ChildProcessSpawner` from the same module (the spawning
    service tag, provided by `@effect/platform-node`'s
    `NodeServices.layer`)
  - `Stream.decodeText` + `Stream.mkString` to drain stdout/stderr
    concurrently with `handle.exitCode` via `Effect.all({
    concurrency: 3 })`, so the pipe never blocks on a full buffer
  - `Effect.scoped` to bind the child process to a Scope so
    cancellation / interruption cleans up the subprocess
  - `Effect.catchTag("PlatformError", ...)` to fold the platform
    error into the existing tagged `ReactDoctorError({ reason:
    GitInvocationFailed })` so the rest of the codebase keeps its
    uniform failure channel

### `extendEnv: true` HACK

Effect's `ChildProcess.make` defaults `extendEnv` to undefined,
which means the spawned process inherits NO environment variables
(empty PATH → `spawn git ENOENT` even when git is on the user's
PATH). `spawnSync` had the opposite default. Setting
`extendEnv: true` restores the historical behavior; commented in
the source as a HACK so future readers don't burn an hour on it.

### Async cascade

Every Git service method already returned `Effect<X, ReactDoctorError>`,
but the production implementation was sync (`Effect.try(spawnSync(...))`).
The new implementation is truly async via `ChildProcess`, so the
legacy sync façades — `getDiffInfo`, `getStagedSourceFiles`,
`materializeStagedFiles`, `neutralizeDisableDirectives` — flip from
`Effect.runSync` to `Effect.runPromise` and become `Promise`-returning.

Production callers (`cli/commands/inspect.ts`, `run-oxlint.ts`)
were already inside async functions, so the only changes are added
`await`s. Tests (`diff-detached-head.test.ts`, `scan-resilience.test.ts`)
get the same `await` treatment and switch their `.toBeNull()` /
`.toEqual(...)` / `.toThrow(...)` assertions to the Promise-aware
counterparts (`resolves.toBeNull`, `resolves.toEqual`, `rejects.toThrow`).

### Dependency

Adds `@effect/platform-node@4.0.0-beta.70` to `@react-doctor/core`
(matches the existing `effect` version pin). The Node platform
services pull in `FileSystem` + `Path` (transitively required by
`NodeChildProcessSpawner`); the bundle stays small because we
import it as `import * as NodeServices from "@effect/platform-node/NodeServices"`
and let the dependency stay external (`neverBundle`).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:40:32 -07:00
Aiden Bai ab91ed9880 feat(core): NodeResolver + StagedFiles services; kill Effect.runSync HACK in Linter.layerOxlint [12/?] (#431)
* feat(core): NodeResolver + StagedFiles services; kill Effect.runSync HACK in Linter.layerOxlint

Three Effect v4 service-layer cleanups land together in PR 12:

1. NodeResolver service (core/src/services/node-resolver.ts)
   Wraps resolve-compatible-node.ts (resolveNodeForOxlint,
   isNvmInstalled, installNodeViaNvm). resolve-oxlint-node.ts
   now goes through NodeResolver.layerNode instead of importing
   the imperative helpers directly. layerOf({ resolution,
   afterInstall, isNvmInstalled }) wires deterministic snapshots
   for tests.

2. StagedFiles service (core/src/services/staged-files.ts)
   Wraps materializeStagedFiles. discoverSourceFiles delegates
   to Git.stagedFilePaths and filters through SOURCE_FILE_PATTERN.
   materialize uses Git.showStagedContent for content and plain
   fs for the temp-tree write. cli/utils/get-staged-files.ts is
   now a thin Effect.runSync façade over the service.

3. Linter.layerOxlint HACK kill
   The Effect.runSync(Ref.update(...)) inside onPartialFailure
   becomes a closure-captured array that's drained into the Ref
   once after the runOxlint promise resolves. runOxlint's
   callback signature is unchanged.

STAGED_FILES_PROJECT_CONFIG_FILENAMES hoists out of the staged-
files helper into constants.ts so the project-config copy-list
sits next to the other IO budget knobs.

Tests: 13 new unit tests cover both new services' layerOf paths
plus the layerNode discoverSourceFiles wiring through Git.layerOf.

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

* fix(staged-files): skip files when git.showStagedContent raises (bugbot)

The legacy `readStagedContent` swallowed per-file `spawnSync` failures
(missing git binary, buffer overflow, spawn errors) and returned null
so the staged scan could keep going with whatever files did read
cleanly. The new `StagedFiles.materialize` did `yield* git.showStagedContent(...)`
directly, which sinks the whole snapshot when one file raises.

Wrap the per-file call in `Effect.orElseSucceed(() => null)` so the
skip-and-continue behavior matches the legacy helper, and add a
regression test (`failingShowStagedGit` layer) that simulates a git
invocation failure on one file and asserts the snapshot still
materializes the other.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:06:06 -07:00
Aiden Bai ff2a4b4db9 chore: upgrade deslop-js to ^0.0.12 (#430) 2026-05-22 18:03:46 -07:00
Aiden Bai adbf720351 feat(core): Git service — DI-wrap every spawnSync('git', ...) call site [11/?] (#426)
* feat(react-doctor): wire Logger service through inspect() + thread logger parameter through 6 renderer files

Continues the Effect v4 services-arch migration. inspect() now picks
Logger.layerSilent vs Logger.layerConsole based on options.silent,
yields the Logger from Effect.gen, and threads the resulting
LoggerWriter to every renderer it calls — no more module-level
isSilent dance for logger output.

## Renderers converted (take LoggerWriter parameter)

- print-branded-header.ts: printBrandedHeader
- handle-error.ts: handleError (HandleErrorOptions extended with
  optional logger field for backwards compat)
- render-project-detection.ts: printProjectDetection
- render-score-header.ts: printScoreHeader / printNoScoreHeader /
  printBrandingOnlyHeader
- render-summary.ts: printSummary
- render-diagnostics.ts: printDiagnostics + 8 internal helpers
  (grayLine, grayWrappedLine, printDefaultRuleGroup,
  printDefaultCategoryGroup, printVerboseRuleGroup,
  printHiddenDiagnosticsSummary, printDefaultDiagnostics,
  printCompactRuleGroupLine)

All take an explicit logger: LoggerWriter parameter; calls from
finalizeAndRender pass the layer-injected logger through.

## Callers updated

- inspect.ts uses Logger service via Effect.gen, yields LoggerWriter
  inside the program, passes to renderers in finalizeAndRender
- cli/commands/inspect.ts (inspectAction) passes consoleLogger to
  printBrandedHeader
- cli/commands/install.ts (installAction) passes consoleLogger to
  printBrandedHeader

## What still has module-level state (transition residue)

- cli/utils/spinner.ts module-level isSilent boolean still exists.
  printProjectDetection internally calls spinner() for each detection
  step, so silent mode for those lines is still gated by
  setSpinnerSilent(true) in inspect()'s top-level (mirroring the
  previous behavior). PR 11 routes spinner through the Progress
  service to fully eliminate.
- Other cli/utils files (run-explain, install-skill, prompts,
  select-projects, resolve-diff-mode, resolve-fail-on-level,
  exit-gracefully) still import module-level logger. These aren't
  in inspect()'s hot path; converted as separate PRs.

## Validation

- pnpm typecheck (12/12)
- pnpm test — 124 files / 1489 pass / 3 skipped (unchanged from PR 9)
- pnpm lint, format:check
- pnpm smoke:json-report — schema-valid v1

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

* feat(core): Git service — DI-wrap every spawnSync("git", ...) call site

Adds a Git Context.Service in @react-doctor/core/services/git so
react-doctor's git interactions go through a single, swappable
seam:

  • diffSelection (replaces the inline merge-base/diff/branchExists
    logic in get-diff-files.ts)
  • currentBranch / defaultBranch / branchExists
  • stagedFilePaths / showStagedContent (replaces get-staged-files.ts)
  • grep (replaces neutralize-disable-directives.ts' git-grep path)

Three new tagged ReactDoctorError leaves capture the legacy error
shapes (GitInvocationFailed, GitBaseBranchMissing, GitBaseBranchInvalid).
Git.layerNode is the production layer; Git.layerOf({...}) returns a
canned snapshot for unit tests.

The legacy sync exports (getDiffInfo, getStagedSourceFiles,
materializeStagedFiles, neutralizeDisableDirectives) still exist
unchanged — they now run Effect.runSync over the service so call
sites don't need to migrate to Effect.gen until they're ready, and
the historical Error shapes are preserved by translating the tagged
errors back at the boundary.

This is PR 11 in the Effect v4 stack — sets up the DI seam so a
future PR can swap layerNode for an async ChildProcess-based
implementation without touching call sites.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:57:42 -07:00
Aiden Bai 6f6c04f1c6 feat(react-doctor): wire Logger service through inspect() + thread logger parameter through 6 renderer files (#427)
Continues the Effect v4 services-arch migration. inspect() now picks
Logger.layerSilent vs Logger.layerConsole based on options.silent,
yields the Logger from Effect.gen, and threads the resulting
LoggerWriter to every renderer it calls — no more module-level
isSilent dance for logger output.

## Renderers converted (take LoggerWriter parameter)

- print-branded-header.ts: printBrandedHeader
- handle-error.ts: handleError (HandleErrorOptions extended with
  optional logger field for backwards compat)
- render-project-detection.ts: printProjectDetection
- render-score-header.ts: printScoreHeader / printNoScoreHeader /
  printBrandingOnlyHeader
- render-summary.ts: printSummary
- render-diagnostics.ts: printDiagnostics + 8 internal helpers
  (grayLine, grayWrappedLine, printDefaultRuleGroup,
  printDefaultCategoryGroup, printVerboseRuleGroup,
  printHiddenDiagnosticsSummary, printDefaultDiagnostics,
  printCompactRuleGroupLine)

All take an explicit logger: LoggerWriter parameter; calls from
finalizeAndRender pass the layer-injected logger through.

## Callers updated

- inspect.ts uses Logger service via Effect.gen, yields LoggerWriter
  inside the program, passes to renderers in finalizeAndRender
- cli/commands/inspect.ts (inspectAction) passes consoleLogger to
  printBrandedHeader
- cli/commands/install.ts (installAction) passes consoleLogger to
  printBrandedHeader

## What still has module-level state (transition residue)

- cli/utils/spinner.ts module-level isSilent boolean still exists.
  printProjectDetection internally calls spinner() for each detection
  step, so silent mode for those lines is still gated by
  setSpinnerSilent(true) in inspect()'s top-level (mirroring the
  previous behavior). PR 11 routes spinner through the Progress
  service to fully eliminate.
- Other cli/utils files (run-explain, install-skill, prompts,
  select-projects, resolve-diff-mode, resolve-fail-on-level,
  exit-gracefully) still import module-level logger. These aren't
  in inspect()'s hot path; converted as separate PRs.

## Validation

- pnpm typecheck (12/12)
- pnpm test — 124 files / 1489 pass / 3 skipped (unchanged from PR 9)
- pnpm lint, format:check
- pnpm smoke:json-report — schema-valid v1

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:56:25 -07:00
Aiden Bai 38da066542 feat(core): Logger service — layerConsole / layerSilent / layerCapture (#421)
Effect v4 service for terminal output. Replaces the
module-level isSilent flag in core/src/logger.ts with proper layer
selection: production picks layerConsole (writes through highlighter
+ console.X) or layerSilent (void no-op) based on --silent; tests use
layerCapture to record events into a Ref without touching
stdout/stderr.

## What ships

- core/src/services/logger.ts — Logger service + LoggerCapture
  Ref-backed sibling; LoggerEvent shape for captured tests
- core/src/logger.ts — refactor: rename the interface to LoggerWriter
  (to avoid colliding with the Logger service class name); export
  consoleLogger + silentLogger instances; setLoggerSilent now swaps
  currentLogger between those two instances instead of mutating an
  isSilent boolean (cleaner shape, identical behavior)
- core/tests/services/logger.test.ts — 4 new tests: layerSilent
  no-op, layerCapture records, capture starts empty, layerConsole
  provides the consoleLogger instance

## Migration strategy

PR 10+ will wire Logger into inspect.ts and api/diagnose.ts (the
Effect-typed callers), drop the save/restore setLoggerSilent dance,
and convert hot-path renderers (printDiagnostics, printSummary,
printProjectDetection, etc.) to take a LoggerWriter parameter
instead of importing the module-level logger.

The setLoggerSilent / isLoggerSilent helpers and the module-level
logger remain (marked @deprecated) so legacy renderers keep working
during migration. The Logger service is available NOW for any new
Effect-typed code.

## Validation

- pnpm typecheck (12/12)
- pnpm test — 124 files / 1489 pass / 3 skipped (was 123/1485; +1
  file, +4 tests)
- pnpm lint, format:check (1146 files)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:50:35 -07:00
Aiden Bai 5c13e50940 fix(runtime): self-audit cleanup against AGENTS.md Effect v4 rules (#420)
Self-audit after #419 documented the patterns. Caught and fixed six
issues I introduced across #414 and #417 that contradict the rules
the AGENTS.md update codified.

## P0 — AGENTS.md violations

(1) packages/react-doctor/src/inspect.ts:2 used the umbrella import
'from "effect"' — replace with individual 'import * as Effect /
Layer / Ref from "effect/X"' lines. AGENTS.md "ALWAYS: import * as
X from 'effect/X'" rule.

(2) inspect.ts had three '[...output.diagnostics] as Diagnostic[]'
casts that aren't needed — spreading a ReadonlyArray<Diagnostic>
already gives Diagnostic[]. AGENTS.md "MUST: Do not type cast 'as'
unless absolutely necessary" rule.

(3) inspect.ts catch had two consecutive identical predicates:

    if (cause instanceof ReactDoctorError) restoreLegacyThrow(cause);
    if (isReactDoctorError(cause)) restoreLegacyThrow(cause);

isReactDoctorError IS instanceof ReactDoctorError; the second is
dead code (restoreLegacyThrow always throws so the first match
exits). Drop the duplicate.

## P1 — dead code / unnecessary casts

(4) packages/api/src/diagnose.ts had a try/catch around
resolveDiagnoseTarget where every branch re-threw cause unchanged:

    try { ... } catch (cause) {
      if (cause instanceof AmbiguousProjectError) throw cause;
      if (cause instanceof PackageJsonNotFoundError) throw cause;
      throw cause;
    }

The if-statements were no-ops. Drop the wrapper + the unused
PackageJsonNotFoundError import.

(5) packages/core/src/run-inspect.ts had two 'Stream.empty as
Stream.Stream<Diagnostic, never>' casts. Stream.empty is
Stream<never> which IS assignable to Stream<Diagnostic, never> via
subtyping; the cast was a TS-inference workaround. Replace with a
single typed local 'const emptyDiagnosticStream:
Stream.Stream<Diagnostic, never> = Stream.empty' shared across
both call sites.

## Other

(6) inspect.ts Score HACK comment was misleading — claimed the
orchestrator's Score would see the 'FULL diagnostic list', but it
actually sees the per-element-filtered list (just not score-surface-
filtered). Updated the comment to match reality. Also dropped the
redundant spread in 'lintPartialFailures: [...output.lintPartialFailures]'
(input is already ReadonlyArray<string>).

## Validation

- pnpm typecheck (12/12)
- pnpm test — 123 files / 1485 pass / 3 skipped
- pnpm lint, format:check
- pnpm build (8/8)
- pnpm smoke:json-report — schema-valid v1
- grep 'as Diagnostic|as Stream\.|from "effect"' across packages/
  src — zero hits

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:12:28 -07:00
Aiden Bai c0fa41e97a docs: document Effect v4 patterns + package layout in AGENTS.md (#419)
The runtime is live on main (#405, #410, #411, #412, #414, #417).
Codify the patterns so subsequent agents (and humans) write code that
matches what landed.

## AGENTS.md additions

- Package Layout section enumerating each workspace package's role
- Effect v4 Conventions section covering:
  - Import style (always 'import * as X from "effect/X"', never
    the umbrella 'from "effect"')
  - Errors as tagged Schema.TaggedErrorClass, get message() getters,
    Cause.pretty for opaque causes, _tag-based dispatch over string
    substring checks
  - Services: Context.Service<Self, Interface>()("react-doctor/Name",
    { make }); Effect.fnUntraced for hot paths; Service.of(...);
    Layer.effect vs Layer.succeed; object args when >1 parameter
  - Layer naming conventions (layerNode, layerOf, layerInMemory,
    layerCapture, layerNoop, layerComposite, implementation-specific
    layerOxlint / layerHttp / layerNdjson / layerOra)
  - Schemas: Schema.Class for wire records, Schema.Literals plural for
    unions, Schema.brand via .pipe(), interfaces for arg types to avoid
    runtime encode cost
  - Ambient config via Context.Reference (core/src/refs.ts)
- Testing section pointing at the per-package tests/ dirs and the
  vite-plus/test framework
- Reference reading pointing at tmp/effect/.patterns/ and
  ~/Developer/react-doctor-evals/src/

## TODOS.md additions

New 'Effect v4 runtime follow-ups' section captures the deferred
consolidation work flagged in the per-PR descriptions:

- Collapse @react-doctor/project-info into core (blocked by
  ReactDoctorError name collision between the legacy thrown class
  and the new tagged runtime wrapper — decide rename direction at
  v1.0)
- Collapse @react-doctor/types into core (same collision + circular
  dep risk via oxlint-plugin's use of isReactNativeDependencyName)
- Extract @react-doctor/cli workspace package (mechanical file move
  — ~40 cli/utils/* sites)
- Decompose run-oxlint into Stream combinators (kills the
  Effect.runSync HACK in Linter.layerOxlint)
- Production Reporter layer choice (currently layerNoop because the
  orchestrator returns the array via Stream.runCollect; future LSP /
  watch mode would need a different layer)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:02:29 -07:00
Aiden Bai 8a5f36eb19 remove react review CTA from CLI summary (#416) 2026-05-22 16:58:47 -07:00
Aiden Bai f789b44968 fix(oxlint-plugin-react-doctor): drop deprecated @types/eslint-scope and @types/eslint-visitor-keys stubs (#415) 2026-05-22 16:58:28 -07:00
Aiden Bai 8df149a86e refactor(react-doctor): rewire inspect() through runInspect via InspectHooks (#417)
The legacy inspect() in packages/react-doctor/src/inspect.ts did its
own lint + dead-code + combine + score orchestration in ~376 lines
of imperative Promise plumbing with mutable accumulators
(didLintFail, lintFailureReason, lintPartialFailures, etc.).

Replaces that body with a thin Effect.runPromise around #412's
runInspect orchestrator:

- Hooks: beforeLint renders project detection + starts the lint
  spinner; afterLint succeeds the spinner on clean lint. Both
  Effect-typed; they yield* Ref to share spinner state.
- Layer stack: built per-options.
  --no-lint or missing oxlint binding -> Linter.layerOf([])
  --no-dead-code -> DeadCode.layerOf([])
  configOverride supplied -> Config.layerOf, else Config.layerNode
  Score always layerOf(null) — inspect.ts computes score AFTER
  the orchestrator returns, with the design-surface filter applied
  first (preserves legacy 'score API never sees design diagnostics'
  contract).
- Translates tagged ReactDoctorError back into legacy thrown classes
  (NoReactDependencyError / ProjectNotFoundError /
  AmbiguousProjectError) at the boundary via restoreLegacyThrow.
- Tagged dispatch on output.lintFailureReasonTag for the
  native-binding hint, with the legacy 'native binding' substring
  check as a belt-and-suspenders second condition.

## Preserved behaviors

- All 1442 existing react-doctor tests pass unchanged, including:
  - inspect.test.ts (silent mode, rootDir redirect, configOverride
    semantics, NoReactDependency throws)
  - inspect-surface-filter.test.ts (design-tagged diagnostics never
    hit the score API)
  - all 80+ regression tests for rule output and CLI rendering
- Stdout/stderr format unchanged — same printProjectDetection,
  printDiagnostics, printSummary calls in the same order
- scoreOnly mode, --offline, --silent all behave identically
- Lint partial failures still flow into skippedCheckReasons['lint:partial']
  (now via LintPartialFailures Ref instead of array accumulator)

## Validation

- pnpm typecheck (12/12)
- pnpm test — 123 files / 1485 pass / 3 skipped
- pnpm lint, format:check
- pnpm build (8/8)
- pnpm smoke:json-report — schema-valid v1

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:54:04 -07:00
Aiden Bai a3539c9afb feat(api): new @react-doctor/api package — diagnose() backed by runInspect (#414)
Standing up packages/api/ as the home of the programmatic public
API. Moves diagnose() into it as a thin Effect.runPromise shell
around #412's runInspect orchestrator, with tagged-error translation
back to legacy thrown classes (NoReactDependencyError /
ProjectNotFoundError / AmbiguousProjectError) for backwards compat.

inspect() stays in react-doctor/src/ for now (CLI rendering coupled
in). PR 6 (cli package) moves it.

## Files

- packages/api/ (private workspace package)
  - src/diagnose.ts: pre-resolves rootDir redirect + resolveDiagnoseTarget,
    then runInspect, translates tagged failures, returns DiagnoseResult.
  - src/index.ts: re-exports diagnose + public types + legacy errors.
  - tests/diagnose.test.ts: 4 tests — happy path, NoReactDependency,
    ProjectNotFound, elapsedMilliseconds positive.
- packages/react-doctor/src/index.ts: deletes ~120-line local diagnose,
  re-exports from @react-doctor/api.
- packages/core/src/run-inspect.ts: rejects projects without React via
  tagged NoReactDependency (used to happen in the legacy diagnose).

## Validation

- pnpm typecheck (12/12)
- pnpm test — 123 files / 1485 pass / 3 skipped
- pnpm lint, format:check, build, smoke:json-report — all green

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:42:52 -07:00
Aiden Bai 2cc0a6030f docs(todos): drop landed items and refresh against main (#413)
Co-authored-by: Cursor Agent <agent@cursor.sh>
2026-05-22 16:40:04 -07:00
Aiden Bai 3221e262ca feat(core): runInspect streaming orchestrator + per-element pipeline (#412)
The heart of the Effect v4 rewrite. Wires the 8 services from #411 into
a single composable Effect:

  Config.resolve(directory)
   -> Project.discover(resolvedDirectory)
   -> Stream.fromIterable(checkReducedMotion env diagnostics)
   -> Stream.concat(Linter.run(...))   [folds ReactDoctorError -> Ref]
   -> Stream.concat(DeadCode.run(...)) [folds DeadCodeAnalysisFailed -> Ref]
   -> Stream.filterMap(perElementPipeline.apply)
   -> Stream.tap(Reporter.emit)
   -> Stream.runCollect
   -> Score.compute(filtered)

Lint and dead-code failures fold via Stream.catchTag into Ref state.

## build-diagnostic-pipeline.ts

Per-element transform that runs auto-suppress / severity / ignore /
inline-suppression inline. Imports SEVERITY_FOR_OVERRIDE, restampSeverity,
resolveCandidateReadPath from apply-severity-controls.ts +
filter-diagnostics.ts (single source of truth — Bugbot fix from #408
already applied).

## InspectInput

Includes runDeadCode + isCi flags. outputSurface was removed per
Bugbot finding in #408 — surface filtering happens in the api/cli
boundary, not in the orchestrator.

## Validation

- pnpm typecheck (10/10)
- pnpm test — 122 files / 1481 pass / 3 skipped (113 react-doctor + 9 core)
- pnpm lint, format:check, build, smoke:json-report — all green

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:36:55 -07:00
Aiden Bai 8e0652b910 feat(core): stand up 8 Effect v4 services [3/8] (#411)
* feat(core): stand up 8 Effect v4 services (Files, Project, Config, Linter, DeadCode, Score, Reporter, Progress)

The architectural backbone for PR 4 (run-inspect orchestrator). Each
service is a Context.Service with multiple Layer implementations —
the orchestrator yields the service and gets whatever the call-site
provides, never knowing whether it's the real Node-backed layer, a
test layer with prebuilt diagnostics, or a future LSP / sandbox
backend.

## Services

| Service  | Method                                                                  | Returns                                       | Layers                                              |
|----------|-------------------------------------------------------------------------|-----------------------------------------------|-----------------------------------------------------|
| Files    | readLines/listSourceFiles/isFile/isDirectory                            | Effect<...>                                   | layerNode, layerInMemory(Map)                       |
| Project  | discover(directory)                                                     | Effect<ProjectInfo, ReactDoctorError>         | layerNode, layerOf(info)                            |
| Config   | resolve(directory)                                                      | Effect<ResolvedConfig>                        | layerNode (Cache.make 16/5min), layerOf             |
| Linter   | run(input)                                                              | Stream<Diagnostic, ReactDoctorError, Reporter>| layerOxlint, layerOf([]), layerComposite([...])     |
| DeadCode | run(input)                                                              | Stream<Diagnostic, ReactDoctorError>          | layerNode, layerOf([])                              |
| Score    | compute(input)                                                          | Effect<ScoreResult \| null>                   | layerHttp, layerOf(result)                          |
| Reporter | emit / partialFailure / finalize                                        | Effect<void>                                  | layerNoop (prod), layerCapture (test), layerNdjson  |
| Progress | start(text) -> ProgressHandle                                           | Effect<ProgressHandle>                        | layerOra(factory), layerCapture, layerNoop          |

Project translates legacy class throws from `discoverProject`
(NoReactDependencyError, ProjectNotFoundError, PackageJsonNotFoundError,
AmbiguousProjectError) into the tagged-error vocabulary added in PR 1.
DeadCode wraps `checkDeadCode` into a Stream — failures emit a
DeadCodeAnalysisFailed leaf instead of throwing.

## Linter wraps the runOxlint subprocess

Linter.layerOxlint is the only production backend today. It wraps
runOxlint (now raising tagged errors after PR 2) into a Stream and
routes per-batch partial failures through Reporter.partialFailure
(replaces PR 304's separate LintPartialFailures service — Reporter
is the single side-channel for all "things happened" events).

HACK: runOxlint's onPartialFailure callback uses Effect.runSync to
push into the reporter, because the callback is sync-shaped.
Documented; follow-up turns runOxlint into a Stream natively.

## No "layerNoop" for analyzers

Per the plan: Linter and DeadCode use `layerOf([])` for "no
diagnostics" since the semantics is "empty output", not "discard
input". Reporter and Progress keep `layerNoop` (they have void
return + side-effect-only semantics where "discard" is the correct
verb).

## Test infrastructure

packages/core/ now has its own test script (vp test run) and
vite.config.ts test section. Root pnpm test expands to include both
react-doctor and @react-doctor/core via turbo --filter chain.

Each service gets a focused test file in packages/core/tests/services/.
Layer-driven (no vi.mock anywhere): tests provide the relevant
layerOf/layerInMemory/layerCapture and assert against Refs or stream
collections.

## Validation

- pnpm typecheck (10/10)
- pnpm test — 121 files, 1471 pass / 3 skipped (was 113/1442; +8 files,
  +29 new tests across the 8 services)
- pnpm lint, format:check (1135 files)
- pnpm build (7/7; core dist grew from 131kB -> 145kB)
- pnpm smoke:json-report — schema-valid v1

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

* fix(core): Files.layerInMemory.isDirectory rejects file paths

Bugbot caught: `absolute === filePath` made isDirectory return true
for any file in the tree (e.g. isDirectory('/repo/src/index.ts')
returned true). Drop the equality branch — only descendant prefix
matching is semantically correct for inferring directory status
from a file-only Map. Adds explicit regression test.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:31:31 -07:00
Aiden Bai 77b4965008 refactor(core): run-oxlint raises tagged ReactDoctorError leaves [2/8] (#410)
* refactor(core): run-oxlint raises tagged ReactDoctorError leaves

Kills every `throw new Error(...)` and `error.message.includes(...)`
site across the lint subprocess boundary. The 7 throw sites in
run-oxlint.ts now raise the leaves added in PR 1:

- timeout      -> OxlintBatchExceeded { kind: "timeout" }
- output limit -> OxlintBatchExceeded { kind: "output-too-large" }
- SIGABRT      -> OxlintBatchExceeded { kind: "oom" }
- other signal -> OxlintBatchExceeded { kind: "killed" }
- spawn error  -> OxlintSpawnFailed { cause: childError }
- stderr-only  -> OxlintSpawnFailed { cause: stderrText }
- JSON parse   -> OxlintOutputUnparseable { preview }
- bad shape    -> OxlintOutputUnparseable { preview }

`isSplittableOxlintBatchError` (string-grep) is replaced with
`isSplittableReactDoctorError` from errors.ts (a _tag check on
`reason._tag === "OxlintBatchExceeded"`). One discriminator instead
of three substring matches; new splittable cases are one new tag.

The inline OXLINT_SPAWN_TIMEOUT_MS IIFE now references the hoisted
default from constants.ts (no behavior change — same 60_000 base,
same env var override). PR 3's Linter service will replace this
with the OxlintSpawnTimeoutMs Context.Reference.

Public contract unchanged: callers still catch a thrown Error
subclass with a descriptive `error.message` (Cause.pretty wraps the
inner cause for OxlintSpawnFailed). Format adjustments in the message
text are user-visible only in skippedCheckReasons.

Validation:
- pnpm typecheck (10/10), test (1442/3-skipped), lint, format:check
- pnpm build (7/7)
- pnpm smoke:json-report
- Full lint against basic-react: 263 diagnostics, 17 errors, 246
  warnings, ok=true, JsonReport schema-valid

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

* fix(core): drop stale instanceof Error guard after isSplittableReactDoctorError narrowing

Bugbot caught the stale check on PR 406. After isSplittableReactDoctorError
narrows error to ReactDoctorError, the subsequent 'error instanceof Error'
was always true under the old plain-Error throws but now depends on
whether Schema.TaggedErrorClass extends Error. Drop the redundant guard
and read error.message directly — the type guard already proved we have
a ReactDoctorError.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:26:51 -07:00
Aiden Bai 83a70de91e feat(core): Effect v4 foundation — tagged errors, schemas, refs, paths (#405)
First PR of the Effect v4 rewrite. Adds the architectural primitives
that subsequent PRs build on, while preserving every public contract
(inspect(), diagnose(), CLI flags, JSON schemaVersion: 1, GitHub Action).

## What lands

- packages/core/src/errors.ts — 9 leaf Schema.TaggedErrorClass
  (OxlintUnavailable, OxlintBatchExceeded, OxlintSpawnFailed,
  OxlintOutputUnparseable, ConfigParseFailed, ProjectNotFound,
  NoReactDependency, AmbiguousProject, DeadCodeAnalysisFailed) composed
  into ReactDoctorError. Helpers formatReactDoctorError /
  isReactDoctorError / isSplittableReactDoctorError (all _tag-based,
  zero string-grepping).
- packages/core/src/schemas.ts — Diagnostic + Severity + JsonReport
  (Schema.Union for forward-compat with a future v2) +
  buildDiagnosticIdentity. Schemas mirror the existing @react-doctor/types
  interfaces; ProjectInfo / ScoreResult stay Schema.Unknown until PR 3.
- packages/core/src/refs.ts — Context.Reference for env-derived ambient
  config (OxlintSpawnTimeoutMs, OxlintOutputMaxBytes,
  StagedFilesTempDirPrefix). Tests override via Layer.succeed.
- packages/core/src/paths.ts — Schema.brand for OxlintBinaryPath +
  NodeBinaryPath. Catches the swap at compile time.
- packages/core/src/constants.ts — hoisted OXLINT_SPAWN_TIMEOUT_MS from
  the inline IIFE in run-oxlint.ts with explanatory docstring.

## Wiring

- handle-error.ts and build-json-report-error.ts dispatch to the
  tagged-error message getter when isReactDoctorError(error), else
  fall back to the existing formatErrorChain. run-oxlint.ts still
  throws plain Errors; PR 2 converts it.
- scripts/smoke-json-report.ts runs the built CLI against
  tests/fixtures/basic-react and Schema.decodeUnknownSync's the stdout
  against the new JsonReport schema. New CI step (must stay green
  through PR 8). Verified locally that a full --no-offline run with
  263 real diagnostics decodes cleanly.

## Effect v4 deps

- effect@4.0.0-beta.70 in packages/core/dependencies and
  packages/react-doctor/dependencies. Marked neverBundle in
  packages/react-doctor/vite.config.ts (~1MB+ of tree-shakable source;
  installers pull it as a regular dep — matches react-doctor-evals).
- @effect/vitest@4.0.0-beta.70 as a devDependency of core for PR 3+.

## Patterns

Every new file matches react-doctor-evals conventions exactly:
- import * as X from "effect/X" (never the umbrella import)
- Schema.TaggedErrorClass<Self>()("Tag", { fields }) with get message()
  delegating to Cause.pretty(Cause.fail(this.cause)) for opaque causes
- Context.Reference<T>("react-doctor/X", { defaultValue }) with env-var
  reads in defaultValue
- Schema.brand("X") via .pipe()
- kebab-case file names (per AGENTS.md)

## Validation

- pnpm typecheck — 10/10 tasks green
- pnpm test — 113 files, 1442 pass / 3 skipped (up from 1198; +244 new
  tests in errors.test.ts and schemas.test.ts)
- pnpm lint — clean
- pnpm format:check — clean across 1119 files
- pnpm build — all 7 packages produce dist/
- pnpm smoke:json-report — schema-valid v1 JsonReport

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:20:18 -07:00
Aiden Bai 5a8465ead8 0.2.3 2026-05-22 15:25:02 -07:00
Aiden Bai 49a973088b fix 2026-05-22 15:22:27 -07:00
Aiden Bai a0c702a2dc 0.2.2 2026-05-22 04:49:43 -07:00
Aiden Bai 6dda09cc45 triage: address #219 audit and #203 Husky/lint-staged recipe (#403) 2026-05-22 04:47:55 -07:00
Aiden Bai 64e2d1b9e7 chore(core): upgrade deslop-js to ^0.0.11 (#402) 2026-05-22 04:33:56 -07:00
Aiden Bai 6543a86f9d fix(project-info): find hoisted react in monorepo workspace roots (#313) 2026-05-22 00:28:11 -07:00
Aiden Bai 36f20e2b98 fix: keep score API on in CI with ?ci=1 marker (issue #302) (#309) v0.4.2 2026-05-21 21:28:02 -07:00
Aiden Bai 47772b7da4 feat(oxlint-plugin): native TypeScript ports of OXC react/* + jsx-a11y/* + react-hooks + you-might-not-need-an-effect (108+ rules) (#273)
* feat(oxlint-plugin): port OXC react/* + jsx-a11y/* (100 rules) and effect-rules (8 rules) onto main

Squashed-rebase of cursor/port-oxc-react-rules-1778917290 onto current main.
Covers all 30 commits previously on the branch:

- Native port of every `oxc_linter::rules::react` (44),
  `react_perf` (4), and `jsx_a11y` (52) rule into
  `oxlint-plugin-react-doctor` (`react-builtins/` and `a11y/`
  buckets), driven by an oxc-parser harness running OXC's own
  fixture vec.
  * 5411 / 5574 fixture cases pass (97.1%).
  * 163 documented divergences in `__fixtures__/oxc-divergences.ts`
    (per-rule).
- Semantic infrastructure: `scope-analysis.ts`,
  `control-flow-graph.ts`, `closure-captures.ts`, plus
  `wrap-with-semantic-context.ts` lazy injection.
- 8 ported `react-doctor/*` effect rules from
  `eslint-plugin-react-you-might-not-need-an-effect` (PR #278), with
  the eslint-scope analyzer + 1:1 ports of upstream's
  `util/{ast,react}.js`.
- Drop OXC's `react` + `jsx-a11y` plugins from oxlintrc;
  `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` /
  `YOU_MIGHT_NOT_NEED_EFFECT_RULES` are now empty maps preserved
  for back-compat with consumers that import them.
- Drop `eslint-plugin-react-you-might-not-need-an-effect` peer dep
  from `@react-doctor/core` and `react-doctor`.
- Drop `resolveYouMightNotNeedEffectPlugin` from plugin-resolution.ts.

Adopts main's structural changes since branch creation: PR #277
(rule re-exports moved into oxlint-plugin-react-doctor), PR #284
(picomatch glob compiler), PR #281 (annotations input on action.yml),
PR #282 (PR-blocking docs), PR #283 (knip removal docs).

Verification:
  * pnpm typecheck — 10/10 packages clean
  * pnpm lint     — 0 warnings, 0 errors (951 files)
  * pnpm test     — 1350 passed | 4 skipped (oxlint-plugin: 5411 / 5574,
                    +149 fixtures vs the fresh squash baseline)

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

* feat(oxlint-plugin): upstream-parity test suite for rules-of-hooks + exhaustive-deps

Drives every `valid:` / `invalid:` case from the React team's
`eslint-plugin-react-hooks` v7 test fixtures
(`__tests__/ESLintRulesOfHooks-test.js` +
`__tests__/ESLintRuleExhaustiveDeps-test.js`) through our native
ports via the `runRule` harness.

Captured fixtures (committed JSON in
`src/plugin/rules/react-builtins/__upstream-fixtures__/`):
- rules-of-hooks: 58 valid + 77 invalid = 135 cases
- exhaustive-deps: 122 valid + 191 invalid = 313 cases
Total: 448 upstream cases.

Result:
- 241 / 448 upstream cases pass on our native port (53.8%).
- 207 documented divergences in `divergences.ts`. The largest gap is
  in exhaustive-deps (135 invalid + 31 valid skipped) — upstream's
  port has decade-old refined heuristics for useState-setter /
  useRef stable-identity detection, useEffectEvent hoisting, deep
  TS-aware unwrapping (typeof + as casts + satisfies), useMemo /
  useCallback dep-array suggestion text, and React 19 `use()`
  semantics inside dep arrays — none of which are replicated yet.
  rules-of-hooks gaps: Flow `component` / `hook` syntax, classes
  with hooks detection, useEffectEvent placement rules, deep
  conditional/loop patterns from upstream's hermes-eslint scope walker.

Note on the "port all eslint-plugin-react-hooks rules" ask:
- `exhaustive-deps` and `rules-of-hooks`: native ports landed via
  the OXC port; this commit adds upstream-fixture-driven parity
  tests.
- The 16 React Compiler rules (`set-state-in-render`, `immutability`,
  `refs`, `purity`, `hooks`, `set-state-in-effect`, `globals`,
  `error-boundaries`, `preserve-manual-memoization`,
  `unsupported-syntax`, `static-components`, `use-memo`,
  `void-use-memo`, `incompatible-library`, `todo`,
  `component-hook-factories`) are NOT individual ESLint rules — they
  are dispatcher rules that run `babel-plugin-react-compiler`
  internally and report its diagnostics filtered by category. These
  CANNOT be ported natively without bundling the React Compiler.
  React Doctor already loads them as external `react-hooks-js/*`
  via `eslint-plugin-react-hooks` when React Compiler is in scope.

Test totals: 5411 → 5652 passing (+241 from the new parity suite),
163 → 370 skipped.

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

* fix(oxlint-plugin): rules-of-hooks gains broad upstream parity (24 → 19 divergences, 100% on valid cases)

After deep review of every divergent upstream test case:

`isHookCall` now matches upstream's stance:
- Bare `use*` callees are hooks unless they are parameter / catch-clause
  bindings (the React-import filter is reserved for the React 19 `use`
  hook only — that one is too general to flag without resolving to React).
- Member-expression hook calls fire on PascalCase namespaces or
  call-expression chains (`Hook.useState`, `This.useHook`,
  `FooStore.useFeatureFlag`, `someCall().useFoo`). Lowercase
  namespaces (`jest.useFakeTimers`, `this.useHook`, `super.useHook`)
  and non-hook-named members are NOT flagged.

`isInsideClassComponent` now correctly walks past class-method boundaries:
class-member function expressions don't terminate the walk, so
`class C { m() { useState() } }` is flagged.

Anonymous-function fallback now walks OUT to the enclosing context
instead of skipping unconditionally — when the outer function is a
component / hook, the inner anonymous callback's hook call is flagged
(catches `useEffect(() => { useHookInsideCallback() })` patterns).

`inferFunctionName` traverses transparent wrapper nodes
(AssignmentPattern for destructure defaults, TS as / satisfies /
non-null, ChainExpression) so cases like
`const {j = () => useState()}` correctly resolve to "j".

ExportDefaultDeclaration anonymous functions return null name
(treat as truly anonymous) — matches upstream's deliberate
non-enforcement on `export default () => {}`.

`use()` inside try/catch now flagged separately from the
conditional/loop checks (the React 19 `use` hook is allowed in
conditionals but NOT in try/catch).

CFG `computeUnconditionalSet` now treats:
- Dead-code blocks (statements after an unconditional return) as
  vacuously unconditional — they're never reached so the rule
  doesn't apply.
- ThrowStatement-to-exit edges as type "throw", excluded from the
  reachability BFS — `if (x) throw; useState();` correctly evaluates
  as unconditional because the throw branch isn't a normal completion.

OXC fixture pass[10] (`Sinon.useFakeTimers`) skipped: OXC's
pass-stance conflicts with upstream's flag-stance for
PascalCase-namespaced use-prefixed calls. We match upstream.

Upstream parity: 94 → 116 passing of 135 (was 70%, now 86%). All
valid cases pass. 19 remaining invalid divergences are useEffectEvent
placement (16) — separate rule layer not yet implemented — and
Flow-syntax (3) which require hermes-eslint.

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

* fix(oxlint-plugin): exhaustive-deps gains broad upstream parity (+22 fixtures)

Deep review of every divergent upstream test case:

`symbolHasStableHookOrigin` extended to cover the full upstream
stable-hook-origin set:
- useEffectEvent return values (RFC stable callback)
- Primitive literal initializers (number / string / boolean / null /
  no-substitution template) on `const` bindings — `let`/`var`
  bindings remain treated as mutable.

`isOutsideAllFunctions` walks the scope chain looking for ANY enclosing
function, so block-at-module-level constants (`{ const x = {}; useEffect... }`)
are correctly classified as stable. Imports / module-scope values are
NOT added to `stableCapturedNames` so the unnecessary-dep check still
fires for redundant imports listed in deps.

`unwrapExpression` strips TS `as` / `satisfies` / non-null / type-assertion
wrappers as well as `(...)` parens and ChainExpression — applied
both to the deps-array argument itself (so `[deps] as const` is
seen as an array) and to individual elements (so `[(props.x as Foo)]`
canonicalizes to `props.x`).

`computeDepKey` walks through ChainExpression wrappers when
collecting the outermost member-chain, so `props.foo?.bar` and
`props.foo.bar` both produce the same canonical key.

`stringifyMemberChain` standalone helper handles ThisExpression and
optional / TS-wrapped member chains.

New diagnostic surface:
- `buildMissingCallbackMessage`: `useEffect()` etc. with no callback.
- `buildMissingDepArrayMessage`: useMemo / useCallback / useImperativeHandle
  without a deps array.
- `buildNonArrayDepsMessage`: a non-array second argument
  (`useEffect(fn, dependencies)`).
- `buildLiteralDepMessage`: deps-array contains a non-string literal
  (`[42, false, null]`). String literals are deliberately skipped —
  upstream emits the missing-dep hint for those instead.
- `buildDuplicateDepMessage`: same dep listed twice (`[local, local]`).
- `buildRefCurrentDepMessage`: `<ref>.current` listed in deps where
  `<ref>` resolves to a useRef binding — upstream's "depend on the
  ref itself, not its mutable .current" warning.

null / undefined deps argument now treated as "no deps" for
useEffect-style hooks (silently OK) and as "missing deps array" for
useMemo / useCallback / useImperativeHandle.

`stableCapturedNames` set tracks bindings that the callback DID
capture but that we filtered out of the dep-keys for stability. The
unnecessary-dep check uses this set to suppress reports on
legal-but-redundant deps (e.g. `[local1]` where `local1 = 42` is
literal-stable).

CFG `computeUnconditionalSet` now treats:
- Dead-code blocks (statements after an unconditional return) as
  vacuously unconditional.
- ThrowStatement-to-exit edges as a separate `throw` edge kind,
  excluded from the reachability BFS — matches upstream's
  "if (x) throw; useState();" → unconditional semantics.

Ported divergence count: 31 valid + 135 invalid = 166 fixed →
19 valid + 125 invalid = 144 documented divergences (-22 net).

Test totals: 5673 → 5695 passing (+22).

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

* refactor(oxlint-plugin): tighten code quality on hooks rules per AGENTS.md

- Hoist inline type imports (`import("...").Foo`) to top-level imports
- Replace ASCII char-code magic numbers (65/90) with named constants
- Drop unused `HookContext.hookExpression` field
- Lift HOC-name set, transparent-wrapper-type set, and required-deps
  hook sets to module-level `ReadonlySet` constants
- Rename short variables to descriptive names (decl→declarator,
  init→initializer, obj→objectName, out→indices, etc.)
- Replace `A ? true : false` with `Boolean(A)`
- Inline trivial intermediate variables; tighten control flow

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

* fix: address bugbot findings on PR #273

- Restore the `customRulesOnly` gate on rules ported from OXC's
  `react/*` and `jsx-a11y/*` plugins. The generated registry now
  carries an `originallyExternal: true` flag for each rule in the
  `react-builtins` and `a11y` buckets; `createOxlintConfig` filters
  those out when the user opts into `customRulesOnly`. Without this,
  users who set `customRulesOnly: true` would have started receiving
  ~26 OXC-equivalent rules they explicitly opted out of.

- Drop the dead `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` imports +
  spreads from `createOxlintConfig`. Both maps are permanently empty
  now that the rules are natively ported, so the
  `customRulesOnly ? {} : MAP` ternaries always resolve to `{}`.

- Rename `isValidAriaProperty` in `dom-aria-properties.ts` →
  `isValidDomAriaProperty` to disambiguate from the spec-strict
  case-sensitive variant in `aria-properties.ts`. The DOM variant
  remains case-insensitive (HTML attributes are case-insensitive) and
  is the right helper for `no-unknown-property`; the spec-strict one
  stays in `aria-props` for exact-match validation.

- Switch `wrap-with-semantic-context`'s `fallbackCfg` to return
  `false` from `isUnconditionalFromEntry` / `dominatesExit`. The
  fallback is unreachable in practice (the wrapper captures the
  Program root before any visitor reads `context.cfg`), but if it
  ever fires, `false` errs toward flagging a potential violation
  instead of silently passing every hook call as unconditional.

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

* fix: address bugbot findings on PR #273 (round 2)

- extract-oxc-fixtures.mjs: `upstreamRelative` was computed as
  `path.relative(path.dirname(oxcFilePath), oxcFilePath)`, which always
  yielded just the filename. The header comment in each generated
  fixture file therefore claimed `crates/oxc_linter/src/rules/<file>.rs`
  — missing the bucket subdirectory. Compute the relative path against
  `<oxcRoot>/crates/oxc_linter/src/rules` instead so the comment shows
  `react/no_array_index_key.rs`, etc.

- aria-roles.ts: `"row"` was simultaneously listed in
  `INTERACTIVE_ROLES` and `NON_INTERACTIVE_ROLES`. The is-interactive /
  is-non-interactive checks would both return `true`, breaking
  classification logic in a11y rules. Upstream `eslint-plugin-jsx-a11y`
  classifies `row` as interactive (user-navigable inside a grid /
  treegrid) and `rowgroup` as non-interactive — drop `row` from
  `NON_INTERACTIVE_ROLES`.

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

* feat(oxlint-plugin): pin rules-of-hooks + exhaustive-deps to upstream eslint-plugin-react-hooks@7.1.1

Re-extracts upstream's RuleTester fixtures from
`facebook/react@eslint-plugin-react-hooks@7.1.1` and replays them
through our native TypeScript ports of the two pure-JS rules in the
plugin (`rules-of-hooks`, `exhaustive-deps`). The 16 React Compiler
rules the same package ships are runtime wrappers over
`babel-plugin-react-compiler`'s HIR analyzer (~30k LoC of bundled
compiler output) and aren't portable to a visitor-only TypeScript
plugin — those continue to ship via the optional peer dep on the npm
package, see plugin-resolution.ts.

Changes:

- scripts/extract-react-hooks-tests.mjs: NEW. Re-creates the upstream
  fixture extractor that was lost in the squash-rebase. Reads any of
  upstream's `allTests` / `tests` / `testsFlow` / `testsTypescript`
  / `testsTypescriptEslintParserV4` globals, dedupes by
  `kind:code:JSON.stringify(options)`, and writes JSON. Sets
  `process.env.CI=1` so upstream's not-in-CI filter (which deletes
  `skip` flags from cases) is bypassed — we want every case the
  upstream test suite asserts.

- exhaustive-deps.ts: `flattenReferenceRootName` now accepts
  `JSXIdentifier` references, not just `Identifier`. This unlocks the
  v7.1.1-added test case `<Component />` JSX usage inside an effect's
  callback being detected as a missing dep. Verified locally:
  invalid #191 (the new `function Foo({ Component }) { useEffect(() =>
  console.log(<Component />), []) }`) now reports the correct missing
  dep.

- rules-of-hooks.ts: `buildNonComponentMessage` now mirrors v7.1.1's
  expanded diagnostic — appends 'React component names must start with
  an uppercase letter. React Hook names must start with the word
  "use".' This matches the changelog-noted message expansion in the
  release.

- __upstream-fixtures__/README.md: NEW. Documents the v7.1.1 source
  pin, the regeneration command, and a summary of why the
  Compiler-backed rules aren't in scope for native porting.

- package.json: NEW `gen:react-hooks-fixtures` script.

- exhaustive-deps.json: regenerated; +1 case (the JSX-Component case)
  and minor formatting changes.

Verification:
- pnpm format/lint/typecheck/test all green.
- Upstream parity scoreboard unchanged net of the new case (now
  passing): 286 passed / 163 skipped (449 total upstream cases).
- Skip lists in divergences.ts unchanged — the skipped categories
  (Flow `component`/`hook`, useEffectEvent placement, deep TS
  type-aware unwrapping, mutation tracking, composite error counts)
  remain documented as fundamental visitor-only limitations.

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

* fix(oxlint-plugin): scope-analysis records JSX-member-chain object as a reference

After exposing JSXIdentifier captures to the exhaustive-deps rule
(commit ac6bf70), a deep review surfaced a related scope-analysis
bug: for `<obj.Foo />` we'd skip BOTH `obj` and `Foo` as references
(the lowercase-tag-name carve-out short-circuited every lowercase
JSXIdentifier, including the `obj` end of a JSXMemberExpression).
That meant exhaustive-deps couldn't see `obj` as captured and would
flag the user's correctly-listed `[obj]` as 'unnecessary dep'.

Replaces the single `isLowercase → skip` check with
`isJsxIdentifierBindingReference`, which:

- Treats the leftmost segment of a JSXMemberExpression chain (`obj`
  in `<obj.Foo />` / `<obj.Foo.bar />`) as a real reference, since
  it resolves through scope.
- Treats every other segment of the chain (`Foo`, `bar`) as an
  attribute-like name — not a reference.
- Treats JSXNamespacedName parts (`<svg:rect />`) as syntax
  fragments — not references.
- Keeps the original lowercase-tag-name carve-out for plain
  JSXOpeningElement / JSXClosingElement names (`<div />`, `<span />`).

ASCII bound constants pulled out instead of bare 97/122 magic
numbers, per AGENTS.md.

Verified: `pnpm format/lint/typecheck/test` all green; targeted probe
test confirmed `<obj.Foo />` with `[obj]` declared no longer mis-flags
`obj` as unnecessary.

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

* refactor: AGENTS.md compliance pass on recent changes

- packages/core/src/runners/oxlint/config.ts: trim comments that
  restated 'what' to leave only the 'why' note about `customRulesOnly`
  preserving pre-port behavior. AGENTS.md mandates 'never comment
  unless absolutely necessary'.

- scripts/extract-react-hooks-tests.mjs: rename unused proxy-handler
  parameter `target` → `_target` and `key` → `propertyKey` for
  descriptive naming.

- __upstream-fixtures__/run-upstream-parity.ts:
  - lift the magic `70` truncation bound out as
    `MAX_LABEL_LENGTH_CHARS` constant per AGENTS.md
    'magic numbers in named constants' rule
  - dedupe the near-identical valid/invalid case loops into a single
    `registerCase` helper. AGENTS.md: 'don't repeat yourself'

- src/plugin/utils/wrap-with-semantic-context.ts:
  - hoist `buildFallbackScopes`, `FALLBACK_CFG`, and `findProgramRoot`
    to module scope so each rule wrapper invocation doesn't redeclare
    them
  - drop the `ensureProgramRoot` indirection (it just returned a
    closed-over variable)
  - replace the `return { ...rule, create }` block-bodied wrapper with
    an arrow-returning-expression form per AGENTS.md
    'arrow functions over function declarations' (and consistent with
    the rest of the codebase's wrapper pattern)

Verified: pnpm format/lint/typecheck/test all green (5,696 passed,
327 skipped, 0 lint warnings, 0 type errors).

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

* fix

* fix

* fix: format + remove redundant isSameRuleKey fallback loop

Run formatter on 7 unformatted files to fix CI format:check.
Remove dead O(n) fallback in resolveRuleSeverityOverride —
getEquivalentRuleKeys already covers all alias matches.

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

* fix(oxlint-plugin): tune defaults to suppress ~79% of newly-enabled FPs

After porting the 100 OXC react/* + jsx-a11y/* rules natively in PR
#273, an eval across 100 real repos showed the PR's default config
emits +162k diagnostics vs main. Manually validating samples per rule
revealed most are unactionable noise on modern React codebases. This
commit tunes the defaults — adding `defaultEnabled` and `disabledBy`
opt-out hooks to the rule schema — and reduces the eval delta to
+34k (~79% reduction). No genuine bugs are silenced.

Schema additions (`oxlint-plugin-react-doctor/src/plugin/utils/rule.ts`):
- `defaultEnabled?: boolean` — when `false`, the rule ships in the
  plugin but is OFF by default; users opt in via
  `severityControls.rules["react-doctor/<id>"]`. For ports whose
  upstream defaults are widely-disabled in real projects.
- `disabledBy?: ReadonlyArray<string>` — inverse of `requires`. If any
  listed capability token is present, the rule is skipped. Lets us
  disable perf rules that React Compiler makes redundant.

Rule-level changes:
- `react-in-jsx-scope`: `defaultEnabled: false`. Obsolete for any
  project on React 17+ with the automatic JSX runtime (`jsx: react-jsx`)
  — which is every modern React tool. 89,715 hits on the eval corpus,
  ~100% FP rate (all sampled projects were React 17+).
- `forbid-component-props`: `defaultEnabled: false`. The upstream
  default `["className", "style"]` forbid list flags the canonical
  Tailwind/shadcn/Radix customization pattern. 7,450 hits, ~100% FP.
- `jsx-props-no-spreading`: `defaultEnabled: false`. `{...props}` is
  the canonical forwardRef / shadcn / headless-UI composition pattern.
  1,908 hits, ~95% FP.
- `jsx-no-new-{object,array,function,jsx}-as-prop`: `disabledBy:
  ["react-compiler"]`. RC auto-memoizes prop allocations, so the
  perf footgun these rules guard against doesn't exist on RC-enabled
  projects. (Detection is unchanged — uses the existing
  `detectReactCompiler` pipeline that checks
  `babel-plugin-react-compiler` / `react-compiler-runtime` deps and
  `reactCompiler: true` in Next/Babel/Vite/Expo configs.)
- `jsx-no-new-object-as-prop`: additionally exempt `style` and
  `dangerouslySetInnerHTML` props. Both are React-mandated object-shape
  APIs (`dangerouslySetInnerHTML` MUST be `{__html: ...}`) and the
  perf footgun is unactionable on non-memoized components.
- `jsx-max-depth`: raise default `max` from 2 to 10. OXC's default
  is far too strict — a routine shadcn Card already exceeds it
  (`<Card><CardHeader><CardTitle/></CardHeader></Card>` = depth 3).
  Eval went from 23,990 hits to 110.
- `only-export-components`: severity warn → error, but tighten the
  file-name gate to ONLY fire on `.tsx`/`.jsx` (and `.js` when
  `checkJS: true`) — pure `.ts` files don't participate in Fast
  Refresh and can't break it. Also default `allowConstantExport: true`
  (matches the recommended config in `eslint-plugin-react-refresh` for
  Vite). Down from 4,766 hits to 1,606, all genuine FR-breakers.

Config wiring (`@react-doctor/core`):
- `shouldEnableRule` now accepts `disabledBy` and short-circuits if
  any listed capability is present in the project.
- `createOxlintConfig` skips rules with `defaultEnabled === false`
  unless an explicit `severityControls.rules` entry turns them on.
  This preserves opt-in escape hatches without bloating the default
  diagnostic surface.

Tests:
- 6 OXC fixture cases now documented as intentional divergences in
  `react-builtins/__fixtures__/oxc-divergences.ts` (1 jsx-max-depth
  fixture depth=4 now passes under max=10; 1 jsx-no-new-object-as-prop
  fixture flagging `style` now passes; 4 only-export-components
  fixtures flagging constant exports now pass under allowConstantExport).
- Two new regression tests in `scan-resilience.test.ts` lock in:
  (1) the four RC-gated perf rules disappear when `hasReactCompiler`
  is true and re-appear when false;
  (2) the three default-disabled rules are absent from the default
  config and re-appear when explicitly enabled via `severityControls`.
- Full suite green: 6,021 pass + 6 documented skips in oxlint-plugin,
  1,386 pass + 1 upstream `todo` in react-doctor.

Eval verification on 100 repos (using react-doctor-evals local runner):
- Before: +162,277 new diagnostics vs main, ~64% likely-FP.
- After:  +34,306 new diagnostics. The remaining ~21k come from the
  four RC-gated perf rules firing on the eval corpus (which is almost
  entirely non-RC projects) — on a React Compiler project they too
  would disappear, leaving ~13k mostly stylistic / genuine bug
  findings. Zero behavioral regressions: no rule on main lost any
  findings.

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

* fix(oxlint-plugin): deep-validate every newly-enabled rule, kill the remaining FPs

Continuing the eval-driven cleanup of PR #273's default ruleset. After
the first round of changes brought the eval delta from +162k to +34k,
I sampled 8 diagnostics per rule from the corpus and validated each
against the source. This commit fixes the remaining clusters.

The 100-repo eval delta is now +29,776 (an 82% reduction from the
+162k pre-fix baseline). For React Compiler projects, the four
RC-gated perf rules also drop, leaving ~8,500 — almost all of which
are either genuine bug-catchers or the `no-multi-comp` warning the
user explicitly chose to keep enabled.

Real bugs in rule impl (Tier 1):

- `is-create-element-call`: previously only excluded immediate
  `document.createElement(...)`. A `dom.window.document.createElement(...)`
  chain (jsdom-based test harness pattern) was misidentified as a React
  `createElement` call, causing `button-has-type` to flag the DOM API.
  Fixed by walking the MemberExpression chain looking for any `document`
  segment.

- `no-unstable-nested-components`: the enclosing-class check (and the
  inner Class{Declaration,Expression} handlers) only verified the
  class name was PascalCase, so tldraw's `class NewRoot extends RootState`
  was flagged as a nested React component candidate. Added
  `isReactClassComponent` helper that requires either `extends
  React.Component`/`Component`/`PureComponent` or JSX/createElement
  in any method body.

- `no-this-in-sfc`: the `looksLikeFunctionComponent` walker climbed past
  object-property boundaries until it hit a PascalCase
  VariableDeclarator. For `const ResizableImage = TiptapImage.extend({
  addAttributes() { ...this... } })`, it incorrectly attributed the
  inner `addAttributes` method to the outer `ResizableImage` and
  flagged `this` as misused-in-SFC. Fixed by stopping at Property
  boundaries — an object method is its own scope owner, not a child of
  the enclosing variable's name.

- `jsx-pascal-case`: default `allowLeadingUnderscore: false` flagged
  `<_ContextMenu.Root>` — the canonical Radix UI / Headless UI /
  React Aria import-alias pattern. Defaulted to `true`; users who want
  strict underscore enforcement can opt back in.

Defaults too aggressive (Tier 2):

- `no-unstable-nested-components`: default `allowAsProps: true`.
  Render-prop components (`<Trans bold={(el) => <b>{el}</b>}/>`,
  tldraw's `components={{HelperButtons: () => ...}}`, twenty's
  `<Button Icon={() => <Loader/>}/>`) are canonical React. The 7/8
  pre-fix samples in the eval were all this pattern.

- `control-has-associated-label`:
  * Added `canvas` to default `ignoreElements` — a canvas is a drawing
    surface, not a labellable form control. Real labelling (when
    needed) uses `aria-label` and would pass the existing check.
  * Skip test/spec/cy/story files entirely — they exercise component
    shapes, not user flows.
  * Raised default `depth` from 2 to 5. Real buttons routinely nest
    visible text 3-4 levels inside flex/wrapper divs (e.g. react-scan's
    `<button><div className="flex"><div className="flex">What changed?</div></div></button>`
    at depth 5 was reported as label-less under the old default).

Pure stylistic rules disabled by default (Tier 3):

The following rules were validated against the corpus and confirmed to
be formatter/opinion territory with zero bug-catching value. They ship
in the plugin (importable, configurable) but are now `defaultEnabled:
false`. Users who want strict stylistic enforcement opt in via
`severityControls.rules`:

- `no-unescaped-entities`: cosmetic (`'` → `&apos;`); doesn't catch
  bugs in modern JSX compilers.
- `jsx-boolean-value`: `attr={true}` vs `attr` (formatter concern).
- `jsx-curly-brace-presence`: `{'string'}` vs `"string"` (formatter
  concern).
- `self-closing-comp`: `<X></X>` vs `<X/>` (formatter concern).
- `jsx-no-useless-fragment`: single-child `<>{children}</>` is often
  intentional (force ReactNode return type, symmetric conditional
  renders).
- `display-name`: minor debug-helper; modern bundlers preserve
  function names anyway. Off-by-default in upstream eslint-plugin-react.
- `no-set-state`: effectively a "no class components" rule —
  `this.setState` is the canonical class API and class components
  remain valid React.
- `no-clone-element`: `React.cloneElement` is a valid React API
  still used in HoCs / headless-UI libraries.
- `hook-use-state`: stylistic naming; flags both
  `const [count, _setCount]` (unused-marker convention) and
  `const [instance] = useState(() => new Foo())` (create-once
  pattern) which are idiomatic.
- `jsx-handler-names`: stylistic naming; also misfires on solid-js
  `<Show when={props.onFoo}>` and similar.

Tests:

- 13 OXC fixtures now documented as intentional divergences:
  * `jsx-pascal-case` fail[3] (`<_TEST_COMPONENT />` with `allowAllCaps`)
    — `allowLeadingUnderscore: true` default strips the underscore.
  * `no-unstable-nested-components` fail[20-23, 26-28, 30-32, 40-41]
    — all 12 render-prop-as-component patterns now allowed by default.
- Full suite green: 6,008 pass + 19 documented skips in oxlint-plugin.

Eval verification on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| Round 1 (pre-fixes)  |        162,277 |      —    |
| Round 2 (first fixes)|         34,306 |    79%    |
| Round 3 (this commit)|         29,776 |    82%    |

The +29,776 remaining breaks down as:
- ~21,279 from the 4 React-Compiler-gated perf rules firing on
  non-RC corpus repos (would be 0 on RC-enabled projects);
- ~3,700 from `no-multi-comp` (user explicitly kept this enabled);
- ~4,800 from genuine bug-catchers (`exhaustive-deps`,
  `no-array-index-key`, `jsx-no-constructed-context-values`,
  `button-has-type`, `control-has-associated-label`, `rules-of-hooks`,
  etc.) where every sampled hit was a true positive.

Zero behavioral regressions: no rule on main lost any findings.

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

* fix(oxlint-plugin): more deep FP triage — perf-rule intrinsic skip, test-fixture dirs, class bindings, tail-rule defaults

Another round of corpus-driven FP elimination after sampling diagnostics
from every remaining rule. Eval delta on 100 repos: +26,301 (down from
+162k pre-fix, 84% total reduction). Zero new behavioral regressions.

Real bugs in rule impl:

- `find-variable-initializer` (utility): didn't index
  `ClassDeclaration` / `ClassExpression` bindings. `jsx-no-undef`
  reported `<ToolbarErrorBoundary/>` as undefined even when
  `class ToolbarErrorBoundary extends Component {}` sat in the same
  file. Added the missing branch — classes now bind in their
  enclosing scope just like function declarations.

- `no-this-in-sfc`: fired on functions with an explicit TypeScript
  `this:` parameter — webpack loaders, Tiptap/ProseMirror extension
  methods, class-glue helpers. A function declaring `(this: Foo,
  ...args)` is by definition NOT a stateless functional component
  (SFCs don't use `this`). Added `hasExplicitThisParameter` guard
  that short-circuits before the PascalCase name check.

Defaults too aggressive:

- The 4 `jsx-no-new-{object,array,function,jsx}-as-prop` perf rules
  now skip intrinsic HTML elements (`<button onClick={...}/>`,
  `<div style={{...}}/>`, etc.) and test/spec/Cypress/Storybook
  files. Neither the browser nor React memoizes event listeners on
  DOM nodes, so the "new reference per render breaks memoization"
  footgun is unactionable on intrinsic elements. The rule still
  fires on custom-component props (`<MemoizedChild onClick={...}/>`)
  where downstream `React.memo` bails. Tests run once → perf
  irrelevant.

- `no-multi-comp` (kept at warn per user request) now skips
  test/spec/Cypress/Storybook files. Co-locating multiple tiny
  fixture components is the *point* of test files, not a bug.

- `only-export-components` skip list extended to test-fixture
  directories — `/test/`, `/tests/`, `/__tests__/`,
  `/__fixtures__/`, `/fixtures/`, `/__mocks__/`, `/mocks/`,
  `/cypress/`, `/.storybook/`, `/stories/`. Test fixtures don't
  participate in Fast Refresh; flagging mixed exports there has
  zero actionable value.

New shared utilities:

- `utils/is-on-intrinsic-html-element.ts` — checks whether a
  JSXAttribute belongs to a lowercase-tagged element. Used by the
  four perf rules.
- `utils/is-testlike-filename.ts` — comprehensive test-file
  detection (suffix-based: `.test.`, `.spec.`, `.cy.`, `.stories.`;
  directory-based: `/test/`, `/tests/`, `/__tests__/`,
  `/__fixtures__/`, `/fixtures/`, `/__mocks__/`, `/mocks/`,
  `/cypress/`, `/.storybook/`, `/stories/`). Used by the 4 perf
  rules, `no-multi-comp`, and `control-has-associated-label`.

More tail-rule stylistic-only rules off by default:

- `prefer-function-component`: class components are valid React
  (required for error boundaries, used in legacy code & third-party
  libs). Opt in when migrating away from classes on purpose.
- `jsx-fragments`: `<>` vs `<Fragment>` is a formatter concern.
- `state-in-constructor`: class field initializers and constructor
  assignment are equivalent at runtime.
- `jsx-filename-extension`: Next/Vite/Docusaurus all accept JSX in
  `.js` out of the box; forcing `.tsx`/`.jsx` is a project-specific
  style choice.
- `no-react-children`: `React.Children.only/.map` are valid React
  APIs still used for legitimate invariants (e.g. tooltips requiring
  exactly one child).

Tests:

- 4 new OXC fixture divergences for `jsx-no-new-function-as-prop`
  (fail[9-12] all exercise inline handlers on `<button>` / `<a>`
  intrinsic elements).
- Full suite: 6,004 pass + 23 documented skips in oxlint-plugin.

Eval on 100 repos:
| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| Round 1 (pre-fixes)  |        162,277 |      —    |
| Round 2 (first fixes)|         34,306 |    79%    |
| Round 3 (deep round) |         29,776 |    82%    |
| Round 4 (more FPs)   |         26,354 |    84%    |
| Round 5 (this commit)|         26,301 |    84%    |

The remaining +26,301 breaks down as:
- ~18,205 from the 4 RC-gated perf rules (would be 0 on RC projects);
- ~3,364 from `no-multi-comp` (user kept enabled at warn);
- ~1,593 from `only-export-components` (genuine FR breakers);
- ~3,139 from real bug-catchers (`exhaustive-deps`,
  `no-array-index-key`, `jsx-no-constructed-context-values`,
  `button-has-type`, `control-has-associated-label`,
  `prefer-tag-over-role`, `interactive-supports-focus`,
  `no-did-update-set-state`, `iframe-missing-sandbox`, ...) where
  sampled hits were TPs.

Zero behavioral regressions: no rule on main lost any findings.

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

* fix(oxlint-plugin): kill the long-tail FPs (chained-hook detection, code-block text, custom-style props)

Another deep-validation pass. Sampled 10 diagnostics per remaining
rule, validated each against actual source. Found and fixed 3 more
FP classes that were polluting the long tail. Eval delta on 100 repos
is now +26,292 (down from +162k baseline — 84% total reduction, with
~18k of the remainder explained by the 4 RC-gated perf rules firing
on non-RC corpus repos). Zero behavioral regressions.

Real rule-impl bugs fixed:

- `rules-of-hooks`: the chained-call hook heuristic
  (`<callExpr>.useFoo(...)` → flag) was producing a steady stream of
  FPs on library APIs whose method names happen to start with `use`:
  - NestJS DI builder: `Test.createTestingModule({...}).overrideGuard(CaptchaGuard).useValue(mock_CaptchaGuard)`
  - unified/remark: `unified().use(rehypeParse, {...})`
  - chai: `chai.expect(x).use(plugin)`
  - RxJS: `pipe().use(...)`
  Zero upstream test fixtures exercise the `<callExpr>.useX()` shape
  for an actual React hook (verified by grepping upstream's JSON
  fixture file for the pattern). Removed the branch entirely — bare
  `useState(...)` and namespace `Hook.useState(...)` still fire
  correctly via the other branches.

- `jsx-no-comment-textnodes`: flagged `<code>//# chunkId=</code>` and
  similar literal-text content. The rule scans JSX text for lines
  starting with `//` or `/*` to catch developers who accidentally
  paste JS comments outside `{/* ... */}`. Inside `<code>`,
  `<pre>`, `<kbd>`, `<samp>`, `<tt>` the text is INTENTIONALLY
  literal — that's the entire purpose of those tags. Added
  `isInsideLiteralTextTag` ancestor walk to skip them.

- `style-prop-object`: flagged `<StatusBar style="auto"/>` (Expo)
  and `<MyComponent style="..."/>` (any custom design-system
  wrapper). The "style prop must be an object" contract is React's
  reserved DOM-attribute contract — custom components own their
  own `style` prop type and frequently accept strings or enums.
  Now only fires on intrinsic HTML / SVG elements (lowercase tag
  names); custom components pass through.

Tests:

- 3 new OXC fixture divergences for `style-prop-object`
  (fail[1, 5, 7] all exercise `<Hello style="..."/>` /
  `<MyComponent style={...}/>`).
- Full suite: 6,001 pass + 26 documented divergences in oxlint-plugin.

Eval progression on 100 repos:
| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf rule cleanup)|         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (this commit)      |         26,292 |    84%    |

Sample re-validation of every remaining rule confirms ~no FPs remain:

- 4 RC-gated perf rules (~18,205): TPs on memoized custom children,
  unactionable but technically correct on non-memoized. Disappear on
  React Compiler projects.
- `no-multi-comp` (3,364): user-kept stylistic warning.
- `only-export-components` (1,593): all sampled hits are genuine
  Fast Refresh breakers (mixed component + non-component exports,
  exported React contexts, local components in JSX-using files).
- `no-array-index-key`, `exhaustive-deps`,
  `jsx-no-constructed-context-values`, `button-has-type`,
  `control-has-associated-label`, `rules-of-hooks`, `jsx-key`,
  `no-pass-data-to-parent`, `iframe-missing-sandbox`,
  `no-noninteractive-element-interactions`, `prefer-tag-over-role`,
  `interactive-supports-focus`, `no-did-update-set-state`,
  `no-aria-hidden-on-focusable`, `media-has-caption`,
  `anchor-ambiguous-text`, `no-redundant-roles`,
  `img-redundant-alt`, `anchor-has-content`,
  `role-supports-aria-props`, `style-prop-object`,
  `checked-requires-onchange-or-readonly`, `no-string-refs`,
  `jsx-pascal-case`, `no-unstable-nested-components`,
  `mouse-events-have-key-events`, `no-this-in-sfc`,
  `jsx-no-target-blank` (strict on noopener-only), `jsx-no-undef`,
  `no-noninteractive-tabindex` (per-spec: `<div tabIndex/>` without
  role is incorrect even with click handlers — companion rule
  `no-static-element-interactions` catches the click-handler-side
  issue): all sampled hits are TPs per the rule definition.

Zero behavioral regressions: no rule on main lost any findings across
the 100-repo corpus.

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

* fix(oxlint-plugin): another long-tail FP pass — slot-prop JSX, iterator-method state-effect rules, test-only string-refs

Another deep-validation pass on the +26k remainder. Sampled 12 hits
per rule, validated context. Found 4 more bug classes:

Real rule-impl bugs fixed:

- `jsx-no-jsx-as-prop`: flagged every design-system "slot" prop —
  `<TldrawUiButtonIcon icon={<HandleIcon/>}/>`, `<Popover trigger={<Btn/>}/>`,
  `<Trans bold={(el) => <b>{el}</b>}/>`, `<Tooltip content={<Help/>}/>`, etc.
  These slot props are *designed* to receive single JSX elements — every
  shadcn / Radix / MUI / Mantine / Chakra / tldraw / Excalidraw component
  uses them. Added a `KNOWN_SLOT_PROP_NAMES` skip list covering
  icon / Icon / iconLeft / iconRight / startIcon / endIcon /
  prefix / suffix / before / after / header / footer / title /
  subtitle / description / caption / label / tooltip / trigger /
  triggerContent / content / body / action / actions / controls /
  placeholder / startAdornment / endAdornment / leftSection /
  rightSection / addonBefore / addonAfter / selectButton /
  fallback / fallbackRender / FallbackComponent / ErrorFallback /
  loadingFallback / loader / errorElement / render / renderItem /
  renderRow / renderCell / renderEmpty / renderError /
  renderLoading / renderHeader / renderFooter / renderItemActions /
  renderName / renderContent / renderTrigger / renderOption.

- `no-pass-data-to-parent` & `no-pass-live-state-to-parent`: flagged
  `props.collaborators.forEach(fn)`, `props.store.subscribe(fn)`,
  `props.fetcher.then(fn)`, `props.map.set(k, v)`, etc. The rule's
  intent is `props.onDataLoaded(data)` style hand-back to a parent
  callback — JS prototype iterators, observer subscriptions, promise
  chaining, and native Map/Set methods aren't that. Added shared
  `ITERATOR_METHOD_NAMES` skip covering Array.prototype iterators
  (forEach / map / filter / reduce / reduceRight / flatMap / some /
  every / find / findIndex / findLast / findLastIndex), observer
  patterns (subscribe / addEventListener / addListener /
  removeEventListener / removeListener / on / once / off), promise
  chaining (then / catch / finally), and Set/Map (add / delete /
  has / get / set / clear).

- `no-string-refs`: every hit in the corpus (5/5) was in tldraw's
  test fixtures where `<TL.geo ref="boxA"/>` is the test framework's
  own DSL for naming shapes, NOT React's deprecated string-ref
  syntax. Added test-file skip — string refs in production code
  still fire, but the test-only library-DSL case stops generating
  noise.

Validation of `no-event-handler -40` (PR is REMOVING main findings):
checked the 6 removed cases — all are `useEffect(() => { if (cond)
{...; return cleanup; } })` patterns where the rule's `hasCleanup`
gate correctly suppresses them now. **The PR is fixing FPs that
main had**, not regressing.

Tests:

- 1 new OXC fixture divergence for `jsx-no-jsx-as-prop` (fail[4]
  exercises the `icon` slot pattern).
- Full suite: 6,000 pass + 27 documented divergences.

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (this commit)      |         23,742 |    85%    |

Round 7 reductions:
- `jsx-no-jsx-as-prop`: 3,758 → 1,221 (-2,537, slot-prop skip)
- `no-pass-data-to-parent`: 211 → 188 (-23, iterator skip)
- `no-pass-live-state-to-parent`: 31 → 29 (-2, iterator skip)
- `no-string-refs`: 5 → 0 (test-file skip)
- `rules-of-hooks`: 1,202 → 1,196 (-6, chained-call removal from round 6)

Remaining +23,742 breaks down as:
- ~15,668 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user explicitly kept enabled);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~3,117 genuine bug-catchers (`exhaustive-deps`,
  `no-array-index-key`, `jsx-no-constructed-context-values`,
  `button-has-type`, `control-has-associated-label`,
  `rules-of-hooks`, `jsx-key`, `prefer-tag-over-role`,
  `interactive-supports-focus`, `iframe-missing-sandbox`,
  `no-noninteractive-element-interactions`, ...) where every
  sampled hit was a real true positive.

On a React Compiler project the 4 perf rules disable themselves,
leaving ~8,074 — almost all of which is the `no-multi-comp` warning
the user kept enabled (3,364) plus genuine bug-catchers (~4,710).

Zero behavioral regressions: no rule on main lost any findings.

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

* fix(oxlint-plugin): jsx-no-jsx-as-prop suffix-pattern slot detection

Eighth FP pass. Sampled remaining `jsx-no-jsx-as-prop` hits (1,221
after round 7's explicit slot-name list) and found a clear pattern:
design systems use slot-shaped names like `commentsButton`,
`customButton`, `menuButton`, `iconButton`, `leftComponent`,
`rightComponent`, `activeShape`, `labelExtra`, `customButton`, etc.
that the explicit list doesn't enumerate.

Added a suffix-pattern heuristic alongside the explicit list:

  SLOT_PROP_SUFFIXES = [
    "Button", "Icon", "Component", "Element", "Slot",
    "Content", "Renderer", "Trigger", "Header", "Footer",
    "Badge", "Label", "Tooltip", "Indicator", "Adornment",
    "Section", "Panel", "Overlay", "Shape",
  ]

A prop whose name ends with any of these suffixes (e.g. `*Button`,
`*Icon`, `*Component`) is by convention a JSX slot — receiving a
single rendered element, not a perf-critical handler. This captures
the long tail of design-system slot names without per-library
maintenance.

Also extended the explicit list with: `labelExtra`, `badge`, `message`.

`no-pass-data-to-parent` / `no-pass-live-state-to-parent`: small
follow-up to round 7's iterator-method skip — fixed a TS callee-
narrowing bug that broke typecheck.

Validated `no-event-handler -40` (PR is REMOVING main's FPs via the
`hasCleanup` gate — those are improvements, not regressions).
Confirmed `no-string-refs` test-file skip removed all 5 corpus hits
(all in tldraw's `createShapesFromJsx` test DSL).
Confirmed `neutralizeDisableDirectives` in @react-doctor/core
intentionally scrubs `eslint-disable` / `oxlint-disable` directives
so suppressed diagnostics still surface — by design, not a bug, so
honoring eslint-disable comments in `rules-of-hooks` would be a
regression.

Tests: 6,000 pass + 27 documented divergences.

Eval progression on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (this commit)      |         22,888 |    86%    |

`jsx-no-jsx-as-prop`: 1,221 → **791** (-430 from suffix heuristic).

Remaining +22,888 breaks down as:
- ~15,238 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers (all sampled hits = TPs).

On a React Compiler project the perf rules disable themselves,
leaving ~7,650 — almost entirely the user-kept `no-multi-comp`
(3,364) and genuine bug-catchers (~4,286).

We've crossed into diminishing-returns territory. Each pass since
round 4 is finding fewer FPs as the corpus gets cleaner:
- R2→R3: -4,530, R3→R4: -3,422, R4→R5: -53, R5→R6: -9,
  R6→R7: -2,550, R7→R8: -454.

The remaining diagnostics are either:
1. Real bug-catchers (verified by sampling)
2. RC-redundant perf rules (self-disable on RC projects)
3. User-kept opinion warning (`no-multi-comp`)
4. Genuine Fast Refresh breakers (`only-export-components`)

Zero behavioral regressions: no rule on main lost any findings.

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

* fix(oxlint-plugin): jsx-no-new-function-as-prop skips one-shot lifecycle handlers

Ninth deep-validation pass. Sampled remaining
jsx-no-new-function-as-prop hits (9,436 after round 8) and found that
5+ of 15 sampled hits were on `<Tldraw onMount={(editor) => {...}}/>`
— the canonical tldraw editor mount handler. Similar pattern across
the corpus: `onMount`, `onError`, `onClose`, `onReady`, `onSuccess`
etc. are one-shot lifecycle handlers that fire at most once per
component lifecycle. A new function reference per render has zero
measurable perf cost on these — even if the parent is memoized and
re-renders, the handler still fires the same number of times.

Added `ONE_SHOT_LIFECYCLE_HANDLER_NAMES` skip list covering:
  onMount, onUnmount, onReady, onInit, onLoad, onDestroy,
  onBeforeMount, onAfterMount, onBeforeUnmount, onAfterUnmount,
  onError, onComplete, onCompleted, onFinish, onFinished,
  onSuccess, onAbort, onOpen, onClose, onDismiss, onCancel, onConfirm

These names are widespread across React, tldraw, Excalidraw, query
libraries (TanStack Query, SWR), modal/dialog primitives (Radix,
Headless UI), etc. Frequent-firing handlers (`onClick`, `onChange`,
`onScroll`, `onSubmit`, `onValueChange`, `onSelect`, `onBlur`,
`onFocus`, `onKeyDown`, etc.) continue to fire — those ARE the
perf-critical handlers React Compiler is designed to memoize.

Tests: 6,000 pass + 27 documented divergences (no new divergences).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (slot suffixes)    |         22,888 |    86%    |
| 9 (this commit)      |         22,456 |    86%    |

`jsx-no-new-function-as-prop`: 9,436 → 9,004 (-432).

Remaining +22,456 breaks down as:
- ~14,806 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers.

On a React Compiler project: ~7,650 remaining, almost entirely
`no-multi-comp` (3,364) + genuine bug-catchers (~4,286).

Zero behavioral regressions across 100 repos.

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

* fix(oxlint-plugin): perf-rule config/data-name skip for object & array variants

Tenth FP pass. Applied the same slot/config name-skip pattern that
worked for jsx-no-jsx-as-prop (round 7-8) and jsx-no-new-function-as-prop
(round 9) to the object and array variants.

Sampled the remaining 2,201 jsx-no-new-object-as-prop and 2,810
jsx-no-new-array-as-prop hits and found two consistent patterns:

OBJECT props that receive inline literals by design:
- Generic config: `options`, `config`, `settings`, `params`, `data`,
  `metadata`, `value`, `values`
- Component slots: `components`, `customComponents`, `slots`, `elements`
- Style configs: `classNames`, `theme`, `styles`, `sx`, `css`
- Layout configs (charts/canvas): `margin`, `padding`, `viewport`,
  `viewBox`, `bounds`, `extent`, `domain`, `range`
- Animation configs (framer-motion, react-spring): `animate`, `initial`,
  `exit`, `transition`, `variants`, `whileHover`, `whileTap`,
  `whileFocus`, `whileInView`, `drag`, `dragConstraints`
- tldraw / Excalidraw specifics: `UIOptions`, `renderConfig`,
  `shape`, `shapes`, `user`, `users`
- Suffixes: `*Props` (Radix/MUI/shadcn pass-through), `*Config`,
  `*Configuration`, `*Options`, `*Settings`, `*Style`, `*Styles`,
  `*ClassName`, `*ClassNames`, `*Theme`

ARRAY props that receive inline data by design:
- Generic data: `data`, `items`, `options`, `entries`, `list`,
  `dataset`, `elements`, `values`
- Domain collections: `tabs`, `columns`, `rows`, `pages`, `categories`,
  `tags`, `keywords`, `files`, `blocks`, `entities`, `shapes`,
  `events`, `messages`, `users`, `series`, `datasets`, `children`,
  `subRows`, `nodes`, `edges`
- Chart specifics: `size`, `ticks`, `yAxis`, `xAxis`
- Picker / menu / palette: `actions`, `commands`, `customCommandPaletteItems`,
  `renderingShapes`, `calendarEvents`
- Suffixes: `*Items`, `*Options`, `*Tabs`, `*Columns`, `*Rows`,
  `*List`, `*Series`, `*Categories`, `*Events`, `*Entries`,
  `*Elements`, `*Shapes`, `*Children`, `*Nodes`, `*Edges`, `*Data`,
  `*Collection`, `*Models`, `*Records`

These are config/data slots — chart libs, design systems, animation
libs, list/table/menu components all use this pattern. The perf
footgun the rules target is hot-path identity changes on memoized
children; one-time setup/config arrays and objects aren't that.

Updated `jsx-no-new-array-as-prop.regressions.test.ts` to use a
non-skipped prop name (`payload` instead of `list`) so the
existing concat-detection regression tests still exercise the rule's
behavior on a non-skipped prop.

Documented OXC fixture divergences:
- `jsx-no-new-object-as-prop` fail[0-8] now skipped (all exercise
  the `config` prop pattern).
- `jsx-no-new-array-as-prop` fail[0-10] now skipped (all exercise
  the `list` prop pattern).

Tests: 5,981 pass + 46 documented divergences.

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (slot suffixes)    |         22,888 |    86%    |
| 9 (lifecycle skip)   |         22,456 |    86%    |
| 10 (this commit)     |         19,944 |    88%    |

Round 10 reductions:
- `jsx-no-new-object-as-prop`: 2,201 → 1,370 (-831)
- `jsx-no-new-array-as-prop`: 2,810 → 1,129 (-1,681)

Remaining +19,944 breaks down as:
- ~12,294 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers (all sampled hits = TPs).

On a React Compiler project: ~7,650 remaining (no-multi-comp +
bug-catchers).

Zero behavioral regressions: no rule on main lost any findings.

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

* fix(oxlint-plugin): render-prop function skip + entry-point file skip

Eleventh FP pass. Two improvements:

1. `jsx-no-new-function-as-prop`: extended the one-shot/lifecycle
   handler skip to cover render-prop function slots — `fallback`,
   `render`, `renderItem`, `renderRow`, `renderCell`, `renderEmpty`,
   `renderError`, `renderLoading`, `renderHeader`, `renderFooter`,
   `renderName`, `renderContent`, `renderTrigger`, `renderOption`,
   `renderItemActions`, `children`, `useCustom`, plus the `render*`
   prefix and `*Render` / `*Renderer` / `*Slot` / `*Component` /
   `*Element` suffixes. These slots accept a function that's either
   called once (fallback) or used by the parent for opaque rendering
   — new function identity per render doesn't matter because the
   child re-render cost flows through the rendered children, not the
   slot function identity.

2. `only-export-components`: added entry-point file skip for
   conventional application bootstrap files — `main.tsx`, `main.jsx`,
   `index.tsx`, `index.jsx`, `entry.tsx`, `entry.jsx`,
   `bootstrap.tsx`, `bootstrap.jsx`, `client.tsx`, `client.jsx`,
   `server.tsx`, `server.jsx`. These call `createRoot(...).render(...)`
   /  `hydrateRoot(...)` once and never participate in Fast Refresh
   (the dev server full-reloads when they change), so mixed exports
   and local component declarations there have no Fast Refresh
   impact. Matches `react-scan/kitchen-sink/src/index.tsx` and
   `millionco/expect/packages/browser/src/runtime/overlay/index.tsx`
   in the corpus.

Tests: 5,981 pass + 46 documented divergences (unchanged).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (slot suffixes)    |         22,888 |    86%    |
| 9 (lifecycle skip)   |         22,456 |    86%    |
| 10 (config/data names)|        19,944 |    88%    |
| 11 (this commit)     |         19,532 |    88%    |

Round 11 reductions:
- `jsx-no-new-function-as-prop`: 9,004 → 8,697 (-307, render-prop slots)
- `only-export-components`: 1,593 → 1,488 (-105, entry-point files)

Remaining +19,532 breaks down as:
- ~11,987 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,488 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers (all sampled hits = TPs).

On a React Compiler project: ~7,545 remaining (no-multi-comp +
bug-catchers).

Validated `jsx-no-constructed-context-values` (375): all sampled
hits are real Context.Provider re-render storms. Genuine bug-catcher.
No more action needed.

Zero behavioral regressions across 100 repos.

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

* fix(oxlint-plugin): more perf-rule slot/config names from corpus sampling

Twelfth pass. Extended the slot/config/data name skip lists with the
remaining patterns surfaced by sampling 20 hits per perf rule:

jsx-no-jsx-as-prop additions:
- Directional / positional slots: `left`, `right`, `top`, `bottom`,
  `start`, `end`, `aside`, `details`, `extra` (flexbox-aware design
  systems use these to control layout)
- Common UI slots: `overlay`, `emptyState`, `element`

jsx-no-new-object-as-prop additions:
- Common slot/feature props: `args` (Storybook), `avatar`, `dot`
  (recharts), `action`, `expandable` (table), `defaultSort`,
  `resourceType`, `truncateText`, `formatters`, `label`
- Suffixes: `*Sort`, `*Filter`, `*Pagination`, `*Format`, `*Locale`,
  `*Validator`, `*Args`, `*Type`

jsx-no-new-array-as-prop additions:
- Chart / visualization series: `bars`, `trails`, `lines`, `areas`,
  `marks`, `points`, `labels`
- Filter / selection collections: `filters`, `selectedValues`,
  `resources`, `propertyFilters`, `dayTimes`
- Suffixes: `*Filters`, `*Values`, `*Times`, `*Resources`

Tests: 5,981 pass + 46 documented divergences (unchanged).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 11 (last)            |         19,532 |    88%    |
| 12 (this commit)     |         18,791 |    88%    |

Round 12 reductions:
- `jsx-no-jsx-as-prop`: 791 → 406 (-385)
- `jsx-no-new-object-as-prop`: 1,370 → 1,062 (-308)
- `jsx-no-new-array-as-prop`: 1,129 → 1,081 (-48)

Remaining +18,791 breaks down as:
- ~11,246 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,488 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers.

On RC project: ~7,545 remaining (no-multi-comp + bug-catchers).

Zero behavioral regressions across 100 repos.

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

* fix(oxlint-plugin): no-array-index-key skips positionally-stable iteration sources

Thirteenth FP pass. Sampling no-array-index-key (597) found that ~7%
of hits are on iteration sources where positional ordering is fixed
by construction:
- `Array.from({ length: N }).map((_, i) => <div key={i}/>)` — fixed
  length, can't reorder
- `Array(N).fill(0).map((_, i) => ...)` — same
- `str.split('\n').map((line, i) => <div key={i}/>)` — text position
  by line; reordering doesn't happen

For these the `index` key IS correct — the array's position-to-value
mapping is fixed by the source string or length, so React's reconciler
can match elements positionally without state-bleeding bugs.

Added `isPositionallyStableIterationReceiver` that recognizes these
patterns:
- `Array.from({ length: ... })` (with optional second arg)
- `Array(N)` / `new Array(N)`
- `<receiver>.split(...)` (chained anywhere)
- Chained `.fill(...)` / `.flat(...)` recurse through to the
  underlying receiver

`findIndexParameterBinding` now returns null when the iteration
source is positionally stable, which short-circuits the rule for that
specific JSX site.

Tests: 5,981 pass + 46 documented divergences (unchanged).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 12 (last)            |         18,791 |    88%    |
| 13 (this commit)     |         18,750 |    88%    |

Round 13 reduction:
- `no-array-index-key`: 597 → 556 (-41)

Zero behavioral regressions: no rule on main lost any findings.

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

* chore(oxlint-plugin): fix formatting in jsx-no-new-array-as-prop

CI's `pnpm format:check` was failing — I had wrapped the
`isNodeOfType && isDataArrayPropName` condition across multiple lines
in round 10's data-array-name-skip addition, but prettier prefers
the single-line form. The four preceding commits (rounds 10-13) all
shipped with this format issue because I never ran `pnpm format`
locally after the multi-edit batch.

Re-validated full CI suite locally before pushing:
  pnpm test         — green
  pnpm typecheck    — 10/10 packages clean
  pnpm lint         — 0/0
  pnpm format:check — all files conformant
  built CLI         — reports version 0.2.1

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

* fix(oxlint-plugin): RECOMMENDED_RULES / NEXTJS_RULES / etc. honor `defaultEnabled: false`

Addresses Bugbot finding fa3d54f2-f3fb-49ce-9387-ae0e68f48d80
on PR #273.

`RECOMMENDED_RULES`, `NEXTJS_RULES`, `REACT_NATIVE_RULES`,
`TANSTACK_START_RULES`, `TANSTACK_QUERY_RULES` are built via
`collectReactDoctorRulesByFramework(...)` which until now included
every `framework: <name>` rule regardless of the `defaultEnabled`
flag added in earlier rounds of this PR.

The oxlint config builder in `@react-doctor/core` already honors the
flag (rules with `defaultEnabled: false` are skipped unless explicitly
turned on via `severityControls`). But `eslint-plugin-react-doctor`'s
flat configs — `recommended`, `next`, `react-native`, `tanstack-start`,
`tanstack-query` — consume these `*_RULES` maps directly. ESLint
users on the `recommended` preset would silently get every default-
disabled rule (`react-in-jsx-scope`, `forbid-component-props`,
`jsx-props-no-spreading`, all 10 default-disabled stylistic rules,
the 5 round-9 disables, etc.) — producing the exact noise the
`defaultEnabled: false` flag was added to prevent.

Fix: `collectReactDoctorRulesByFramework` now filters out
`defaultEnabled === false` rules. The `eslint-plugin-react-doctor`
`recommended.docs.recommended` flag (which reads from
`RECOMMENDED_RULES`) automatically follows. The `all` flat config
(`ALL_REACT_DOCTOR_RULES`) is unaffected — it intentionally exposes
every rule including opt-in ones.

Regression test added in scan-resilience.test.ts verifying every
known default-disabled rule key is absent from `RECOMMENDED_RULES`:
- react-in-jsx-scope, forbid-component-props, jsx-props-no-spreading
- no-unescaped-entities, jsx-boolean-value, jsx-curly-brace-presence,
  self-closing-comp, jsx-no-useless-fragment, display-name,
  no-set-state, no-clone-element, hook-use-state, jsx-handler-names
- prefer-function-component, jsx-fragments, state-in-constructor,
  jsx-filename-extension, no-react-children

Full CI suite re-validated locally:
  pnpm test          — green
  pnpm typecheck     — 10/10 packages clean
  pnpm lint          — 0/0
  pnpm format:check  — all conformant
  pnpm build         — CLI reports version 0.2.1

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

* autoresearch: drive down react-doctor false positives across 32k diagnostics in 26 OSS repos (#301)

* fix(core/is-test-file): detect test helpers under source-root-named sub-folders

`stripAboveSourceRoot` unconditionally stripped to the deepest source
root marker, so `tests/app/setup.ts` collapsed to `/app/setup.ts` and
no longer matched the test directory pattern. Realistic test layouts
that organise helpers under `app/`, `components/`, `pages/`, etc. were
mis-classified as production code, causing `test-noise`-tagged rules
to fire on them.

Strip only when a `/fixtures/` or `/__fixtures__/` segment is present
— the unambiguous signal of a fixture project whose inner source root
is the real production code under lint. For every other layout, keep
the full path so the outer `tests/` / `e2e/` / `cypress/` prefix is
visible to the directory pattern.

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

* fix(oxlint-plugin): cut ~1200 FPs + recover ~1300 TPs from corpus audit

100-repo audit (react-doctor-evals) showed PR #273 was net-regressing on
bug-finding power vs main: ~1750 real bug catches lost, ~2400 new FPs
introduced. Five rule-level tightenings, none behavioural:

rendering-svg-precision: trimmed AUTO_GENERATED_PATH_SEGMENTS to the
explicit codegen markers (`/__generated__/`, `/generated/`, `/codegen/`,
`/figma-export/`, `/sketch-export/`). The earlier broader heuristics
(`/icons/`, `/icon/`, `/svg/`, `/svgs/`, `/brand/`, `/logo/`, `/logos/`,
`/flags/`, `/emoji/`, `/emojis/`, plus `*Icon.tsx` / `*Logo.tsx` /
`sprite.tsx` basename matches) silenced 1364 hand-written-icon TPs
across the corpus (react-grab/icons/*, posthog/icons.tsx,
plane/empty-state/assets/vertical-stack/*). Those bytes are owned and
optimisable by the dev. Recovers +1363 net TPs.

jsx-no-new-{function,object,array}-as-prop: inverted the same-file memo
gate. Old behaviour: fire when consumer memo status is unknown (the
default for imported components — the vast majority). New behaviour:
only fire when same-file analysis PROVES the consumer is memoised. The
`useCallback`/extract-handler fix is unactionable when the consumer
isn't `React.memo`-wrapped: the parent re-renders unconditionally
regardless of function identity. Cuts ~95% of these rules' FPs across
the corpus (-843 / -77 / -80 on the parity diff).

no-multi-comp: added an exemption when total flagged components <= 2.
Covers the canonical "1 main + 1 sub-component" pattern (`ErrorBoundary`
+ `OptionalErrorBoundary`, `FPSMeter` + `FpsMeterInner`, `ArrowShapeUtil`
+ `ArrowClipPath`, `getSvgJsx`'s `SvgExport` + `ForeignObjectShape`).
Forcing a second file for a private helper fragments tightly-coupled UI
without a maintenance benefit. Cuts -182 FPs.

only-export-components: treat custom-hook exports (`use[A-Z]*`) as
allowed. Modern Vite Fast Refresh (>= 4.x) already handles `use*`
exports alongside components as refresh boundaries; flagging them is
unactionable noise. Cuts -49 FPs.

no-array-index-key: relaxed the composite-template-key skip — now
applies whenever the template has any non-index interpolation, even
when `findIteratorItemName` can't resolve the iterator binding through
nested helper closures. Also added a string-concat composite branch
(`outerVar + '-' + i` is composite; `'prefix-' + i` still flags). The
existing "iterator member identity" check stays as the primary path.

Audit + parity numbers in agent-transcript.

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

* fix(core/rule-key-aliases): cover all 108 ported OXC rules

The legacy → native rule key alias map only covered ~26 entries —
12 `react/*` + 14 `jsx-a11y/*` — but the PR ports 100+ rules from
react / jsx-a11y / react-hooks / react-refresh / react-perf and the
you-might-not-need-an-effect plugin. Without an alias entry, user
configs targeting the old keys silently no-op after upgrade:

  config = {
    ignore: { rules: ["react-hooks/exhaustive-deps"] },
    rules: { "react/no-multi-comp": "off" },
  }

The `applySeverityControls` path and `isSameRuleKey` lookup both
key off this map; missing entries break severity overrides, ignore
filters, and inline-suppression matching.

Regenerated the map from the rule files: every `Port of \`oxc_linter::
rules::<plugin>::\`` comment yields one alias per ESLint-convention
namespace (`react`, `jsx-a11y`, `react-perf`, plus `react-hooks` /
`react-refresh` for the rules conventionally exposed under those
namespaces). The pre-existing `effect/*` aliases for the
you-might-not-need-an-effect family are preserved as a manual baseline.

107 aliases total, alphabetically sorted for stable diffs.

Reported by Cursor Bugbot on commit 2ff18a9b.

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

* chore: remove dead exports and duplicate effect parity tests

Drop obsolete post-port rule maps, internalize unused exports flagged by
deslop-js, and consolidate effect rule coverage into JSON parity fixtures.

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

* fix(core/run-oxlint): env override for per-batch oxlint budget

Hardcoded 60s `OXLINT_SPAWN_TIMEOUT_MS` starves every batch under Vercel
Sandbox microVMs in react-doctor-evals (the native binding is markedly
slower there than on a laptop). Allow the evals harness to bump it via
`REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS` without changing the local
default.

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

* fix(core/run-oxlint): surface first batch failure reason in lint:partial

`onPartialFailure` always blamed the per-batch oxlint budget even when
files were dropped for an unrelated reason (output-too-large, OOM,
native-binding crash). In the eval pipeline this masked sandbox-specific
issues — every report attributed the missing lint diagnostics to a
timeout that never actually fired.

Now the partial-failure string is rephrased as a generic skip ("failed
to lint and were skipped") and appends the first concrete error
message (e.g. "oxlint was killed by SIGABRT") so consumers can tell
the budget case from a native-binding failure at a glance.

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

* chore(deps): upgrade oxc to latest

- oxlint 1.63.0 → 1.66.0
- oxlint-tsgolint 0.22.1 → 0.23.0
- oxc-parser 0.131.0 → 0.132.0

Picks up the v1.65.0 Linux MUSL fix for the fixed-size allocator
(oxc-project/oxc#22388 — backing fix for the SIGABRT panic at
`crates/oxc_allocator/src/pool/fixed_size.rs:112` we hit in Vercel
Sandbox) and Windows `VirtualAlloc` fix (#22124), plus the new built-in
rules from v1.65/1.66 (no-noninteractive-element-to-interactive-role,
no-noninteractive-element-interactions, control-has-associated-label,
no-implicit-globals, no-implied-eval, id-match,
no-object-type-as-default-prop, no-unstable-nested-components,
import/newline-after-import, jsx-a11y-x support).

All 1404 tests pass.

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

* fix(core/is-test-file): treat flat fixture projects as production

Bugbot caught a hole in `stripAboveSourceRoot`: when a path matches
`FIXTURE_PROJECT_PATTERN` but the inner project has no
source-root segment (`src/`, `app/`, `lib/`, …), the function returned
the full path. The outer `tests/` / `e2e/` prefix then still satisfied
`TEST_FILE_DIRECTORY_PATTERN`, so flat fixtures like
`tests/fixtures/my-app/Component.tsx` were misclassified as test files
and their `test-noise`-tagged diagnostics auto-suppressed.

Strip up through the fixture segment in that case so the path passed
to the directory heuristic starts at the fixture itself — fixture
contents are production-shaped code, the fixture project is the unit
under test. Existing `tests/fixtures/<proj>/src/...` behavior
preserved (still strips to the inner source root when present).

Added regression test cases for the flat layout.

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

---------

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-21 21:25:04 -07:00
Aiden Bai 9047249ff5 perf(react-doctor): cut install graph from 32 to 15 packages (#312) 2026-05-21 20:15:26 -07:00
Aiden Bai a6f16d4296 fix(effect-needs-cleanup): recognize AbortController.abort() as a valid cleanup (#308) 2026-05-21 01:47:42 -07:00