mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
codex/oxc-source-patch
1865 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
31141f03eb | fix | ||
|
|
eecd90cc3d | feat(cli): add native agent hook installer (#452) | ||
|
|
241444d352 | fix: format | ||
|
|
fd6f46f631 | fix | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
26239055c9 |
Revert "docs: README integration coverage + TODOS bookkeeping (#425)" (#445)
This reverts commit
|
||
|
|
523696940c |
Revert "feat(action): React Doctor Review composite action (#307)" (#443)
This reverts commit
v0.5.1
|
||
|
|
6047362e14 |
docs: remove README blockquotes (vs comparison + knip migration note) (#442)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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>
|
||
|
|
5759fd4699 | feat(skills): add thermo-nuclear-code-quality-review skill (#436) | ||
|
|
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>
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
3260381203 | ci(security): pin permissions and add fork guards on existing workflows (#424) | ||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
ff2a4b4db9 | chore: upgrade deslop-js to ^0.0.12 (#430) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
8a5f36eb19 | remove react review CTA from CLI summary (#416) | ||
|
|
f789b44968 |
fix(oxlint-plugin-react-doctor): drop deprecated @types/eslint-scope and @types/eslint-visitor-keys stubs (#415)
|
||
|
|
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> |
||
|
|
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> |
||
|
|
2cc0a6030f |
docs(todos): drop landed items and refresh against main (#413)
Co-authored-by: Cursor Agent <agent@cursor.sh> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
5a8465ead8 | 0.2.3 | ||
|
|
49a973088b | fix | ||
|
|
a0c702a2dc | 0.2.2 | ||
|
|
6dda09cc45 | triage: address #219 audit and #203 Husky/lint-staged recipe (#403) | ||
|
|
64e2d1b9e7 | chore(core): upgrade deslop-js to ^0.0.11 (#402) | ||
|
|
6543a86f9d | fix(project-info): find hoisted react in monorepo workspace roots (#313) | ||
|
|
36f20e2b98 | fix: keep score API on in CI with ?ci=1 marker (issue #302) (#309) v0.4.2 | ||
|
|
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
|
||
|
|
9047249ff5 | perf(react-doctor): cut install graph from 32 to 15 packages (#312) | ||
|
|
a6f16d4296 | fix(effect-needs-cleanup): recognize AbortController.abort() as a valid cleanup (#308) |