Skip to content

Commit 8556b31

Browse files
authored
feat: address user feedback — reduce false positives, improve scoring transparency, and add config options (#208)
* feat(browser-poc): add AST normalizers for production React analysis Add 12 AST normalizers that transform minified production code into patterns the react-doctor plugin rules can analyze. Enables 30+ rules to fire on production bundles with 0 parse errors and 0 rule failures across 6 tested sites (vercel, notion, linear, discord, shopify, ami). Normalizers: SequenceExpression callee unwrap, OXC literal type normalization, boolean/void recovery, return/expression sequence splitting, JSX reconstruction (jsx/jsxs/createElement → JSXElement tree with Fragment, ExpressionContainer, key extraction), setter binding + reference rename, and component name uppercase recovery. Also adds parent reference tracking in visitAst, "use client" directive injection, WASM failure caching for CSP-blocked sites, truncated source skip, score calculation, and a null-safety fix for prefer-useReducer. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: address user feedback — reduce false positives, improve scoring transparency, and add config options - Add `offline`, `designRules`, and `entryFiles` config options - Suppress React 19 deprecation rules on React 18 (migration-hint gate) - Skip `rn-no-raw-text` for `.web.*` files (RN platform convention) - Add sleep/delay and paginated-fetch heuristics to `asyncAwaitInLoop` - Remove `noEmDashInJsxText` rule (em dashes are standard punctuation) - Add `designRules` toggle to disable opinionated design rules - Thread `entryFiles` to knip for dead-code false positive reduction - Export `calculateScoreBreakdown` and show formula in `--verbose` - Document scoring formula, diff/staged modes, and agent integration - Switch to `@changesets/changelog-github` for richer changelogs - Add GitHub Releases workflow Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: capabilities-based rule gating system Replace bespoke flags and filter functions with a unified capabilities + tags system: - Add buildCapabilities(project) that derives a flat Set<string> from ProjectInfo (react:19, nextjs, tanstack-query, etc.) - Add RULE_METADATA map with requires[] (capability gates) and tags (static classification like "design", "test-noise") - Replace filterRulesByReactMajor, filterDesignRules, VERSION_GATED_RULE_IDS, VersionGateMode, and conditional spreads with one shouldEnableRule predicate - Replace 9 individual fields on RunOxlintOptions/OxlintConfigOptions with project: ProjectInfo - Add ignore.tags to user config (replaces designRules boolean) - Cherry-pick from cursor/library-aware-deprecation-rules-ec3b: peerRangeSupportsLegacyReact, isTestFilePath, isLikelyBuildEntry, parseTailwindMajorMinor, isLikelyStringReceiver (js-set-map-lookups fix) - Compute effective React version from min(installed, peerRangeFloor) so library-targeting-legacy is handled by version gating alone Co-authored-by: Cursor <cursoragent@cursor.com> * fix: library peer-range detection + README designRules cleanup - Fix Bugbot: peerRangeMinMajor computes the floor major from the peer range so effective version is min(installed, peerFloor) instead of null - Fix Bugbot: replace designRules config key with ignore.tags in README - Add peerRangeMinMajor tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: wire isLikelyBuildEntry + isTestFilePath into post-scan suppression - Add auto-suppression in mergeAndFilterDiagnostics: suppress knip/files diagnostics when a matching build artifact exists, suppress test-noise tagged rules in test/fixture files - Tag deprecation and design rules with "test-noise" in RULE_METADATA - Fixes Bugbot: isLikelyBuildEntry is no longer dead code Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove dead DESIGN_TAGS constant, clear auto-suppression caches Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove dead peerRangeSupportsLegacyReact, unused ruleKey param, new Promise false negative Co-authored-by: Cursor <cursoragent@cursor.com> * fix: forward ignoredTags and entryFiles in diagnose() programmatic API Co-authored-by: Cursor <cursoragent@cursor.com> * fix: handle destructuring in loop-carried dependency detection Co-authored-by: Cursor <cursoragent@cursor.com> * fix: apply sleep/dependency heuristics to callback-based iteration The loopBodyHasOnlySleepLikeAwaits and hasLoopCarriedDependency checks were only applied in inspectLoopBody (for/while/do-while) but skipped for callback-based iteration (.forEach, .map, etc.), causing false positives on patterns like arr.forEach(async item => { await sleep(500) }). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: guard framework-specific rules against missing RULE_METADATA Rules from framework-specific maps (NEXTJS_RULES, REACT_NATIVE_RULES, etc.) without a RULE_METADATA entry were unconditionally enabled for all projects. Now they are skipped at runtime, and validateRuleRegistration warns about the gap at dev time. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: scope RULE_METADATA validation to framework-specific rules only Global rules intentionally omit RULE_METADATA entries since they're unconditionally enabled. Extracted FRAMEWORK_SPECIFIC_RULE_KEYS to share the set between the runtime guard and the validation check. ---------
1 parent 052cd20 commit 8556b31

45 files changed

Lines changed: 2479 additions & 490 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
3-
"changelog": "@changesets/cli/changelog",
3+
"changelog": ["@changesets/changelog-github", { "repo": "millionco/react-doctor" }],
44
"commit": false,
55
"fixed": [],
66
"linked": [],

.github/workflows/release.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
concurrency: ${{ github.workflow }}-${{ github.ref }}
8+
9+
permissions:
10+
contents: write
11+
pull-requests: write
12+
13+
jobs:
14+
release:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v5
18+
19+
- uses: pnpm/action-setup@v4
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: 24
24+
cache: pnpm
25+
registry-url: https://registry.npmjs.org
26+
27+
- run: pnpm install --frozen-lockfile
28+
29+
- name: Create Release PR or Publish
30+
id: changesets
31+
uses: changesets/action@v1
32+
with:
33+
publish: pnpm release
34+
title: "chore: version packages"
35+
commit: "chore: version packages"
36+
env:
37+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
39+
40+
- name: Create GitHub Releases
41+
if: steps.changesets.outputs.published == 'true'
42+
run: |
43+
for pkg in $(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | @base64'); do
44+
NAME=$(echo "$pkg" | base64 -d | jq -r '.name')
45+
VERSION=$(echo "$pkg" | base64 -d | jq -r '.version')
46+
TAG="${NAME}@${VERSION}"
47+
if [ "$NAME" = "react-doctor" ]; then
48+
TAG="v${VERSION}"
49+
fi
50+
gh release create "$TAG" \
51+
--title "$TAG" \
52+
--generate-notes \
53+
--target main || true
54+
done
55+
env:
56+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"leaderboard:update": "node --experimental-strip-types --no-warnings scripts/update-leaderboard.ts"
2727
},
2828
"devDependencies": {
29+
"@changesets/changelog-github": "^0.7.0",
2930
"@changesets/cli": "^2.31.0",
3031
"@types/node": "^25.6.0",
3132
"@voidzero-dev/vite-plus-core": "^0.1.15",

packages/react-doctor/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,15 +234,64 @@ When a suppression isn't working, `--explain <file:line>` (or its alias `--why <
234234
| `failOn` | `"error" \| "warning" \| "none"` | `"none"` |
235235
| `customRulesOnly` | `boolean` | `false` |
236236
| `share` | `boolean` | `true` |
237+
| `offline` | `boolean` | `false` |
237238
| `textComponents` | `string[]` | `[]` |
238239
| `rawTextWrapperComponents` | `string[]` | `[]` |
239240
| `respectInlineDisables` | `boolean` | `true` |
240241
| `adoptExistingLintConfig` | `boolean` | `true` |
242+
| `ignore.tags` | `string[]` | `[]` |
243+
| `entryFiles` | `string[]` | `[]` |
241244
242245
`textComponents` is the broad escape hatch for `rn-no-raw-text` — list components that themselves behave like React Native's `<Text>` (custom `Typography`, `NativeTabs.Trigger.Label`, etc.) and the rule will treat them as text containers regardless of what their children look like.
243246
244247
`rawTextWrapperComponents` is the narrower option for components that are not text elements but safely route string-only children through an internal `<Text>` (e.g. `heroui-native`'s `Button`, which stringifies its children and renders them through a `ButtonLabel`). Listed wrappers suppress `rn-no-raw-text` only when their children are entirely stringifiable. A wrapper with mixed children — e.g. `<Button>Save<Icon /></Button>` — still reports because the wrapper can't safely route raw text alongside a sibling JSX element.
245248
249+
`ignore.tags` suppresses entire categories of rules by tag. For example, `"tags": ["design"]` disables all opinionated design rules (gradient text, pure black backgrounds, side tab borders, default Tailwind palettes). Available tags: `"design"`.
250+
251+
`entryFiles` tells the dead-code detector about files that are executed directly but not imported (test runner configs, eval scripts, CLI entry points). These are forwarded to [knip](https://knip.dev) as additional entry points. Example: `"entryFiles": ["scripts/*.ts", "evalite.config.ts"]`. If your project already has a `knip.json`, those entry points are respected automatically.
252+
253+
`offline` skips the score API call and calculates the score locally. Automatically enabled in CI environments (GitHub Actions, GitLab CI, CircleCI). Set `true` in config to always score locally.
254+
255+
## Scoring
256+
257+
The health score formula: `100 - (unique_error_rules x 1.5) - (unique_warning_rules x 0.75)`.
258+
259+
Key details:
260+
261+
- The score counts **unique rules triggered**, not total instances. Fixing 49 of 50 `no-barrel-import` violations does not change the score; fixing all 50 removes the 0.75 penalty for that rule.
262+
- Error-severity rules cost 1.5 points each. Warning-severity rules cost 0.75 points each.
263+
- Category breakdowns shown in the output are for display only and do not weight the score.
264+
- Run `--verbose` to see which exact rules contributed to the score and how the penalty was computed.
265+
266+
Score labels: 75+ is **Great**, 50 to 74 is **Needs work**, under 50 is **Critical**.
267+
268+
Scores may decrease across releases as new rules are added. Each new rule that fires in your codebase introduces an additional penalty. This is expected — it means the tool is catching more issues, not that your code got worse. Pin to a specific react-doctor version in CI if you need stable scores across upgrades.
269+
270+
## Diff and staged modes
271+
272+
React Doctor can scan only changed files instead of the full project:
273+
274+
- **`--diff [base]`** scans files changed vs a base branch. Auto-detects `main`/`master`, or pass an explicit branch: `--diff develop`. Also available as a config key: `"diff": true` or `"diff": "develop"`.
275+
- **`--staged`** scans only files in the git staging area (index). Designed for pre-commit hooks — materializes staged file contents into a temp directory so the scan reflects exactly what will be committed.
276+
- **`--full`** forces a full scan, overriding any `diff` value in config or CLI.
277+
278+
When on a feature branch without explicit flags, you'll be prompted: "Only scan changed files?" This prompt is suppressed in CI, `--json` mode, and non-interactive environments.
279+
280+
`--staged` and `--diff` cannot be combined. Both modes skip dead-code detection (knip needs the full project to detect unused files).
281+
282+
## Agent and CI integration
283+
284+
React Doctor detects 50+ coding agents (Claude Code, Cursor, Codex, OpenCode, Windsurf, and more) and adapts its behavior automatically:
285+
286+
- **Install for agents**: `npx react-doctor@latest install` writes agent-specific rule files (SKILL.md, AGENTS.md, .cursorrules) into your project so agents learn React best practices.
287+
- **JSON output**: `--json` produces a structured `JsonReport` on stdout. Errors still produce a valid JSON document with `ok: false`. Use `--json-compact` for minimal whitespace.
288+
- **Score-only output**: `--score` outputs just the numeric score (0-100), useful for threshold checks in agent loops.
289+
- **GitHub Actions annotations**: `--annotations` emits `::error` / `::warning` format for inline PR annotations.
290+
- **Exit codes**: `--fail-on error` (default) exits non-zero when error-severity diagnostics are found. Use `--fail-on warning` or `--fail-on none` to tune CI gating.
291+
- **Programmatic API**: `import { diagnose } from "react-doctor/api"` for direct integration in scripts and automation.
292+
293+
In CI environments, prompts are automatically skipped and scoring runs locally (offline mode).
294+
246295
## Node.js API
247296
248297
```js

packages/react-doctor/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,23 @@
5555
"./eslint-plugin": {
5656
"types": "./dist/eslint-plugin.d.ts",
5757
"default": "./dist/eslint-plugin.js"
58+
},
59+
"./browser-poc": {
60+
"types": "./dist/browser-poc.d.ts",
61+
"default": "./dist/browser-poc.js"
5862
}
5963
},
6064
"scripts": {
6165
"dev": "vp pack --watch",
62-
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && NODE_ENV=production vp pack",
66+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && NODE_ENV=production vp pack && esbuild src/browser-poc.ts --bundle --format=iife --global-name=ReactDoctorBrowserPoc --platform=browser --target=es2022 --loader:.wasm=binary --outfile=dist/browser-poc.global.js",
67+
"poc:browser": "vite --host 127.0.0.1",
6368
"typecheck": "tsc --noEmit",
6469
"test": "vp test run"
6570
},
6671
"dependencies": {
72+
"@oxc-parser/wasm": "^0.60.0",
6773
"agent-install": "0.0.5",
74+
"bippy": "^0.5.39",
6875
"commander": "^14.0.3",
6976
"knip": "^6.10.0",
7077
"ora": "^9.4.0",

0 commit comments

Comments
 (0)