diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..648ec4888c --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,14 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "react-doctor": "0.1.6", + "website": "0.1.0" + }, + "changesets": [ + "tailwind-version-detection", + "url-asset-imports-and-info-annotations", + "v2-categorization-and-compat", + "v2-rewrite" + ] +} diff --git a/.changeset/url-asset-imports-and-info-annotations.md b/.changeset/url-asset-imports-and-info-annotations.md new file mode 100644 index 0000000000..df778bc799 --- /dev/null +++ b/.changeset/url-asset-imports-and-info-annotations.md @@ -0,0 +1,39 @@ +--- +"react-doctor": patch +--- + +Temporarily disable the codebase graph checks (dead-code, dependencies, +react-architecture) by default in the CLI. They still produce too many +false positives on large/monorepo codebases (e.g. PostHog: 14k+ warnings, +1.6k errors driven by `unresolved-import`, `unused-export`, etc.) to be +acceptable as default-on diagnostics. Opt back in per-run with +`--dead-code` or persistently via `"reactDoctor": { "deadCode": true }` +in `package.json` / `react-doctor.config.json`. The SDK behavior is +unchanged (it was already opt-in there). + +Fix three false-positive sources that surfaced when the graph is enabled: + +- The codebase analyzer's extractor records every + `new URL(specifier, import.meta.url)` as an asset import, but the + resolver still ran ordinary module resolution against the specifier. + Idiomatic Node config patterns like + `fileURLToPath(new URL("./src", import.meta.url))` therefore emitted + an `unresolved-import` error even though the URL is only used for + path computation. Asset URLs that don't resolve as modules are now + treated as silent asset references (tracking the file path when it + exists on disk so dead-code detection still sees the reference). +- `--annotations` mapped every non-error severity to `::warning`, which + promoted `info` diagnostics (e.g. the demoted `unused-type-export` + rule) to warning-level CI annotations. Info diagnostics are exempt + from scoring and meant to surface only in `--verbose`; they are now + skipped entirely from GitHub Actions annotations. +- The `vite` and `nextjs` codebase plugins registered + `vite.config.{*}` and `next.config.{*}` as **runtime** entrypoints, + which made their build-time plugin imports + (`@vitejs/plugin-react`, `@tailwindcss/vite`, `@next/mdx`, ...) look + like runtime dependencies and triggered `runtime-dev-dependency` + warnings on packages that are correctly declared in devDependencies. + Both plugins now leave config files to be picked up as `support` + entries by the generic `*.config.*` rule in `SUPPORT_ENTRY_PATTERNS` + — plugin imports are still tracked as used dependencies, but no + longer count as runtime usage. diff --git a/.changeset/v2-categorization-and-compat.md b/.changeset/v2-categorization-and-compat.md new file mode 100644 index 0000000000..fcd6249356 --- /dev/null +++ b/.changeset/v2-categorization-and-compat.md @@ -0,0 +1,31 @@ +--- +"react-doctor": patch +--- + +Fixes carried out alongside the v2 rewrite: + +- **Restore the v1 error-class surface on `react-doctor/api`.** The compat + module now re-exports `AmbiguousProjectError`, `NoReactDependencyError`, + `PackageJsonNotFoundError`, `ProjectNotFoundError`, `ReactDoctorError`, and + `isReactDoctorError` so existing v1 consumers (sandbox runners, + third-party diagnose wrappers) keep importing the same names. +- **Fix oxlint category routing.** The runner had a stale duplicate + `RULE_CATEGORY_MAP` that covered only ~half the v2 rules; the other half + (`tailwind-*`, `client-*`, `effect-*`, `nextjs-*`, `tanstack-*`, `rn-*`, + many `no-*`, …) silently fell through to the `Other` category. Switched + the runner to the comprehensive `resolveOxlintDiagnosticCategory()` that + already existed in `core/rules/lint/utils`, deleted the duplicated map, + and added a registry test that asserts every rule resolves to a real + category (never `Other`). Category breakdowns now look meaningful: + Performance/Architecture/Accessibility/State & Effects/etc. instead of a + giant `Other` bucket. +Scoring calibration note: v2's local score function is more expressive +than v1's (`100 - errorRules*1.5 - warningRules*0.75`) — per-category +caps and log-scaled per-rule amplification mean high-instance rules cost +more and many small categories cost more than v1's flat per-unique-rule +penalty. The same project will score lower under v2 than v1 even when v2 +finds fewer total issues. The remote `react.doctor` score endpoint +should be updated to use this package's `react-doctor/score` export so +server and local results match; until then, the remote API will reject +the v2 payload shape and clients will silently fall back to local v2 +scoring. diff --git a/.changeset/v2-rewrite.md b/.changeset/v2-rewrite.md new file mode 100644 index 0000000000..e3b0bbaff6 --- /dev/null +++ b/.changeset/v2-rewrite.md @@ -0,0 +1,13 @@ +--- +"react-doctor": minor +--- + +v2 rewrite: SDK-first surface (`react-doctor` exports the SDK; the legacy +`diagnose()` shape lives at `react-doctor/api`). Adds a new +`react-doctor/score` subpath. Drops the `react-doctor/browser-poc` export. +Drops the `eslint-plugin-react-hooks` / +`eslint-plugin-react-you-might-not-need-an-effect` peer dependencies +(`eslint-plugin-react-hooks` is now a regular dependency; the +"you-might-not-need-an-effect" rules are skipped unless the plugin is +installed in the consumer project). Other runtime deps trimmed: `knip`, +`bippy`, `@oxc-parser/wasm` dropped; `oxc-parser`, `oxc-resolver` added. diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000000..e2a0a61d1d --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,30 @@ +name: Preview Release + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: {} + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build react-doctor + run: pnpm --filter react-doctor build + + - name: Publish preview release + run: pnpm exec pkg-pr-new publish './packages/react-doctor' diff --git a/.gitignore b/.gitignore index 0aa80188c4..1bda08ad79 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ review-report.md review-*.md *.review.md *.tgz +.regression/ diff --git a/package.json b/package.json index 2a7241e38f..32551fb54c 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,13 @@ "@changesets/cli": "^2.31.0", "@types/node": "^25.6.0", "@voidzero-dev/vite-plus-core": "^0.1.15", + "pkg-pr-new": "^0.0.71", "turbo": "^2.9.7", "typescript": "^6.0.3", "vite-plus": "^0.1.15" }, "engines": { - "node": ">=22", + "node": ">=22.12.0", "pnpm": ">=8" }, "packageManager": "pnpm@10.29.1", @@ -48,6 +49,13 @@ "overrides": { "oxlint": "^1.63.0", "oxlint-tsgolint": "^0.22.1" + }, + "packageExtensions": { + "@voidzero-dev/vite-plus-core": { + "dependencies": { + "picomatch": "^4.0.4" + } + } } } } diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index 5a97e1999c..1d723aa6ea 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,102 @@ # react-doctor +## 0.2.0-beta.1 + +### Patch Changes + +- [#217](https://github.com/millionco/react-doctor/pull/217) [`3abd2a0`](https://github.com/millionco/react-doctor/commit/3abd2a0b1ca47245d380720e428070718912db66) Thanks [@aidenybai](https://github.com/aidenybai)! - Temporarily disable the codebase graph checks (dead-code, dependencies, + react-architecture) by default in the CLI. They still produce too many + false positives on large/monorepo codebases (e.g. PostHog: 14k+ warnings, + 1.6k errors driven by `unresolved-import`, `unused-export`, etc.) to be + acceptable as default-on diagnostics. Opt back in per-run with + `--dead-code` or persistently via `"reactDoctor": { "deadCode": true }` + in `package.json` / `react-doctor.config.json`. The SDK behavior is + unchanged (it was already opt-in there). + + Fix three false-positive sources that surfaced when the graph is enabled: + + - The codebase analyzer's extractor records every + `new URL(specifier, import.meta.url)` as an asset import, but the + resolver still ran ordinary module resolution against the specifier. + Idiomatic Node config patterns like + `fileURLToPath(new URL("./src", import.meta.url))` therefore emitted + an `unresolved-import` error even though the URL is only used for + path computation. Asset URLs that don't resolve as modules are now + treated as silent asset references (tracking the file path when it + exists on disk so dead-code detection still sees the reference). + - `--annotations` mapped every non-error severity to `::warning`, which + promoted `info` diagnostics (e.g. the demoted `unused-type-export` + rule) to warning-level CI annotations. Info diagnostics are exempt + from scoring and meant to surface only in `--verbose`; they are now + skipped entirely from GitHub Actions annotations. + - The `vite` and `nextjs` codebase plugins registered + `vite.config.{*}` and `next.config.{*}` as **runtime** entrypoints, + which made their build-time plugin imports + (`@vitejs/plugin-react`, `@tailwindcss/vite`, `@next/mdx`, ...) look + like runtime dependencies and triggered `runtime-dev-dependency` + warnings on packages that are correctly declared in devDependencies. + Both plugins now leave config files to be picked up as `support` + entries by the generic `*.config.*` rule in `SUPPORT_ENTRY_PATTERNS` + — plugin imports are still tracked as used dependencies, but no + longer count as runtime usage. + +## 0.2.0-beta.0 + +### Minor Changes + +- [#217](https://github.com/millionco/react-doctor/pull/217) [`cfc65f2`](https://github.com/millionco/react-doctor/commit/cfc65f28c8ccd3e540cdacce97574dd65b99ef19) Thanks [@aidenybai](https://github.com/aidenybai)! - v2 rewrite: SDK-first surface (`react-doctor` exports the SDK; the legacy + `diagnose()` shape lives at `react-doctor/api`). Adds a new + `react-doctor/score` subpath. Drops the `react-doctor/browser-poc` export. + Drops the `eslint-plugin-react-hooks` / + `eslint-plugin-react-you-might-not-need-an-effect` peer dependencies + (`eslint-plugin-react-hooks` is now a regular dependency; the + "you-might-not-need-an-effect" rules are skipped unless the plugin is + installed in the consumer project). Other runtime deps trimmed: `knip`, + `bippy`, `@oxc-parser/wasm` dropped; `oxc-parser`, `oxc-resolver` added. + +### Patch Changes + +- [#202](https://github.com/millionco/react-doctor/pull/202) [`53fa4df`](https://github.com/millionco/react-doctor/commit/53fa4dffe837e0157fb850fef700fccaaec191ea) Thanks [@aidenybai](https://github.com/aidenybai)! - Detect the project's Tailwind version (`tailwindcss` in `package.json`, + including pnpm and Bun catalog references) and gate Tailwind-aware + rules on it. `design-no-redundant-size-axes` (which suggests collapsing + `w-N h-N` → `size-N`) now stays silent on Tailwind v3.0 … v3.3 — those + versions predate the `size-N` shorthand and the suggestion would + generate classes that don't compile. The rule still fires on Tailwind + v3.4+, v4+, and when the version cannot be resolved (the same + "assume latest" fallback used by the React-major gate). + + A new `tailwindVersion` field is added to `ProjectInfo` and printed + during scans so it's visible alongside the detected React version and + framework. + +- [#217](https://github.com/millionco/react-doctor/pull/217) [`7797985`](https://github.com/millionco/react-doctor/commit/77979851d288e29b19f808168742e1b15e5ec8ae) Thanks [@aidenybai](https://github.com/aidenybai)! - Fixes carried out alongside the v2 rewrite: + + - **Restore the v1 error-class surface on `react-doctor/api`.** The compat + module now re-exports `AmbiguousProjectError`, `NoReactDependencyError`, + `PackageJsonNotFoundError`, `ProjectNotFoundError`, `ReactDoctorError`, and + `isReactDoctorError` so existing v1 consumers (sandbox runners, + third-party diagnose wrappers) keep importing the same names. + - **Fix oxlint category routing.** The runner had a stale duplicate + `RULE_CATEGORY_MAP` that covered only ~half the v2 rules; the other half + (`tailwind-*`, `client-*`, `effect-*`, `nextjs-*`, `tanstack-*`, `rn-*`, + many `no-*`, …) silently fell through to the `Other` category. Switched + the runner to the comprehensive `resolveOxlintDiagnosticCategory()` that + already existed in `core/rules/lint/utils`, deleted the duplicated map, + and added a registry test that asserts every rule resolves to a real + category (never `Other`). Category breakdowns now look meaningful: + Performance/Architecture/Accessibility/State & Effects/etc. instead of a + giant `Other` bucket. + Scoring calibration note: v2's local score function is more expressive + than v1's (`100 - errorRules*1.5 - warningRules*0.75`) — per-category + caps and log-scaled per-rule amplification mean high-instance rules cost + more and many small categories cost more than v1's flat per-unique-rule + penalty. The same project will score lower under v2 than v1 even when v2 + finds fewer total issues. The remote `react.doctor` score endpoint + should be updated to use this package's `react-doctor/score` export so + server and local results match; until then, the remote API will reject + the v2 payload shape and clients will silently fall back to local v2 + scoring. + ## 0.1.6 ### Patch Changes diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 03c413edac..ce4c0b4c74 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -9,13 +9,13 @@ Your agent writes bad React, this catches it. -One command scans your codebase and outputs a **0 to 100 health score** with actionable diagnostics. +React Doctor scans React projects with native codebase analysis, a curated oxlint rule set, and actionable diagnostics. -Works with Next.js, Vite, and React Native. +Works with React, Next.js, React Native, Expo, TanStack Start, and common React ecosystem libraries. -### [See it in action →](https://react.doctor) +### [See it in action](https://react.doctor) -## Install +## Run Run this at your project root: @@ -23,104 +23,119 @@ Run this at your project root: npx -y react-doctor@latest . ``` -You'll get a score (75+ Great, 50 to 74 Needs work, under 50 Critical) and a list of issues across state & effects, performance, architecture, security, accessibility, and dead code. Rules toggle automatically based on your framework and React version. +By default React Doctor runs: + +- native project structure and codebase graph checks +- oxlint with the React Doctor custom plugin +- scoring and grouped human output + +You get a 0 to 100 score and a list of issues across state and effects, performance, architecture, security, accessibility, framework usage, dependencies, and dead code. Rules toggle automatically based on your framework, React version, and detected libraries. https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570 -## Install for your coding agent +## React Doctor Skill -Teach your coding agent React best practices so it stops writing the bad code in the first place. +React Doctor also ships as an agent Skill. The CLI catches problems after code is written; the Skill teaches your coding agent the same React, framework, and performance guidance before it writes the next patch. ```bash npx -y react-doctor@latest install ``` -You'll be prompted to pick which detected agents to install for. Pass `--yes` to skip prompts. - -Works with Claude Code, Cursor, Codex, OpenCode, and 50+ other agents. +Use the Skill when you want agents to: -## GitHub Actions +- avoid common state and effect mistakes +- choose framework-native APIs for Next.js, React Native, Expo, and TanStack Start +- keep rendering, animation, data fetching, and accessibility choices high-signal +- understand React Doctor diagnostics and fix the underlying issue instead of hiding it -A composite action ships with this repository. Drop it into `.github/workflows/react-doctor.yml`: +The installer detects supported coding agents and prompts you to choose where to install the Skill. Pass `--yes` to accept the default detected targets. -```yaml -name: React Doctor +## CLI -on: - pull_request: - push: - branches: [main] +```bash +react-doctor [directory] +``` -permissions: - contents: read - pull-requests: write # required to post PR comments +Useful flags: -jobs: - react-doctor: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 # required for `diff` - - uses: millionco/react-doctor@main - with: - diff: main - github-token: ${{ secrets.GITHUB_TOKEN }} +```bash +react-doctor apps/web --json +react-doctor apps/web --json --json-compact +react-doctor apps/web --no-lint +react-doctor apps/web --no-dead-code +react-doctor apps/web --custom-rules-only +react-doctor apps/web --staged +react-doctor apps/web --unstaged +react-doctor apps/web --changed +react-doctor apps/web --diff main +react-doctor apps/web --offline +react-doctor apps/web --fail-on error ``` -When `github-token` is set on `pull_request` events, findings are posted (and updated) as a PR comment. The action also exposes a `score` output (0–100) you can use in subsequent steps. +Changed-file modes only inspect matching source files: -**Inputs:** `directory`, `verbose`, `project`, `diff`, `github-token`, `fail-on` (`error` / `warning` / `none`), `offline`, `node-version`. See [`action.yml`](https://github.com/millionco/react-doctor/blob/main/action.yml) for full descriptions. +- `--staged` scans the git index for pre-commit flows. +- `--unstaged` scans unstaged and untracked source files. +- `--changed` scans staged, unstaged, and untracked source files since `HEAD`. +- `--diff [base]` scans files changed against a base branch, defaulting to `main`. -Prefer not to add a marketplace action? The bare `npx` form works too: +If no changed source files are found, source checks are skipped instead of falling back to a full scan. -```yaml -- run: npx -y react-doctor@latest --fail-on warning -``` +`--fail-on` accepts `error`, `warning`, or `none`. ## Configuration -Create a `react-doctor.config.json` in your project root: +React Doctor looks for configuration in: + +- `react-doctor.config.json` +- `package.json#reactDoctor` + +Config lookup starts at the requested directory and walks ancestors until a project boundary. `rootDir` is resolved relative to the config source, not the current working directory. ```json { + "rootDir": "apps/web", + "lint": true, + "deadCode": true, + "customRulesOnly": false, + "offline": true, + "failOn": "error", + "respectInlineDisables": true, + "adoptExistingLintConfig": false, + "includeEcosystemRules": true, + "ignoredTags": ["design"], + "textComponents": ["Trans"], + "rawTextWrapperComponents": ["Button"], "ignore": { - "rules": ["react/no-danger", "jsx-a11y/no-autofocus"], + "rules": ["react-doctor/no-gradient-text"], "files": ["src/generated/**"], "overrides": [ { - "files": ["components/modules/diff/**"], - "rules": ["react-doctor/no-array-index-as-key", "react-doctor/no-render-in-render"] - }, - { - "files": ["components/search/HighlightedSnippet.tsx"], - "rules": ["react/no-danger"] + "files": ["src/legacy/**"], + "rules": ["react-doctor/no-default-props"] } ] } } ``` -Three nested keys, three layers of granularity — pick the narrowest one that fits: +Pick the narrowest ignore that fits: -- **`ignore.rules`** silences a rule across the whole codebase. -- **`ignore.files`** silences **every** rule on the matched files (use sparingly — it loses coverage for unrelated rules). +- **`ignore.rules`** silences a rule across the codebase. +- **`ignore.files`** silences every rule on matched files. - **`ignore.overrides`** silences only the listed rules on the matched files, leaving every other rule active. This is what you want when a single file (or glob) legitimately needs an exemption from one or two rules but should still be scanned for everything else. -You can also use the `"reactDoctor"` key in `package.json`. CLI flags always override config values. +React Doctor scans only its curated rule set by default. Set `adoptExistingLintConfig` to `true` to adopt the first JSON `.oxlintrc.json` or `.eslintrc.json` found while walking ancestors. -React Doctor respects `.gitignore`, `.eslintignore`, `.oxlintignore`, `.prettierignore`, and `linguist-vendored` / `linguist-generated` annotations in `.gitattributes`. Inline `// eslint-disable*` and `// oxlint-disable*` comments are honored too. +`ignoredTags` lets you trim noisy categories without turning the whole scanner off. For example, `["design"]` keeps structural React checks while skipping subjective visual style suggestions. -If you have a JSON oxlint or eslint config (`.oxlintrc.json` or `.eslintrc.json`), its rules get merged into the scan automatically and count toward the score. Set `adoptExistingLintConfig: false` to opt out. +For React Native, `textComponents` marks custom components that behave like ``, while `rawTextWrapperComponents` marks components that safely wrap string-only children in text internally. -#### Optional companion plugins +## Scoring -When the following ESLint plugins are installed in the scanned project (or hoisted in your monorepo), React Doctor folds their rules into the same scan. Both are listed as **optional peer dependencies** — install only what you want. +Scores are a simple health signal, not a moral judgment. React Doctor starts at 100, subtracts more for error-level rule families than warning-level families, and counts a repeated rule once so one noisy pattern does not dominate the whole project. -| Plugin | Adds | Namespace | -| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| [`eslint-plugin-react-hooks`](https://www.npmjs.com/package/eslint-plugin-react-hooks) (v6 or v7) | The React Compiler frontend's correctness rules — fired when a React Compiler is detected in the project. | `react-hooks-js/*` | -| [`eslint-plugin-react-you-might-not-need-an-effect`](https://github.com/nickjvandyke/eslint-plugin-react-you-might-not-need-an-effect) (v0.10+) | Complementary effects-as-anti-pattern rules (`no-derived-state`, `no-chain-state-updates`, `no-event-handler`, `no-pass-data-to-parent`, …) that run alongside React Doctor's native State & Effects rules. | `effect/*` | +The score can move between releases as rules become more precise, new framework rules are added, or noisy checks are demoted. Treat the detailed diagnostics as the source of truth and use the score for trend tracking across repeated runs. ### Inline suppressions @@ -139,7 +154,7 @@ When two rules fire on the same line, you have two equivalent options. Comma-sep const [localSearch, setLocalSearch] = useState(searchQuery); ``` -Or stack one comment per rule directly above the diagnostic. Stacked comments are honored as long as nothing but other `react-doctor-disable-next-line` comments sits between them and the target line: +Or stack one comment per rule directly above the diagnostic: ```tsx // react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers @@ -147,171 +162,142 @@ Or stack one comment per rule directly above the diagnostic. Stacked comments ar const [localSearch, setLocalSearch] = useState(searchQuery); ``` -A code line between stacked comments breaks the chain: only the comment immediately above the diagnostic (and any contiguous `react-doctor-disable-next-line` comments stacked on top of it) is honored. If a comment looks adjacent but the rule still fires, run `react-doctor --explain ` — it reports whether a nearby suppression was found, what rules it covers, and why it didn't apply. - Block comments work inside JSX: ```tsx -{/* react-doctor-disable-next-line react/no-danger */} +{/* react-doctor-disable-next-line no-danger */}
``` For multi-line JSX, putting the comment immediately above the opening tag covers the entire attribute list (matching ESLint convention). -## Lint plugin (standalone) +## Lint Integrations The same rule set ships as both an oxlint plugin and an ESLint plugin, so you can wire it into whichever lint engine your project already runs. -**oxlint** in `.oxlintrc.json`: +Oxlint (`.oxlintrc.json`): ```jsonc { "jsPlugins": [{ "name": "react-doctor", "specifier": "react-doctor/oxlint-plugin" }], "rules": { "react-doctor/no-fetch-in-effect": "warn", - "react-doctor/no-derived-state-effect": "warn", }, } ``` -**ESLint** flat config: +ESLint: ```js import reactDoctor from "react-doctor/eslint-plugin"; export default [ - reactDoctor.configs.recommended, - reactDoctor.configs.next, - reactDoctor.configs["react-native"], - reactDoctor.configs["tanstack-start"], - reactDoctor.configs["tanstack-query"], + { + plugins: { + "react-doctor": reactDoctor, + }, + rules: { + "react-doctor/no-fetch-in-effect": "warn", + }, + }, ]; ``` -The full rule list lives in [`oxlint-config.ts`](https://github.com/millionco/react-doctor/blob/main/packages/react-doctor/src/oxlint-config.ts). - -## CLI reference - -``` -Usage: react-doctor [directory] [options] - -Options: - -v, --version display the version number - --no-lint skip linting - --no-dead-code skip dead code detection - --verbose show every rule and per-file details (default shows top 3 rules) - --score output only the score - --json output a single structured JSON report - -y, --yes skip prompts, scan all workspace projects - --full skip prompts, always run a full scan - --project select workspace project (comma-separated for multiple) - --diff [base] scan only files changed vs base branch - --staged scan only staged files (for pre-commit hooks) - --offline skip telemetry - --fail-on exit with error on diagnostics: error, warning, none - --annotations output diagnostics as GitHub Actions annotations - --explain diagnose why a rule fired or why a suppression didn't apply - --why alias for --explain - -h, --help display help -``` - -When a suppression isn't working, `--explain ` (or its alias `--why `) reports what the scanner sees at that location, including why a nearby `react-doctor-disable-next-line` didn't apply. The diagnosis distinguishes the common failure modes — adjacent comment for a different rule (use the comma form), a code line between the comment and the diagnostic (the chain is broken), or no nearby suppression at all. The same hint surfaces inline with `--verbose` for every flagged site, and in `--json` output as `diagnostic.suppressionHint`, so a single scan doubles as a suppression audit without a separate flag. +The ESLint wrapper reuses the same rule implementations and metadata as the oxlint plugin. -`--json` produces a parsable object on stdout with all human-readable output suppressed. Errors still produce a JSON object with `ok: false`, so stdout is always a valid document. +## SDK -### Config keys +```ts +import { createReactDoctor, inspectReactProject } from "react-doctor"; -| Key | Type | Default | -| -------------------------- | -------------------------------- | -------- | -| `ignore.rules` | `string[]` | `[]` | -| `ignore.files` | `string[]` | `[]` | -| `ignore.overrides` | `{ files, rules? }[]` | `[]` | -| `lint` | `boolean` | `true` | -| `deadCode` | `boolean` | `true` | -| `verbose` | `boolean` | `false` | -| `diff` | `boolean \| string` | | -| `failOn` | `"error" \| "warning" \| "none"` | `"none"` | -| `customRulesOnly` | `boolean` | `false` | -| `share` | `boolean` | `true` | -| `offline` | `boolean` | `false` | -| `textComponents` | `string[]` | `[]` | -| `rawTextWrapperComponents` | `string[]` | `[]` | -| `respectInlineDisables` | `boolean` | `true` | -| `adoptExistingLintConfig` | `boolean` | `true` | -| `ignore.tags` | `string[]` | `[]` | -| `entryFiles` | `string[]` | `[]` | +const result = await inspectReactProject({ + rootDirectory: "apps/web", + lint: true, + deadCode: true, +}); -`textComponents` is the broad escape hatch for `rn-no-raw-text` — list components that themselves behave like React Native's `` (custom `Typography`, `NativeTabs.Trigger.Label`, etc.) and the rule will treat them as text containers regardless of what their children look like. +const reactDoctor = createReactDoctor({ rootDirectory: "apps/web" }); +const nextResult = await reactDoctor.inspect(); +``` -`rawTextWrapperComponents` is the narrower option for components that are not text elements but safely route string-only children through an internal `` (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. `` — still reports because the wrapper can't safely route raw text alongside a sibling JSX element. +The result includes project metadata, check results, normalized issues, score, and timing. -`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"`. +```ts +import { buildReactDoctorJsonReport } from "react-doctor"; -`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. +const report = buildReactDoctorJsonReport(result); +``` -`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. +Typed runtime errors are exported from the main SDK: -## Scoring +```ts +import { ReactDoctorInvalidConfigError, isReactDoctorError } from "react-doctor"; +``` -The health score formula: `100 - (unique_error_rules x 1.5) - (unique_warning_rules x 0.75)`. +## Compatibility API -Key details: +Deprecated compatibility APIs live under `react-doctor/api` and are intentionally isolated from the main runtime. -- 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. -- Error-severity rules cost 1.5 points each. Warning-severity rules cost 0.75 points each. -- Category breakdowns shown in the output are for display only and do not weight the score. -- Run `--verbose` to see which exact rules contributed to the score and how the penalty was computed. +```ts +import { diagnose, clearCaches } from "react-doctor/api"; -Score labels: 75+ is **Great**, 50 to 74 is **Needs work**, under 50 is **Critical**. +const result = await diagnose("apps/web", { + lint: true, + deadCode: true, +}); -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. +clearCaches(); +``` -## Diff and staged modes +Prefer `createReactDoctor()` or `inspectReactProject()` for new integrations. -React Doctor can scan only changed files instead of the full project: +## Development -- **`--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"`. -- **`--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. -- **`--full`** forces a full scan, overriding any `diff` value in config or CLI. +Run package checks from the package directory: -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. +```bash +nr typecheck +nr test +nr build +``` -`--staged` and `--diff` cannot be combined. Both modes skip dead-code detection (knip needs the full project to detect unused files). +Run workspace formatting and linting from the repository root: -## Agent and CI integration +```bash +nr format:check packages/react-doctor/src packages/react-doctor/tests +nr lint packages/react-doctor/src packages/react-doctor/tests +``` -React Doctor detects 50+ coding agents (Claude Code, Cursor, Codex, OpenCode, Windsurf, and more) and adapts its behavior automatically: +## Regression testing -- **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. -- **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. -- **Score-only output**: `--score` outputs just the numeric score (0-100), useful for threshold checks in agent loops. -- **GitHub Actions annotations**: `--annotations` emits `::error` / `::warning` format for inline PR annotations. -- **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. -- **Programmatic API**: `import { diagnose } from "react-doctor/api"` for direct integration in scripts and automation. +Drive the sandbox test suite in the sibling [`react-review`](https://github.com/millionco/react-review) repo against a fleet of real React projects, using the local working copy of `react-doctor` (packed into a tarball). -In CI environments, prompts are automatically skipped and scoring runs locally (offline mode). +```bash +pnpm --filter react-doctor test:regression +``` -## Node.js API +The script builds the package, packs it into `packages/react-doctor/.regression/react-doctor-.tgz`, then shells out to `~/Developer/react-review/apps/api` and runs `pnpm test` with: -```js -import { diagnose, toJsonReport, summarizeDiagnostics } from "react-doctor/api"; +- `GITHUB_TOKEN` from `gh auth token` (required so GitHub does not 403 the tarball downloads). +- `REACT_DOCTOR_SPECIFIERS` pointing at the local tarball — `react-review`'s sandbox test detects the `.tgz`, uploads it into the Vercel Sandbox, and installs via `file:`. +- `REACT_DOCTOR_TEST_REPOS` defaulting to the first 10 repos from a curated fleet, comma-separated `owner/repo` entries. -const result = await diagnose("./path/to/your/react-project"); +Preconditions the script checks for and surfaces clear errors when missing: -console.log(result.score); // { score: 82, label: "Great" } or null -console.log(result.diagnostics); // Diagnostic[] -console.log(result.project); // detected framework, React version, etc. -``` +- `gh auth token` succeeds (run `gh auth login` first if not). +- `~/Developer/react-review/apps/api` exists. +- `~/Developer/react-review/apps/api/.env.local` exists (run `vercel env pull` inside `~/Developer/react-review/apps/api` to populate `@vercel/sandbox` credentials). -`diagnose` accepts a second argument: `{ lint?: boolean, deadCode?: boolean }`. +Overrides: -```js -const report = toJsonReport(result, { version: "1.0.0" }); -const counts = summarizeDiagnostics(result.diagnostics); +```bash +REACT_DOCTOR_REGRESSION_SAMPLE=25 pnpm --filter react-doctor test:regression +REACT_DOCTOR_REGRESSION_SAMPLE=all pnpm --filter react-doctor test:regression +REACT_DOCTOR_TEST_REPOS="vercel/ai-chatbot,shadcn-ui/ui" pnpm --filter react-doctor test:regression ``` -`react-doctor/api` re-exports `JsonReport`, `JsonReportSummary`, `JsonReportProjectEntry`, `JsonReportMode`, plus the lower-level `buildJsonReport` and `buildJsonReportError` builders. See [`packages/react-doctor/src/api.ts`](https://github.com/millionco/react-doctor/blob/main/packages/react-doctor/src/api.ts) for the full types. +Each repo runs sequentially inside a Vercel Sandbox; expect a few minutes per repo. The default 10-repo sample is the sane batch size for a single run. ## Leaderboard @@ -345,13 +331,15 @@ Looking to contribute back? Clone the repo, install, build, and submit a PR. ```bash git clone https://github.com/millionco/react-doctor cd react-doctor -pnpm install -pnpm build -node packages/react-doctor/bin/react-doctor.js /path/to/your/react-project +ni +nr build +node packages/react-doctor/bin/react-doctor.js apps/web ``` Find a bug? Head to the [issue tracker](https://github.com/millionco/react-doctor/issues). +Release notes are published on [GitHub Releases](https://github.com/millionco/react-doctor/releases). + ### License React Doctor is MIT-licensed open-source software. diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 755d3a1e43..dd43458aa8 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "react-doctor", - "version": "0.1.6", + "version": "0.2.0-beta.1", "description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues", "keywords": [ "accessibility", @@ -41,63 +41,51 @@ "sideEffects": false, "exports": { ".": { - "types": "./dist/cli.d.ts", - "default": "./dist/cli.js" + "types": "./dist/sdk.d.ts", + "default": "./dist/sdk.js" }, "./api": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/compat.d.ts", + "default": "./dist/compat.js" }, - "./oxlint-plugin": { - "types": "./dist/react-doctor-plugin.d.ts", - "default": "./dist/react-doctor-plugin.js" + "./score": { + "types": "./dist/score.d.ts", + "default": "./dist/score.js" }, "./eslint-plugin": { "types": "./dist/eslint-plugin.d.ts", "default": "./dist/eslint-plugin.js" }, - "./browser-poc": { - "types": "./dist/browser-poc.d.ts", - "default": "./dist/browser-poc.js" + "./oxlint-plugin": { + "types": "./dist/oxlint-plugin.d.ts", + "default": "./dist/oxlint-plugin.js" } }, "scripts": { "dev": "vp pack --watch", - "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", - "poc:browser": "vite --host 127.0.0.1", + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && NODE_ENV=production vp pack", "typecheck": "tsc --noEmit", - "test": "vp test run" + "test": "vp test run", + "test:regression": "node --experimental-strip-types --no-warnings scripts/run-regression.ts" }, "dependencies": { - "@oxc-parser/wasm": "^0.60.0", "agent-install": "0.0.5", - "bippy": "^0.5.39", "commander": "^14.0.3", - "knip": "^6.10.0", + "eslint-plugin-react-hooks": "^7.1.1", "ora": "^9.4.0", + "oxc-parser": "^0.130.0", + "oxc-resolver": "^11.19.1", "oxlint": "^1.63.0", "picocolors": "^1.1.1", - "prompts": "^2.4.2", - "typescript": ">=5.0.4 <7" + "prompts": "^2.4.2" }, "devDependencies": { + "@oxc-project/types": "^0.130.0", + "@types/node": "^25.6.0", "@types/prompts": "^2.4.9", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1" - }, - "peerDependencies": { - "eslint-plugin-react-hooks": "^6 || ^7", - "eslint-plugin-react-you-might-not-need-an-effect": "^0.10" - }, - "peerDependenciesMeta": { - "eslint-plugin-react-hooks": { - "optional": true - }, - "eslint-plugin-react-you-might-not-need-an-effect": { - "optional": true - } + "vite-plus": "^0.1.15" }, "engines": { - "node": ">=22" + "node": ">=22.12.0" } } diff --git a/packages/react-doctor/scripts/run-regression.ts b/packages/react-doctor/scripts/run-regression.ts new file mode 100644 index 0000000000..c7e4f3dd58 --- /dev/null +++ b/packages/react-doctor/scripts/run-regression.ts @@ -0,0 +1,691 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_FLEET_SAMPLE_SIZE = 10; +const REGRESSION_OUTPUT_DIRECTORY_NAME = ".regression"; +const REACT_REVIEW_API_RELATIVE_PATH = "Developer/react-review/apps/api"; + +const REGRESSION_FLEET: readonly string[] = [ + "pierrecomputer/pierre", + "aidenybai/react-grab", + "aidenybai/bippy", + "millionco/react-doctor", + "millionco/same", + "millionco/ami", + "nisargio/scissors", + "millionco/expect", + "aidenybai/react-scan", + "pingdotgg/t3code", + "tldraw/tldraw", + "excalidraw/excalidraw", + "twentyhq/twenty", + "makeplane/plane", + "formbricks/formbricks", + "PostHog/posthog", + "supabase/supabase", + "onlook-dev/onlook", + "payloadcms/payload", + "getsentry/sentry", + "calcom/cal.com", + "dubinc/dub", + "nodejs/nodejs.org", + "shadcn-ui/ui", + "lobehub/lobe-chat", + "langfuse/langfuse", + "unkeyed/unkey", + "triggerdotdev/trigger.dev", + "baptisteArno/typebot.io", + "medusajs/medusa", + "appsmithorg/appsmith", + "ToolJet/ToolJet", + "RocketChat/Rocket.Chat", + "RhysSullivan/executor", + "better-auth/better-auth", + "mastra-ai/mastra", + "freeCodeCamp/freeCodeCamp", + "facebook/create-react-app", + "ChatGPTNextWeb/NextChat", + "lobehub/lobehub", + "grafana/grafana", + "apache/superset", + "toeverything/AFFiNE", + "usememos/memos", + "laurent22/joplin", + "mastodon/mastodon", + "penpot/penpot", + "metabase/metabase", + "AykutSarac/jsoncrack.com", + "usebruno/bruno", + "calcom/cal.diy", + "nexu-io/open-design", + "outline/outline", + "Kong/insomnia", + "LAION-AI/Open-Assistant", + "drawdb-io/drawdb", + "amruthpillai/reactive-resume", + "mattermost/mattermost", + "umami-software/umami", + "Dokploy/dokploy", + "hasura/graphql-engine", + "conductor-oss/conductor", + "CopilotKit/CopilotKit", + "gitroomhq/postiz-app", + "gethomepage/homepage", + "bigint/hey", + "onyx-dot-app/onyx", + "jitsi/jitsi-meet", + "t3-oss/create-t3-app", + "nrwl/nx", + "getredash/redash", + "labring/FastGPT", + "srbhr/Resume-Matcher", + "SigNoz/signoz", + "Infisical/infisical", + "actualbudget/actual", + "karakeep-app/karakeep", + "responsively-org/responsively-app", + "nocobase/nocobase", + "chartdb/chartdb", + "teableio/teable", + "navidrome/navidrome", + "readest/readest", + "dyad-sh/dyad", + "wulkano/Kap", + "decaporg/decap-cms", + "CapSoftware/Cap", + "linkwarden/linkwarden", + "bluesky-social/social-app", + "signalapp/Signal-Desktop", + "infinitered/reactotron", + "apache/answer", + "apitable/apitable", + "midday-ai/midday", + "streetwriters/notesnook", + "vercel/commerce", + "tinacms/tinacms", + "element-hq/element-web", + "documenso/documenso", + "Automattic/wp-calypso", + "alibaba/formily", + "BasedHardware/omi", + "illacloud/illa-builder", + "openreplay/openreplay", + "logto-io/logto", + "plankanban/planka", + "codexu/note-gen", + "giscus/giscus", + "wojtekmaj/react-pdf", + "kusti8/proton-native", + "elie222/inbox-zero", + "reactide/reactide", + "jhen0409/react-native-debugger", + "blinkospace/blinko", + "hackjutsu/Lepton", + "woocommerce/woocommerce", + "devhubapp/devhub", + "polarsource/polar", + "zuiidea/antd-admin", + "InsForge/InsForge", + "hyperdxio/hyperdx", + "yang991178/fluent-reader", + "berty/berty", + "nhost/nhost", + "gridstack/gridstack.js", + "heyform/heyform", + "BuilderIO/builder", + "openstatusHQ/openstatus", + "webstudio-is/webstudio", + "xanderfrangos/twinkle-tray", + "idurar/idurar-erp-crm", + "Shopify/react-native-skia", + "maotoumao/MusicFreeDesktop", + "papermark/papermark", + "relax/relax", + "Snouzy/workout-cool", + "dilanx/craco", + "ajnart/homarr", + "react-native-webview/react-native-webview", + "yinxin630/fiora", + "buildship-ai/rowy", + "plasmicapp/plasmic", + "hexclave/stack-auth", + "software-mansion/react-native-gesture-handler", + "nraiden/cofounder", + "ganeshrvel/openmtp", + "jpuri/react-draft-wysiwyg", + "standardnotes/app", + "Flagsmith/flagsmith", + "OpenSignLabs/OpenSign", + "sanity-io/sanity", + "jvalen/pixel-art-react", + "lightdash/lightdash", + "Uniswap/interface", + "czy0729/Bangumi", + "Flipkart/recyclerlistview", + "lingodotdev/lingo.dev", + "AmanVarshney01/create-better-t-stack", + "gitify-app/gitify", + "benoitvallon/react-native-nw-react-calculator", + "rcbyr/keen-slider", + "edp963/davinci", + "react-native-config/react-native-config", + "mediacms-io/mediacms", + "Expensify/App", + "clidey/whodb", + "streamlabs/desktop", + "gitpoint/git-point", + "sqlectron/sqlectron", + "liveblocks/liveblocks", + "oliverschwendener/ueli", + "Yooooomi/your_spotify", + "Bowen7/regex-vis", + "rainbow-me/rainbow", + "OHIF/Viewers", + "outsourc-e/hermes-workspace", + "prazzon/Flexbox-Labs", + "chartbrew/chartbrew", + "wojtekmaj/react-calendar", + "iSimar/HackerNews-React-Native", + "software-mansion/react-native-screens", + "plouc/mozaik", + "Pagedraw/pagedraw", + "lbryio/lbry-desktop", + "chaskiq/chaskiq", + "React-Proto/react-proto", + "MauriceNino/dashdot", + "attentiveness/reading", + "pashpashpash/vault-ai", + "CherryHQ/cherry-studio-app", + "Peppermint-Lab/peppermint", + "creativetimofficial/material-dashboard-react", + "fangpenlin/avataaars-generator", + "MetaMask/metamask-mobile", + "chrisvel/tududi", + "microsoft/vscode-react-native", + "edrlab/thorium-reader", + "kentcdodds/bookshelf", + "ohmplatform/FreedomGPT", + "IceEnd/Yosoro", + "batnoter/batnoter", + "nz-m/SocialEcho", + "adrianhajdin/aora", + "RocketChat/Rocket.Chat.ReactNative", + "OneKeyHQ/app-monorepo", + "adrianhajdin/ecommerce_sanity_stripe", + "running-elephant/datart", + "Flaque/quirk", + "wwayne/react-native-nba-app", + "ammarahm-ed/react-native-actions-sheet", + "BlackHatDevX/openspot-music-app", + "martpie/museeks", + "Matterwiki/Matterwiki", + "catalinmiron/react-native-dribbble-app", + "mohamedsamara/mern-ecommerce", + "LucasBassetti/react-simple-chatbot", + "stoneWeb/elm-react-native", + "adrianhajdin/project_shareme_social_media", + "birkir/prime", + "Jellify-Music/App", + "binaricat/Netcatty", + "CaviraOSS/PageLM", + "ed-roh/react-admin-dashboard", + "learnhouse/learnhouse", + "expo/react-native-action-sheet", + "adrianhajdin/project_medical_pager_chat", + "adrianhajdin/social_media_app", + "growilabs/growi", + "027xiguapi/pear-rec", + "Raathigesh/dazzle", + "seniv/react-native-notifier", + "79E/ChatGpt-Web", + "jgudo/ecommerce-react", + "DeadWaveWave/opencove", + "qiutongxue/oba-live-tool", + "aws-samples/bedrock-chat", + "storybookjs/react-native", + "mb21/panwriter", + "amicalhq/amical", + "bangle-io/bangle-io", + "KittyCAD/modeling-app", + "Shpendrr/react-app-structure", + "mariusandra/insights", + "ed-roh/mern-social-media", + "alishobeiri/thread-notebook", + "JasonStu/ReactNative_Shopping", + "estevanmaito/windmill-dashboard-react", + "mCodex/react-native-sensitive-info", + "r-park/todo-react-redux", + "KieSun/Chat-Buy-React", + "saleor/saleor-dashboard", + "hasan-py/Hayroo", + "Rabithua/Rote", + "pupilfirst/pupilfirst", + "raineroviir/react-redux-socketio-chat", + "koolkishan/chat-app-react-nodejs", + "777genius/agent-teams-ai", + "chatwoot/chatwoot-mobile-app", + "blueberrycongee/Lumina-Note", + "fireship-io/react-firebase-chat", + "jamaljsr/polar", + "victoralvesf/aonsoku", + "sahat/newedenfaces-react", + "kizuna-ai-lab/sokuji", + "victorbalssa/abacus", + "zhufengketang/app", + "shamahoque/mern-social", + "kuwala-io/kuwala", + "aws-samples/swift-chat", + "LiuYuYang01/ThriveX-Blog", + "shwosner/realtime-chat-supabase-react", + "bukinoshita/taskr", + "MrXujiang/next-admin", + "letterpad/letterpad", + "expo/orbit", + "cometchat/cometchat-uikit-react-native", + "C-JSN/D3-ID3", + "anisul-Islam/react-assignment-1-products-listing-app", + "cometchat/cometchat-uikit-react", + "TeXlyre/texlyre", + "Ohh-889/skyroc-admin", + "unigraph-dev/unigraph-dev", + "CromwellCMS/Cromwell", + "KSJaay/Lunalytics", + "ftzi/react-native-shadow-2", + "hitarth-gg/zenshin", + "heylinda/heylinda-app", + "awehook/blink-mind-desktop", + "RameshMF/ReactJS-Spring-Boot-CRUD-Full-Stack-App", + "tsurupin/portfolio", + "BradGroux/veritas-kanban", + "transmute-app/transmute", + "EkiZR/Portofolio_V5", + "functionland/fx-fotos", + "dharness/react-chat-window", + "Sherlockouo/music", + "adrianhajdin/react_native-restate", + "yoonic/nicistore", + "songxiaoliang/ReactNativeApp", + "bndkt/react-native-app-clip", + "WJZ-P/TFT-Hextech-Helper", + "misa-j/social-network", + "overlayeddev/overlayed", + "bbplayer-app/BBPlayer", + "sneljo1/auryo", + "nguymin4/react-videocall", + "Grashjs/cmms", + "nfl/react-metrics", + "febobo/react-native-redux-FeInn", + "fireyy/react-antd-admin", + "nelsonkuang/ant-admin", + "rgommezz/react-native-chatgpt", + "nikunjsingh93/react-glass-keep", + "unvalley/ephe", + "safe-global/safe-wallet-monorepo", + "lionsharecapital/lionshare-desktop", + "ltadpoles/react-admin", + "santifer/cv-santiago", + "RARgames/4gaBoards", + "southliu/south-admin-react", + "mrktsm/codecafe", + "crisanlucid/vite-react-tailwind-bionic-reading", + "creativetimofficial/purity-ui-dashboard", + "NiceDash/Vibe", + "DLand-Team/moderate-react-admin", + "netbirdio/dashboard", + "api-platform/admin", + "f/agentlytics", + "clawwork-ai/ClawWork", + "Brainfock/Brainfock", + "biaochenxuying/blog-react-admin", + "creativetimofficial/black-dashboard-react", + "LinMoQC/Memory-Blog", + "SolidZORO/leaa", + "walljser/cms_community_e_commerce", + "open-source-labs/ReacType", + "creativetimofficial/argon-dashboard-react", + "phongna07/fireverse", + "creativetimofficial/material-tailwind-dashboard-react", + "picturama/picturama", + "rock-solid/pwa-theme-woocommerce", + "lucavallin/verto", + "sqlrooms/sqlrooms", + "soroushchehresa/unsplash-wallpapers", + "Justin-lu/react-redux-antd", + "itzpradip/react-native-firebase-social-app", + "LanceMoe/openai-translator", + "RavelloH/NeutralPress", + "alextselegidis/plainpad", + "mihir0699/Video-Chat", + "computing-den/unforget", + "ketchuphq/ketchup", + "wangrongding/wallpaper-box", + "JSLancerTeam/crystal-dashboard", + "bluedaniel/Kakapo-app", + "merikbest/ecommerce-spring-reactjs", + "namespace-ee/upcount", + "moollaza/repo-remover", + "SuperViz/superviz", + "levelopers/Ecommerce-Reactjs", + "davehowson/chat-app", + "quintuslabs/fashion-cube", + "lvwangbeta/Poplar", + "Xtrendence/Cryptofolio", + "mvdicarlo/postybirb", + "proshoumma/Mister-Poster", + "CodeWithHarry/iNotebook-React", + "seenaburns/isolate", + "Cezerin2/Cezerin2", + "ErickKS/vite-deploy", + "tinode/webapp", + "ujjavaldesai07/spring-boot-react-ecommerce-app", + "goshacmd/pabla", + "lydiahallie/React-Ecommerce", + "patrick-michelberger/serverless-shop", + "dhatGuy/PERN-Store", + "dxx/mango-music", + "z-9527/admin", + "elbwalker/walkerOS", + "yeahhe365/Prisma", + "Fanzzzd/repo-wizard", + "rocketseat-education/nlw-expert-react", + "inovex/scrumlr.io", + "abahmed/Deer", + "jrussbautista/dress-shop", + "creativetimofficial/soft-ui-dashboard-react", + "software-mansion-labs/react-native-rag", + "Beever-AI/beever-atlas", + "earthcomfy/lets-chat", + "veyliss/ai-localbase", + "DaiYz/react-native-easy-chat-ui", + "Bourhjoul/Mern-Ecommerce-website", + "LeDat98/NexusRAG", + "1ven/do", + "jotyy/Mantine-Admin", + "Levix0501/notra", + "sanjeevyadavIT/magento_react_native", + "ZahraMirzaei/online-shop", + "DefiLlama/defillama-app", + "machadop1407/react-socketio-chat-app", + "arifszn/ezfolio", + "leemonade/leemons", + "schneidmaster/socializer", + "betaacid/expo-analytics", + "basementstudio/commerce-toolkit", + "raj074/mern-social-media", + "kaloraat/react-node-ecommerce", + "mohammadoftadeh/next-ecommerce-shopco", + "Syncano/syncano-dashboard", + "Rajatm544/MERN-Blog-App", + "LiuYuYang01/ThriveX-Admin", + "TheCoderDream/React-Ecommerce-App-with-Redux", + "meilisearch/mini-dashboard", + "kamjin3086/chatless", + "papercups-io/chat-widget", + "design-sparx/antd-multipurpose-dashboard", + "stephensanwo/fullstack-ai-chatbot", + "DanialK/ReactJS-Realtime-Chat", + "funador/react-auth-client", + "ecency/ecency-mobile", + "nusr/excel", + "stuyy/chat-platform-react", + "baimingxuan/react-admin-design", + "bmcmahen/toasted-notes", + "burakorkmez/react-admin-dashboard", + "danloh/mdSilo-web", + "baiwumm/react-admin", + "ed-roh/react-ecommerce", + "seawind8888/Nobibi", + "enatega/shopping-cart-ecommerce", + "dabit3/heard", + "composify-js/composify", + "stellar/dashboard", + "burdy-io/burdy", + "walljser/community_e_commerce", + "Ujjalzaman/Easy-Consulting-react", + "andrewcoelho/react-text-editor", + "trananhtuat/tua-react-admin", + "ZainRk/React-Admin-Dashboard-public", + "creativetimofficial/paper-dashboard-react", + "ele828/leanote-ios-rn", + "yTakkar/React-Mini-Social-Network", + "MrXujiang/XPCMS", + "adrianhajdin/travel-agency-dashboard", + "converge/instapy-dashboard", + "inifarhan/skaters", + "acmerobotics/ftc-dashboard", + "taniarascia/chat", + "OpenBeta/open-tacos", + "liuguanhua/react-antd-admin", + "elibenjii/ecommerce-react", + "bkywksj/knowledge-base", + "loveRandy/react-admin", + "bidah/universal-medusa", + "creativetimofficial/muse-ant-design-dashboard", + "zeus-12/uxie", + "seanmiller802/BrowserTime", + "ensdomains/ens-app-v3", + "yonatanmgr/mathberet", + "llorentegerman/react-admin-dashboard", + "j471n/j471n.in", + "praveen-sripati/nexus", + "coderdost/MERN-ecommerce-Frontend", + "boluo2077/deep-rag", + "satnaing/satnaing.dev", + "Bereky/mern-ecommerce", + "bldrs-ai/Share", + "alexindigo/ndash", + "ruppysuppy/Pizza-Man", + "jeandv/jeanrondon.dev", + "eminbasbayan/full-stack-e-commerce", + "developer-junaid/DeveloperFolio", + "creativetimofficial/vision-ui-dashboard-react", + "e2b-dev/dashboard", + "betterlytics/betterlytics", + "Skyfay/SkySend", + "ajaybor0/MERN-eCommerce", + "focallocal/fl-maps", + "rahulsahay19/Java-React-FullStack", + "betomoedano/ChatApp", + "ButterCMS/react-cms-blog-with-next-js", + "john-smilga/react-phone-e-commerce-project", + "kriziu/collabio", + "Timonwa/react-chat", + "chenjun1127/react-antd-admin", + "creativetimofficial/now-ui-dashboard-react", + "AdamNowotny/BuildReactor", + "guyariely/noteworthy", + "creativetimofficial/nextjs-argon-dashboard", + "prabinmagar/dashboard-ui-with-reactjs", + "pattjoshi/Multi_vondor_E_shop", + "taskrabbit/react-native-zendesk-chat", + "Morelitea/initiative", + "Saurabh-8585/MERN-E-Commerce-Frontend", + "ConnectyCube/connectycube-reactnative-samples", + "0mar-helal/multimart-react-ecommerce", + "PierreCapo/react-native-socials", + "mudzikalfahri/wefootwear-store", + "ElSierra/Social-app-React-Native", + "unrealmanu/ga-4-react", + "taiwo-adewale/ecommerce-admin", + "mithunjmistry/ecommerce-React-Redux-Laravel", + "cktang88/spaceboard", + "seeden/react-g-analytics", + "DulanjaliSenarathna/react-chat-app", + "themisvaltinos/Auction-Website", + "Govind783/react-e-commerce-", + "damianstone/toogether-mobile", + "neiker/analytics-react-native", + "YashMarmat/FullStack_Ecommerce_App", + "ximing/weditor", + "offlegacy/event-tracker", + "FLiotta/Yasei", + "gianlucajahn/react-ecommerce-store", + "dch133/Social-Media-App", + "Mohitur669/Realtime-Collaborative-Code-Editor", + "Qeagle/reporter-engine", + "HosseinNamvar/bitex", + "coding-with-chaim/react-video-chat", + "concrnt/concrnt-world", + "etesync/etesync-notes", + "vivekkakadiya/Organica", + "jagjot26/faeshare", + "UsamaSarwar/reactnative-ecommerce-charlie", + "ReactNativeSchool/react-native-social-media-app", + "shubham1710/MERN-E-Commerce", + "yTakkar/MERN-Social-Network", +]; + +interface PackageJson { + name: string; + version: string; +} + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const packageDirectory = resolve(scriptDirectory, ".."); +const workspaceRootDirectory = resolve(packageDirectory, "..", ".."); +const regressionOutputDirectory = join(packageDirectory, REGRESSION_OUTPUT_DIRECTORY_NAME); +const reactReviewApiDirectory = join(homedir(), REACT_REVIEW_API_RELATIVE_PATH); +const reactReviewEnvLocalPath = join(reactReviewApiDirectory, ".env.local"); + +const formatErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const captureGitHubAuthToken = (): string => { + try { + const rawToken = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim(); + if (!rawToken) { + throw new Error("`gh auth token` returned an empty string"); + } + return rawToken; + } catch (gitHubAuthError) { + throw new Error( + [ + "Failed to obtain a GitHub token via `gh auth token`.", + "GitHub returns 403 for tarball downloads without authentication, which makes the test pointless.", + "Run `gh auth login` first and retry.", + `Underlying error: ${formatErrorMessage(gitHubAuthError)}`, + ].join("\n"), + ); + } +}; + +const ensureReactReviewWorkspaceIsReady = (): void => { + if (!existsSync(reactReviewApiDirectory)) { + throw new Error( + [ + `Missing react-review checkout at \`${reactReviewApiDirectory}\`.`, + "Clone the sibling repo with `git clone https://github.com/millionco/react-review ~/Developer/react-review`.", + ].join("\n"), + ); + } + if (!existsSync(reactReviewEnvLocalPath)) { + throw new Error( + [ + `Missing \`${reactReviewEnvLocalPath}\`.`, + "The sandbox test needs Vercel credentials. Run `vercel env pull` inside", + `\`${reactReviewApiDirectory}\` first.`, + ].join("\n"), + ); + } +}; + +const readPackageJson = (): PackageJson => + JSON.parse(readFileSync(join(packageDirectory, "package.json"), "utf-8")) as PackageJson; + +const buildReactDoctorPackage = (): void => { + console.log("[regression] Building react-doctor..."); + execFileSync("pnpm", ["--filter", "react-doctor", "build"], { + stdio: "inherit", + cwd: workspaceRootDirectory, + }); +}; + +const packReactDoctorTarball = (): string => { + mkdirSync(regressionOutputDirectory, { recursive: true }); + console.log(`[regression] Packing tarball into ${regressionOutputDirectory}...`); + execFileSync( + "pnpm", + ["--filter", "react-doctor", "pack", "--pack-destination", regressionOutputDirectory], + { + stdio: "inherit", + cwd: workspaceRootDirectory, + }, + ); + + const packageMetadata = readPackageJson(); + const expectedTarballName = `react-doctor-${packageMetadata.version}.tgz`; + const expectedTarballPath = join(regressionOutputDirectory, expectedTarballName); + if (!existsSync(expectedTarballPath)) { + throw new Error( + `Expected tarball \`${expectedTarballPath}\` to exist after \`pnpm pack\` but it was not produced.`, + ); + } + const tarballStats = statSync(expectedTarballPath); + if (!tarballStats.isFile() || tarballStats.size === 0) { + throw new Error(`Tarball \`${expectedTarballPath}\` exists but is empty or not a file.`); + } + return expectedTarballPath; +}; + +const parseSampleSizeOverride = (rawSampleSize: string | undefined): number => { + if (rawSampleSize === undefined || rawSampleSize === "") return DEFAULT_FLEET_SAMPLE_SIZE; + if (rawSampleSize.toLowerCase() === "all") return REGRESSION_FLEET.length; + const parsedSampleSize = Number.parseInt(rawSampleSize, 10); + if (!Number.isFinite(parsedSampleSize) || parsedSampleSize <= 0) { + throw new Error( + `Invalid REACT_DOCTOR_REGRESSION_SAMPLE value: \`${rawSampleSize}\`. Use a positive integer or \`all\`.`, + ); + } + return Math.min(parsedSampleSize, REGRESSION_FLEET.length); +}; + +const resolveRegressionRepos = (): readonly string[] => { + const userProvidedRepos = process.env.REACT_DOCTOR_TEST_REPOS; + if (userProvidedRepos && userProvidedRepos.trim()) { + const explicitRepos = userProvidedRepos + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if (explicitRepos.length > 0) return explicitRepos; + } + const sampleSize = parseSampleSizeOverride(process.env.REACT_DOCTOR_REGRESSION_SAMPLE); + return REGRESSION_FLEET.slice(0, sampleSize); +}; + +const runReactReviewSandboxTest = ( + tarballAbsolutePath: string, + gitHubAuthToken: string, + testRepos: readonly string[], +): void => { + if (!isAbsolute(tarballAbsolutePath)) { + throw new Error(`Expected absolute tarball path, got \`${tarballAbsolutePath}\``); + } + console.log( + `[regression] Driving react-review test against ${testRepos.length} repo(s): ${testRepos.join(", ")}`, + ); + execFileSync("pnpm", ["test"], { + stdio: "inherit", + cwd: reactReviewApiDirectory, + env: { + ...process.env, + GITHUB_TOKEN: gitHubAuthToken, + REACT_DOCTOR_SPECIFIERS: tarballAbsolutePath, + REACT_DOCTOR_TEST_REPOS: testRepos.join(","), + }, + }); +}; + +const runRegression = (): void => { + ensureReactReviewWorkspaceIsReady(); + const gitHubAuthToken = captureGitHubAuthToken(); + buildReactDoctorPackage(); + const tarballAbsolutePath = packReactDoctorTarball(); + console.log(`[regression] Tarball: ${tarballAbsolutePath}`); + const testRepos = resolveRegressionRepos(); + runReactReviewSandboxTest(tarballAbsolutePath, gitHubAuthToken, testRepos); +}; + +runRegression(); diff --git a/packages/react-doctor/src/browser-poc.ts b/packages/react-doctor/src/browser-poc.ts deleted file mode 100644 index dc43edbe39..0000000000 --- a/packages/react-doctor/src/browser-poc.ts +++ /dev/null @@ -1,1083 +0,0 @@ -import { - _fiberRoots, - getDisplayName, - getNearestHostFiber, - getTimings, - getType, - instrument, - isCompositeFiber, - secure, - traverseFiber, - type Fiber, - type FiberRoot, -} from "bippy"; -import { initSync, parseSync } from "@oxc-parser/wasm/web/oxc_parser_wasm.js"; -import oxcParserWasmBytes from "@oxc-parser/wasm/web/oxc_parser_wasm_bg.wasm"; -import reactDoctorPlugin from "./plugin/index.js"; -import type { EsTreeNode, RuleVisitors } from "./plugin/types.js"; -import { - BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS, - BROWSER_POC_HOST_SELECTOR_MAX_COUNT, - ERROR_RULE_PENALTY, - PERFECT_SCORE, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, - WARNING_RULE_PENALTY, -} from "./constants.js"; - -export interface BrowserPocOptions { - dangerouslyRunInProduction?: boolean; - log?: boolean; -} - -export interface BrowserPocSourceLocation { - fileName: string; - lineNumber: number; - columnNumber: number | null; -} - -export interface BrowserPocRuntimeDiagnostic { - rule: string; - severity: "warning" | "error"; - message: string; - lineNumber: number | null; - columnNumber: number | null; -} - -export interface BrowserPocRuleFailure { - rule: string; - error: string; -} - -export interface BrowserPocRuleRunResult { - attempted: number; - completed: number; - failed: number; - failedRules: BrowserPocRuleFailure[]; -} - -export interface BrowserPocParseResult { - status: "parsed" | "parse-error" | "skipped" | "wasm-error"; - error: string | null; -} - -export interface BrowserPocComponentRecord { - id: number; - displayName: string; - tag: number; - instanceCount: number; - commitCount: number; - selfTime: number; - totalTime: number; - hookNames: string[]; - hostSelector: string | null; - sourceLocation: BrowserPocSourceLocation | null; - source: string | null; - parseResult: BrowserPocParseResult; - ruleRunResult: BrowserPocRuleRunResult; - diagnostics: BrowserPocRuntimeDiagnostic[]; -} - -interface BrowserPocAstNode extends EsTreeNode { - type: string; - name?: string; - value?: unknown; - raw?: string; - operator?: string; - argument?: BrowserPocAstNode; - object?: BrowserPocAstNode; - property?: BrowserPocAstNode; - callee?: BrowserPocAstNode; - id?: BrowserPocAstNode; - init?: BrowserPocAstNode; - body?: BrowserPocAstNode | BrowserPocAstNode[]; - program?: BrowserPocAstNode; - declarations?: BrowserPocAstNode[]; - elements?: Array; - expressions?: BrowserPocAstNode[]; - arguments?: BrowserPocAstNode[]; - params?: BrowserPocAstNode[]; - [key: string]: unknown; -} - -export interface BrowserPocScoreResult { - score: number; - label: string; -} - -export interface BrowserPocSnapshot { - isActive: boolean; - lastRendererID: number | null; - rootCount: number; - commitCount: number; - components: BrowserPocComponentRecord[]; - scoreResult: BrowserPocScoreResult; -} - -export interface BrowserPocController { - snapshot: () => BrowserPocSnapshot; - collectNow: () => BrowserPocSnapshot; -} - -declare global { - interface Window { - __reactDoctorBrowserPocOptions?: BrowserPocOptions; - reactDoctorBrowserPoc: BrowserPocController; - } -} - -const componentRecordsByType = new Map(); -const componentTypesByID = new Map(); -let nextComponentID = 1; -let lastRendererID: number | null = null; -let commitCount = 0; -let isActive = false; -let oxcWasmState: "pending" | "ready" | BrowserPocParseResult = "pending"; -const STACK_LOCATION_PATTERN = /\(?((?:[a-zA-Z][a-zA-Z\d+.-]*:\/\/|\/).+):(\d+):(\d+)\)?$/; - -const SKIPPED_PARSE_RESULT: BrowserPocParseResult = { - status: "skipped", - error: null, -}; - -const SKIPPED_RULE_RUN_RESULT: BrowserPocRuleRunResult = { - attempted: 0, - completed: 0, - failed: 0, - failedRules: [], -}; - -const initializeOxcWasm = (): BrowserPocParseResult | null => { - if (oxcWasmState === "ready") return null; - if (typeof oxcWasmState === "object") return oxcWasmState; - try { - initSync(oxcParserWasmBytes); - oxcWasmState = "ready"; - return null; - } catch (error) { - const wasmError: BrowserPocParseResult = { - status: "wasm-error", - error: error instanceof Error ? error.message : String(error), - }; - oxcWasmState = wasmError; - return wasmError; - } -}; - -interface BrowserPocFunctionSource { - code: string; - isTruncated: boolean; -} - -const getFunctionSource = (componentType: unknown): BrowserPocFunctionSource | null => { - if (typeof componentType !== "function") return null; - const source = Function.prototype.toString.call(componentType); - if (!source || source.includes("[native code]")) return null; - if (source.length > BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS) { - return { - code: source.slice(0, BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS), - isTruncated: true, - }; - } - return { code: source, isTruncated: false }; -}; - -const getSourceLocation = (fiber: Fiber): BrowserPocSourceLocation | null => { - const debugSource = fiber._debugSource; - if (debugSource) { - return { - fileName: debugSource.fileName, - lineNumber: debugSource.lineNumber, - columnNumber: debugSource.columnNumber ?? null, - }; - } - - const debugStack = fiber._debugStack?.stack; - if (!debugStack) return null; - for (const stackLine of debugStack.split("\n")) { - const match = STACK_LOCATION_PATTERN.exec(stackLine.trim()); - if (!match) continue; - const fileName = match[1]; - const lineNumber = Number(match[2]); - const columnNumber = Number(match[3]); - if (!fileName || !Number.isFinite(lineNumber)) continue; - if (fileName.includes("/node_modules/")) continue; - return { - fileName, - lineNumber, - columnNumber: Number.isFinite(columnNumber) ? columnNumber : null, - }; - } - return null; -}; - -const getHostSelector = (fiber: Fiber): string | null => { - const hostFiber = getNearestHostFiber(fiber); - const hostNode = hostFiber?.stateNode; - if (!(hostNode instanceof Element)) return null; - const selectorParts: string[] = []; - let currentElement: Element | null = hostNode; - while ( - currentElement && - selectorParts.length < BROWSER_POC_HOST_SELECTOR_MAX_COUNT && - currentElement !== document.documentElement - ) { - const id = currentElement.id ? `#${CSS.escape(currentElement.id)}` : ""; - const testId = currentElement.getAttribute("data-testid"); - const dataSelector = testId ? `[data-testid="${CSS.escape(testId)}"]` : ""; - const selector = id || dataSelector || currentElement.tagName.toLowerCase(); - selectorParts.unshift(selector); - if (id) break; - currentElement = currentElement.parentElement; - } - return selectorParts.join(" > "); -}; - -const getHookNames = (fiber: Fiber): string[] => { - const hookNames = fiber._debugHookTypes; - return Array.isArray(hookNames) ? [...new Set(hookNames)] : []; -}; - -const isAstNode = (value: unknown): value is BrowserPocAstNode => { - if (!value || typeof value !== "object") return false; - const maybeNode = value as Record; - return typeof maybeNode.type === "string"; -}; - -const walkAst = (node: unknown, visitor: (child: BrowserPocAstNode) => void): void => { - if (!isAstNode(node)) return; - visitor(node); - for (const [key, value] of Object.entries(node as Record)) { - if (key === "parent") continue; - if (Array.isArray(value)) { - for (const item of value) { - walkAst(item, visitor); - } - } else { - walkAst(value, visitor); - } - } -}; - -const isIdentifier = (node: BrowserPocAstNode | null | undefined, name: string): boolean => - node?.type === "Identifier" && node.name === name; - -const isHookCall = (node: BrowserPocAstNode, hookName: string): boolean => { - if (node.type !== "CallExpression") return false; - const callee = node.callee; - if (isIdentifier(callee, hookName)) return true; - return callee?.type === "MemberExpression" && isIdentifier(callee.property, hookName); -}; - -const isFetchCall = (node: BrowserPocAstNode): boolean => - node.type === "CallExpression" && isIdentifier(node.callee, "fetch"); - -const getEffectCallback = (node: BrowserPocAstNode): BrowserPocAstNode | null => { - if (!isHookCall(node, "useEffect")) return null; - const callback = node.arguments?.[0]; - if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") { - return callback; - } - return null; -}; - -const hasFetchCall = (node: BrowserPocAstNode): boolean => { - let didFindFetch = false; - walkAst(node, (child) => { - if (isFetchCall(child)) didFindFetch = true; - }); - return didFindFetch; -}; - -const createBrowserPocDiagnosticsFromAst = ( - program: BrowserPocAstNode, -): BrowserPocRuntimeDiagnostic[] => { - const diagnostics: BrowserPocRuntimeDiagnostic[] = []; - walkAst(program, (node) => { - const effectCallback = getEffectCallback(node); - if (!effectCallback) return; - if (hasFetchCall(effectCallback)) { - diagnostics.push({ - rule: "browser-poc/no-fetch-in-effect", - severity: "warning", - message: "Component has fetch() inside useEffect().", - lineNumber: null, - columnNumber: null, - }); - } - }); - return diagnostics; -}; - -const getLineColumn = ( - source: string, - offset: unknown, -): { lineNumber: number | null; columnNumber: number | null } => { - if (typeof offset !== "number" || !Number.isFinite(offset)) { - return { lineNumber: null, columnNumber: null }; - } - let lineNumber = 1; - let columnNumber = 1; - for (let index = 0; index < offset && index < source.length; index += 1) { - if (source[index] === "\n") { - lineNumber += 1; - columnNumber = 1; - } else { - columnNumber += 1; - } - } - return { lineNumber, columnNumber }; -}; - -const getAstChildren = (node: BrowserPocAstNode): BrowserPocAstNode[] => { - const children: BrowserPocAstNode[] = []; - for (const [key, value] of Object.entries(node)) { - if (key === "parent") continue; - if (Array.isArray(value)) { - for (const item of value) { - if (isAstNode(item)) children.push(item); - } - } else if (isAstNode(value)) { - children.push(value); - } - } - return children; -}; - -const visitAst = ( - node: BrowserPocAstNode, - visitors: RuleVisitors, - parent?: BrowserPocAstNode, -): void => { - node.parent = parent; - const enter = visitors[node.type]; - if (enter) enter(node); - for (const child of getAstChildren(node)) { - visitAst(child, visitors, node); - } - const exit = visitors[`${node.type}:exit`]; - if (exit) exit(node); -}; - -const runReactDoctorRules = ( - program: BrowserPocAstNode, - source: string, -): { diagnostics: BrowserPocRuntimeDiagnostic[]; ruleRunResult: BrowserPocRuleRunResult } => { - const diagnostics: BrowserPocRuntimeDiagnostic[] = []; - const failedRules: BrowserPocRuleFailure[] = []; - let completed = 0; - const ruleEntries = Object.entries(reactDoctorPlugin.rules); - - for (const [ruleName, rule] of ruleEntries) { - try { - const visitors = rule.create({ - getFilename: () => "app/component.tsx", - report: ({ node, message }) => { - const location = getLineColumn(source, node.start); - diagnostics.push({ - rule: `${reactDoctorPlugin.meta.name}/${ruleName}`, - severity: "warning", - message, - lineNumber: location.lineNumber, - columnNumber: location.columnNumber, - }); - }, - }); - visitAst(program, visitors); - completed += 1; - } catch (error) { - failedRules.push({ - rule: `${reactDoctorPlugin.meta.name}/${ruleName}`, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return { - diagnostics, - ruleRunResult: { - attempted: ruleEntries.length, - completed, - failed: failedRules.length, - failedRules, - }, - }; -}; - -const morphNode = (target: BrowserPocAstNode, replacement: BrowserPocAstNode): void => { - for (const key of Object.keys(target)) { - if (key === "start" || key === "end") continue; - delete target[key]; - } - for (const [key, value] of Object.entries(replacement)) { - if (key === "start" || key === "end") continue; - target[key] = value; - } -}; - -const SETTER_PREFIX = "set"; - -const buildSetterName = (valueName: string): string => - `${SETTER_PREFIX}${valueName.charAt(0).toUpperCase()}${valueName.slice(1)}`; - -const JSX_CALLEE_NAMES = new Set(["jsx", "jsxs", "_jsx", "_jsxs", "createElement"]); - -const getNodeCalleeName = (node: BrowserPocAstNode): string | null => { - const callee = node.callee; - if (!callee) return null; - if (callee.type === "Identifier") return callee.name ?? null; - if (callee.type === "MemberExpression" && callee.property?.type === "Identifier") { - return callee.property.name ?? null; - } - return null; -}; - -const buildJsxName = (elementType: BrowserPocAstNode): BrowserPocAstNode | null => { - if (elementType.type === "Literal" && typeof elementType.value === "string") { - return { type: "JSXIdentifier", name: elementType.value } as BrowserPocAstNode; - } - if (elementType.type === "Identifier") { - return { type: "JSXIdentifier", name: elementType.name } as BrowserPocAstNode; - } - if (elementType.type === "MemberExpression" && elementType.property?.type === "Identifier") { - const objectJsxName = buildJsxName(elementType.object as BrowserPocAstNode); - if (!objectJsxName) return null; - return { - type: "JSXMemberExpression", - object: objectJsxName, - property: { type: "JSXIdentifier", name: elementType.property.name }, - } as BrowserPocAstNode; - } - return null; -}; - -const buildJsxAttributeValue = (valueNode: BrowserPocAstNode): BrowserPocAstNode | null => { - if (valueNode.type === "Literal" && typeof valueNode.value === "string") { - return valueNode; - } - if (valueNode.type === "Literal" && valueNode.value === true) { - return null; - } - return { - type: "JSXExpressionContainer", - expression: valueNode, - } as BrowserPocAstNode; -}; - -const JSX_NODE_TYPES = new Set(["JSXElement", "JSXFragment", "JSXText", "JSXExpressionContainer"]); - -const wrapJsxChild = (child: BrowserPocAstNode): BrowserPocAstNode => { - if (JSX_NODE_TYPES.has(child.type)) return child; - if (child.type === "Literal" && typeof child.value === "string") { - return { type: "JSXText", value: child.value, raw: String(child.value) } as BrowserPocAstNode; - } - return { - type: "JSXExpressionContainer", - expression: child, - } as BrowserPocAstNode; -}; - -const isFragmentType = (elementType: BrowserPocAstNode): boolean => { - if (elementType.type === "Identifier" && elementType.name === "Fragment") return true; - if ( - elementType.type === "MemberExpression" && - elementType.property?.type === "Identifier" && - elementType.property.name === "Fragment" - ) { - return true; - } - return false; -}; - -const reconstructJsx = (node: BrowserPocAstNode): void => { - if (node.type !== "CallExpression") return; - const calleeName = getNodeCalleeName(node); - if (!calleeName || !JSX_CALLEE_NAMES.has(calleeName)) return; - - const nodeArguments = node.arguments; - if (!Array.isArray(nodeArguments) || nodeArguments.length < 1) return; - - const elementType = nodeArguments[0] as BrowserPocAstNode; - const isFragment = isFragmentType(elementType); - - if (!isFragment) { - const jsxName = buildJsxName(elementType); - if (!jsxName) return; - } - - const jsxName = isFragment ? null : buildJsxName(elementType); - if (!isFragment && !jsxName) return; - - const attributes: BrowserPocAstNode[] = []; - const children: BrowserPocAstNode[] = []; - const propsArg = nodeArguments[1] as BrowserPocAstNode | undefined; - const isCreateElement = calleeName === "createElement"; - - if (propsArg && propsArg.type === "ObjectExpression" && Array.isArray(propsArg.properties)) { - for (const property of propsArg.properties as BrowserPocAstNode[]) { - if (property.type === "SpreadElement" || property.type === "RestElement") { - attributes.push({ - type: "JSXSpreadAttribute", - argument: property.argument, - } as BrowserPocAstNode); - continue; - } - if (property.type !== "Property") continue; - - const keyNode = property.key as BrowserPocAstNode | undefined; - if (!keyNode) continue; - const keyName = - keyNode.type === "Identifier" - ? keyNode.name - : keyNode.type === "Literal" - ? String(keyNode.value) - : null; - if (!keyName) continue; - - const propertyValue = property.value as BrowserPocAstNode; - - if (keyName === "children") { - if (propertyValue.type === "ArrayExpression" && Array.isArray(propertyValue.elements)) { - for (const element of propertyValue.elements as BrowserPocAstNode[]) { - if (element) children.push(wrapJsxChild(element)); - } - } else { - children.push(wrapJsxChild(propertyValue)); - } - continue; - } - - const attributeValue = buildJsxAttributeValue(propertyValue); - attributes.push({ - type: "JSXAttribute", - name: { type: "JSXIdentifier", name: keyName }, - value: attributeValue, - } as unknown as BrowserPocAstNode); - } - } - - if (isCreateElement) { - for (let argumentIndex = 2; argumentIndex < nodeArguments.length; argumentIndex++) { - children.push(wrapJsxChild(nodeArguments[argumentIndex] as BrowserPocAstNode)); - } - } else if (nodeArguments.length >= 3) { - const keyArg = nodeArguments[2] as BrowserPocAstNode; - if (keyArg && keyArg.type !== "Identifier") { - attributes.push({ - type: "JSXAttribute", - name: { type: "JSXIdentifier", name: "key" }, - value: buildJsxAttributeValue(keyArg), - } as unknown as BrowserPocAstNode); - } - } - - if (isFragment) { - morphNode(node, { - type: "JSXFragment", - openingFragment: { type: "JSXOpeningFragment" }, - closingFragment: { type: "JSXClosingFragment" }, - children, - } as unknown as BrowserPocAstNode); - return; - } - - const hasChildren = children.length > 0; - const openingElement = { - type: "JSXOpeningElement", - name: jsxName, - attributes, - selfClosing: !hasChildren, - } as unknown as BrowserPocAstNode; - - morphNode(node, { - type: "JSXElement", - openingElement, - closingElement: hasChildren - ? ({ type: "JSXClosingElement", name: { ...jsxName } } as unknown as BrowserPocAstNode) - : null, - children, - } as unknown as BrowserPocAstNode); -}; - -const normalizeMinifiedAst = (node: BrowserPocAstNode, displayName: string | null): void => { - for (const [key, value] of Object.entries(node)) { - if (key === "parent" || key === "type" || key === "start" || key === "end") continue; - if (Array.isArray(value)) { - for (const item of value) { - if (isAstNode(item)) normalizeMinifiedAst(item, displayName); - } - } else if (isAstNode(value)) { - normalizeMinifiedAst(value as BrowserPocAstNode, displayName); - } - } - - if ( - (node.type === "CallExpression" || node.type === "NewExpression") && - node.callee?.type === "SequenceExpression" - ) { - const expressions = node.callee.expressions; - if (Array.isArray(expressions) && expressions.length > 0) { - node.callee = expressions[expressions.length - 1] as BrowserPocAstNode; - } - } - - if ( - node.type === "StringLiteral" || - node.type === "NumericLiteral" || - node.type === "BooleanLiteral" || - node.type === "NullLiteral" - ) { - const preservedValue = node.type === "NullLiteral" ? null : node.value; - const preservedRaw = (node.raw as string | undefined) ?? String(preservedValue); - morphNode(node, { - type: "Literal", - value: preservedValue, - raw: preservedRaw, - } as BrowserPocAstNode); - } - - if ( - node.type === "UnaryExpression" && - node.operator === "!" && - node.argument?.type === "Literal" && - typeof node.argument.value === "number" - ) { - const numericValue = node.argument.value as number; - if (numericValue === 0 || numericValue === 1) { - morphNode(node, { - type: "Literal", - value: numericValue === 0, - raw: numericValue === 0 ? "true" : "false", - } as BrowserPocAstNode); - } - } - - if ( - node.type === "UnaryExpression" && - node.operator === "void" && - node.argument?.type === "Literal" && - node.argument.value === 0 - ) { - morphNode(node, { type: "Identifier", name: "undefined" } as BrowserPocAstNode); - } - - if (node.type === "ReturnStatement" && node.argument?.type === "SequenceExpression") { - const sequenceExpressions = node.argument.expressions; - if (Array.isArray(sequenceExpressions) && sequenceExpressions.length > 1) { - const lastExpression = sequenceExpressions[ - sequenceExpressions.length - 1 - ] as BrowserPocAstNode; - const sideEffectStatements = sequenceExpressions.slice(0, -1).map( - (expression) => - ({ - type: "ExpressionStatement", - expression, - }) as unknown as BrowserPocAstNode, - ); - node.argument = lastExpression; - node._hoistedStatements = sideEffectStatements; - } - } - - if ((node.type === "BlockStatement" || node.type === "Program") && Array.isArray(node.body)) { - const expandedBody: BrowserPocAstNode[] = []; - let didExpand = false; - for (const statement of node.body as BrowserPocAstNode[]) { - if ( - Array.isArray(statement._hoistedStatements) && - (statement._hoistedStatements as BrowserPocAstNode[]).length > 0 - ) { - expandedBody.push(...(statement._hoistedStatements as BrowserPocAstNode[])); - delete statement._hoistedStatements; - didExpand = true; - } - const statementExpression = statement.expression as BrowserPocAstNode | undefined; - if ( - statement.type === "ExpressionStatement" && - statementExpression?.type === "SequenceExpression" - ) { - const expressions = statementExpression.expressions; - if (Array.isArray(expressions)) { - for (const expression of expressions as BrowserPocAstNode[]) { - expandedBody.push({ - type: "ExpressionStatement", - expression, - } as unknown as BrowserPocAstNode); - } - didExpand = true; - continue; - } - } - expandedBody.push(statement); - } - if (didExpand) node.body = expandedBody; - } - - if ( - node.type === "VariableDeclaration" && - Array.isArray(node.declarations) && - node.declarations.length > 1 && - node.parent && - isAstNode(node.parent) && - (node.parent.type === "BlockStatement" || node.parent.type === "Program") - ) { - node._splitDeclarations = (node.declarations as BrowserPocAstNode[]).map( - (declarator) => - ({ - type: "VariableDeclaration", - kind: node.kind, - declarations: [declarator], - }) as unknown as BrowserPocAstNode, - ); - } - - if ((node.type === "BlockStatement" || node.type === "Program") && Array.isArray(node.body)) { - let didSplit = false; - const splitBody: BrowserPocAstNode[] = []; - for (const statement of node.body as BrowserPocAstNode[]) { - if (statement.type === "VariableDeclaration" && Array.isArray(statement._splitDeclarations)) { - splitBody.push(...(statement._splitDeclarations as BrowserPocAstNode[])); - delete statement._splitDeclarations; - didSplit = true; - } else { - splitBody.push(statement); - } - } - if (didSplit) node.body = splitBody; - } - - const arrowBody = node.type === "ArrowFunctionExpression" ? node.body : null; - if ( - arrowBody && - !Array.isArray(arrowBody) && - isAstNode(arrowBody) && - arrowBody.type === "SequenceExpression" && - Array.isArray(arrowBody.expressions) - ) { - const arrowExpressions = arrowBody.expressions as BrowserPocAstNode[]; - if (arrowExpressions.length > 1) { - const lastArrowExpression = arrowExpressions[arrowExpressions.length - 1]; - const leadingStatements = arrowExpressions - .slice(0, -1) - .map( - (expression) => - ({ type: "ExpressionStatement", expression }) as unknown as BrowserPocAstNode, - ); - node.body = { - type: "BlockStatement", - body: [ - ...leadingStatements, - { - type: "ReturnStatement", - argument: lastArrowExpression, - } as unknown as BrowserPocAstNode, - ], - } as unknown as BrowserPocAstNode; - } - } - - if ( - node.type === "MemberExpression" && - node.computed === true && - node.property?.type === "Literal" && - typeof node.property.value === "string" && - /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(node.property.value) - ) { - node.computed = false; - node.property = { - type: "Identifier", - name: node.property.value, - } as BrowserPocAstNode; - } - - reconstructJsx(node); - - if (displayName) { - const uppercasedDisplayName = displayName.charAt(0).toUpperCase() + displayName.slice(1); - - if ( - node.type === "VariableDeclarator" && - node.id?.type === "Identifier" && - node.id.name === "__reactDoctorComponent" && - node.init && - (node.init.type === "FunctionExpression" || node.init.type === "ArrowFunctionExpression") - ) { - node.id.name = uppercasedDisplayName; - } - - if ( - node.type === "FunctionDeclaration" && - node.id?.type === "Identifier" && - node.id.name && - /^[a-z]/.test(node.id.name) && - node.params && - Array.isArray(node.params) && - node.params.length <= 2 - ) { - node.id.name = uppercasedDisplayName; - } - } -}; - -const renameMinifiedSetters = (program: BrowserPocAstNode): void => { - const setterRenames = new Map(); - - walkAst(program, (node) => { - if (node.type !== "VariableDeclarator" || node.id?.type !== "ArrayPattern") return; - const initNode = node.init; - if (!initNode || initNode.type !== "CallExpression") return; - - const callee = initNode.callee; - const isUseStateCall = - (callee?.type === "Identifier" && callee.name === "useState") || - (callee?.type === "MemberExpression" && - callee.property?.type === "Identifier" && - callee.property.name === "useState"); - if (!isUseStateCall) return; - - const elements = node.id.elements; - if (!Array.isArray(elements) || elements.length < 2) return; - const valueElement = elements[0] as BrowserPocAstNode | null; - const setterElement = elements[1] as BrowserPocAstNode | null; - if ( - valueElement?.type !== "Identifier" || - setterElement?.type !== "Identifier" || - !valueElement.name || - !setterElement.name - ) { - return; - } - if (/^set[A-Z]/.test(setterElement.name)) return; - - const newSetterName = buildSetterName(valueElement.name); - setterRenames.set(setterElement.name, newSetterName); - setterElement.name = newSetterName; - }); - - if (setterRenames.size === 0) return; - - walkAst(program, (node) => { - if (node.type !== "Identifier" || !node.name) return; - const newName = setterRenames.get(node.name); - if (newName) node.name = newName; - }); -}; - -const parseComponentSource = ( - functionSource: BrowserPocFunctionSource | null, - displayName: string | null, -): { - diagnostics: BrowserPocRuntimeDiagnostic[]; - parseResult: BrowserPocParseResult; - ruleRunResult: BrowserPocRuleRunResult; -} => { - if (!functionSource) { - return { - diagnostics: [], - parseResult: SKIPPED_PARSE_RESULT, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - if (functionSource.isTruncated) { - return { - diagnostics: [], - parseResult: { status: "skipped", error: null }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - const rawSource = functionSource.code; - const wasmError = initializeOxcWasm(); - if (wasmError) { - return { diagnostics: [], parseResult: wasmError, ruleRunResult: SKIPPED_RULE_RUN_RESULT }; - } - try { - const wrappedSource = `"use client";\nconst __reactDoctorComponent = ${rawSource};`; - const result = parseSync(wrappedSource, { - sourceFilename: "app/component.tsx", - }); - if (result.errors.length > 0) { - return { - diagnostics: [], - parseResult: { - status: "parse-error", - error: result.errors.map((error) => error.message).join("\n"), - }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - const program = result.program as unknown; - if (!isAstNode(program)) { - return { - diagnostics: [], - parseResult: { - status: "parse-error", - error: "OXC returned a non-ESTree program.", - }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - normalizeMinifiedAst(program, displayName); - renameMinifiedSetters(program); - const reactDoctorRuleResult = runReactDoctorRules(program, wrappedSource); - const browserPocDiagnostics = createBrowserPocDiagnosticsFromAst(program).filter( - (diagnostic) => - diagnostic.rule !== "browser-poc/no-fetch-in-effect" || - !reactDoctorRuleResult.diagnostics.some( - (reactDoctorDiagnostic) => - reactDoctorDiagnostic.rule === `${reactDoctorPlugin.meta.name}/no-fetch-in-effect`, - ), - ); - return { - diagnostics: [...reactDoctorRuleResult.diagnostics, ...browserPocDiagnostics], - parseResult: { - status: "parsed", - error: null, - }, - ruleRunResult: reactDoctorRuleResult.ruleRunResult, - }; - } catch (error) { - return { - diagnostics: [], - parseResult: { - status: "parse-error", - error: error instanceof Error ? error.message : String(error), - }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } -}; - -const getRecord = (fiber: Fiber): BrowserPocComponentRecord | null => { - const componentType = getType(fiber.type) ?? fiber.type; - if (!componentType) return null; - const existingRecord = componentRecordsByType.get(componentType); - if (existingRecord) return existingRecord; - - const functionSource = getFunctionSource(componentType); - const displayName = getDisplayName(componentType) ?? "Anonymous"; - const parsed = parseComponentSource(functionSource, displayName); - const id = nextComponentID; - nextComponentID += 1; - const record: BrowserPocComponentRecord = { - id, - displayName, - tag: fiber.tag, - instanceCount: 0, - commitCount: 0, - selfTime: 0, - totalTime: 0, - hookNames: [], - hostSelector: null, - sourceLocation: null, - source: functionSource?.code ?? null, - parseResult: parsed.parseResult, - ruleRunResult: parsed.ruleRunResult, - diagnostics: parsed.diagnostics, - }; - componentRecordsByType.set(componentType, record); - componentTypesByID.set(id, componentType); - return record; -}; - -const collectFiber = (fiber: Fiber): void => { - if (!isCompositeFiber(fiber)) return; - const record = getRecord(fiber); - if (!record) return; - record.instanceCount += 1; - record.commitCount += 1; - record.hookNames = getHookNames(fiber); - record.hostSelector = getHostSelector(fiber); - record.sourceLocation = getSourceLocation(fiber); - const timings = getTimings(fiber); - record.selfTime = timings.selfTime; - record.totalTime = timings.totalTime; -}; - -const collectRoot = (rendererID: number | null, root: FiberRoot): void => { - lastRendererID = rendererID; - commitCount += 1; - traverseFiber(root.current, collectFiber); -}; - -const getScoreLabel = (score: number): string => { - if (score >= SCORE_GOOD_THRESHOLD) return "Great"; - if (score >= SCORE_OK_THRESHOLD) return "Needs work"; - return "Critical"; -}; - -const calculateBrowserPocScore = ( - diagnostics: BrowserPocRuntimeDiagnostic[], -): BrowserPocScoreResult => { - const errorRules = new Set(); - const warningRules = new Set(); - for (const diagnostic of diagnostics) { - if (diagnostic.severity === "error") { - errorRules.add(diagnostic.rule); - } else { - warningRules.add(diagnostic.rule); - } - } - const penalty = errorRules.size * ERROR_RULE_PENALTY + warningRules.size * WARNING_RULE_PENALTY; - const score = Math.max(0, Math.round(PERFECT_SCORE - penalty)); - return { score, label: getScoreLabel(score) }; -}; - -const buildSnapshot = (): BrowserPocSnapshot => { - const components: BrowserPocComponentRecord[] = []; - const allDiagnostics: BrowserPocRuntimeDiagnostic[] = []; - for (const componentID of componentTypesByID.keys()) { - const componentType = componentTypesByID.get(componentID); - const record = componentRecordsByType.get(componentType); - if (record) { - components.push(record); - allDiagnostics.push(...record.diagnostics); - } - } - return { - isActive, - lastRendererID, - rootCount: _fiberRoots.size, - commitCount, - components, - scoreResult: calculateBrowserPocScore(allDiagnostics), - }; -}; - -const collectNow = (): BrowserPocSnapshot => { - for (const root of _fiberRoots) { - collectRoot(lastRendererID, root); - } - return buildSnapshot(); -}; - -export const startBrowserPoc = (options: BrowserPocOptions = {}): BrowserPocController => { - const controller: BrowserPocController = { - snapshot: buildSnapshot, - collectNow, - }; - - instrument( - secure( - { - name: "react-doctor-browser-poc", - onActive: () => { - isActive = true; - }, - onCommitFiberRoot: (rendererID, root) => { - collectRoot(rendererID, root); - if (options.log) console.log("[react-doctor/browser-poc]", buildSnapshot()); - }, - }, - { - dangerouslyRunInProduction: options.dangerouslyRunInProduction ?? false, - }, - ), - ); - - window.reactDoctorBrowserPoc = controller; - return controller; -}; - -if (typeof window !== "undefined") { - startBrowserPoc(window.__reactDoctorBrowserPocOptions); -} diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts deleted file mode 100644 index c0cfc6eab3..0000000000 --- a/packages/react-doctor/src/cli.ts +++ /dev/null @@ -1,743 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { performance } from "node:perf_hooks"; -import { Command } from "commander"; -import { CANONICAL_GITHUB_URL } from "./constants.js"; -import { runInstallSkill } from "./install-skill.js"; -import { scan } from "./scan.js"; -import type { - Diagnostic, - DiffInfo, - FailOnLevel, - JsonReport, - JsonReportMode, - ReactDoctorConfig, - ScanOptions, - ScanResult, -} from "./types.js"; -import { buildJsonReport } from "./utils/build-json-report.js"; -import { buildJsonReportError } from "./utils/build-json-report-error.js"; -import { filterSourceFiles, getDiffInfo } from "./utils/get-diff-files.js"; -import { getStagedSourceFiles, materializeStagedFiles } from "./utils/get-staged-files.js"; -import { handleError } from "./utils/handle-error.js"; -import { highlighter } from "./utils/highlighter.js"; -import { loadConfigWithSource } from "./utils/load-config.js"; -import { resolveConfigRootDir } from "./utils/resolve-config-root-dir.js"; -import { logger, setLoggerSilent } from "./utils/logger.js"; -import { encodeAnnotationProperty, encodeAnnotationMessage } from "./utils/annotation-encoding.js"; -import { findOwningProjectDirectory } from "./utils/find-owning-project.js"; -import { parseFileLineArgument } from "./utils/parse-file-line-argument.js"; -import { prompts } from "./utils/prompts.js"; -import { selectProjects } from "./utils/select-projects.js"; -import { toRelativePath } from "./utils/to-relative-path.js"; - -const VERSION = process.env.VERSION ?? "0.0.0"; - -interface CliFlags { - lint: boolean; - deadCode: boolean; - verbose: boolean; - score: boolean; - json: boolean; - jsonCompact: boolean; - yes: boolean; - full: boolean; - offline: boolean; - annotations: boolean; - staged: boolean; - respectInlineDisables: boolean; - project?: string; - diff?: boolean | string; - explain?: string; - why?: string; - failOn: string; -} - -const VALID_FAIL_ON_LEVELS = new Set(["error", "warning", "none"]); - -const isValidFailOnLevel = (level: string): level is FailOnLevel => - VALID_FAIL_ON_LEVELS.has(level as FailOnLevel); - -const shouldFailForDiagnostics = (diagnostics: Diagnostic[], failOnLevel: FailOnLevel): boolean => { - if (failOnLevel === "none") return false; - if (failOnLevel === "warning") return diagnostics.length > 0; - return diagnostics.some((diagnostic) => diagnostic.severity === "error"); -}; - -const resolveFailOnLevel = ( - programInstance: Command, - flags: CliFlags, - userConfig: ReactDoctorConfig | null, -): FailOnLevel => { - const isCliOverride = programInstance.getOptionValueSource("failOn") === "cli"; - const sourceValue = isCliOverride ? flags.failOn : (userConfig?.failOn ?? flags.failOn); - - if (isValidFailOnLevel(sourceValue)) return sourceValue; - logger.warn( - `Invalid failOn level "${sourceValue}". Expected one of: error, warning, none. Falling back to "none".`, - ); - return "none"; -}; - -const printAnnotations = (diagnostics: Diagnostic[], routeToStderr: boolean): void => { - const writeLine = routeToStderr - ? (line: string) => process.stderr.write(`${line}\n`) - : (line: string) => process.stdout.write(`${line}\n`); - for (const diagnostic of diagnostics) { - const level = diagnostic.severity === "error" ? "error" : "warning"; - const title = `${diagnostic.plugin}/${diagnostic.rule}`; - const fileSegment = `file=${encodeAnnotationProperty(diagnostic.filePath)}`; - const lineSegment = diagnostic.line > 0 ? `,line=${diagnostic.line}` : ""; - const titleSegment = `,title=${encodeAnnotationProperty(title)}`; - const message = encodeAnnotationMessage(diagnostic.message); - writeLine(`::${level} ${fileSegment}${lineSegment}${titleSegment}::${message}`); - } -}; - -let isJsonModeActive = false; -let resolvedDirectoryForCancel: string | null = null; -let cancelStartTime = 0; -let currentReportMode: JsonReportMode = "full"; - -const exitGracefully = () => { - if (isJsonModeActive) { - writeJsonReport( - buildJsonReportError({ - version: VERSION, - directory: resolvedDirectoryForCancel ?? process.cwd(), - error: new Error("Scan cancelled by user (SIGINT/SIGTERM)"), - elapsedMilliseconds: performance.now() - cancelStartTime, - mode: currentReportMode, - }), - ); - process.exit(130); - } - logger.break(); - logger.log("Cancelled."); - logger.break(); - process.exit(130); -}; - -process.on("SIGINT", exitGracefully); -process.on("SIGTERM", exitGracefully); - -// HACK: env vars that mean "user is not at an interactive shell." We use this -// to skip prompts but NOT to auto-flip --offline, because dev shells often -// have JENKINS_URL / TF_BUILD set as ambient config without actually running -// in CI. -const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [ - "CI", - "GITHUB_ACTIONS", - "GITLAB_CI", - "BUILDKITE", - "JENKINS_URL", - "TF_BUILD", - "CODEBUILD_BUILD_ID", - "TEAMCITY_VERSION", - "BITBUCKET_BUILD_NUMBER", - "CIRCLECI", - "TRAVIS", - "DRONE", - "CLAUDECODE", - "CLAUDE_CODE", - "CURSOR_AGENT", - "CODEX_CI", - "OPENCODE", - "AMP_HOME", -]; - -// HACK: only flip --offline by default for the narrowest set of CI signals -// where we're confident the run is automated and a share URL would be -// useless. Other tools that set non-interactive env vars (Jenkins agents, -// Azure DevOps tasks running interactively, agentic coding sessions) still -// get telemetry-on-by-default; users can pass --offline explicitly. -const CI_ENVIRONMENT_VARIABLES = ["GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI"]; - -const isNonInteractiveEnvironment = (): boolean => - NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])); - -const isCiEnvironment = (): boolean => - CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || - process.env.CI === "true"; - -const resolveCliScanOptions = ( - flags: CliFlags, - userConfig: ReactDoctorConfig | null, - programInstance: Command, -): ScanOptions => { - const isCliOverride = (optionName: string) => - programInstance.getOptionValueSource(optionName) === "cli"; - - return { - lint: isCliOverride("lint") ? flags.lint : (userConfig?.lint ?? true), - deadCode: isCliOverride("deadCode") ? flags.deadCode : (userConfig?.deadCode ?? true), - verbose: isCliOverride("verbose") ? flags.verbose : (userConfig?.verbose ?? false), - scoreOnly: flags.score, - offline: flags.offline || (userConfig?.offline ?? false) || isCiEnvironment(), - silent: flags.json, - respectInlineDisables: isCliOverride("respectInlineDisables") - ? flags.respectInlineDisables - : (userConfig?.respectInlineDisables ?? true), - }; -}; - -let isCompactJsonOutput = false; - -const writeJsonReport = (report: JsonReport): void => { - const serialized = isCompactJsonOutput ? JSON.stringify(report) : JSON.stringify(report, null, 2); - process.stdout.write(`${serialized}\n`); -}; - -// HACK: only the exact lowercase `"true"` / `"false"` literals are -// coerced to booleans — anything else stays as a (case-sensitive) branch -// name so that real branches like `True-Branch` / `FALSE-vN` aren't -// silently turned into a flag. -const coerceDiffValue = (value: unknown): boolean | string | undefined => { - if (value === undefined) return undefined; - if (typeof value === "boolean") return value; - if (typeof value === "string") { - if (value.length === 0) return undefined; - if (value === "false") return false; - if (value === "true") return true; - return value; - } - // HACK: write directly to stderr so the warning is visible even in - // `--json` mode (where the logger is silenced to keep stdout a - // single valid JSON document). - process.stderr.write( - `[react-doctor] invalid diff value (expected boolean or string): ${typeof value}. Falling back to no diff.\n`, - ); - return undefined; -}; - -const resolveEffectiveDiff = ( - flags: CliFlags, - userConfig: ReactDoctorConfig | null, - programInstance: Command, -): boolean | string | undefined => { - // HACK: --full is the documented "always run a full scan" escape hatch. - // It must override config-set `diff: true` / `diff: "main"`, otherwise - // the flag is silently ignored when a project's react-doctor.config.json - // has any diff value. - if (flags.full) return false; - const isDiffCliOverride = programInstance.getOptionValueSource("diff") === "cli"; - const rawValue = isDiffCliOverride ? flags.diff : userConfig?.diff; - return coerceDiffValue(rawValue); -}; - -const resolveDiffMode = async ( - diffInfo: DiffInfo | null, - effectiveDiff: boolean | string | undefined, - shouldSkipPrompts: boolean, - isQuiet: boolean, -): Promise => { - if (effectiveDiff !== undefined && effectiveDiff !== false) { - if (diffInfo) return true; - if (!isQuiet) { - logger.warn("No feature branch or uncommitted changes detected. Running full scan."); - logger.break(); - } - return false; - } - - if (effectiveDiff === false || !diffInfo) return false; - - const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles); - if (changedSourceFiles.length === 0) return false; - if (shouldSkipPrompts) return false; - if (isQuiet) return false; - - const promptMessage = diffInfo.isCurrentChanges - ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan those?` - : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} files changed vs ${diffInfo.baseBranch}). Only scan changed files?`; - - const { shouldScanChangedOnly } = await prompts({ - type: "confirm", - name: "shouldScanChangedOnly", - message: promptMessage, - initial: true, - }); - return Boolean(shouldScanChangedOnly); -}; - -interface ExplainContext { - resolvedDirectory: string; - userConfig: ReactDoctorConfig | null; - scanOptions: ScanOptions; - projectFlag: string | undefined; -} - -const colorizeRuleByDiagnostic = (text: string, severity: Diagnostic["severity"]): string => - severity === "error" ? highlighter.error(text) : highlighter.warn(text); - -const runExplain = async (fileLineArgument: string, context: ExplainContext): Promise => { - const { filePath, line } = parseFileLineArgument(fileLineArgument); - const targetDirectory = await resolveExplainTargetDirectory(filePath, context); - - const scanResult = await scan(targetDirectory, { - ...context.scanOptions, - silent: true, - offline: true, - configOverride: context.userConfig, - }); - - const requestedRelativePath = toRelativePath(filePath, targetDirectory); - const matchingDiagnostics = scanResult.diagnostics.filter( - (diagnostic) => - diagnostic.line === line && - toRelativePath(diagnostic.filePath, targetDirectory) === requestedRelativePath, - ); - - if (matchingDiagnostics.length === 0) { - logger.log(`No react-doctor diagnostics at ${filePath}:${line}.`); - return; - } - - for (const diagnostic of matchingDiagnostics) { - const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`; - const severitySymbol = diagnostic.severity === "error" ? "✗" : "⚠"; - const colorizedRule = colorizeRuleByDiagnostic(ruleIdentifier, diagnostic.severity); - const severityLabel = colorizeRuleByDiagnostic(diagnostic.severity, diagnostic.severity); - logger.log( - `${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`, - ); - if (diagnostic.category) logger.dim(` Category: ${diagnostic.category}`); - if (diagnostic.help) logger.dim(` ${diagnostic.help}`); - if (diagnostic.suppressionHint) { - logger.break(); - logger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`); - } else { - logger.dim( - " No nearby react-doctor-disable-next-line comment was detected — add one immediately above this line to suppress.", - ); - } - logger.break(); - } -}; - -const resolveExplainTargetDirectory = async ( - filePath: string, - context: ExplainContext, -): Promise => { - if (context.projectFlag) { - const matchedDirectories = await selectProjects( - context.resolvedDirectory, - context.projectFlag, - true, - ); - if (matchedDirectories.length === 0) return context.resolvedDirectory; - if (matchedDirectories.length > 1) { - throw new Error( - `--explain takes a single project; --project resolved to ${matchedDirectories.length} projects.`, - ); - } - return matchedDirectories[0]; - } - return findOwningProjectDirectory(context.resolvedDirectory, filePath); -}; - -const validateModeFlags = (flags: CliFlags): void => { - // HACK: use the same coercion as resolveEffectiveDiff so a bare - // `--diff false` (or `--diff ""`) is treated as "no diff" and doesn't - // trip the mutual-exclusion check against --staged. - const coercedDiff = coerceDiffValue(flags.diff); - const exclusiveModes = [ - flags.staged ? "--staged" : null, - coercedDiff !== undefined && coercedDiff !== false ? "--diff" : null, - ].filter((modeName): modeName is string => modeName !== null); - - if (exclusiveModes.length > 1) { - throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`); - } - if (flags.yes && flags.full) { - throw new Error("Cannot combine --yes and --full; pick one."); - } - if (flags.score && flags.json) { - throw new Error("Cannot combine --score and --json; pick one output mode."); - } - if (flags.annotations && (flags.json || flags.score)) { - throw new Error("--annotations cannot be combined with --json or --score."); - } - if (flags.explain !== undefined && flags.why !== undefined) { - throw new Error("Use --explain or --why, not both — they're aliases of the same flag."); - } - const explainArgument = flags.explain ?? flags.why; - if ( - explainArgument !== undefined && - (flags.json || flags.score || flags.annotations || flags.staged) - ) { - throw new Error( - "--explain cannot be combined with --json, --score, --annotations, or --staged.", - ); - } -}; - -const program = new Command() - .name("react-doctor") - .description("Diagnose React codebase health") - .version(VERSION, "-v, --version", "display the version number") - .argument("[directory]", "project directory to scan", ".") - .option("--lint", "enable linting") - .option("--no-lint", "skip linting") - .option("--dead-code", "enable dead code detection") - .option("--no-dead-code", "skip dead code detection") - .option("--verbose", "show every rule and per-file details (default shows top 3 rules)") - .option("--score", "output only the score") - .option("--json", "output a single structured JSON report (suppresses other output)") - .option("--json-compact", "with --json, emit compact JSON (no indentation)") - .option("-y, --yes", "skip prompts, scan all workspace projects") - .option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)") - .option("--project ", "select workspace project (comma-separated for multiple)") - .option( - "--diff [base]", - "scan only files changed vs base branch (pass `false` to disable; overridden by --full)", - ) - .option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)") - .option("--staged", "scan only staged (git index) files for pre-commit hooks") - .option("--fail-on ", "exit with error code on diagnostics: error, warning, none", "error") - .option("--annotations", "output diagnostics as GitHub Actions annotations") - .option( - "--explain ", - "diagnose why a rule fired or why a suppression didn't apply at a specific location", - ) - .option("--why ", "alias for --explain") - .option( - "--respect-inline-disables", - "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)", - ) - .option( - "--no-respect-inline-disables", - "audit mode: neutralize inline lint suppressions before scanning", - ) - .action(async (directory: string, flags: CliFlags) => { - const isScoreOnly = flags.score; - const isJsonMode = flags.json; - const isQuiet = isScoreOnly || isJsonMode; - const requestedDirectory = path.resolve(directory); - const jsonStartTime = performance.now(); - - isJsonModeActive = isJsonMode; - isCompactJsonOutput = Boolean(flags.jsonCompact); - resolvedDirectoryForCancel = requestedDirectory; - cancelStartTime = jsonStartTime; - - if (isJsonMode) { - setLoggerSilent(true); - } - - try { - validateModeFlags(flags); - - const loadedConfig = loadConfigWithSource(requestedDirectory); - const userConfig = loadedConfig?.config ?? null; - const redirectedDirectory = resolveConfigRootDir( - loadedConfig?.config ?? null, - loadedConfig?.sourceDirectory ?? null, - ); - const resolvedDirectory = redirectedDirectory ?? requestedDirectory; - resolvedDirectoryForCancel = resolvedDirectory; - if (redirectedDirectory && !isQuiet) { - logger.dim( - `Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`, - ); - logger.break(); - } - - const explainArgument = flags.explain ?? flags.why; - if (explainArgument !== undefined) { - await runExplain(explainArgument, { - resolvedDirectory, - userConfig, - scanOptions: resolveCliScanOptions(flags, userConfig, program), - projectFlag: flags.project, - }); - return; - } - - if (!isQuiet) { - logger.log(`react-doctor v${VERSION}`); - logger.break(); - } - - const scanOptions = resolveCliScanOptions(flags, userConfig, program); - const shouldSkipPrompts = - flags.yes || - flags.full || - isJsonMode || - isNonInteractiveEnvironment() || - !process.stdin.isTTY; - - if (!flags.offline && isCiEnvironment() && !isQuiet) { - logger.dim("CI detected — scoring locally."); - logger.break(); - } - - if (flags.staged) { - currentReportMode = "staged"; - const stagedFiles = getStagedSourceFiles(resolvedDirectory); - if (stagedFiles.length === 0) { - if (isJsonMode) { - writeJsonReport( - buildJsonReport({ - version: VERSION, - directory: resolvedDirectory, - mode: "staged", - diff: null, - scans: [], - totalElapsedMilliseconds: performance.now() - jsonStartTime, - }), - ); - } else if (!isScoreOnly) { - logger.dim("No staged source files found."); - } - return; - } - - if (!isQuiet) { - logger.log(`Scanning ${highlighter.info(`${stagedFiles.length}`)} staged files...`); - logger.break(); - } - - let tempDirectory: string | null = null; - let cleanupSnapshot: (() => void) | null = null; - try { - tempDirectory = mkdtempSync(path.join(tmpdir(), "react-doctor-staged-")); - const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, tempDirectory); - cleanupSnapshot = snapshot.cleanup; - - const scanResult = await scan(snapshot.tempDirectory, { - ...scanOptions, - includePaths: snapshot.stagedFiles, - configOverride: userConfig, - }); - - const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({ - ...diagnostic, - filePath: path.isAbsolute(diagnostic.filePath) - ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) - : diagnostic.filePath, - })); - - if (isJsonMode) { - const remappedScanResult: ScanResult = { - ...scanResult, - diagnostics: remappedDiagnostics, - project: { - ...scanResult.project, - rootDirectory: resolvedDirectory, - }, - }; - writeJsonReport( - buildJsonReport({ - version: VERSION, - directory: resolvedDirectory, - mode: "staged", - diff: null, - scans: [{ directory: resolvedDirectory, result: remappedScanResult }], - totalElapsedMilliseconds: performance.now() - jsonStartTime, - }), - ); - } - - if (flags.annotations) { - printAnnotations(remappedDiagnostics, isJsonMode); - } - - if ( - !isScoreOnly && - shouldFailForDiagnostics( - remappedDiagnostics, - resolveFailOnLevel(program, flags, userConfig), - ) - ) { - process.exitCode = 1; - } - } finally { - cleanupSnapshot?.(); - } - return; - } - - const projectDirectories = await selectProjects( - resolvedDirectory, - flags.project, - shouldSkipPrompts, - ); - - const effectiveDiff = resolveEffectiveDiff(flags, userConfig, program); - const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : undefined; - const wantsDiffMode = effectiveDiff !== undefined && effectiveDiff !== false; - // HACK: also call getDiffInfo when we MIGHT prompt the user — without - // it, resolveDiffMode short-circuits at !diffInfo and the - // "Only scan changed files?" prompt never appears for users on a - // feature branch who didn't explicitly pass --diff. - const shouldDetectDiff = wantsDiffMode || (!shouldSkipPrompts && !isQuiet); - const diffInfo = shouldDetectDiff ? getDiffInfo(resolvedDirectory, explicitBaseBranch) : null; - const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet); - - // HACK: set the cancel-mode marker BEFORE the scan loop runs — if the - // user hits Ctrl-C mid-scan, the SIGINT handler reads currentReportMode - // for the JSON cancel report. Setting it after the loop completes - // means a cancelled diff scan would report mode: "full". - currentReportMode = isDiffMode ? "diff" : "full"; - - if (isDiffMode && diffInfo && !isQuiet) { - if (diffInfo.isCurrentChanges) { - logger.log("Scanning uncommitted changes"); - } else { - logger.log( - `Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`, - ); - } - logger.break(); - } - - const allDiagnostics: Diagnostic[] = []; - const completedScans: Array<{ directory: string; result: ScanResult }> = []; - - for (const projectDirectory of projectDirectories) { - let includePaths: string[] | undefined; - if (isDiffMode) { - const projectDiffInfo = - projectDirectory === resolvedDirectory - ? diffInfo - : getDiffInfo(projectDirectory, explicitBaseBranch); - if (projectDiffInfo) { - const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); - if (changedSourceFiles.length === 0) { - if (!isQuiet) { - logger.dim(`No changed source files in ${projectDirectory}, skipping.`); - logger.break(); - } - continue; - } - includePaths = changedSourceFiles; - } else if (!isQuiet) { - logger.dim( - `Cannot detect diff for ${projectDirectory} (not a git repository?) — scanning all files.`, - ); - logger.break(); - } - } - - if (!isQuiet) { - logger.dim(`Scanning ${projectDirectory}...`); - logger.break(); - } - const scanResult = await scan(projectDirectory, { - ...scanOptions, - includePaths, - configOverride: userConfig, - }); - allDiagnostics.push(...scanResult.diagnostics); - completedScans.push({ directory: projectDirectory, result: scanResult }); - if (!isQuiet) { - logger.break(); - } - } - - const reportMode: JsonReportMode = isDiffMode ? "diff" : "full"; - - if (isJsonMode) { - writeJsonReport( - buildJsonReport({ - version: VERSION, - directory: resolvedDirectory, - mode: reportMode, - diff: isDiffMode ? diffInfo : null, - scans: completedScans, - totalElapsedMilliseconds: performance.now() - jsonStartTime, - }), - ); - } - - if (flags.annotations) { - printAnnotations(allDiagnostics, isJsonMode); - } - - if ( - !isScoreOnly && - shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig)) - ) { - process.exitCode = 1; - } - } catch (error) { - try { - if (isJsonMode) { - writeJsonReport( - buildJsonReportError({ - version: VERSION, - directory: resolvedDirectoryForCancel ?? requestedDirectory, - error, - elapsedMilliseconds: performance.now() - jsonStartTime, - mode: currentReportMode, - }), - ); - process.exitCode = 1; - return; - } - handleError(error); - } catch { - if (isJsonMode) { - process.stdout.write( - '{"schemaVersion":1,"ok":false,"error":{"message":"Internal error","name":"Error","chain":[]}}\n', - ); - } - process.exitCode = 1; - } - } - }) - .addHelpText( - "after", - ` -${highlighter.dim("Configuration:")} - Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info('"reactDoctor"')} key in your package.json) in the project root. - CLI flags always override config values. See the README for the full schema. - -${highlighter.dim("Learn more:")} - ${highlighter.info(CANONICAL_GITHUB_URL)} -`, - ); - -program - .command("install") - .description("Install the react-doctor skill into your coding agents") - .option("-y, --yes", "skip prompts, install for all detected agents") - .option("--dry-run", "show what would be installed without writing files") - .action(async (options: { yes?: boolean; dryRun?: boolean }) => { - try { - await runInstallSkill({ yes: options.yes, dryRun: options.dryRun }); - } catch (error) { - handleError(error); - } - }); - -// HACK: when stdout is piped into a process that closes early (e.g. -// `react-doctor . | head`), Node throws an uncaught EPIPE on the next -// write. Exit cleanly instead of dumping a stack trace. -process.stdout.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EPIPE") process.exit(0); -}); - -program.parseAsync().catch((error: unknown) => { - if (isJsonModeActive) { - try { - writeJsonReport( - buildJsonReportError({ - version: VERSION, - directory: resolvedDirectoryForCancel ?? process.cwd(), - error, - elapsedMilliseconds: performance.now() - cancelStartTime, - mode: currentReportMode, - }), - ); - } catch { - process.stdout.write( - '{"schemaVersion":1,"ok":false,"error":{"message":"Internal error","name":"Error","chain":[]}}\n', - ); - } - process.exit(1); - } - handleError(error); -}); diff --git a/packages/react-doctor/src/utils/get-diff-files.ts b/packages/react-doctor/src/cli/get-diff-files.ts similarity index 96% rename from packages/react-doctor/src/utils/get-diff-files.ts rename to packages/react-doctor/src/cli/get-diff-files.ts index e6c2e81296..9c7bd60ff1 100644 --- a/packages/react-doctor/src/utils/get-diff-files.ts +++ b/packages/react-doctor/src/cli/get-diff-files.ts @@ -1,6 +1,12 @@ import { spawnSync } from "node:child_process"; import { DEFAULT_BRANCH_CANDIDATES, SOURCE_FILE_PATTERN } from "../constants.js"; -import type { DiffInfo } from "../types.js"; + +export interface DiffInfo { + currentBranch: string; + baseBranch: string; + changedFiles: string[]; + isCurrentChanges?: boolean; +} const runGit = (cwd: string, args: string[]): string | null => { const result = spawnSync("git", args, { diff --git a/packages/react-doctor/src/utils/get-staged-files.ts b/packages/react-doctor/src/cli/get-staged-files.ts similarity index 53% rename from packages/react-doctor/src/utils/get-staged-files.ts rename to packages/react-doctor/src/cli/get-staged-files.ts index 112b2f4530..1d5d314c53 100644 --- a/packages/react-doctor/src/utils/get-staged-files.ts +++ b/packages/react-doctor/src/cli/get-staged-files.ts @@ -3,8 +3,6 @@ import fs from "node:fs"; import path from "node:path"; import { GIT_SHOW_MAX_BUFFER_BYTES, SOURCE_FILE_PATTERN } from "../constants.js"; -// HACK: --diff-filter=ACMR excludes Deleted (D) — staged-only scans cannot -// lint files that no longer exist in the staging area. const getStagedFilePaths = (directory: string): string[] => { const result = spawnSync( "git", @@ -27,7 +25,7 @@ const readStagedContent = (directory: string, relativePath: string): string | nu return result.stdout.toString(); }; -interface StagedSnapshot { +export interface StagedSnapshot { tempDirectory: string; stagedFiles: string[]; cleanup: () => void; @@ -40,6 +38,7 @@ const PROJECT_CONFIG_FILENAMES = [ "tsconfig.json", "tsconfig.base.json", "package.json", + "pnpm-workspace.yaml", "react-doctor.config.json", "knip.json", "knip.jsonc", @@ -49,6 +48,34 @@ const PROJECT_CONFIG_FILENAMES = [ ".oxlintrc.json", ]; +const collectConfigFilePaths = (stagedFiles: string[]): string[] => { + const configFilePaths = new Set(PROJECT_CONFIG_FILENAMES); + for (const stagedFile of stagedFiles) { + let directory = path.dirname(stagedFile); + while (directory !== ".") { + for (const configFilename of PROJECT_CONFIG_FILENAMES) { + configFilePaths.add(path.join(directory, configFilename)); + } + const parentDirectory = path.dirname(directory); + if (parentDirectory === directory) break; + directory = parentDirectory; + } + } + return [...configFilePaths].sort(); +}; + +const resolveSafeStagedTargetPath = ( + tempDirectory: string, + relativePath: string, +): string | null => { + if (path.isAbsolute(relativePath)) return null; + const normalizedTempDirectory = path.resolve(tempDirectory); + const targetPath = path.resolve(normalizedTempDirectory, relativePath); + const relativeToTemp = path.relative(normalizedTempDirectory, targetPath); + if (relativeToTemp.startsWith("..") || path.isAbsolute(relativeToTemp)) return null; + return targetPath; +}; + export const materializeStagedFiles = ( directory: string, stagedFiles: string[], @@ -59,17 +86,26 @@ export const materializeStagedFiles = ( for (const relativePath of stagedFiles) { const content = readStagedContent(directory, relativePath); if (content === null) continue; - - const targetPath = path.join(tempDirectory, relativePath); + const targetPath = resolveSafeStagedTargetPath(tempDirectory, relativePath); + if (!targetPath) continue; fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, content); materializedFiles.push(relativePath); } - for (const configFilename of PROJECT_CONFIG_FILENAMES) { - const sourcePath = path.join(directory, configFilename); - const targetPath = path.join(tempDirectory, configFilename); - if (fs.existsSync(sourcePath) && !fs.existsSync(targetPath)) { + for (const configFilePath of collectConfigFilePaths(stagedFiles)) { + const targetPath = resolveSafeStagedTargetPath(tempDirectory, configFilePath); + if (!targetPath) continue; + if (fs.existsSync(targetPath)) continue; + const stagedContent = readStagedContent(directory, configFilePath); + if (stagedContent !== null) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, stagedContent); + continue; + } + const sourcePath = path.join(directory, configFilePath); + if (fs.existsSync(sourcePath)) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.cpSync(sourcePath, targetPath); } } @@ -81,7 +117,7 @@ export const materializeStagedFiles = ( try { fs.rmSync(tempDirectory, { recursive: true, force: true }); } catch { - // Best-effort cleanup; tempdir reapers will eventually clean up. + // Best-effort cleanup. } }, }; diff --git a/packages/react-doctor/src/cli/handle-error.ts b/packages/react-doctor/src/cli/handle-error.ts new file mode 100644 index 0000000000..9b94d17f61 --- /dev/null +++ b/packages/react-doctor/src/cli/handle-error.ts @@ -0,0 +1,39 @@ +import { CANONICAL_GITHUB_URL, EXIT_FAILURE_CODE } from "../constants.js"; +import { highlighter } from "./highlighter.js"; + +const stringifyError = (error: unknown): string => { + if (error instanceof Error) return error.message || error.name; + return String(error); +}; + +const getErrorMessageChain = (error: unknown): string[] => { + const messages: string[] = []; + const visited = new Set(); + let currentError = error; + + while (currentError instanceof Error && !visited.has(currentError)) { + visited.add(currentError); + messages.push(stringifyError(currentError)); + currentError = currentError.cause; + } + + if (messages.length === 0) { + messages.push(stringifyError(error)); + } + + return messages; +}; + +export const handleCliError = (error: unknown): void => { + const errorChain = getErrorMessageChain(error).join("\nCaused by: "); + + console.error(""); + console.error(highlighter.error("Something went wrong. Please check the error below.")); + console.error( + highlighter.error(`If the problem persists, open an issue at ${CANONICAL_GITHUB_URL}/issues.`), + ); + console.error(""); + console.error(highlighter.error(errorChain)); + console.error(""); + process.exitCode = EXIT_FAILURE_CODE; +}; diff --git a/packages/react-doctor/src/utils/highlighter.ts b/packages/react-doctor/src/cli/highlighter.ts similarity index 100% rename from packages/react-doctor/src/utils/highlighter.ts rename to packages/react-doctor/src/cli/highlighter.ts diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts new file mode 100644 index 0000000000..e69f9419ca --- /dev/null +++ b/packages/react-doctor/src/cli/index.ts @@ -0,0 +1,1686 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { Command } from "commander"; +import { + CANONICAL_GITHUB_URL, + DEFAULT_DIRECTORY, + EXIT_FAILURE_CODE, + FILESYSTEM_WALK_IGNORED_DIRECTORIES, + FRAMEWORK_DISPLAY_NAMES, + MAX_SCORE_DRAINS_SHOWN, + NON_VERBOSE_LOCATIONS_PER_GROUP, + PER_CATEGORY_PENALTY_CAP, + PERFECT_SCORE, + REACT_PROJECT_DEPENDENCIES, + SEVERITY_ORDER, + SHARE_BASE_URL, + SIGINT_EXIT_CODE, + SOURCE_FILE_PATTERN, +} from "../constants.js"; +import { handleCliError } from "./handle-error.js"; +import { highlighter } from "./highlighter.js"; +import { printReactReviewCta, printScoreHeader } from "./render-score-header.js"; +import { selectProjects } from "./select-projects.js"; +import type { DiscoveredProject } from "./select-projects.js"; +import { getStagedSourceFiles, materializeStagedFiles } from "./get-staged-files.js"; +import { getDiffInfo, filterSourceFiles, type DiffInfo } from "./get-diff-files.js"; +import { prompts } from "./prompts.js"; +import { createProgressSpinner } from "./utils/create-progress-spinner.js"; +import { formatElapsedTime } from "./utils/format-elapsed-time.js"; +import { + buildReactDoctorJsonReport, + createReactDoctor, + loadReactDoctorConfig, + resolveConfigRootDirectory, +} from "../sdk/index.js"; +import { createCodebaseAnalysisConfig } from "../core/rules/codebase/analyzer/config.js"; +import { discoverWorkspaces } from "../core/rules/codebase/analyzer/workspace.js"; +import { collectScoreDiagnostics } from "../core/issue-to-score-diagnostic.js"; +import { calculateScoreBreakdown, rulePenalty } from "../core/score.js"; +import { getScoringPluginKey, getScoringRuleKey } from "../core/scoring-key.js"; +import type { + ReactDoctorConfig, + ReactDoctorFailOnLevel, + ReactDoctorIssue, + ReactDoctorResult, +} from "../sdk/index.js"; +import type { WorkspaceInfo } from "../core/rules/codebase/analyzer/index.js"; + +const VERSION = process.env.VERSION ?? "0.0.0"; + +interface CliFlags { + json: boolean; + jsonCompact: boolean; + lint: boolean; + deadCode: boolean; + verbose: boolean; + customRulesOnly: boolean; + staged: boolean; + unstaged: boolean; + changed: boolean; + diff?: boolean | string; + offline: boolean; + failOn: string; + project?: string; + yes: boolean; + score: boolean; + full: boolean; + annotations: boolean; + respectInlineDisables: boolean; + explain?: string; + why?: string; +} + +// HACK: env vars that mean "user is not at an interactive shell." We use this +// to skip prompts but NOT to auto-flip --offline, because dev shells often +// have JENKINS_URL / TF_BUILD set as ambient config without actually running +// in CI. +const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "BUILDKITE", + "JENKINS_URL", + "TF_BUILD", + "CODEBUILD_BUILD_ID", + "TEAMCITY_VERSION", + "BITBUCKET_BUILD_NUMBER", + "CIRCLECI", + "TRAVIS", + "DRONE", + "CLAUDECODE", + "CLAUDE_CODE", + "CURSOR_AGENT", + "CODEX_CI", + "OPENCODE", + "AMP_HOME", +]; + +// HACK: only flip --offline by default for the narrowest set of CI signals +// where we're confident the run is automated and a share URL would be useless. +const CI_ENVIRONMENT_VARIABLES = ["GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI"]; + +const isNonInteractiveEnvironment = (): boolean => + NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])); + +const isCiEnvironment = (): boolean => + CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || + process.env.CI === "true"; + +const isSourceFile = (filePath: string): boolean => SOURCE_FILE_PATTERN.test(filePath); + +const isReactWorkspace = (workspace: WorkspaceInfo): boolean => + [...REACT_PROJECT_DEPENDENCIES].some((dependencyName) => + workspace.dependencyNames.has(dependencyName), + ); + +interface FilesystemPackageManifest { + name?: unknown; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +} + +const isFilesystemPackageManifest = (value: unknown): value is FilesystemPackageManifest => + typeof value === "object" && value !== null && !Array.isArray(value); + +const parsePackageManifest = (manifestText: string): FilesystemPackageManifest | null => { + try { + const parsed: unknown = JSON.parse(manifestText); + return isFilesystemPackageManifest(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const hasReactDependencyInManifest = (manifest: FilesystemPackageManifest): boolean => { + for (const bucket of [ + manifest.dependencies, + manifest.devDependencies, + manifest.peerDependencies, + manifest.optionalDependencies, + ]) { + if (!bucket) continue; + for (const dependencyName of REACT_PROJECT_DEPENDENCIES) { + if (dependencyName in bucket) return true; + } + } + return false; +}; + +const discoverReactProjectsByFilesystem = async (rootDirectory: string): Promise => { + const directories: string[] = []; + const pending: string[] = [rootDirectory]; + + while (pending.length > 0) { + const current = pending.shift(); + if (!current) continue; + + try { + const manifestText = await fs.readFile(path.join(current, "package.json"), "utf8"); + const manifest = parsePackageManifest(manifestText); + if (manifest && hasReactDependencyInManifest(manifest)) { + directories.push(current); + } + } catch { + // No package.json or unreadable — keep walking. + } + + let entries: Array<{ name: string; isDirectory: () => boolean }>; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith(".") || + FILESYSTEM_WALK_IGNORED_DIRECTORIES.has(entry.name) + ) { + continue; + } + pending.push(path.join(current, entry.name)); + } + } + + return directories.sort((first, second) => first.localeCompare(second)); +}; + +const toNamedProject = (workspace: WorkspaceInfo): DiscoveredProject => ({ + name: workspace.name ?? path.basename(workspace.directory), + directory: workspace.directory, +}); + +const toNamedProjectFromDirectory = async (directory: string): Promise => { + try { + const manifestText = await fs.readFile(path.join(directory, "package.json"), "utf8"); + const manifest = parsePackageManifest(manifestText); + const manifestName = typeof manifest?.name === "string" ? manifest.name : null; + return { name: manifestName ?? path.basename(directory), directory }; + } catch { + return { name: path.basename(directory), directory }; + } +}; + +const discoverProjects = async ( + rootDirectory: string, + configHasRootDirectory: boolean, + shouldUseSingleProject: boolean, +): Promise => { + if (configHasRootDirectory || shouldUseSingleProject) { + return [await toNamedProjectFromDirectory(rootDirectory)]; + } + const workspaces = await discoverWorkspaces(createCodebaseAnalysisConfig({ rootDirectory })); + const reactWorkspaces = workspaces.filter(isReactWorkspace); + if (reactWorkspaces.length > 1) { + return reactWorkspaces.map(toNamedProject); + } + if (reactWorkspaces.length === 1) { + const onlyWorkspace = reactWorkspaces[0]; + if (onlyWorkspace.directory !== rootDirectory) return [toNamedProject(onlyWorkspace)]; + } + const filesystemDirectories = await discoverReactProjectsByFilesystem(rootDirectory); + if (filesystemDirectories.length > 0) { + return Promise.all(filesystemDirectories.map(toNamedProjectFromDirectory)); + } + if (reactWorkspaces.length === 1) return [toNamedProject(reactWorkspaces[0])]; + return [await toNamedProjectFromDirectory(rootDirectory)]; +}; + +const getGitFiles = (rootDirectory: string, args: string[]): string[] => { + const result = spawnSync("git", args, { + cwd: rootDirectory, + encoding: "utf8", + }); + if (result.error || result.status !== 0) return []; + return result.stdout + .split("\0") + .map((filePath) => filePath.trim()) + .filter((filePath) => filePath.length > 0 && isSourceFile(filePath)); +}; + +const dedupeFilePaths = (filePaths: string[]): string[] => [...new Set(filePaths)]; + +const resolveIncludePaths = (rootDirectory: string, flags: CliFlags): string[] | undefined => { + if (flags.unstaged) { + return dedupeFilePaths([ + ...getGitFiles(rootDirectory, [ + "diff", + "--name-only", + "-z", + "--diff-filter=ACMR", + "--relative", + ]), + ...getGitFiles(rootDirectory, [ + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--relative", + ]), + ]); + } + if (flags.changed) { + return dedupeFilePaths([ + ...getGitFiles(rootDirectory, [ + "diff", + "--name-only", + "-z", + "--diff-filter=ACMR", + "--relative", + "HEAD", + ]), + ...getGitFiles(rootDirectory, [ + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--relative", + ]), + ]); + } + return undefined; +}; + +const isActiveChangedFileMode = (flags: CliFlags, isDiffMode: boolean): boolean => + flags.staged || flags.unstaged || flags.changed || isDiffMode; + +const resolveProjectIncludePaths = ( + rootDirectory: string, + projectDirectory: string, + includePaths: string[] | undefined, +): string[] | undefined => { + if (includePaths === undefined) return undefined; + return includePaths + .map((includePath) => path.resolve(rootDirectory, includePath)) + .filter((absolutePath) => { + const relativePath = path.relative(projectDirectory, absolutePath); + return ( + relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); + }) + .map((absolutePath) => path.relative(projectDirectory, absolutePath).split(path.sep).join("/")) + .filter((includePath) => includePath.length > 0); +}; + +const getCliOptionOverride = ( + command: Command, + optionName: string, + value: Value, +): Value | undefined => (command.getOptionValueSource(optionName) === "cli" ? value : undefined); + +const resolveBooleanInspectOption = ( + command: Command, + optionName: string, + flagValue: boolean, + configValue: boolean | undefined, + defaultValue: boolean, +): boolean => { + const cliValue = getCliOptionOverride(command, optionName, flagValue); + if (cliValue !== undefined) return cliValue; + if (configValue !== undefined) return configValue; + return defaultValue; +}; + +const normalizeFailOnLevel = (value: string | undefined): ReactDoctorFailOnLevel => { + if (value === "error" || value === "warning" || value === "none") return value; + console.error( + `[react-doctor] Invalid failOn level "${value}". Expected: error, warning, none. Falling back to "error".`, + ); + return "error"; +}; + +const shouldFailForIssues = ( + issues: ReactDoctorIssue[], + failOnLevel: ReactDoctorFailOnLevel, +): boolean => { + if (failOnLevel === "none") return false; + if (failOnLevel === "warning") { + return issues.some((issue) => issue.severity === "error" || issue.severity === "warning"); + } + return issues.some((issue) => issue.severity === "error"); +}; + +const getWorstScoreValue = (results: ReactDoctorResult[]): number => { + const scores = results + .map((result) => result.score) + .filter((score): score is NonNullable => score !== null); + return scores.length > 0 ? Math.min(...scores.map((score) => score.value)) : PERFECT_SCORE; +}; + +interface TitleGroup { + title: string; + issues: ReactDoctorIssue[]; +} + +interface CategoryGroup { + category: string; + issues: ReactDoctorIssue[]; + groups: TitleGroup[]; +} + +const groupBy = (items: readonly T[], key: (item: T) => K): Map => { + const buckets = new Map(); + for (const item of items) { + const bucket = buckets.get(key(item)); + if (bucket) bucket.push(item); + else buckets.set(key(item), [item]); + } + return buckets; +}; + +const severityRank = (severity: ReactDoctorIssue["severity"]): number => + SEVERITY_ORDER[severity] ?? 2; + +const worstSeverity = (issues: readonly ReactDoctorIssue[]): number => + Math.min(...issues.map((issue) => severityRank(issue.severity))); + +const buildCategoryGroups = (issues: readonly ReactDoctorIssue[]): CategoryGroup[] => + [...groupBy(issues, (issue) => issue.category)] + .map(([category, categoryIssues]) => ({ + category, + issues: categoryIssues, + groups: [...groupBy(categoryIssues, (issue) => issue.title)] + .map(([title, titleIssues]) => ({ title, issues: titleIssues })) + .toSorted( + (a, b) => + severityRank(a.issues[0].severity) - severityRank(b.issues[0].severity) || + b.issues.length - a.issues.length, + ), + })) + .toSorted( + (a, b) => + worstSeverity(a.issues) - worstSeverity(b.issues) || + b.issues.length - a.issues.length || + a.category.localeCompare(b.category), + ); + +const buildCheckNameLookup = ( + result: ReactDoctorResult | ReactDoctorResult[], +): ReadonlyMap => { + const lookup = new Map(); + const results = Array.isArray(result) ? result : [result]; + for (const innerResult of results) { + for (const check of innerResult.checks) { + lookup.set(check.id, check.name); + } + } + return lookup; +}; + +const encodeAnnotationProperty = (value: string): string => + value + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A") + .replace(/:/g, "%3A") + .replace(/,/g, "%2C"); + +const encodeAnnotationMessage = (value: string): string => + value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + +const printAnnotations = (issues: ReactDoctorIssue[], routeToStderr: boolean): void => { + const writeLine = routeToStderr + ? (line: string) => process.stderr.write(`${line}\n`) + : (line: string) => process.stdout.write(`${line}\n`); + for (const issue of issues) { + // `info` severity diagnostics are exempt from scoring and meant to be + // visible only in --verbose. Emitting them as `::warning` annotations + // floods CI reviews with low-signal noise (e.g. unused type exports). + if (issue.severity === "info") continue; + const level = issue.severity === "error" ? "error" : "warning"; + const title = issue.title; + const filePath = issue.location?.filePath ?? ""; + const fileSegment = `file=${encodeAnnotationProperty(filePath)}`; + const lineSegment = issue.location?.line ? `,line=${issue.location.line}` : ""; + const titleSegment = `,title=${encodeAnnotationProperty(title)}`; + const message = encodeAnnotationMessage(issue.message); + writeLine(`::${level} ${fileSegment}${lineSegment}${titleSegment}::${message}`); + } +}; + +const formatFrameworkName = (framework: string): string => + FRAMEWORK_DISPLAY_NAMES[framework] ?? framework; + +const printProjectDetection = (result: ReactDoctorResult): void => { + const projectInfo = result.project; + const frameworkLabel = formatFrameworkName(projectInfo.framework); + const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript"; + + const completedStep = (message: string) => { + console.log(` ${highlighter.success("✔")} ${message}`); + }; + + completedStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`); + if (projectInfo.reactVersion) { + completedStep( + `Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`, + ); + } + completedStep( + `Detecting Tailwind. ${ + projectInfo.tailwindVersion + ? `Found ${highlighter.info(`Tailwind ${projectInfo.tailwindVersion}`)}.` + : "Not found." + }`, + ); + completedStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`); + completedStep( + `Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`, + ); + completedStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`); + + for (const check of result.checks) { + if (check.status === "completed") { + completedStep(`${check.name}.`); + } else if (check.status === "failed") { + console.log(` ${highlighter.error("✗")} ${check.name} failed (non-fatal, skipping).`); + } + } + + console.log(""); +}; + +const formatLocation = (issue: ReactDoctorIssue): string | null => { + const filePath = issue.location?.filePath; + if (!filePath) return null; + return issue.location?.line ? `${filePath}:${issue.location.line}` : filePath; +}; + +const printTitleGroup = (group: TitleGroup, isVerbose: boolean): void => { + const first = group.issues[0]; + const marker = first.severity === "error" ? highlighter.error("✗") : highlighter.warn("⚠"); + const countBadge = + group.issues.length > 1 ? ` ${highlighter.gray(`×${group.issues.length}`)}` : ""; + console.log(` ${marker} ${group.title}${countBadge}`); + console.log(` ${highlighter.gray(first.message)}`); + if (first.recommendation) console.log(` ${highlighter.gray(first.recommendation)}`); + + const locations = group.issues + .map(formatLocation) + .filter((location): location is string => location !== null); + const limit = isVerbose ? locations.length : NON_VERBOSE_LOCATIONS_PER_GROUP; + for (const location of locations.slice(0, limit)) { + console.log(` ${highlighter.gray(location)}`); + } + const overflow = locations.length - limit; + if (overflow > 0) { + console.log(` ${highlighter.gray(`+${overflow} more — run with --verbose`)}`); + } +}; + +const printIssueSections = (issues: ReactDoctorIssue[], isVerbose: boolean): void => { + for (const category of buildCategoryGroups(issues)) { + const label = `${category.issues.length} ${category.issues.length === 1 ? "issue" : "issues"}`; + console.log(`${highlighter.bold(category.category)} ${highlighter.dim(label)}`); + for (const group of category.groups) printTitleGroup(group, isVerbose); + console.log(""); + } +}; + +const collectAffectedFiles = (issues: ReactDoctorIssue[]): Set => + new Set(issues.flatMap((issue) => (issue.location?.filePath ? [issue.location.filePath] : []))); + +const printCountsSummaryLine = ( + issues: ReactDoctorIssue[], + totalSourceFileCount: number, + elapsedMilliseconds: number, +): void => { + const errorCount = issues.filter((issue) => issue.severity === "error").length; + const warningCount = issues.filter((issue) => issue.severity === "warning").length; + const affectedFileCount = collectAffectedFiles(issues).size; + const totalIssueCount = issues.length; + const elapsedTimeLabel = formatElapsedTime(elapsedMilliseconds); + + const issueCountColor = + errorCount > 0 ? highlighter.error : warningCount > 0 ? highlighter.warn : highlighter.dim; + const issueCountText = `${totalIssueCount} ${totalIssueCount === 1 ? "issue" : "issues"}`; + const fileCountText = + totalSourceFileCount > 0 + ? `across ${affectedFileCount}/${totalSourceFileCount} files` + : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`; + const elapsedTimeText = `in ${elapsedTimeLabel}`; + + console.log( + ` ${issueCountColor(issueCountText)} ${highlighter.dim(`${fileCountText} ${elapsedTimeText}`)}`, + ); +}; + +const buildShareUrl = ( + issues: ReactDoctorIssue[], + score: number | null, + projectName: string, +): string => { + const errorCount = issues.filter((issue) => issue.severity === "error").length; + const warningCount = issues.filter((issue) => issue.severity === "warning").length; + const affectedFileCount = collectAffectedFiles(issues).size; + + const params = new URLSearchParams(); + params.set("p", projectName); + if (score !== null) params.set("s", String(score)); + if (errorCount > 0) params.set("e", String(errorCount)); + if (warningCount > 0) params.set("w", String(warningCount)); + if (affectedFileCount > 0) params.set("f", String(affectedFileCount)); + + return `${SHARE_BASE_URL}?${params.toString()}`; +}; + +const writeDiagnosticsDirectory = (issues: ReactDoctorIssue[]): string | null => { + try { + const diagnosticsDirectory = path.join(tmpdir(), `react-doctor-diagnostics-${Date.now()}`); + mkdirSync(diagnosticsDirectory, { recursive: true }); + writeFileSync( + path.join(diagnosticsDirectory, "diagnostics.json"), + JSON.stringify(issues, null, 2), + ); + return diagnosticsDirectory; + } catch { + return null; + } +}; + +interface ScoreDrain { + ruleKey: string; + category: string; + severity: "error" | "warning"; + count: number; + penalty: number; + displayTitle: string; +} + +const collectScoreDrains = ( + issues: ReactDoctorIssue[], + checkNameByCheckId: ReadonlyMap, +): ScoreDrain[] => { + const aggregates = new Map< + string, + { + category: string; + severity: "error" | "warning"; + count: number; + displayTitle: string; + } + >(); + for (const issue of issues) { + if (issue.severity === "info") continue; + const scoringRuleKey = getScoringRuleKey(issue); + const ruleKey = `${getScoringPluginKey(issue)}/${scoringRuleKey}`; + const severity: "error" | "warning" = issue.severity === "error" ? "error" : "warning"; + const existing = aggregates.get(ruleKey); + if (existing) { + existing.count += 1; + if (severity === "error") existing.severity = "error"; + continue; + } + // For collapsed custom checks (scoring key = checkId) prefer the + // human-readable check name; for per-rule scoring (oxlint) prefer the + // issue's own title so each rule is independently identifiable. + const isCollapsedCheck = issue.source?.checkId === scoringRuleKey; + const displayTitle = isCollapsedCheck + ? (checkNameByCheckId.get(scoringRuleKey) ?? issue.title ?? ruleKey) + : (issue.title ?? scoringRuleKey); + aggregates.set(ruleKey, { + category: issue.category, + severity, + count: 1, + displayTitle, + }); + } + return [...aggregates.entries()] + .map(([ruleKey, aggregate]) => ({ + ruleKey, + category: aggregate.category, + severity: aggregate.severity, + count: aggregate.count, + penalty: rulePenalty(aggregate.severity, aggregate.count), + displayTitle: aggregate.displayTitle, + })) + .toSorted((first, second) => second.penalty - first.penalty); +}; + +const printVerboseScoreBreakdown = ( + issues: ReactDoctorIssue[], + score: number, + checkNameByCheckId: ReadonlyMap, +): void => { + const breakdown = calculateScoreBreakdown(collectScoreDiagnostics(issues)); + const drains = collectScoreDrains(issues, checkNameByCheckId); + + console.log(""); + console.log(highlighter.bold(" Score breakdown")); + console.log( + highlighter.dim( + ` Final: ${score} / ${PERFECT_SCORE} | raw penalty ${breakdown.totalRawPenalty.toFixed( + 1, + )}, capped to ${breakdown.totalCappedPenalty.toFixed(1)} (per-category cap ${PER_CATEGORY_PENALTY_CAP})`, + ), + ); + + if (breakdown.perCategory.length > 0) { + console.log(""); + console.log(highlighter.bold(" By category")); + for (const categoryBreakdown of breakdown.perCategory) { + const cappedNote = + categoryBreakdown.rawPenalty > categoryBreakdown.cappedPenalty + ? highlighter.warn(` (capped from ${categoryBreakdown.rawPenalty.toFixed(1)})`) + : ""; + console.log( + ` ${highlighter.gray( + `-${categoryBreakdown.cappedPenalty.toFixed(1).padStart(5, " ")}`, + )} ${categoryBreakdown.category} ${highlighter.dim( + `(${categoryBreakdown.ruleKeys} rule${categoryBreakdown.ruleKeys === 1 ? "" : "s"})`, + )}${cappedNote}`, + ); + } + } + + if (drains.length > 0) { + console.log(""); + console.log(highlighter.bold(" Top score drains")); + for (const drain of drains.slice(0, MAX_SCORE_DRAINS_SHOWN)) { + const marker = drain.severity === "error" ? highlighter.error("✗") : highlighter.warn("⚠"); + console.log( + ` ${highlighter.gray(`-${drain.penalty.toFixed(1).padStart(4, " ")}`)} ${marker} ${ + drain.displayTitle + } ${highlighter.dim(`(${drain.count} issue${drain.count === 1 ? "" : "s"})`)}`, + ); + } + if (drains.length > MAX_SCORE_DRAINS_SHOWN) { + console.log( + highlighter.dim( + ` … and ${drains.length - MAX_SCORE_DRAINS_SHOWN} more rule${ + drains.length - MAX_SCORE_DRAINS_SHOWN === 1 ? "" : "s" + } contributing penalty`, + ), + ); + } + } +}; + +const printProjectHeader = (result: ReactDoctorResult): void => { + console.log( + `${highlighter.bold(result.project.projectName)} ${highlighter.dim(result.project.rootDirectory)}`, + ); + console.log(""); +}; + +const printResultScoreBlock = (result: ReactDoctorResult): void => { + const scoreValue = result.score?.value ?? PERFECT_SCORE; + const scoreLabel = result.score?.label ?? "Great"; + printScoreHeader(scoreValue, scoreLabel); +}; + +const printSkippedChecksWarning = (result: ReactDoctorResult): void => { + const failedChecks = result.checks + .filter((check) => check.status === "failed" || check.status === "skipped") + .map((check) => check.name); + if (failedChecks.length > 0) { + const skippedLabel = failedChecks.join(" and "); + console.log( + ` ${highlighter.warn(`Note: ${skippedLabel} checks failed — score may be incomplete.`)}`, + ); + console.log(""); + } +}; + +const printInspectionResult = ( + result: ReactDoctorResult, + flags: CliFlags, + isOffline: boolean, +): void => { + if (flags.json) { + const report = buildReactDoctorJsonReport(result); + process.stdout.write( + `${flags.jsonCompact ? JSON.stringify(report) : JSON.stringify(report, null, 2)}\n`, + ); + return; + } + + printProjectHeader(result); + printProjectDetection(result); + + if (result.issues.length === 0) { + console.log(`${highlighter.success("✔")} No React Doctor issues found.`); + console.log(""); + printResultScoreBlock(result); + printSkippedChecksWarning(result); + printReactReviewCta(); + return; + } + + printIssueSections(result.issues, flags.verbose); + + printResultScoreBlock(result); + printCountsSummaryLine( + result.issues, + result.project.sourceFileCount, + result.durationMilliseconds, + ); + + const diagnosticsDirectory = writeDiagnosticsDirectory(result.issues); + if (diagnosticsDirectory) { + console.log(highlighter.gray(` Full diagnostics written to ${diagnosticsDirectory}`)); + } + + if (!isOffline) { + console.log(""); + const shareUrl = buildShareUrl( + result.issues, + result.score?.value ?? null, + result.project.projectName, + ); + console.log(` ${highlighter.bold("→ Share your results:")} ${highlighter.info(shareUrl)}`); + } + + if (flags.verbose && result.score && result.issues.length > 0) { + printVerboseScoreBreakdown(result.issues, result.score.value, buildCheckNameLookup(result)); + } + + printSkippedChecksWarning(result); + console.log(""); + printReactReviewCta(); +}; + +const toAggregateJsonReport = (results: ReactDoctorResult[]) => { + const reports = results.map(buildReactDoctorJsonReport); + const issues = results.flatMap((result) => result.issues); + const checks = results.flatMap((result) => result.checks); + const affectedFiles = new Set( + issues.flatMap((issue) => (issue.location?.filePath ? [issue.location.filePath] : [])), + ); + const scores = results + .map((result) => result.score?.value) + .filter((score): score is number => typeof score === "number"); + const worstScore = scores.length ? Math.min(...scores) : null; + const worstScoreLabel = + results.find((result) => result.score?.value === worstScore)?.score?.label ?? null; + return { + schemaVersion: 1, + ok: reports.every((report) => report.ok), + projects: reports.map((report) => ({ + project: report.project, + issues: report.issues, + checks: report.checks, + summary: report.summary, + startedAt: report.startedAt, + completedAt: report.completedAt, + durationMilliseconds: report.durationMilliseconds, + })), + issues, + checks, + summary: { + errorCount: issues.filter((issue) => issue.severity === "error").length, + warningCount: issues.filter((issue) => issue.severity === "warning").length, + affectedFileCount: affectedFiles.size, + totalIssueCount: issues.length, + score: worstScore, + scoreLabel: worstScoreLabel, + }, + startedAt: results[0]?.startedAt, + completedAt: results.at(-1)?.completedAt, + durationMilliseconds: results.reduce((total, result) => total + result.durationMilliseconds, 0), + }; +}; + +const printInspectionResults = ( + results: ReactDoctorResult[], + flags: CliFlags, + isOffline: boolean, +): void => { + if (results.length === 1) { + printInspectionResult(results[0], flags, isOffline); + return; + } + if (flags.json) { + const report = toAggregateJsonReport(results); + process.stdout.write( + `${flags.jsonCompact ? JSON.stringify(report) : JSON.stringify(report, null, 2)}\n`, + ); + return; + } + + for (const result of results) { + printProjectHeader(result); + printProjectDetection(result); + if (result.issues.length === 0) { + console.log(`${highlighter.success("✔")} No React Doctor issues found.`); + console.log(""); + } else { + printIssueSections(result.issues, flags.verbose); + } + printResultScoreBlock(result); + if (result.issues.length > 0) { + printCountsSummaryLine( + result.issues, + result.project.sourceFileCount, + result.durationMilliseconds, + ); + console.log(""); + } + if (flags.verbose && result.score && result.issues.length > 0) { + printVerboseScoreBreakdown(result.issues, result.score.value, buildCheckNameLookup(result)); + } + printSkippedChecksWarning(result); + } + + const allIssues = results.flatMap((result) => result.issues); + + if (allIssues.length > 0) { + const diagnosticsDirectory = writeDiagnosticsDirectory(allIssues); + if (diagnosticsDirectory) { + console.log(highlighter.gray(` Full diagnostics written to ${diagnosticsDirectory}`)); + } + } + + if (!isOffline) { + const scores = results + .map((result) => result.score?.value) + .filter((score): score is number => typeof score === "number"); + const worstScore = scores.length ? Math.min(...scores) : null; + const shareUrl = buildShareUrl(allIssues, worstScore, results[0]?.project.projectName ?? ""); + console.log(` ${highlighter.bold("→ Share your results:")} ${highlighter.info(shareUrl)}`); + console.log(""); + } + + printReactReviewCta(); +}; + +// --- Signal + error handling --- + +let isJsonModeActive = false; +let isCompactJsonOutput = false; +let resolvedDirectoryForCancel: string | null = null; +let cancelStartTime = 0; +const pendingCleanups = new Set<() => void>(); + +const registerPendingCleanup = (cleanup: () => void): (() => void) => { + pendingCleanups.add(cleanup); + return () => { + pendingCleanups.delete(cleanup); + }; +}; + +const runPendingCleanups = (): void => { + for (const cleanup of pendingCleanups) { + try { + cleanup(); + } catch { + // Best-effort cleanup during shutdown. + } + } + pendingCleanups.clear(); +}; + +const writeJsonErrorReport = (error: unknown, directory: string, elapsed: number): void => { + const errorMessage = error instanceof Error ? error.message || error.name : String(error); + const errorName = error instanceof Error ? error.name : "Error"; + const report = { + schemaVersion: 1, + ok: false, + projects: [], + issues: [], + checks: [], + summary: { + errorCount: 0, + warningCount: 0, + affectedFileCount: 0, + totalIssueCount: 0, + score: null, + scoreLabel: null, + }, + error: { message: errorMessage, name: errorName }, + directory, + durationMilliseconds: elapsed, + }; + const serialized = isCompactJsonOutput ? JSON.stringify(report) : JSON.stringify(report, null, 2); + process.stdout.write(`${serialized}\n`); +}; + +const exitGracefully = () => { + runPendingCleanups(); + if (isJsonModeActive) { + writeJsonErrorReport( + new Error("Scan cancelled by user (SIGINT/SIGTERM)"), + resolvedDirectoryForCancel ?? process.cwd(), + performance.now() - cancelStartTime, + ); + process.exit(SIGINT_EXIT_CODE); + } + console.log(""); + console.log("Cancelled."); + console.log(""); + process.exit(SIGINT_EXIT_CODE); +}; + +process.on("SIGINT", exitGracefully); +process.on("SIGTERM", exitGracefully); + +// HACK: when stdout is piped into a process that closes early (e.g. +// `react-doctor . | head`), Node throws an uncaught EPIPE on the next +// write. Exit cleanly instead of dumping a stack trace. +process.stdout.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") process.exit(0); +}); + +// --- Mode validation --- + +const coerceDiffValue = (value: unknown): boolean | string | undefined => { + if (value === undefined) return undefined; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + if (value.length === 0) return undefined; + if (value === "false") return false; + if (value === "true") return true; + return value; + } + process.stderr.write( + `[react-doctor] invalid diff value (expected boolean or string): ${typeof value}. Falling back to no diff.\n`, + ); + return undefined; +}; + +const validateModeFlags = (flags: CliFlags): void => { + const coercedDiff = coerceDiffValue(flags.diff); + const exclusiveModes = [ + flags.staged ? "--staged" : null, + flags.unstaged ? "--unstaged" : null, + flags.changed ? "--changed" : null, + coercedDiff !== undefined && coercedDiff !== false ? "--diff" : null, + ].filter((modeName): modeName is string => modeName !== null); + + if (exclusiveModes.length > 1) { + throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`); + } + if (flags.yes && flags.full) { + throw new Error("Cannot combine --yes and --full; pick one."); + } + if (flags.score && flags.json) { + throw new Error("Cannot combine --score and --json; pick one output mode."); + } + if (flags.annotations && (flags.json || flags.score)) { + throw new Error("--annotations cannot be combined with --json or --score."); + } + if (flags.explain !== undefined && flags.why !== undefined) { + throw new Error("Use --explain or --why, not both — they're aliases of the same flag."); + } + const explainArgument = flags.explain ?? flags.why; + if ( + explainArgument !== undefined && + (flags.json || flags.score || flags.annotations || flags.staged) + ) { + throw new Error( + "--explain cannot be combined with --json, --score, --annotations, or --staged.", + ); + } +}; + +// --- Diff mode prompt --- + +const resolveDiffMode = async ( + diffInfo: DiffInfo | null, + effectiveDiff: boolean | string | undefined, + shouldSkipPrompts: boolean, + isQuiet: boolean, +): Promise => { + if (effectiveDiff !== undefined && effectiveDiff !== false) { + if (diffInfo) return true; + if (!isQuiet) { + console.log( + highlighter.warn("No feature branch or uncommitted changes detected. Running full scan."), + ); + console.log(""); + } + return false; + } + + if (effectiveDiff === false || !diffInfo) return false; + + const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles); + if (changedSourceFiles.length === 0) return false; + if (shouldSkipPrompts) return false; + if (isQuiet) return false; + + const promptMessage = diffInfo.isCurrentChanges + ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan those?` + : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} files changed vs ${diffInfo.baseBranch}). Only scan changed files?`; + + const { shouldScanChangedOnly } = await prompts({ + type: "confirm", + name: "shouldScanChangedOnly", + message: promptMessage, + initial: true, + }); + return Boolean(shouldScanChangedOnly); +}; + +// --- Explain mode --- + +const parseFileLineArgument = (argument: string): { filePath: string; line: number } => { + const lastColonIndex = argument.lastIndexOf(":"); + if (lastColonIndex === -1) { + throw new Error(`Expected file:line format, got "${argument}".`); + } + const filePath = path.resolve(argument.slice(0, lastColonIndex)); + const line = Number.parseInt(argument.slice(lastColonIndex + 1), 10); + if (Number.isNaN(line) || line <= 0) { + throw new Error(`Invalid line number in "${argument}".`); + } + return { filePath, line }; +}; + +const runExplain = async ( + fileLineArgument: string, + rootDirectory: string, + config: ReactDoctorConfig, + projectFlag: string | undefined, +): Promise => { + const { filePath, line } = parseFileLineArgument(fileLineArgument); + + let targetDirectory = rootDirectory; + if (projectFlag) { + const discoveredProjects = await discoverProjects(rootDirectory, false, false); + const matched = await selectProjects( + discoveredProjects, + rootDirectory, + projectFlag, + true, + true, + ); + if (matched.length === 0) { + throw new Error(`--project resolved to no projects. Cannot run --explain.`); + } + if (matched.length > 1) { + throw new Error( + `--explain takes a single project; --project resolved to ${matched.length} projects.`, + ); + } + targetDirectory = matched[0]; + } + + const result = await createReactDoctor({ + rootDirectory: targetDirectory, + }).inspect({ + offline: true, + lint: true, + deadCode: true, + config, + }); + + const matchingIssues = result.issues.filter( + (issue) => + issue.location?.line === line && + issue.location?.filePath && + path.resolve(targetDirectory, issue.location.filePath) === filePath, + ); + + if (matchingIssues.length === 0) { + console.log(`No react-doctor diagnostics at ${filePath}:${line}.`); + return; + } + + for (const issue of matchingIssues) { + const severitySymbol = issue.severity === "error" ? "✗" : "⚠"; + const colorizeRule = issue.severity === "error" ? highlighter.error : highlighter.warn; + const severityLabel = colorizeRule(issue.severity); + console.log( + `${severitySymbol} ${colorizeRule(issue.title)} ${highlighter.dim(`(${severityLabel})`)} — ${issue.message}`, + ); + if (issue.category) console.log(highlighter.dim(` Category: ${issue.category}`)); + if (issue.recommendation) console.log(highlighter.dim(` ${issue.recommendation}`)); + console.log( + highlighter.dim( + " Add a react-doctor-disable-next-line comment immediately above this line to suppress.", + ), + ); + console.log(""); + } +}; + +// --- Install subcommand --- + +const runInstall = async (installOptions: { yes?: boolean; dryRun?: boolean }): Promise => { + let agentInstall: typeof import("agent-install"); + try { + agentInstall = await import("agent-install"); + } catch (error) { + const causeMessage = error instanceof Error && error.message ? `: ${error.message}` : ""; + console.error( + highlighter.error( + `Failed to load the bundled "agent-install" module${causeMessage}. Please open an issue at ${CANONICAL_GITHUB_URL}/issues with this output.`, + ), + ); + process.exitCode = EXIT_FAILURE_CODE; + return; + } + + const { + installSkillsFromSource, + SKILL_MANIFEST_FILE, + getSkillAgentTypes, + detectInstalledSkillAgents, + } = agentInstall; + const { existsSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + + const distDirectory = path.dirname(fileURLToPath(import.meta.url)); + const sourceDir = path.join(distDirectory, "skills", "react-doctor"); + + if (!existsSync(path.join(sourceDir, SKILL_MANIFEST_FILE))) { + console.error( + highlighter.error("Could not locate the react-doctor skill bundled with this package."), + ); + process.exitCode = EXIT_FAILURE_CODE; + return; + } + + const detectedAgents = await detectInstalledSkillAgents(); + if (detectedAgents.length === 0) { + console.error(highlighter.error("No supported coding agents detected.")); + console.error( + highlighter.dim( + " Looked for config dirs in $HOME (~/.claude, ~/.cursor, ~/.codex, ~/.gemini, ...).", + ), + ); + process.exitCode = EXIT_FAILURE_CODE; + return; + } + + const skipPrompts = Boolean(installOptions.yes) || !process.stdin.isTTY; + const allAgentTypes = getSkillAgentTypes().filter( + (agent) => agent !== "universal" && detectedAgents.includes(agent), + ); + + const selectedAgents = skipPrompts + ? allAgentTypes + : (( + await prompts({ + type: "multiselect", + name: "agents", + message: `Install the ${highlighter.info("react-doctor")} skill for:`, + choices: allAgentTypes.map((agent) => ({ + title: String(agent), + value: agent, + selected: true, + })), + instructions: false, + min: 1, + }) + ).agents ?? []); + + if (selectedAgents.length === 0) return; + + if (installOptions.dryRun) { + console.log("Dry run — would install react-doctor skill for:"); + for (const agent of selectedAgents) { + console.log(highlighter.dim(` - ${String(agent)}`)); + } + console.log(highlighter.dim(` Source: ${sourceDir}`)); + return; + } + + console.log("Installing react-doctor skill..."); + const installResult = await installSkillsFromSource({ + source: sourceDir, + agents: selectedAgents, + cwd: process.cwd(), + mode: "copy", + }); + + if (installResult.failed?.length > 0) { + console.error(highlighter.error("Some installations failed:")); + for (const failure of installResult.failed) { + console.error(highlighter.error(` ${failure.agent}: ${failure.error}`)); + } + process.exitCode = EXIT_FAILURE_CODE; + return; + } + + console.log( + `${highlighter.success("✔")} react-doctor skill installed for ${selectedAgents.join(", ")}.`, + ); +}; + +// --- Main CLI --- + +const program = new Command() + .name("react-doctor") + .description("Diagnose React codebase health") + .version(VERSION, "-v, --version", "display the version number") + .argument("[directory]", "project directory to scan", DEFAULT_DIRECTORY) + .option("--lint", "enable linting") + .option("--no-lint", "skip oxlint checks") + .option("--dead-code", "enable dead-code, dependency, and architecture graph checks (off by default)") + .option("--no-dead-code", "skip codebase graph checks") + .option("--verbose", "show every rule and per-file details (default shows top 3 rules)") + .option("--custom-rules-only", "run only react-doctor custom oxlint rules") + .option("--staged", "only inspect staged source files (materializes git index snapshot)") + .option("--unstaged", "only inspect unstaged and untracked source files") + .option("--changed", "only inspect source files changed since HEAD") + .option( + "--diff [base]", + "scan only files changed vs base branch (pass `false` to disable; overridden by --full)", + ) + .option("--json", "output a single structured JSON report (suppresses other output)") + .option("--json-compact", "with --json, emit compact JSON (no indentation)") + .option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)") + .option("--project ", "select workspace project (comma-separated for multiple)") + .option("-y, --yes", "skip prompts, scan all workspace projects") + .option("--score", "output only the score") + .option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)") + .option("--annotations", "output diagnostics as GitHub Actions annotations") + .option("--fail-on ", "exit with error code on diagnostics: error, warning, none", "error") + .option("--explain ", "diagnose why a rule fired at a specific location") + .option("--why ", "alias for --explain") + .option( + "--respect-inline-disables", + "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)", + ) + .option( + "--no-respect-inline-disables", + "audit mode: neutralize inline lint suppressions before scanning", + ) + .action(async (directory: string, flags: CliFlags, command: Command) => { + const isScoreOnly = flags.score; + const isJsonMode = flags.json; + const isQuiet = isScoreOnly || isJsonMode; + const rootDirectory = path.resolve(directory); + const jsonStartTime = performance.now(); + + isJsonModeActive = isJsonMode; + isCompactJsonOutput = Boolean(flags.jsonCompact); + resolvedDirectoryForCancel = rootDirectory; + cancelStartTime = jsonStartTime; + + try { + validateModeFlags(flags); + + const loadedConfig = await loadReactDoctorConfig(rootDirectory); + const config: ReactDoctorConfig = loadedConfig?.config ?? {}; + const scanRootDirectory = await resolveConfigRootDirectory(loadedConfig, rootDirectory); + resolvedDirectoryForCancel = scanRootDirectory; + + const explainArgument = flags.explain ?? flags.why; + if (explainArgument !== undefined) { + await runExplain(explainArgument, scanRootDirectory, config, flags.project); + return; + } + + if (!isQuiet) { + console.log(`react-doctor ${highlighter.dim(`v${VERSION}`)}`); + console.log(""); + } + + if (!flags.offline && isCiEnvironment() && !isQuiet) { + console.log(highlighter.dim("CI detected — scoring locally.")); + console.log(""); + } + + const configuredDiff = flags.full + ? false + : command.getOptionValueSource("diff") === "cli" + ? flags.diff + : (config.diff ?? flags.diff); + const effectiveFlags: CliFlags = { + ...flags, + verbose: + command.getOptionValueSource("verbose") === "cli" + ? Boolean(flags.verbose) + : Boolean(config.verbose ?? flags.verbose), + diff: coerceDiffValue(configuredDiff), + }; + + const failOn = + command.getOptionValueSource("failOn") === "cli" + ? normalizeFailOnLevel(flags.failOn) + : normalizeFailOnLevel(config.failOn ?? flags.failOn); + + const shouldSkipPrompts = + flags.yes || + flags.full || + isJsonMode || + isNonInteractiveEnvironment() || + !process.stdin.isTTY; + + const isOffline = flags.offline || (config.offline ?? false) || isCiEnvironment(); + + // --- Staged mode with materialization --- + if (effectiveFlags.staged) { + const stagedFiles = getStagedSourceFiles(scanRootDirectory); + if (stagedFiles.length === 0) { + if (isJsonMode) { + const emptyReport = { + schemaVersion: 1, + ok: true, + projects: [], + issues: [], + checks: [], + summary: { + errorCount: 0, + warningCount: 0, + affectedFileCount: 0, + totalIssueCount: 0, + score: null, + scoreLabel: null, + }, + mode: "staged", + durationMilliseconds: performance.now() - jsonStartTime, + }; + process.stdout.write( + `${flags.jsonCompact ? JSON.stringify(emptyReport) : JSON.stringify(emptyReport, null, 2)}\n`, + ); + } else if (!isScoreOnly) { + console.log(highlighter.dim("No staged source files found.")); + } + return; + } + + const stagedFileLabel = `${stagedFiles.length} staged ${stagedFiles.length === 1 ? "file" : "files"}`; + const stagedSpinner = !isQuiet + ? createProgressSpinner(`Analyzing ${highlighter.info(stagedFileLabel)}`) + : null; + + let tempDirectory: string | null = null; + let cleanupSnapshot: (() => void) | null = null; + let unregisterCleanup: (() => void) | null = null; + try { + tempDirectory = mkdtempSync(path.join(tmpdir(), "react-doctor-staged-")); + const snapshot = materializeStagedFiles(scanRootDirectory, stagedFiles, tempDirectory); + cleanupSnapshot = snapshot.cleanup; + unregisterCleanup = registerPendingCleanup(snapshot.cleanup); + + // Strip rootDir + skip loadedConfig: the temp snapshot already + // mirrors the contents at the resolved rootDir, so re-anchoring + // via loadedConfig.sourceDirectory would redirect the scan back + // to the original working tree. Spread the config explicitly so + // ignore/ignoredTags/textComponents/adoptExistingLintConfig still + // propagate to the SDK. + const { rootDir: _stagedRootDir, ...stagedConfig } = config; + const stagedInspectOptions = { + lint: resolveBooleanInspectOption(command, "lint", flags.lint, config.lint, true), + deadCode: false, + customRulesOnly: resolveBooleanInspectOption( + command, + "customRulesOnly", + flags.customRulesOnly, + config.customRulesOnly, + false, + ), + offline: isOffline, + respectInlineDisables: resolveBooleanInspectOption( + command, + "respectInlineDisables", + flags.respectInlineDisables, + config.respectInlineDisables, + true, + ), + silentLogs: isQuiet, + config: stagedConfig, + loadedConfig: null, + }; + + const snapshotProjects = await discoverProjects( + snapshot.tempDirectory, + Boolean(config.rootDir), + false, + ); + const stagedProjectDirectories = snapshotProjects + .map((project) => project.directory) + .filter( + (projectDirectory) => + resolveProjectIncludePaths( + snapshot.tempDirectory, + projectDirectory, + snapshot.stagedFiles, + )?.length !== 0, + ); + const projectDirectories = + stagedProjectDirectories.length > 0 + ? stagedProjectDirectories + : [snapshot.tempDirectory]; + + const results = await Promise.all( + projectDirectories.map(async (projectDirectory) => { + const projectIncludePaths = resolveProjectIncludePaths( + snapshot.tempDirectory, + projectDirectory, + snapshot.stagedFiles, + ); + const result = await createReactDoctor({ + rootDirectory: projectDirectory, + includePaths: projectIncludePaths, + }).inspect(stagedInspectOptions); + const projectRelativePath = path.relative(snapshot.tempDirectory, projectDirectory); + const originalProjectRoot = path.resolve(scanRootDirectory, projectRelativePath); + return { + ...result, + project: { ...result.project, rootDirectory: originalProjectRoot }, + }; + }), + ); + + stagedSpinner?.stop(); + + const allIssues = results.flatMap((result) => result.issues); + + if (flags.score) { + console.log(String(getWorstScoreValue(results))); + } else { + printInspectionResults(results, effectiveFlags, isOffline); + } + + if (flags.annotations) { + printAnnotations(allIssues, isJsonMode); + } + + if (shouldFailForIssues(allIssues, failOn)) { + process.exitCode = EXIT_FAILURE_CODE; + } + } finally { + stagedSpinner?.stop(); + unregisterCleanup?.(); + cleanupSnapshot?.(); + } + return; + } + + // --- Diff mode with interactive prompt --- + const effectiveDiff = coerceDiffValue(effectiveFlags.diff); + const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : undefined; + const wantsDiffMode = effectiveDiff !== undefined && effectiveDiff !== false; + const shouldDetectDiff = wantsDiffMode || (!shouldSkipPrompts && !isQuiet); + const diffInfo = shouldDetectDiff ? getDiffInfo(scanRootDirectory, explicitBaseBranch) : null; + const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet); + + let includePaths: string[] | undefined; + if (isDiffMode && diffInfo) { + includePaths = filterSourceFiles(diffInfo.changedFiles); + if (!isQuiet) { + if (diffInfo.isCurrentChanges) { + console.log("Scanning uncommitted changes"); + } else { + console.log( + `Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`, + ); + } + console.log(""); + } + } else if (!effectiveFlags.staged) { + // When --diff was requested but diff detection failed, we already + // told the user "Running full scan" — so suppress the diff flag here + // to avoid resolveIncludePaths invoking a doomed git command that + // returns [] and silently scans nothing. + const includePathsFlags: CliFlags = + wantsDiffMode && !isDiffMode ? { ...effectiveFlags, diff: false } : effectiveFlags; + includePaths = resolveIncludePaths(scanRootDirectory, includePathsFlags); + } + + const shouldSkipSourceChecks = + isActiveChangedFileMode(effectiveFlags, isDiffMode) && includePaths?.length === 0; + + const discoveredProjects = await discoverProjects( + scanRootDirectory, + Boolean(config.rootDir), + shouldSkipSourceChecks, + ); + const projectDirectories = await selectProjects( + discoveredProjects, + scanRootDirectory, + flags.project, + shouldSkipPrompts, + isQuiet, + ); + + const inspectOptions = { + lint: shouldSkipSourceChecks + ? false + : resolveBooleanInspectOption(command, "lint", flags.lint, config.lint, true), + deadCode: + shouldSkipSourceChecks || isActiveChangedFileMode(effectiveFlags, isDiffMode) + ? false + : // Temporarily off-by-default: the codebase graph (dead-code, + // dependencies, react-architecture) still produces too many + // false positives on large/monorepo codebases like PostHog + // (unresolved imports across non-standard module layouts, + // exports kept for downstream packages, runtime/test + // reachability gaps, etc.). Opt in with `--dead-code` or + // `"reactDoctor": { "deadCode": true }` in package.json. + resolveBooleanInspectOption( + command, + "deadCode", + flags.deadCode, + config.deadCode, + false, + ), + customRulesOnly: resolveBooleanInspectOption( + command, + "customRulesOnly", + flags.customRulesOnly, + config.customRulesOnly, + false, + ), + offline: isOffline, + respectInlineDisables: resolveBooleanInspectOption( + command, + "respectInlineDisables", + flags.respectInlineDisables, + config.respectInlineDisables, + true, + ), + silentLogs: isQuiet, + config, + loadedConfig, + }; + + const selectedProjectNames = projectDirectories.map((projectDirectory) => { + const matchedProject = discoveredProjects.find( + (project) => project.directory === projectDirectory, + ); + return matchedProject?.name ?? path.basename(projectDirectory); + }); + const scanSpinnerLabel = + selectedProjectNames.length === 1 + ? `Analyzing ${highlighter.info(selectedProjectNames[0])}` + : `Analyzing ${highlighter.info(`${selectedProjectNames.length} projects`)}`; + const scanSpinner = !isQuiet ? createProgressSpinner(scanSpinnerLabel) : null; + + let results: ReactDoctorResult[]; + try { + results = await Promise.all( + projectDirectories.map((projectDirectory) => { + const projectIncludePaths = shouldSkipSourceChecks + ? undefined + : resolveProjectIncludePaths(scanRootDirectory, projectDirectory, includePaths); + const shouldSkipProjectSourceChecks = + isActiveChangedFileMode(effectiveFlags, isDiffMode) && + projectIncludePaths?.length === 0; + return createReactDoctor({ + rootDirectory: projectDirectory, + includePaths: shouldSkipProjectSourceChecks ? undefined : projectIncludePaths, + }).inspect({ + ...inspectOptions, + lint: shouldSkipProjectSourceChecks ? false : inspectOptions.lint, + deadCode: shouldSkipProjectSourceChecks ? false : inspectOptions.deadCode, + }); + }), + ); + } finally { + scanSpinner?.stop(); + } + + const allIssues = results.flatMap((result) => result.issues); + + if (flags.annotations) { + printAnnotations(allIssues, isJsonMode); + } + + if (flags.score) { + console.log(String(getWorstScoreValue(results))); + } else { + printInspectionResults(results, effectiveFlags, isOffline); + } + + if (shouldFailForIssues(allIssues, failOn)) { + process.exitCode = EXIT_FAILURE_CODE; + } + } catch (error) { + if (isJsonModeActive) { + writeJsonErrorReport( + error, + resolvedDirectoryForCancel ?? rootDirectory, + performance.now() - jsonStartTime, + ); + process.exitCode = EXIT_FAILURE_CODE; + return; + } + handleCliError(error); + } + }) + .addHelpText( + "after", + ` +${highlighter.dim("Configuration:")} + Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info('"reactDoctor"')} key in your package.json) in the project root. + CLI flags always override config values. See the README for the full schema. + +${highlighter.dim("Learn more:")} + ${highlighter.info(CANONICAL_GITHUB_URL)} +`, + ); + +program + .command("install") + .description("Install the react-doctor skill into your coding agents") + .option("-y, --yes", "skip prompts, install for all detected agents") + .option("--dry-run", "show what would be installed without writing files") + .action(async (options: { yes?: boolean; dryRun?: boolean }) => { + try { + await runInstall(options); + } catch (error) { + handleCliError(error); + } + }); + +program.parseAsync().catch((error: unknown) => { + if (isJsonModeActive) { + try { + writeJsonErrorReport( + error, + resolvedDirectoryForCancel ?? process.cwd(), + performance.now() - cancelStartTime, + ); + } catch { + process.stdout.write( + '{"schemaVersion":1,"ok":false,"error":{"message":"Internal error","name":"Error"}}\n', + ); + } + process.exit(1); + } + handleCliError(error); +}); diff --git a/packages/react-doctor/src/cli/prompts.ts b/packages/react-doctor/src/cli/prompts.ts new file mode 100644 index 0000000000..211fdc6140 --- /dev/null +++ b/packages/react-doctor/src/cli/prompts.ts @@ -0,0 +1,83 @@ +import { createRequire } from "node:module"; +import basePrompts, { type PromptObject, type Answers } from "prompts"; +import { SIGINT_EXIT_CODE } from "../constants.js"; + +interface PromptMultiselectChoiceState { + selected?: boolean; + disabled?: boolean; +} + +interface PromptMultiselectContext { + maxChoices?: number; + cursor: number; + value: PromptMultiselectChoiceState[]; + bell: () => void; + render: () => void; +} + +const esmRequire = createRequire(import.meta.url); +const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect"; +let didPatchToggleAll = false; +let didPatchSubmit = false; + +const onCancel = () => { + console.log(""); + console.log("Cancelled."); + console.log(""); + process.exit(SIGINT_EXIT_CODE); +}; + +const shouldSelectAll = (choiceStates: PromptMultiselectChoiceState[]): boolean => + choiceStates + .filter((choiceState) => !choiceState.disabled) + .some((choiceState) => choiceState.selected !== true); + +const shouldAutoSelectCurrent = ( + choiceStates: PromptMultiselectChoiceState[], + cursor: number, +): boolean => { + if (choiceStates.some((choiceState) => choiceState.selected)) return false; + const currentChoice = choiceStates[cursor]; + return Boolean(currentChoice) && !currentChoice.disabled; +}; + +const patchMultiselectToggleAll = (): void => { + if (didPatchToggleAll) return; + didPatchToggleAll = true; + + const multiselectConstructor = esmRequire(PROMPTS_MULTISELECT_MODULE_PATH); + multiselectConstructor.prototype.toggleAll = function (this: PromptMultiselectContext): void { + if (this.maxChoices !== undefined || Boolean(this.value[this.cursor]?.disabled)) { + this.bell(); + return; + } + const shouldSelectAllEnabled = shouldSelectAll(this.value); + for (const choiceState of this.value) { + if (choiceState.disabled) continue; + choiceState.selected = shouldSelectAllEnabled; + } + this.render(); + }; +}; + +const patchMultiselectSubmit = (): void => { + if (didPatchSubmit) return; + didPatchSubmit = true; + + const multiselectConstructor = esmRequire(PROMPTS_MULTISELECT_MODULE_PATH); + const originalSubmit = multiselectConstructor.prototype.submit; + multiselectConstructor.prototype.submit = function (this: PromptMultiselectContext): void { + if (shouldAutoSelectCurrent(this.value, this.cursor)) { + this.value[this.cursor].selected = true; + } + originalSubmit.call(this); + }; +}; + +export const prompts = ( + questions: PromptObject | PromptObject[], +): Promise> => { + patchMultiselectToggleAll(); + patchMultiselectSubmit(); + return basePrompts(questions, { onCancel }); +}; diff --git a/packages/react-doctor/src/cli/render-score-header.ts b/packages/react-doctor/src/cli/render-score-header.ts new file mode 100644 index 0000000000..9aaadefc25 --- /dev/null +++ b/packages/react-doctor/src/cli/render-score-header.ts @@ -0,0 +1,62 @@ +import { + PERFECT_SCORE, + REACT_REVIEW_URL, + SCORE_BAR_WIDTH_CHARS, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, +} from "../constants.js"; +import { highlighter } from "./highlighter.js"; + +const BRANDING_LINE = `React Doctor ${highlighter.dim("(www.react.doctor)")}`; + +const colorizeByScore = (text: string, score: number): string => { + if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text); + if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text); + return highlighter.error(text); +}; + +const buildScoreBar = (score: number): string => { + const filledCount = Math.round((score / PERFECT_SCORE) * SCORE_BAR_WIDTH_CHARS); + const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount; + return colorizeByScore("█".repeat(filledCount), score) + highlighter.dim("░".repeat(emptyCount)); +}; + +const getDoctorFace = (score: number): [string, string] => { + if (score >= SCORE_GOOD_THRESHOLD) return ["◠ ◠", " ▽ "]; + if (score >= SCORE_OK_THRESHOLD) return ["• •", " ─ "]; + return ["x x", " ▽ "]; +}; + +const buildFaceRenderedLines = (score: number): string[] => { + const [eyes, mouth] = getDoctorFace(score); + return ["┌─────┐", `│ ${eyes} │`, `│ ${mouth} │`, "└─────┘"].map((text) => + colorizeByScore(text, score), + ); +}; + +export const printScoreHeader = (score: number, label: string): void => { + const renderedFaceLines = buildFaceRenderedLines(score); + const scoreNumber = colorizeByScore(`${score}`, score); + const scoreLabel = colorizeByScore(label, score); + const scoreLine = `${scoreNumber} ${highlighter.dim(`/ ${PERFECT_SCORE}`)} ${scoreLabel}`; + const rightColumnLines = [scoreLine, buildScoreBar(score), BRANDING_LINE, ""]; + for (let lineIndex = 0; lineIndex < renderedFaceLines.length; lineIndex += 1) { + const rightColumnContent = rightColumnLines[lineIndex] ?? ""; + const separator = rightColumnContent.length > 0 ? " " : ""; + console.log(` ${renderedFaceLines[lineIndex]}${separator}${rightColumnContent}`); + } + console.log(""); +}; + +export const printReactReviewCta = (): void => { + console.log( + ` ${highlighter.bold("→ Catch these issues on every PR:")} ${highlighter.info(REACT_REVIEW_URL)}`, + ); + console.log( + ` ${highlighter.dim("React Review is a GitHub App built on React Doctor — it runs on each pull request,")}`, + ); + console.log( + ` ${highlighter.dim("posts new issues as inline review comments, and tracks your team's score over time.")}`, + ); + console.log(""); +}; diff --git a/packages/react-doctor/src/cli/select-projects.ts b/packages/react-doctor/src/cli/select-projects.ts new file mode 100644 index 0000000000..7ed0ae3d06 --- /dev/null +++ b/packages/react-doctor/src/cli/select-projects.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import { highlighter } from "./highlighter.js"; +import { prompts } from "./prompts.js"; + +export interface DiscoveredProject { + name: string; + directory: string; +} + +export const selectProjects = async ( + discoveredProjects: DiscoveredProject[], + rootDirectory: string, + projectFlag: string | undefined, + skipPrompts: boolean, + silent: boolean = false, +): Promise => { + if (discoveredProjects.length === 0) return [rootDirectory]; + + if (discoveredProjects.length === 1) { + if (!silent) { + console.log( + `${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${discoveredProjects[0].name}`, + ); + } + return [discoveredProjects[0].directory]; + } + + if (projectFlag) return resolveProjectFlag(projectFlag, discoveredProjects); + + if (skipPrompts) { + if (!silent) { + console.log( + `${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${discoveredProjects.map((project) => project.name).join(", ")}`, + ); + } + return discoveredProjects.map((project) => project.directory); + } + + return promptProjectSelection(discoveredProjects, rootDirectory); +}; + +const resolveProjectFlag = ( + projectFlag: string, + discoveredProjects: DiscoveredProject[], +): string[] => { + const requestedNames = projectFlag.split(",").map((segment) => segment.trim()); + const resolvedDirectories: string[] = []; + + for (const requestedName of requestedNames) { + const matched = discoveredProjects.find( + (project) => + project.name === requestedName || path.basename(project.directory) === requestedName, + ); + + if (!matched) { + const availableNames = discoveredProjects.map((project) => project.name).join(", "); + throw new Error(`Project "${requestedName}" not found. Available: ${availableNames}`); + } + + resolvedDirectories.push(matched.directory); + } + + return resolvedDirectories; +}; + +const promptProjectSelection = async ( + discoveredProjects: DiscoveredProject[], + rootDirectory: string, +): Promise => { + const { selectedDirectories } = await prompts({ + type: "multiselect", + name: "selectedDirectories", + message: "Select projects to scan", + choices: discoveredProjects.map((project) => ({ + title: project.name, + description: path.relative(rootDirectory, project.directory), + value: project.directory, + })), + min: 1, + }); + + return Array.isArray(selectedDirectories) ? selectedDirectories : []; +}; diff --git a/packages/react-doctor/src/cli/utils/create-progress-spinner.ts b/packages/react-doctor/src/cli/utils/create-progress-spinner.ts new file mode 100644 index 0000000000..f57985ebe1 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/create-progress-spinner.ts @@ -0,0 +1,47 @@ +import { performance } from "node:perf_hooks"; +import ora, { type Ora } from "ora"; +import { SPINNER_FRAME_INTERVAL_MS } from "../../constants.js"; +import { highlighter } from "../highlighter.js"; +import { formatElapsedTime } from "./format-elapsed-time.js"; + +export interface ProgressSpinner { + stop: () => void; +} + +const NOOP_SPINNER: ProgressSpinner = { stop: () => {} }; + +const renderSpinnerText = (label: string, elapsedMilliseconds: number): string => + `${label} ${highlighter.dim(formatElapsedTime(elapsedMilliseconds))}`; + +export const createProgressSpinner = (label: string): ProgressSpinner => { + if (!process.stdout.isTTY) return NOOP_SPINNER; + + const startTimeMilliseconds = performance.now(); + const spinner: Ora = ora({ + text: renderSpinnerText(label, 0), + color: "cyan", + discardStdin: false, + }).start(); + + const tickHandle = setInterval(() => { + spinner.text = renderSpinnerText(label, performance.now() - startTimeMilliseconds); + }, SPINNER_FRAME_INTERVAL_MS); + + let isStopped = false; + const cleanupOnExit = () => { + if (isStopped) return; + clearInterval(tickHandle); + spinner.stop(); + }; + process.once("exit", cleanupOnExit); + + return { + stop: () => { + if (isStopped) return; + isStopped = true; + clearInterval(tickHandle); + spinner.stop(); + process.off("exit", cleanupOnExit); + }, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/format-elapsed-time.ts b/packages/react-doctor/src/cli/utils/format-elapsed-time.ts new file mode 100644 index 0000000000..a0760957d3 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/format-elapsed-time.ts @@ -0,0 +1,8 @@ +import { MILLISECONDS_PER_SECOND } from "../../constants.js"; + +export const formatElapsedTime = (elapsedMilliseconds: number): string => { + if (elapsedMilliseconds < MILLISECONDS_PER_SECOND) { + return `${Math.round(elapsedMilliseconds)}ms`; + } + return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`; +}; diff --git a/packages/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index e47eda023c..690df3277c 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -1,72 +1,60 @@ -export const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/; - -export const JSX_FILE_PATTERN = /\.(tsx|jsx)$/; - -export const MILLISECONDS_PER_SECOND = 1000; - -export const ERROR_PREVIEW_LENGTH_CHARS = 200; - -export const BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS = 20_000; - -export const BROWSER_POC_HOST_SELECTOR_MAX_COUNT = 4; - +export const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor"; +export const DEFAULT_DIRECTORY = "."; +export const EXIT_FAILURE_CODE = 1; +export const SIGINT_EXIT_CODE = 130; +export const REACT_DOCTOR_CONFIG_FILENAME = "react-doctor.config.json"; +export const PACKAGE_JSON_FILENAME = "package.json"; +export const PACKAGE_JSON_CONFIG_KEY = "reactDoctor"; export const PERFECT_SCORE = 100; - export const SCORE_GOOD_THRESHOLD = 75; - export const SCORE_OK_THRESHOLD = 50; - export const SCORE_BAR_WIDTH_CHARS = 50; - -export const SCORE_API_URL = "https://www.react.doctor/api/score"; - +export const REACT_REVIEW_URL = "https://react.review"; export const SHARE_BASE_URL = "https://www.react.doctor/share"; - +export const ERROR_RULE_PENALTY = 1.0; +export const WARNING_RULE_PENALTY = 0.5; +export const PER_RULE_LOG_AMPLIFICATION_CAP = 4; +// Per-category penalty cap. Without this, a codebase with many distinct +// oxlint rules firing each contributes additively and crashes the score +// regardless of how "good" each individual rule's signal is. Capping each +// category turns the score from "sum of all problems" into "worst category +// dominates". 35 is calibrated so a clean repo isn't capped and a noisy +// monorepo still feels the hit but doesn't floor to 0. +export const PER_CATEGORY_PENALTY_CAP = 35; +export const SCORE_API_URL = "https://www.react.doctor/api/score"; export const FETCH_TIMEOUT_MS = 10_000; - -export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024; - -// HACK: Windows CreateProcessW limits total command-line length to 32,767 chars. -// Use a conservative threshold to leave room for the executable path and quoting overhead. -export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000; - -// HACK: oxlint can SIGABRT on very large file sets due to memory pressure. -// Cap each batch to avoid OOM crashes on projects with 100+ source files. -export const OXLINT_MAX_FILES_PER_BATCH = 500; - -export const OFFLINE_MESSAGE = "Score calculated locally (offline mode)."; - +export const MILLISECONDS_PER_SECOND = 1000; +export const SPINNER_FRAME_INTERVAL_MS = 80; +export const NON_VERBOSE_LOCATIONS_PER_GROUP = 3; +export const MAX_SCORE_DRAINS_SHOWN = 5; export const DEFAULT_BRANCH_CANDIDATES = ["main", "master"]; +export const GIT_SHOW_MAX_BUFFER_BYTES = 50 * 1024 * 1024; +export const SOURCE_FILE_PATTERN = /\.(cjs|cts|js|jsx|mjs|mts|ts|tsx)$/; + +export const FRAMEWORK_DISPLAY_NAMES: Record = { + nextjs: "Next.js", + "react-native": "React Native", + "tanstack-start": "TanStack Start", + cra: "Create React App", + expo: "Expo", + gatsby: "Gatsby", + remix: "Remix", + vite: "Vite", + react: "React", +}; + +export const REACT_PROJECT_DEPENDENCIES = new Set([ + "@remix-run/react", + "@tanstack/react-start", + "expo", + "gatsby", + "next", + "react", + "react-native", + "react-scripts", +]); -export const ERROR_RULE_PENALTY = 1.5; - -export const WARNING_RULE_PENALTY = 0.75; - -export const KNIP_CONFIG_LOCATIONS = [ - "knip.json", - "knip.jsonc", - ".knip.json", - ".knip.jsonc", - "knip.ts", - "knip.js", - "knip.config.ts", - "knip.config.js", -]; - -// JSON-format oxlint / eslint configs react-doctor can fold into the -// scan via oxlint's `extends` field. JS / TS configs need a runtime -// to evaluate and aren't supported by oxlint's `extends`. Listed in -// detection priority order — oxlint native first, eslint legacy as a -// compatibility fallback. Also used by tests as the source of truth. -export const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"]; - -export const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0"; - -export const OXLINT_RECOMMENDED_NODE_MAJOR = 24; - -export const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024; - -export const IGNORED_DIRECTORIES = new Set([ +export const FILESYSTEM_WALK_IGNORED_DIRECTORIES = new Set([ ".git", ".next", ".nuxt", @@ -81,60 +69,4 @@ export const IGNORED_DIRECTORIES = new Set([ "storybook-static", ]); -export const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor"; - -export const SKILL_NAME = "react-doctor"; - -export const KNIP_TOTAL_ATTEMPTS = 6; - -export const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024; - -export const buildNoReactDependencyError = (directory: string): string => - `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`; - -// HACK: minimum React major versions for the deprecation rule gates in -// `oxlint-config.ts`. React-19-deprecated APIs (forwardRef, useContext, -// Foo.defaultProps) shouldn't fire on 17/18 codebases — those are still -// the current surface there. The legacy react-dom root API -// (render/hydrate/unmountComponentAtNode/findDOMNode) was deprecated -// in 18, so we light those up one major earlier. -export const REACT_19_DEPRECATION_MIN_MAJOR = 19; - -export const REACT_DOM_LEGACY_API_MIN_MAJOR = 18; - -// HACK: lookahead cap for JSX opener-span scanning; bounds worst-case -// work on pathological files. Real openers stay well under this. -export const JSX_OPENER_SCAN_MAX_LINES = 32; - -// HACK: lookback cap for stacked / near-miss disable-next-line scanning. -// Larger gaps stop being intentional suppressions and become noise. -export const SUPPRESSION_NEAR_MISS_MAX_LINES = 10; - -// `useEffectEvent` requires React 19+. Below the threshold, the rule -// that suggests it (`prefer-use-effect-event`) stays silent. -export const USE_EFFECT_EVENT_MIN_MAJOR = 19; - -// HACK: minimum Tailwind major.minor for the `size-N` shorthand. The -// rule that suggests collapsing `w-N h-N` (`design-no-redundant-size-axes`) -// requires Tailwind v3.4+ — recommending `size-N` to a v3.0…v3.3 -// project would generate classes that simply don't compile. Below -// the threshold the rule stays silent. v4 inherits the shorthand, -// so a single major.minor floor covers every supported Tailwind line. -export const TAILWIND_SIZE_SHORTHAND_MIN_MAJOR = 3; -export const TAILWIND_SIZE_SHORTHAND_MIN_MINOR = 4; - -// In the default human output, show several category sections like an -// audit report, but cap each section so one noisy category does not -// bury the rest of the scan. -export const MAX_CATEGORY_GROUPS_SHOWN_NON_VERBOSE = 5; - -export const MAX_RULE_GROUPS_PER_CATEGORY_NON_VERBOSE = 3; - -// Minimum width of the rule-name column in the diagnostics list. Pads -// shorter rule names so the right-aligned `N sites` count stays in a -// consistent column even when one rule has a much longer identifier. -export const RULE_NAME_COLUMN_WIDTH_CHARS = 36; - -export const OUTPUT_DETAIL_WRAP_WIDTH_CHARS = 88; - -export const SPINNER_INDENT_CHARS = 0; +export const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 }; diff --git a/packages/react-doctor/src/core/config.ts b/packages/react-doctor/src/core/config.ts new file mode 100644 index 0000000000..c3561caaa7 --- /dev/null +++ b/packages/react-doctor/src/core/config.ts @@ -0,0 +1,261 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + PACKAGE_JSON_CONFIG_KEY, + PACKAGE_JSON_FILENAME, + REACT_DOCTOR_CONFIG_FILENAME, +} from "../constants.js"; +import { ReactDoctorInvalidConfigError } from "./errors.js"; +import type { LoadedReactDoctorConfig, ReactDoctorConfig } from "./types.js"; + +interface UnknownRecord { + [key: string]: unknown; +} + +interface ValidatorContext { + sourcePath: string; +} + +const configCache = new Map(); + +const isRecord = (value: unknown): value is UnknownRecord => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const pathExists = async (filePath: string): Promise => { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +}; + +const isDirectory = async (filePath: string): Promise => { + try { + return (await fs.stat(filePath)).isDirectory(); + } catch { + return false; + } +}; + +const parseJsonFile = async (filePath: string): Promise => { + try { + return JSON.parse(await fs.readFile(filePath, "utf8")); + } catch (error) { + throw new ReactDoctorInvalidConfigError(`Failed to parse ${filePath}.`, { cause: error }); + } +}; + +const assertStringArray = ( + value: unknown, + fieldName: string, + context: ValidatorContext, +): string[] | undefined => { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new ReactDoctorInvalidConfigError( + `${context.sourcePath}: "${fieldName}" must be an array of strings.`, + ); + } + return value; +}; + +const assertBoolean = ( + value: unknown, + fieldName: string, + context: ValidatorContext, +): boolean | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "boolean") { + throw new ReactDoctorInvalidConfigError( + `${context.sourcePath}: "${fieldName}" must be a boolean.`, + ); + } + return value; +}; + +const assertString = ( + value: unknown, + fieldName: string, + context: ValidatorContext, +): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string") { + throw new ReactDoctorInvalidConfigError( + `${context.sourcePath}: "${fieldName}" must be a string.`, + ); + } + return value; +}; + +const assertFailOnLevel = ( + value: unknown, + context: ValidatorContext, +): ReactDoctorConfig["failOn"] => { + if (value === undefined) return undefined; + if (value === "error" || value === "warning" || value === "none") return value; + throw new ReactDoctorInvalidConfigError( + `${context.sourcePath}: "failOn" must be "error", "warning", or "none".`, + ); +}; + +const assertDiff = (value: unknown, context: ValidatorContext): ReactDoctorConfig["diff"] => { + if (value === undefined) return undefined; + if (typeof value === "boolean" || typeof value === "string") return value; + throw new ReactDoctorInvalidConfigError( + `${context.sourcePath}: "diff" must be a boolean or branch name string.`, + ); +}; + +const assertIgnoreConfig = ( + value: unknown, + context: ValidatorContext, +): ReactDoctorConfig["ignore"] => { + if (value === undefined) return undefined; + if (!isRecord(value)) { + throw new ReactDoctorInvalidConfigError(`${context.sourcePath}: "ignore" must be an object.`); + } + + const overrides: NonNullable["overrides"] = []; + if (value.overrides !== undefined) { + if ( + !Array.isArray(value.overrides) || + value.overrides.some((override) => !isRecord(override)) + ) { + throw new ReactDoctorInvalidConfigError( + `${context.sourcePath}: "ignore.overrides" must be an array of objects.`, + ); + } + for (const override of value.overrides) { + if (!isRecord(override)) continue; + overrides.push({ + files: assertStringArray(override.files, "ignore.overrides[].files", context) ?? [], + rules: assertStringArray(override.rules, "ignore.overrides[].rules", context), + }); + } + } + + return { + rules: assertStringArray(value.rules, "ignore.rules", context), + files: assertStringArray(value.files, "ignore.files", context), + overrides, + }; +}; + +const validateConfig = (value: unknown, sourcePath: string): ReactDoctorConfig => { + if (!isRecord(value)) { + throw new ReactDoctorInvalidConfigError(`${sourcePath}: config must be a JSON object.`); + } + + const context = { sourcePath }; + return { + ignore: assertIgnoreConfig(value.ignore, context), + lint: assertBoolean(value.lint, "lint", context), + deadCode: assertBoolean(value.deadCode, "deadCode", context), + verbose: assertBoolean(value.verbose, "verbose", context), + diff: assertDiff(value.diff, context), + offline: assertBoolean(value.offline, "offline", context), + failOn: assertFailOnLevel(value.failOn, context), + customRulesOnly: assertBoolean(value.customRulesOnly, "customRulesOnly", context), + rootDir: assertString(value.rootDir, "rootDir", context), + textComponents: assertStringArray(value.textComponents, "textComponents", context), + rawTextWrapperComponents: assertStringArray( + value.rawTextWrapperComponents, + "rawTextWrapperComponents", + context, + ), + respectInlineDisables: assertBoolean( + value.respectInlineDisables, + "respectInlineDisables", + context, + ), + adoptExistingLintConfig: assertBoolean( + value.adoptExistingLintConfig, + "adoptExistingLintConfig", + context, + ), + includeEcosystemRules: assertBoolean( + value.includeEcosystemRules, + "includeEcosystemRules", + context, + ), + ignoredTags: assertStringArray(value.ignoredTags, "ignoredTags", context), + }; +}; + +const loadConfigFromDirectory = async ( + directory: string, +): Promise => { + const configPath = path.join(directory, REACT_DOCTOR_CONFIG_FILENAME); + if (await pathExists(configPath)) { + return { + config: validateConfig(await parseJsonFile(configPath), configPath), + sourceDirectory: directory, + sourcePath: configPath, + }; + } + + const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME); + if (!(await pathExists(packageJsonPath))) return null; + const packageJson = await parseJsonFile(packageJsonPath); + if (!isRecord(packageJson) || !isRecord(packageJson[PACKAGE_JSON_CONFIG_KEY])) return null; + + return { + config: validateConfig(packageJson[PACKAGE_JSON_CONFIG_KEY], packageJsonPath), + sourceDirectory: directory, + sourcePath: `${packageJsonPath}#${PACKAGE_JSON_CONFIG_KEY}`, + }; +}; + +const isProjectBoundary = async (directory: string): Promise => + (await pathExists(path.join(directory, ".git"))) || + (await pathExists(path.join(directory, "pnpm-workspace.yaml"))) || + (await pathExists(path.join(directory, "turbo.json"))) || + (await pathExists(path.join(directory, "nx.json"))); + +export const clearReactDoctorConfigCache = (): void => { + configCache.clear(); +}; + +export const loadReactDoctorConfig = async ( + startDirectory: string, +): Promise => { + const rootDirectory = path.resolve(startDirectory); + const cachedConfig = configCache.get(rootDirectory); + if (cachedConfig !== undefined) return cachedConfig; + + let currentDirectory = rootDirectory; + while (true) { + const loadedConfig = await loadConfigFromDirectory(currentDirectory); + if (loadedConfig) { + configCache.set(rootDirectory, loadedConfig); + return loadedConfig; + } + + const parentDirectory = path.dirname(currentDirectory); + if (currentDirectory === parentDirectory || (await isProjectBoundary(currentDirectory))) { + configCache.set(rootDirectory, null); + return null; + } + currentDirectory = parentDirectory; + } +}; + +export const resolveConfigRootDirectory = async ( + loadedConfig: LoadedReactDoctorConfig | null, + fallbackDirectory: string, +): Promise => { + if (!loadedConfig) return fallbackDirectory; + const rootDir = loadedConfig.config.rootDir?.trim(); + if (!rootDir) return fallbackDirectory; + + const resolvedDirectory = path.isAbsolute(rootDir) + ? rootDir + : path.resolve(loadedConfig.sourceDirectory, rootDir); + if (!(await isDirectory(resolvedDirectory))) { + throw new ReactDoctorInvalidConfigError( + `${loadedConfig.sourcePath}: "rootDir" resolved to ${resolvedDirectory}, which is not a directory.`, + ); + } + return resolvedDirectory; +}; diff --git a/packages/react-doctor/src/core/diagnostics.ts b/packages/react-doctor/src/core/diagnostics.ts new file mode 100644 index 0000000000..6366995c00 --- /dev/null +++ b/packages/react-doctor/src/core/diagnostics.ts @@ -0,0 +1,425 @@ +import path from "node:path"; +import { isTestFilePath } from "./is-test-file-path.js"; +import { getReactDoctorRuleTags } from "./rules/lint/config.js"; +import type { ReactDoctorConfig, ReactDoctorIssue } from "./types.js"; + +const TEST_NOISE_TAG = "test-noise"; +const WRAPPED_RULE_ID_PATTERN = /^([a-zA-Z][\w-]*)\(([^)]+)\)$/; +const REACT_BUILTIN_RULE_PREFIX = /^(?:react|jsx-a11y)\//; +const JSX_A11Y_RULE_PREFIX = "jsx-a11y/"; +const OG_IMAGE_FILE_PATTERN = /\/(?:opengraph-image|twitter-image|icon|apple-icon)\.[jt]sx?$/; +const OG_JSX_FILE_PATTERN = + /\/(?:api\/)?og(?:\/|$)|\/(?:opengraph-image|twitter-image|icon|apple-icon)\.[jt]sx?$/; +const NON_REACT_JSX_IMPORT_PATTERN = /(?:^|\n)\s*import\s.*from\s+['"](?:solid-js|preact)/; +const NON_REACT_JSX_SOURCES = new Set(["preact", "solid-js", "vue", "svelte"]); +const EMOTION_IMPORT_PATTERN = /(?:^|\n)\s*import\s.*from\s+['"]@emotion\/react['"]/; +const IMAGE_RESPONSE_IMPORT_PATTERN = + /(?:^|\n)\s*import\s.*\bImageResponse\b.*from\s+['"](?:next\/og|@vercel\/og)['"]/; +const SATORI_TW_PROP_PATTERN = /\btw\s*=/; +const EMOTION_CSS_PROP_PATTERN = /\bcss\s*=/; + +const REACT_DOCTOR_DISABLE_LINE_DIRECTIVE = "react-doctor-disable-line"; +const REACT_DOCTOR_DISABLE_NEXT_LINE_DIRECTIVE = "react-doctor-disable-next-line"; +const REACT_DOCTOR_RULE_NAMESPACE = "react-doctor/"; +const DISABLE_TOKEN_SEPARATOR_PATTERN = /[\s,]+/; +const DISABLE_COMMENT_BOUNDARY_PATTERN = /\*\/|-->/; +const REGEX_METACHARACTER_PATTERN = /[.*+?^${}()|[\]\\]/g; +const ECOSYSTEM_RULE_PREFIX_PATTERN = + /^(?:nextjs|rn|tailwind|query|swr|mobx|shadcn|radix|rhf|r3f|storybook|testing)-/; + +const escapeRegExpMetacharacters = (value: string): string => + value.replace(REGEX_METACHARACTER_PATTERN, "\\$&"); + +const EFFECT_RULE_ALIASES: ReadonlyMap = new Map([ + ["react-doctor/effect-no-event-handler", "effect-event-handler"], + ["react-doctor/no-effect-event-handler", "effect-event-handler"], + ["effect/no-event-handler", "effect-event-handler"], + ["react-doctor/effect-no-derived-state", "effect-derived-state"], + ["react-doctor/no-derived-state-effect", "effect-derived-state"], + ["effect/no-derived-state", "effect-derived-state"], + ["react-doctor/effect-no-chain-state-updates", "effect-chain-state"], + ["react-doctor/no-effect-chain", "effect-chain-state"], + ["effect/no-chain-state-updates", "effect-chain-state"], + ["react-doctor/effect-no-adjust-state-on-prop-change", "effect-adjust-prop"], + ["effect/no-adjust-state-on-prop-change", "effect-adjust-prop"], + ["react-doctor/effect-no-initialize-state", "effect-init-state"], + ["effect/no-initialize-state", "effect-init-state"], + ["react-doctor/effect-no-pass-data-to-parent", "effect-pass-parent"], + ["effect/no-pass-data-to-parent", "effect-pass-parent"], + ["react-doctor/effect-no-pass-live-state-to-parent", "effect-pass-live-state"], + ["effect/no-pass-live-state-to-parent", "effect-pass-live-state"], + ["react-doctor/effect-no-reset-all-state-on-prop-change", "effect-reset-state"], + ["effect/no-reset-all-state-on-prop-change", "effect-reset-state"], +]); + +const toCanonicalEffectKey = (ruleId: string): string | null => + EFFECT_RULE_ALIASES.get(ruleId) ?? null; + +const toMetadataRuleKey = (issue: ReactDoctorIssue): string | null => { + const ruleId = issue.source?.ruleId; + if (!ruleId) return null; + const wrapped = WRAPPED_RULE_ID_PATTERN.exec(ruleId); + if (wrapped) return `${wrapped[1]}/${wrapped[2]}`; + if (issue.source?.pluginName && !ruleId.includes("/")) { + return `${issue.source.pluginName}/${ruleId}`; + } + return ruleId; +}; + +const isAutoSuppressedTestNoise = (issue: ReactDoctorIssue, relativeFilePath: string): boolean => { + if (!relativeFilePath) return false; + const ruleKey = toMetadataRuleKey(issue); + if (!ruleKey) return false; + if (!getReactDoctorRuleTags(ruleKey).has(TEST_NOISE_TAG)) return false; + return isTestFilePath(relativeFilePath); +}; + +interface CompiledIgnoreOverride { + files: string[]; + rules: Set | null; +} + +interface ComponentMatch { + innerText: string; + startIndex: number; + endIndex: number; +} + +const RN_NO_RAW_TEXT_RULE_ID = "rn-no-raw-text"; + +const normalizePath = (filePath: string): string => filePath.replace(/\\/g, "/"); + +const normalizeRuleId = (issue: ReactDoctorIssue): string => { + if (issue.source?.pluginName && issue.source.ruleId) { + return `${issue.source.pluginName}/${issue.source.ruleId}`; + } + return issue.source?.ruleId ?? issue.id; +}; + +const stripRuleNamespace = (ruleId: string): string => ruleId.split("/").at(-1) ?? ruleId; + +const matchesRule = (issue: ReactDoctorIssue, rulePatterns: ReadonlySet): boolean => { + const ruleId = normalizeRuleId(issue); + return rulePatterns.has(ruleId) || rulePatterns.has(stripRuleNamespace(ruleId)); +}; + +const matchesPathPattern = (filePath: string, pattern: string): boolean => { + const normalizedFilePath = normalizePath(filePath); + const normalizedPattern = normalizePath(pattern).replace(/^\.\//, ""); + if (normalizedPattern.endsWith("/**")) { + const directoryPattern = normalizedPattern.slice(0, -3); + return ( + normalizedFilePath === directoryPattern || + normalizedFilePath.startsWith(`${directoryPattern}/`) + ); + } + if (normalizedPattern.includes("*")) { + const expression = new RegExp( + `^${normalizedPattern.split("*").map(escapeRegExpMetacharacters).join(".*")}$`, + ); + return expression.test(normalizedFilePath); + } + return ( + normalizedFilePath === normalizedPattern || + normalizedFilePath.startsWith(`${normalizedPattern}/`) + ); +}; + +const toRelativeIssuePath = (issue: ReactDoctorIssue, rootDirectory: string): string => { + const filePath = issue.location?.filePath; + if (!filePath) return ""; + if (!path.isAbsolute(filePath)) return normalizePath(filePath); + return normalizePath(path.relative(rootDirectory, filePath)); +}; + +const compileOverrides = (config: ReactDoctorConfig): CompiledIgnoreOverride[] => + (config.ignore?.overrides ?? []).map((override) => ({ + files: override.files, + rules: override.rules ? new Set(override.rules) : null, + })); + +const isIgnoredByOverride = ( + issue: ReactDoctorIssue, + filePath: string, + overrides: CompiledIgnoreOverride[], +): boolean => { + for (const override of overrides) { + if (!override.files.some((pattern) => matchesPathPattern(filePath, pattern))) continue; + if (!override.rules || matchesRule(issue, override.rules)) return true; + } + return false; +}; + +const tokenizeReactDoctorDisableDirective = ( + commentLine: string, + directive: string, +): string[] | null => { + const directiveIndex = commentLine.indexOf(directive); + if (directiveIndex === -1) return null; + const afterDirective = commentLine.slice(directiveIndex + directive.length); + const boundaryMatch = DISABLE_COMMENT_BOUNDARY_PATTERN.exec(afterDirective); + const ruleSection = boundaryMatch ? afterDirective.slice(0, boundaryMatch.index) : afterDirective; + return ruleSection + .split(DISABLE_TOKEN_SEPARATOR_PATTERN) + .map((token) => token.trim()) + .filter((token) => token.length > 0); +}; + +const matchesReactDoctorDisableToken = (token: string, ruleId: string): boolean => { + if (token === ruleId) return true; + if (!token.startsWith(REACT_DOCTOR_RULE_NAMESPACE)) return false; + return token.slice(REACT_DOCTOR_RULE_NAMESPACE.length) === ruleId; +}; + +const isLineDisabledByReactDoctorComment = ( + commentLine: string, + directive: string, + ruleId: string, +): boolean => { + const tokens = tokenizeReactDoctorDisableDirective(commentLine, directive); + if (tokens === null) return false; + if (tokens.length === 0) return true; + return tokens.some((token) => matchesReactDoctorDisableToken(token, ruleId)); +}; + +const isDisabledByStackedDisableNextLine = ( + sourceLines: string[], + issueLineIndex: number, + ruleId: string, +): boolean => { + let cursorLineIndex = issueLineIndex - 2; + while (cursorLineIndex >= 0) { + const commentLine = sourceLines[cursorLineIndex]; + if (commentLine === undefined) return false; + const tokens = tokenizeReactDoctorDisableDirective( + commentLine, + REACT_DOCTOR_DISABLE_NEXT_LINE_DIRECTIVE, + ); + if (tokens === null) return false; + if (tokens.length === 0) return true; + if (tokens.some((token) => matchesReactDoctorDisableToken(token, ruleId))) return true; + cursorLineIndex -= 1; + } + return false; +}; + +const isDisabledByEcosystemDisableNextLine = (previousLine: string, ruleId: string): boolean => { + if ( + !previousLine.includes("eslint-disable-next-line") && + !previousLine.includes("oxlint-disable-next-line") + ) { + return false; + } + if (previousLine.includes(ruleId)) return true; + const baseRuleName = ruleId.replace(ECOSYSTEM_RULE_PREFIX_PATTERN, ""); + return baseRuleName !== ruleId && previousLine.includes(baseRuleName); +}; + +const isDisabledByInlineComment = ( + issue: ReactDoctorIssue, + sourceLines: string[] | undefined, +): boolean => { + const line = issue.location?.line; + if (!line || !sourceLines) return false; + + const ruleId = stripRuleNamespace(normalizeRuleId(issue)); + const sameLine = sourceLines[line - 1] ?? ""; + if (isLineDisabledByReactDoctorComment(sameLine, REACT_DOCTOR_DISABLE_LINE_DIRECTIVE, ruleId)) { + return true; + } + if (isDisabledByStackedDisableNextLine(sourceLines, line, ruleId)) return true; + + const previousLine = sourceLines[line - 2] ?? ""; + return isDisabledByEcosystemDisableNextLine(previousLine, ruleId); +}; + +const toLineStartIndex = (sourceLines: string[], line: number): number => { + let startIndex = 0; + for (let lineIndex = 0; lineIndex < line - 1; lineIndex++) { + startIndex += (sourceLines[lineIndex] ?? "").length + 1; + } + return startIndex; +}; + +const findComponentMatches = (sourceText: string, componentName: string): ComponentMatch[] => { + const escapedComponentName = escapeRegExpMetacharacters(componentName); + const componentPattern = new RegExp( + `<${escapedComponentName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${escapedComponentName}>`, + "g", + ); + const matches: ComponentMatch[] = []; + for (const match of sourceText.matchAll(componentPattern)) { + if (match.index === undefined) continue; + matches.push({ + innerText: match[1] ?? "", + startIndex: match.index, + endIndex: match.index + match[0].length, + }); + } + return matches; +}; + +const isStringOnlyWrapperContent = (innerText: string): boolean => { + const trimmedInnerText = innerText.trim(); + return trimmedInnerText.length > 0 && !/[<{]/.test(trimmedInnerText); +}; + +const isInsideComponentMatch = (issueIndex: number, match: ComponentMatch): boolean => + issueIndex >= match.startIndex && issueIndex <= match.endIndex; + +const isSuppressedRnRawTextIssue = ( + issue: ReactDoctorIssue, + config: ReactDoctorConfig, + sourceLines: string[] | undefined, +): boolean => { + if (stripRuleNamespace(normalizeRuleId(issue)) !== RN_NO_RAW_TEXT_RULE_ID) return false; + const line = issue.location?.line; + if (!line || !sourceLines) return false; + + const sourceText = sourceLines.join("\n"); + const issueIndex = toLineStartIndex(sourceLines, line); + for (const componentName of config.textComponents ?? []) { + if ( + findComponentMatches(sourceText, componentName).some((match) => + isInsideComponentMatch(issueIndex, match), + ) + ) { + return true; + } + } + for (const componentName of config.rawTextWrapperComponents ?? []) { + if ( + findComponentMatches(sourceText, componentName).some( + (match) => + isInsideComponentMatch(issueIndex, match) && isStringOnlyWrapperContent(match.innerText), + ) + ) { + return true; + } + } + return false; +}; + +const isSuppressedUnknownPropertyIssue = ( + issue: ReactDoctorIssue, + relativeFilePath: string, + sourceLines: string[] | undefined, +): boolean => { + const ruleKey = toMetadataRuleKey(issue) ?? normalizeRuleId(issue); + if (ruleKey !== "react/no-unknown-property") return false; + if (!sourceLines) return false; + const line = issue.location?.line; + if (!line) return false; + const sourceLine = sourceLines[line - 1] ?? ""; + const sourceHeader = sourceLines.slice(0, 30).join("\n"); + if ( + SATORI_TW_PROP_PATTERN.test(sourceLine) && + (OG_JSX_FILE_PATTERN.test(relativeFilePath) || IMAGE_RESPONSE_IMPORT_PATTERN.test(sourceHeader)) + ) { + return true; + } + if (EMOTION_CSS_PROP_PATTERN.test(sourceLine) && EMOTION_IMPORT_PATTERN.test(sourceHeader)) { + return true; + } + return sourceLine.includes(" string[] | undefined, + options?: FilterReactDoctorIssuesOptions, +): ReactDoctorIssue[] => { + const ignoredRules = new Set(config.ignore?.rules ?? []); + const ignoredFiles = config.ignore?.files ?? []; + const overrides = compileOverrides(config); + + const isNonReactJsxProject = + options?.jsxImportSource !== undefined && NON_REACT_JSX_SOURCES.has(options.jsxImportSource); + const nonReactJsxFileCache = new Map(); + const isNonReactJsxFile = (relPath: string): boolean => { + const cached = nonReactJsxFileCache.get(relPath); + if (cached !== undefined) return cached; + const lines = readSourceLines?.(relPath); + const isNonReact = Boolean( + lines && NON_REACT_JSX_IMPORT_PATTERN.test(lines.slice(0, 30).join("\n")), + ); + nonReactJsxFileCache.set(relPath, isNonReact); + return isNonReact; + }; + + const filtered = issues.filter((issue) => { + const relativeFilePath = toRelativeIssuePath(issue, rootDirectory); + if (isAutoSuppressedTestNoise(issue, relativeFilePath)) return false; + + const ruleId = normalizeRuleId(issue); + const unwrappedRuleId = toMetadataRuleKey(issue) ?? ruleId; + const sourceLines = relativeFilePath ? readSourceLines?.(relativeFilePath) : undefined; + if ( + REACT_BUILTIN_RULE_PREFIX.test(unwrappedRuleId) && + (isNonReactJsxProject || (relativeFilePath && isNonReactJsxFile(relativeFilePath))) + ) { + return false; + } + if ( + relativeFilePath && + isSuppressedUnknownPropertyIssue(issue, relativeFilePath, sourceLines) + ) { + return false; + } + if ( + unwrappedRuleId.startsWith(JSX_A11Y_RULE_PREFIX) && + relativeFilePath && + OG_IMAGE_FILE_PATTERN.test(relativeFilePath) + ) { + return false; + } + + if (matchesRule(issue, ignoredRules)) return false; + if ( + relativeFilePath && + ignoredFiles.some((pattern) => matchesPathPattern(relativeFilePath, pattern)) + ) { + return false; + } + if (isIgnoredByOverride(issue, relativeFilePath, overrides)) return false; + if ( + config.respectInlineDisables !== false && + relativeFilePath && + isDisabledByInlineComment(issue, sourceLines) + ) { + return false; + } + if (relativeFilePath && isSuppressedRnRawTextIssue(issue, config, sourceLines)) { + return false; + } + return true; + }); + + const seen = new Set(); + + return filtered.filter((issue) => { + const loc = issue.location; + if (!loc?.filePath || loc.line === undefined) return true; + + const unwrapped = toMetadataRuleKey(issue) ?? normalizeRuleId(issue); + const baseKey = `${loc.filePath}:${loc.line}`; + const dedupeKey = `${baseKey}:${unwrapped}`; + if (seen.has(dedupeKey)) return false; + seen.add(dedupeKey); + + const canonicalEffect = toCanonicalEffectKey(unwrapped); + if (canonicalEffect) { + const effectCanonKey = `${baseKey}:effect-canonical:${canonicalEffect}`; + if (seen.has(effectCanonKey)) return false; + seen.add(effectCanonKey); + } + + return true; + }); +}; diff --git a/packages/react-doctor/src/core/errors.ts b/packages/react-doctor/src/core/errors.ts new file mode 100644 index 0000000000..97b640569c --- /dev/null +++ b/packages/react-doctor/src/core/errors.ts @@ -0,0 +1,215 @@ +export interface ReactDoctorErrorInfo { + name: string; + message: string; + code: string; + cause?: ReactDoctorErrorInfo; +} + +export interface ReactDoctorErrorOptions extends ErrorOptions { + code?: string; +} + +export class ReactDoctorError extends Error { + override readonly name: string = "ReactDoctorError"; + readonly code: string; + + constructor(message: string, options: ReactDoctorErrorOptions = {}) { + super(message, options); + Object.setPrototypeOf(this, new.target.prototype); + this.code = options.code ?? "react-doctor/error"; + } +} + +export class ReactDoctorCancelledError extends ReactDoctorError { + override readonly name: string = "ReactDoctorCancelledError"; + + constructor(message = "React Doctor run was cancelled.", options?: ErrorOptions) { + super(message, { ...options, code: "react-doctor/cancelled" }); + } +} + +export class ReactDoctorConfigError extends ReactDoctorError { + override readonly name: string = "ReactDoctorConfigError"; + + constructor(message: string, options: ReactDoctorErrorOptions = {}) { + super(message, { ...options, code: options.code ?? "react-doctor/config-error" }); + } +} + +export class ReactDoctorConfigNotFoundError extends ReactDoctorConfigError { + override readonly name: string = "ReactDoctorConfigNotFoundError"; + + constructor(message = "React Doctor config was not found.", options?: ErrorOptions) { + super(message, { ...options, code: "react-doctor/config-not-found" }); + } +} + +export class ReactDoctorInvalidConfigError extends ReactDoctorConfigError { + override readonly name: string = "ReactDoctorInvalidConfigError"; + + constructor(message: string, options?: ErrorOptions) { + super(message, { ...options, code: "react-doctor/invalid-config" }); + } +} + +export class ReactDoctorProjectError extends ReactDoctorError { + override readonly name: string = "ReactDoctorProjectError"; + readonly rootDirectory: string; + + constructor(rootDirectory: string, message: string, options: ReactDoctorErrorOptions = {}) { + super(message, { ...options, code: options.code ?? "react-doctor/project-error" }); + this.rootDirectory = rootDirectory; + } +} + +export class ReactDoctorProjectNotFoundError extends ReactDoctorProjectError { + override readonly name: string = "ReactDoctorProjectNotFoundError"; + + constructor(rootDirectory: string, options?: ErrorOptions) { + super(rootDirectory, `No React project found in ${rootDirectory}.`, { + ...options, + code: "react-doctor/project-not-found", + }); + } +} + +export class ReactDoctorPackageJsonNotFoundError extends ReactDoctorProjectError { + override readonly name: string = "ReactDoctorPackageJsonNotFoundError"; + + constructor(rootDirectory: string, options?: ErrorOptions) { + super(rootDirectory, `No package.json found in ${rootDirectory}.`, { + ...options, + code: "react-doctor/package-json-not-found", + }); + } +} + +export class ReactDoctorNoReactDependencyError extends ReactDoctorProjectError { + override readonly name: string = "ReactDoctorNoReactDependencyError"; + + constructor(rootDirectory: string, options?: ErrorOptions) { + super(rootDirectory, `No React dependency found in ${rootDirectory}.`, { + ...options, + code: "react-doctor/no-react-dependency", + }); + } +} + +export class ReactDoctorAmbiguousProjectError extends ReactDoctorProjectError { + override readonly name: string = "ReactDoctorAmbiguousProjectError"; + readonly candidates: readonly string[]; + + constructor(rootDirectory: string, candidates: readonly string[], options?: ErrorOptions) { + super( + rootDirectory, + `Multiple React projects found in ${rootDirectory}: ${candidates.join(", ")}.`, + { ...options, code: "react-doctor/ambiguous-project" }, + ); + this.candidates = candidates; + } +} + +export class ReactDoctorCheckError extends ReactDoctorError { + override readonly name: string = "ReactDoctorCheckError"; + readonly checkId: string; + + constructor(checkId: string, message: string, options: ReactDoctorErrorOptions = {}) { + super(message, { ...options, code: options.code ?? "react-doctor/check-error" }); + this.checkId = checkId; + } +} + +export class ReactDoctorCheckFailedError extends ReactDoctorCheckError { + override readonly name: string = "ReactDoctorCheckFailedError"; + + constructor(checkId: string, message: string, options?: ErrorOptions) { + super(checkId, message, { ...options, code: "react-doctor/check-failed" }); + } +} + +export class ReactDoctorCheckSkippedError extends ReactDoctorCheckError { + override readonly name: string = "ReactDoctorCheckSkippedError"; + + constructor(checkId: string, message: string, options?: ErrorOptions) { + super(checkId, message, { ...options, code: "react-doctor/check-skipped" }); + } +} + +export class ReactDoctorRunnerUnavailableError extends ReactDoctorCheckError { + override readonly name: string = "ReactDoctorRunnerUnavailableError"; + + constructor(checkId: string, message: string, options?: ErrorOptions) { + super(checkId, message, { ...options, code: "react-doctor/runner-unavailable" }); + } +} + +export class ReactDoctorUnsupportedRuntimeError extends ReactDoctorError { + override readonly name: string = "ReactDoctorUnsupportedRuntimeError"; + + constructor(message: string, options?: ErrorOptions) { + super(message, { ...options, code: "react-doctor/unsupported-runtime" }); + } +} + +export class ReactDoctorTimeoutError extends ReactDoctorError { + override readonly name: string = "ReactDoctorTimeoutError"; + + constructor(message: string, options?: ErrorOptions) { + super(message, { ...options, code: "react-doctor/timeout" }); + } +} + +export class ReactDoctorReportError extends ReactDoctorError { + override readonly name: string = "ReactDoctorReportError"; + + constructor(message: string, options?: ErrorOptions) { + super(message, { ...options, code: "react-doctor/report-error" }); + } +} + +export const isReactDoctorError = (value: unknown): value is ReactDoctorError => + value instanceof ReactDoctorError; + +const toReactDoctorErrorInfoWithVisited = ( + error: unknown, + visited: Set, +): ReactDoctorErrorInfo => { + if (visited.has(error)) { + return { + name: "Error", + message: "Cycle detected in error cause chain.", + code: "react-doctor/cause-cycle", + }; + } + if (error instanceof Error) visited.add(error); + + const toCause = (cause: unknown): ReactDoctorErrorInfo | undefined => + cause === undefined ? undefined : toReactDoctorErrorInfoWithVisited(cause, visited); + + if (error instanceof ReactDoctorError) { + return { + name: error.name, + message: error.message, + code: error.code, + cause: toCause(error.cause), + }; + } + + if (error instanceof Error) { + return { + name: error.name || "Error", + message: error.message || error.name || "Unknown error", + code: "react-doctor/unknown-error", + cause: toCause(error.cause), + }; + } + + return { + name: "Error", + message: String(error), + code: "react-doctor/unknown-error", + }; +}; + +export const toReactDoctorErrorInfo = (error: unknown): ReactDoctorErrorInfo => + toReactDoctorErrorInfoWithVisited(error, new Set()); diff --git a/packages/react-doctor/src/core/inspect-react-project.ts b/packages/react-doctor/src/core/inspect-react-project.ts new file mode 100644 index 0000000000..bc6cba5619 --- /dev/null +++ b/packages/react-doctor/src/core/inspect-react-project.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DEFAULT_DIRECTORY } from "../constants.js"; +import { filterReactDoctorIssues } from "./diagnostics.js"; +import { toReactDoctorErrorInfo } from "./errors.js"; +import { calculateReactDoctorScore } from "./reports.js"; +import { OXLINT_CHECK_ID, runOxlint } from "./runners/oxlint.js"; +import { loadReactDoctorConfig, resolveConfigRootDirectory } from "./config.js"; +import { discoverReactProject, toOxlintProjectInfo } from "./project.js"; +import { proxyFetch } from "./proxy-fetch.js"; +import { tryScoreFromApi } from "./try-score-from-api.js"; +import { createRuleRegistry } from "./rules/index.js"; +import { runCodebaseAnalysis } from "./rules/codebase/analyzer/index.js"; +import { + DEAD_CODE_RULE_ID, + DEPENDENCIES_RULE_ID, + REACT_ARCHITECTURE_RULE_ID, +} from "./rules/index.js"; +import type { + InspectReactProjectOptions, + LoadedReactDoctorConfig, + ReactDoctorCheckResult, + ReactDoctorConfig, + ReactDoctorIssue, + ReactDoctorResult, + ReactDoctorRuleSelection, +} from "./types.js"; + +const mergeConfig = ( + loadedConfig: LoadedReactDoctorConfig | null, + options: InspectReactProjectOptions, +): ReactDoctorConfig => ({ + ...loadedConfig?.config, + ...options.config, + lint: options.lint ?? options.config?.lint ?? loadedConfig?.config.lint, + deadCode: options.deadCode ?? options.config?.deadCode ?? loadedConfig?.config.deadCode, + customRulesOnly: + options.customRulesOnly ?? + options.config?.customRulesOnly ?? + loadedConfig?.config.customRulesOnly, + respectInlineDisables: + options.respectInlineDisables ?? + options.config?.respectInlineDisables ?? + loadedConfig?.config.respectInlineDisables, + offline: options.offline ?? options.config?.offline ?? loadedConfig?.config.offline, +}); + +const INLINE_CONFIG_SOURCE_PATH = ""; + +const toInlineLoadedConfig = ( + config: ReactDoctorConfig, + sourceDirectory: string, +): LoadedReactDoctorConfig => ({ + config, + sourceDirectory, + sourcePath: INLINE_CONFIG_SOURCE_PATH, +}); + +const withCallerRootDir = ( + loadedConfig: LoadedReactDoctorConfig, + callerRootDir: string, + callerSourceDirectory: string, +): LoadedReactDoctorConfig => ({ + ...loadedConfig, + // Caller-supplied rootDir is resolved relative to the caller's requested + // directory, not the disk config's source directory, so we re-anchor here. + sourceDirectory: callerSourceDirectory, + config: { ...loadedConfig.config, rootDir: callerRootDir }, +}); + +const getLoadedConfig = async ( + requestedRootDirectory: string, + options: InspectReactProjectOptions, +): Promise => { + if (options.loadedConfig !== undefined) return options.loadedConfig; + if (!options.config) return loadReactDoctorConfig(requestedRootDirectory); + if (!options.config.rootDir) return null; + // Caller passed rootDir programmatically: honor it directly rather than + // requiring a matching react-doctor.config.json on disk, but still pick up + // adjacent disk config when present so other keys (ignore, ignoredTags, ...) + // still apply. The caller's rootDir always wins over any rootDir already + // declared on disk. + const diskConfig = await loadReactDoctorConfig(requestedRootDirectory); + if (!diskConfig) return toInlineLoadedConfig(options.config, requestedRootDirectory); + return withCallerRootDir(diskConfig, options.config.rootDir, requestedRootDirectory); +}; + +const mergeRuleSelection = ( + selection: ReactDoctorRuleSelection | undefined, + config: ReactDoctorConfig, +): ReactDoctorRuleSelection => { + const enabledRuleIds = [...(selection?.enabledRuleIds ?? [])]; + if (config.deadCode) { + enabledRuleIds.push(DEAD_CODE_RULE_ID, DEPENDENCIES_RULE_ID, REACT_ARCHITECTURE_RULE_ID); + } + return { + enabledRuleIds, + disabledRuleIds: selection?.disabledRuleIds, + }; +}; + +const readSourceLines = (rootDirectory: string, filePath: string): string[] | undefined => { + try { + return fs.readFileSync(path.resolve(rootDirectory, filePath), "utf8").split(/\r?\n/); + } catch { + return undefined; + } +}; + +const readJsxImportSource = (rootDirectory: string): string | undefined => { + try { + const tsconfigPath = path.resolve(rootDirectory, "tsconfig.json"); + const raw = fs.readFileSync(tsconfigPath, "utf8"); + const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + const parsed = JSON.parse(cleaned); + return parsed?.compilerOptions?.jsxImportSource; + } catch { + return undefined; + } +}; + +const createOxlintCheck = async ( + rootDirectory: string, + config: ReactDoctorConfig, + options: InspectReactProjectOptions, + project: ReactDoctorResult["project"], +): Promise => { + if (config.lint !== true) return null; + + const startedMilliseconds = globalThis.performance.now(); + try { + const issues = await runOxlint({ + rootDirectory, + includePaths: options.includePaths, + excludePatterns: options.excludePatterns, + project: toOxlintProjectInfo(project), + customRulesOnly: config.customRulesOnly, + includeEcosystemRules: config.includeEcosystemRules, + adoptExistingLintConfig: config.adoptExistingLintConfig, + ignoredTags: config.ignoredTags ? new Set(config.ignoredTags) : undefined, + signal: options.signal, + }); + return { + id: OXLINT_CHECK_ID, + name: "Oxlint", + status: "completed", + issues, + durationMilliseconds: globalThis.performance.now() - startedMilliseconds, + }; + } catch (error) { + return { + id: OXLINT_CHECK_ID, + name: "Oxlint", + status: "failed", + issues: [], + durationMilliseconds: globalThis.performance.now() - startedMilliseconds, + error: toReactDoctorErrorInfo(error), + }; + } +}; + +const applyIssueFiltering = ( + checks: ReactDoctorCheckResult[], + filteredIssues: ReactDoctorIssue[], +): ReactDoctorCheckResult[] => { + const issueIds = new Set(filteredIssues.map((issue) => issue.id)); + return checks.map((check) => ({ + ...check, + issues: check.issues.filter((issue) => issueIds.has(issue.id)), + })); +}; + +export const inspectReactProjectCore = async ( + options: InspectReactProjectOptions = {}, +): Promise => { + options.signal?.throwIfAborted(); + + const startedAt = new Date(); + const startedMilliseconds = globalThis.performance.now(); + const requestedRootDirectory = path.resolve(options.rootDirectory ?? DEFAULT_DIRECTORY); + const loadedConfig = await getLoadedConfig(requestedRootDirectory, options); + const rootDirectory = await resolveConfigRootDirectory(loadedConfig, requestedRootDirectory); + const config = mergeConfig(loadedConfig, options); + const project = await discoverReactProject(rootDirectory); + + options.signal?.throwIfAborted(); + + const registry = createRuleRegistry(); + let codebaseAnalysisPromise: ReturnType | null = null; + const getCodebaseAnalysis = () => { + codebaseAnalysisPromise ??= runCodebaseAnalysis({ + rootDirectory, + includePaths: options.includePaths, + excludePatterns: options.excludePatterns, + signal: options.signal, + }); + return codebaseAnalysisPromise; + }; + const checks = await registry.runRules({ + rootDirectory, + includePaths: options.includePaths, + excludePatterns: options.excludePatterns, + selection: mergeRuleSelection(options.rules, config), + signal: options.signal, + getCodebaseAnalysis, + }); + const oxlintCheck = await createOxlintCheck(rootDirectory, config, options, project); + const allChecks = oxlintCheck ? [...checks, oxlintCheck] : checks; + const completedAt = new Date(); + const issues = filterReactDoctorIssues( + allChecks.flatMap((check) => check.issues), + config, + rootDirectory, + (filePath) => readSourceLines(rootDirectory, filePath), + { jsxImportSource: readJsxImportSource(rootDirectory) }, + ); + const filteredChecks = applyIssueFiltering(allChecks, issues); + const hasFailedChecks = filteredChecks.some((check) => check.status === "failed"); + const remoteScore = config.offline + ? null + : await tryScoreFromApi(issues, proxyFetch, { silent: options.silentLogs === true }); + const score = remoteScore ?? calculateReactDoctorScore(issues); + + return { + status: hasFailedChecks ? "completed-with-errors" : "completed", + project, + issues, + checks: filteredChecks, + score, + startedAt: startedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMilliseconds: globalThis.performance.now() - startedMilliseconds, + }; +}; diff --git a/packages/react-doctor/src/core/is-test-file-path.ts b/packages/react-doctor/src/core/is-test-file-path.ts new file mode 100644 index 0000000000..f0d241c689 --- /dev/null +++ b/packages/react-doctor/src/core/is-test-file-path.ts @@ -0,0 +1,8 @@ +const TEST_FILE_PATH_PATTERN = + /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\/|\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/; + +export const isTestFilePath = (relativePath: string): boolean => { + if (relativePath.length === 0) return false; + const forwardSlashed = relativePath.replaceAll("\\", "/"); + return TEST_FILE_PATH_PATTERN.test(forwardSlashed); +}; diff --git a/packages/react-doctor/src/core/issue-to-score-diagnostic.ts b/packages/react-doctor/src/core/issue-to-score-diagnostic.ts new file mode 100644 index 0000000000..30d868b062 --- /dev/null +++ b/packages/react-doctor/src/core/issue-to-score-diagnostic.ts @@ -0,0 +1,26 @@ +import type { ScoreDiagnostic } from "./score.js"; +import { getScoringPluginKey, getScoringRuleKey } from "./scoring-key.js"; +import type { ReactDoctorIssue } from "./types.js"; + +// Convert a `ReactDoctorIssue` to a `ScoreDiagnostic`, returning `null` +// for info-severity issues so they don't contribute to scoring. Shared +// between local scoring (`reports.ts`), the verbose CLI breakdown, and +// the remote scoring path so the three never drift. +export const issueToScoreDiagnostic = (issue: ReactDoctorIssue): ScoreDiagnostic | null => { + if (issue.severity === "info") return null; + return { + plugin: getScoringPluginKey(issue), + rule: getScoringRuleKey(issue), + category: issue.category, + severity: issue.severity === "error" ? "error" : "warning", + }; +}; + +export const collectScoreDiagnostics = (issues: ReactDoctorIssue[]): ScoreDiagnostic[] => { + const scoringDiagnostics: ScoreDiagnostic[] = []; + for (const issue of issues) { + const diagnostic = issueToScoreDiagnostic(issue); + if (diagnostic) scoringDiagnostics.push(diagnostic); + } + return scoringDiagnostics; +}; diff --git a/packages/react-doctor/src/core/project.ts b/packages/react-doctor/src/core/project.ts new file mode 100644 index 0000000000..28ee5d3fd8 --- /dev/null +++ b/packages/react-doctor/src/core/project.ts @@ -0,0 +1,642 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import path from "node:path"; +import { + IGNORED_DIRECTORY_NAMES, + PACKAGE_JSON_FILENAME, + SOURCE_FILE_EXTENSIONS, +} from "./rules/codebase/analyzer/constants.js"; +import { readPackageJson } from "./rules/codebase/analyzer/manifest.js"; +import type { PackageJsonObject } from "./rules/codebase/analyzer/index.js"; +import type { ReactDoctorOxlintFramework, ReactDoctorOxlintProjectInfo } from "./rules/index.js"; +import type { ReactProjectFramework, ReactProjectInfo } from "./types.js"; + +interface DependencyInfo { + reactVersion: string | null; + reactPeerDependencyRange: string | null; + tailwindVersion: string | null; + framework: ReactProjectFramework; + hasReactCompiler: boolean; + hasTanStackAI: boolean; + hasTanStackQuery: boolean; +} + +interface PackageInfo { + manifest: PackageJsonObject | null; + packageJsonPath: string | null; + catalogs: CatalogInfo; +} + +interface SourceFileInfo { + count: number; + hasTypeScript: boolean; +} + +interface CatalogInfo { + defaultVersions: Map; + groupedVersions: Map>; +} + +const FRAMEWORK_PACKAGES: Record = { + "@remix-run/react": "remix", + "@tanstack/react-start": "tanstack-start", + expo: "expo", + gatsby: "gatsby", + next: "nextjs", + "react-native": "react-native", + "react-scripts": "cra", + vite: "vite", +}; + +const REACT_COMPILER_PACKAGES: ReadonlySet = new Set([ + "babel-plugin-react-compiler", + "eslint-plugin-react-compiler", + "react-compiler-runtime", +]); + +const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = + /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/; +const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/; + +const NEXT_CONFIG_FILENAMES: ReadonlyArray = [ + "next.config.cjs", + "next.config.js", + "next.config.mjs", + "next.config.ts", +]; +const BABEL_CONFIG_FILENAMES: ReadonlyArray = [ + ".babelrc", + ".babelrc.json", + "babel.config.cjs", + "babel.config.js", + "babel.config.json", + "babel.config.mjs", +]; +const VITE_CONFIG_FILENAMES: ReadonlyArray = [ + "vite.config.cjs", + "vite.config.cts", + "vite.config.js", + "vite.config.mjs", + "vite.config.mts", + "vite.config.ts", + "vitest.config.js", + "vitest.config.ts", +]; +const EXPO_CONFIG_FILENAMES: ReadonlyArray = ["app.config.js", "app.config.ts", "app.json"]; + +const TANSTACK_AI_PACKAGES: ReadonlySet = new Set([ + "@tanstack/ai", + "@tanstack/ai-code-mode", +]); + +const TANSTACK_QUERY_PACKAGES: ReadonlySet = new Set([ + "@tanstack/query-core", + "@tanstack/react-query", + "react-query", +]); + +const SOURCE_FILE_EXTENSION_SET: ReadonlySet = new Set(SOURCE_FILE_EXTENSIONS); + +const createEmptyCatalogInfo = (): CatalogInfo => ({ + defaultVersions: new Map(), + groupedVersions: new Map(), +}); + +const isSourceFileName = (fileName: string): boolean => + SOURCE_FILE_EXTENSION_SET.has(path.extname(fileName)); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const addCatalogVersions = (target: Map, value: unknown): void => { + if (!isRecord(value)) return; + for (const [packageName, version] of Object.entries(value)) { + if (typeof version === "string") target.set(packageName, version); + } +}; + +const addGroupedCatalogVersions = (catalogs: CatalogInfo, value: unknown): void => { + if (!isRecord(value)) return; + for (const [catalogName, entries] of Object.entries(value)) { + const versions = catalogs.groupedVersions.get(catalogName) ?? new Map(); + addCatalogVersions(versions, entries); + catalogs.groupedVersions.set(catalogName, versions); + } +}; + +const mergeManifestCatalogs = (catalogs: CatalogInfo, manifest: PackageJsonObject | null): void => { + if (!manifest) return; + addCatalogVersions(catalogs.defaultVersions, manifest.catalog); + addGroupedCatalogVersions(catalogs, manifest.catalogs); + const workspaces: unknown = manifest.workspaces; + if (isRecord(workspaces)) { + addCatalogVersions(catalogs.defaultVersions, workspaces.catalog); + addGroupedCatalogVersions(catalogs, workspaces.catalogs); + } +}; + +const PNPM_WORKSPACE_FILENAME = "pnpm-workspace.yaml"; + +const stripYamlComment = (line: string): string => { + let quote: string | null = null; + for (let index = 0; index < line.length; index++) { + const character = line[index]; + if ((character === '"' || character === "'") && line[index - 1] !== "\\") { + quote = quote === character ? null : (quote ?? character); + } + if (character === "#" && !quote) return line.slice(0, index); + } + return line; +}; + +const stripYamlValue = (value: string): string => + stripYamlComment(value) + .trim() + .replace(/^["']|["']$/g, ""); + +interface PnpmWorkspaceFile { + patterns: string[]; + defaultCatalog: Map; + namedCatalogs: Map>; +} + +const parsePnpmWorkspaceFile = (content: string): PnpmWorkspaceFile => { + const result: PnpmWorkspaceFile = { + patterns: [], + defaultCatalog: new Map(), + namedCatalogs: new Map(), + }; + type Section = "none" | "packages" | "catalog" | "catalogs" | "named-catalog"; + let section: Section = "none"; + let currentCatalogName = ""; + + for (const rawLine of content.split("\n")) { + const line = stripYamlComment(rawLine); + if (line.trim().length === 0) continue; + const indent = line.length - line.trimStart().length; + const trimmed = line.trim(); + + if (indent === 0) { + if (trimmed === "packages:") { + section = "packages"; + continue; + } + if (trimmed === "catalog:") { + section = "catalog"; + continue; + } + if (trimmed === "catalogs:") { + section = "catalogs"; + continue; + } + // Flat-form list item ("packages:\n- apps/*") — stay in the + // current section instead of resetting. + if (trimmed.startsWith("-") && section === "packages") { + const pattern = stripYamlValue(trimmed.slice(1)); + if (pattern) result.patterns.push(pattern); + continue; + } + section = "none"; + continue; + } + + if (section === "packages") { + if (trimmed.startsWith("-")) { + const pattern = stripYamlValue(trimmed.slice(1)); + if (pattern) result.patterns.push(pattern); + } + continue; + } + + if (section === "catalog") { + const colonIndex = trimmed.indexOf(":"); + if (colonIndex > 0) { + const key = stripYamlValue(trimmed.slice(0, colonIndex)); + const value = stripYamlValue(trimmed.slice(colonIndex + 1)); + if (key && value) result.defaultCatalog.set(key, value); + } + continue; + } + + if (section === "catalogs") { + if (trimmed.endsWith(":") && !trimmed.includes(" ")) { + currentCatalogName = stripYamlValue(trimmed.slice(0, -1)); + result.namedCatalogs.set(currentCatalogName, new Map()); + section = "named-catalog"; + } + continue; + } + + if (section === "named-catalog") { + if (indent <= 2 && trimmed.endsWith(":") && !trimmed.includes(" ")) { + currentCatalogName = stripYamlValue(trimmed.slice(0, -1)); + result.namedCatalogs.set(currentCatalogName, new Map()); + continue; + } + const colonIndex = trimmed.indexOf(":"); + if (colonIndex > 0 && currentCatalogName) { + const key = stripYamlValue(trimmed.slice(0, colonIndex)); + const value = stripYamlValue(trimmed.slice(colonIndex + 1)); + if (key && value) { + const catalog = result.namedCatalogs.get(currentCatalogName); + if (catalog) catalog.set(key, value); + } + } + } + } + + return result; +}; + +const readPnpmWorkspaceFile = async (directory: string): Promise => { + try { + const content = await fs.readFile(path.join(directory, PNPM_WORKSPACE_FILENAME), "utf8"); + return parsePnpmWorkspaceFile(content); + } catch { + return null; + } +}; + +const mergePnpmWorkspaceCatalogs = (catalogs: CatalogInfo, file: PnpmWorkspaceFile): void => { + for (const [name, version] of file.defaultCatalog) { + catalogs.defaultVersions.set(name, version); + } + for (const [catalogName, entries] of file.namedCatalogs) { + const target = catalogs.groupedVersions.get(catalogName) ?? new Map(); + for (const [name, version] of entries) target.set(name, version); + catalogs.groupedVersions.set(catalogName, target); + } +}; + +const collectAncestorCatalogs = async (rootDirectory: string): Promise => { + const catalogs = createEmptyCatalogInfo(); + let currentDirectory = rootDirectory; + while (true) { + mergeManifestCatalogs(catalogs, await readPackageJson(currentDirectory)); + const pnpmFile = await readPnpmWorkspaceFile(currentDirectory); + if (pnpmFile) mergePnpmWorkspaceCatalogs(catalogs, pnpmFile); + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) return catalogs; + currentDirectory = parentDirectory; + } +}; + +const readNearestPackageInfo = async (rootDirectory: string): Promise => { + const catalogs = await collectAncestorCatalogs(rootDirectory); + let currentDirectory = rootDirectory; + while (true) { + const manifest = await readPackageJson(currentDirectory); + if (manifest) { + return { + manifest, + packageJsonPath: path.join(currentDirectory, PACKAGE_JSON_FILENAME), + catalogs, + }; + } + + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return { manifest: null, packageJsonPath: null, catalogs }; + } + currentDirectory = parentDirectory; + } +}; + +const collectDependencies = (manifest: PackageJsonObject | null): Map => + new Map( + [ + ...Object.entries(manifest?.peerDependencies ?? {}), + ...Object.entries(manifest?.dependencies ?? {}), + ...Object.entries(manifest?.devDependencies ?? {}), + ...Object.entries(manifest?.optionalDependencies ?? {}), + ].filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); + +const hasAnyDependency = ( + dependencies: ReadonlyMap, + packageNames: ReadonlySet, +): boolean => { + for (const packageName of packageNames) { + if (dependencies.has(packageName)) return true; + } + return false; +}; + +const hasReactCompilerDependency = (manifest: PackageJsonObject | null): boolean => + hasAnyDependency(collectDependencies(manifest), REACT_COMPILER_PACKAGES); + +const detectFramework = (dependencies: ReadonlyMap): ReactProjectFramework => { + for (const [packageName, framework] of Object.entries(FRAMEWORK_PACKAGES)) { + if (dependencies.has(packageName)) return framework; + } + return dependencies.has("react") ? "react" : "unknown"; +}; + +const toResolvedDependencyVersion = ( + packageName: string, + version: string | null | undefined, + catalogs: CatalogInfo, +): string | null => { + if (!version) return null; + if (version.startsWith("catalog:")) { + const catalogName = version.slice("catalog:".length); + if (!catalogName) return catalogs.defaultVersions.get(packageName) ?? null; + return catalogs.groupedVersions.get(catalogName)?.get(packageName) ?? null; + } + if (version.startsWith("workspace:")) return null; + return version; +}; + +export const parseReactMajorVersion = (version: string | null): number | null => { + if (!version) return null; + const match = version.match(/\d+/); + if (!match) return null; + return Number.parseInt(match[0], 10); +}; + +const getDependencyInfo = (packageInfo: PackageInfo): DependencyInfo => { + const { catalogs, manifest } = packageInfo; + const dependencies = collectDependencies(manifest); + const reactVersion = toResolvedDependencyVersion("react", dependencies.get("react"), catalogs); + return { + reactVersion, + reactPeerDependencyRange: + typeof manifest?.peerDependencies?.react === "string" + ? manifest.peerDependencies.react + : null, + tailwindVersion: toResolvedDependencyVersion( + "tailwindcss", + dependencies.get("tailwindcss"), + catalogs, + ), + framework: detectFramework(dependencies), + hasReactCompiler: hasReactCompilerDependency(manifest), + hasTanStackAI: hasAnyDependency(dependencies, TANSTACK_AI_PACKAGES), + hasTanStackQuery: hasAnyDependency(dependencies, TANSTACK_QUERY_PACKAGES), + }; +}; + +const readTextFile = async (filePath: string): Promise => { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return null; + } +}; + +const hasReactCompilerConfigText = (content: string): boolean => + REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || + REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content); + +const hasReactCompilerInConfigFiles = async ( + directory: string, + filenames: ReadonlyArray, +): Promise => { + for (const filename of filenames) { + const content = await readTextFile(path.join(directory, filename)); + if (content && hasReactCompilerConfigText(content)) return true; + } + return false; +}; + +const hasReactCompilerInLocalConfig = async (directory: string): Promise => + (await hasReactCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) || + (await hasReactCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) || + (await hasReactCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) || + (await hasReactCompilerInConfigFiles(directory, EXPO_CONFIG_FILENAMES)); + +const hasWorkspaceBoundary = (manifest: PackageJsonObject | null): boolean => + Boolean(manifest?.workspaces); + +const hasDirectoryEntry = async (directory: string, entryName: string): Promise => { + try { + await fs.access(path.join(directory, entryName)); + return true; + } catch { + return false; + } +}; + +const hasReactCompilerInAncestorPackage = async (rootDirectory: string): Promise => { + let currentDirectory = path.dirname(rootDirectory); + while (currentDirectory !== path.dirname(currentDirectory)) { + const manifest = await readPackageJson(currentDirectory); + if (hasReactCompilerDependency(manifest)) return true; + if (hasWorkspaceBoundary(manifest) || (await hasDirectoryEntry(currentDirectory, ".git"))) { + return false; + } + currentDirectory = path.dirname(currentDirectory); + } + return false; +}; + +const detectReactCompiler = async ( + rootDirectory: string, + manifest: PackageJsonObject | null, +): Promise => { + if (hasReactCompilerDependency(manifest)) return true; + if (await hasReactCompilerInLocalConfig(rootDirectory)) return true; + return hasReactCompilerInAncestorPackage(rootDirectory); +}; + +const collectSourceFileInfo = async (rootDirectory: string): Promise => { + const sourceFileInfo: SourceFileInfo = { + count: 0, + hasTypeScript: false, + }; + const directories = [rootDirectory]; + + while (directories.length > 0) { + const directory = directories.pop(); + if (!directory) continue; + + let entries: Dirent[]; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + if (!entry.name.startsWith(".") && !IGNORED_DIRECTORY_NAMES.has(entry.name)) { + directories.push(path.join(directory, entry.name)); + } + continue; + } + if (entry.isFile() && isSourceFileName(entry.name)) { + sourceFileInfo.count++; + sourceFileInfo.hasTypeScript ||= /\.(cts|mts|ts|tsx)$/.test(entry.name); + } + } + } + + return sourceFileInfo; +}; + +export const toOxlintProjectInfo = (project: ReactProjectInfo): ReactDoctorOxlintProjectInfo => { + const framework: ReactDoctorOxlintFramework = + project.framework === "nextjs" || + project.framework === "expo" || + project.framework === "react-native" || + project.framework === "tanstack-start" + ? project.framework + : "react"; + + return { + framework, + hasReactCompiler: project.hasReactCompiler, + hasTanStackAI: project.hasTanStackAI, + hasTanStackQuery: project.hasTanStackQuery, + hasTypeScript: project.hasTypeScript, + reactMajorVersion: project.reactMajorVersion, + reactPeerDependencyRange: project.reactPeerDependencyRange, + tailwindVersion: project.tailwindVersion, + }; +}; + +const hasFile = async (filePath: string): Promise => { + try { + const stats = await fs.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +}; + +const toNpmWorkspacePatterns = (manifest: PackageJsonObject | null): string[] => { + const workspaces: unknown = manifest?.workspaces; + if (!workspaces) return []; + if (Array.isArray(workspaces)) { + return workspaces.filter((value): value is string => typeof value === "string"); + } + if (isRecord(workspaces) && Array.isArray(workspaces.packages)) { + return workspaces.packages.filter((value): value is string => typeof value === "string"); + } + return []; +}; + +const isMonorepoRoot = async (directory: string): Promise => { + const manifest = await readPackageJson(directory); + if (toNpmWorkspacePatterns(manifest).length > 0) return true; + return hasFile(path.join(directory, PNPM_WORKSPACE_FILENAME)); +}; + +const findAncestorMonorepoRoot = async (startDirectory: string): Promise => { + let currentDirectory = path.dirname(startDirectory); + while (currentDirectory !== path.dirname(currentDirectory)) { + if (await isMonorepoRoot(currentDirectory)) return currentDirectory; + currentDirectory = path.dirname(currentDirectory); + } + return null; +}; + +const expandWorkspacePattern = async ( + rootDirectory: string, + pattern: string, +): Promise => { + // HACK: collapse "**" to "*" — sufficient for dependency lookup in + // the common (single-level) workspace layout. Deep nested workspaces + // are rare and finding tailwindcss in *any* workspace is enough. + const normalized = pattern.replace(/\*\*/g, "*"); + const wildcardIndex = normalized.indexOf("*"); + if (wildcardIndex < 0) { + const directory = path.resolve(rootDirectory, normalized); + return (await hasFile(path.join(directory, PACKAGE_JSON_FILENAME))) ? [directory] : []; + } + const prefix = normalized.slice(0, wildcardIndex).replace(/\/$/, ""); + const suffix = normalized.slice(wildcardIndex + 1).replace(/^\//, ""); + const baseDirectory = path.resolve(rootDirectory, prefix || "."); + let entries: Dirent[]; + try { + entries = await fs.readdir(baseDirectory, { withFileTypes: true }); + } catch { + return []; + } + const directories: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".") || IGNORED_DIRECTORY_NAMES.has(entry.name)) continue; + const candidate = path.join(baseDirectory, entry.name, suffix); + if (await hasFile(path.join(candidate, PACKAGE_JSON_FILENAME))) directories.push(candidate); + } + return directories; +}; + +const findTailwindcssInWorkspaces = async ( + monorepoRoot: string, + catalogs: CatalogInfo, +): Promise => { + const manifest = await readPackageJson(monorepoRoot); + const npmPatterns = toNpmWorkspacePatterns(manifest); + const pnpmFile = await readPnpmWorkspaceFile(monorepoRoot); + const pnpmPatterns = pnpmFile?.patterns ?? []; + const patterns = [...new Set([...npmPatterns, ...pnpmPatterns])].filter( + (entry) => !entry.startsWith("!"), + ); + + for (const pattern of patterns) { + const directories = await expandWorkspacePattern(monorepoRoot, pattern); + for (const directory of directories) { + const workspaceManifest = await readPackageJson(directory); + const dependencies = collectDependencies(workspaceManifest); + const resolved = toResolvedDependencyVersion( + "tailwindcss", + dependencies.get("tailwindcss"), + catalogs, + ); + if (resolved) return resolved; + } + } + return null; +}; + +export const discoverReactProject = async (rootDirectory: string): Promise => { + const resolvedRootDirectory = path.resolve(rootDirectory); + const packageInfo = await readNearestPackageInfo(resolvedRootDirectory); + const dependencyInfo = getDependencyInfo(packageInfo); + + let tailwindVersion = dependencyInfo.tailwindVersion; + if (!tailwindVersion && (await isMonorepoRoot(resolvedRootDirectory))) { + tailwindVersion = await findTailwindcssInWorkspaces( + resolvedRootDirectory, + packageInfo.catalogs, + ); + } + // HACK: leaf workspace inside a monorepo — walk up to the ancestor + // monorepo root and search its sibling workspaces for tailwindcss. + // Mirrors v1's findDependencyInfoFromMonorepoRoot. Trade-off: a leaf + // package with no Tailwind that lives inside a Tailwind-using + // monorepo will be (correctly) treated as Tailwind-capable, since the + // toolchain it ships against is Tailwind-flavoured. + if (!tailwindVersion) { + const ancestorMonorepoRoot = await findAncestorMonorepoRoot(resolvedRootDirectory); + if (ancestorMonorepoRoot) { + tailwindVersion = await findTailwindcssInWorkspaces( + ancestorMonorepoRoot, + packageInfo.catalogs, + ); + } + } + + const sourceFileInfo = await collectSourceFileInfo(resolvedRootDirectory); + const hasReactCompiler = + dependencyInfo.hasReactCompiler || + (await detectReactCompiler(resolvedRootDirectory, packageInfo.manifest)); + + return { + rootDirectory: resolvedRootDirectory, + projectName: packageInfo.manifest?.name ?? path.basename(resolvedRootDirectory), + packageJsonPath: packageInfo.packageJsonPath, + reactVersion: dependencyInfo.reactVersion, + reactMajorVersion: parseReactMajorVersion(dependencyInfo.reactVersion), + reactPeerDependencyRange: dependencyInfo.reactPeerDependencyRange, + tailwindVersion, + framework: dependencyInfo.framework, + hasTypeScript: sourceFileInfo.hasTypeScript, + hasReactCompiler, + hasTanStackAI: dependencyInfo.hasTanStackAI, + hasTanStackQuery: dependencyInfo.hasTanStackQuery, + sourceFileCount: sourceFileInfo.count, + }; +}; diff --git a/packages/react-doctor/src/utils/proxy-fetch.ts b/packages/react-doctor/src/core/proxy-fetch.ts similarity index 53% rename from packages/react-doctor/src/utils/proxy-fetch.ts rename to packages/react-doctor/src/core/proxy-fetch.ts index 62b91ec13c..d5fb93c2a3 100644 --- a/packages/react-doctor/src/utils/proxy-fetch.ts +++ b/packages/react-doctor/src/core/proxy-fetch.ts @@ -3,9 +3,13 @@ interface GlobalProcessLike { versions?: { node?: string }; } +const isGlobalProcessLike = (value: unknown): value is GlobalProcessLike => + typeof value === "object" && value !== null && "versions" in value; + const getGlobalProcess = (): GlobalProcessLike | undefined => { - const candidate = (globalThis as { process?: GlobalProcessLike }).process; - return candidate?.versions?.node ? candidate : undefined; + const candidate = Reflect.get(globalThis, "process"); + if (!isGlobalProcessLike(candidate)) return undefined; + return candidate.versions?.node ? candidate : undefined; }; const getProxyUrl = (): string | undefined => { @@ -14,7 +18,9 @@ const getProxyUrl = (): string | undefined => { return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy; }; -const createProxyDispatcher = async (proxyUrl: string): Promise => { +const dispatcherCache = new Map>(); + +const loadProxyDispatcher = async (proxyUrl: string): Promise => { try { // @ts-expect-error undici is bundled with Node.js 22+ but lacks standalone type declarations const { ProxyAgent } = await import("undici"); @@ -24,17 +30,21 @@ const createProxyDispatcher = async (proxyUrl: string): Promise = } }; -// HACK: Node.js's global fetch (undici) accepts `dispatcher` for proxy routing, -// which isn't part of the standard RequestInit type — extend it locally. +const getProxyDispatcher = (proxyUrl: string): Promise => { + const cached = dispatcherCache.get(proxyUrl); + if (cached) return cached; + const pending = loadProxyDispatcher(proxyUrl); + dispatcherCache.set(proxyUrl, pending); + return pending; +}; + interface ProxyFetchInit extends RequestInit { dispatcher?: object; } -// HACK: caller (tryScoreFromApi) is responsible for the timeout via init.signal — -// we don't double-apply one here. Our only contribution is the proxy dispatcher. export const proxyFetch: typeof fetch = async (url, init) => { const proxyUrl = getProxyUrl(); - const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null; + const dispatcher = proxyUrl ? await getProxyDispatcher(proxyUrl) : null; const fetchInit: ProxyFetchInit = { ...init, diff --git a/packages/react-doctor/src/core/reports.ts b/packages/react-doctor/src/core/reports.ts new file mode 100644 index 0000000000..4312835305 --- /dev/null +++ b/packages/react-doctor/src/core/reports.ts @@ -0,0 +1,42 @@ +import { collectScoreDiagnostics } from "./issue-to-score-diagnostic.js"; +import { calculateScore, getScoreLabel } from "./score.js"; +import type { + ReactDoctorIssue, + ReactDoctorJsonReport, + ReactDoctorJsonReportSummary, + ReactDoctorResult, + ReactDoctorScore, +} from "./types.js"; + +export const calculateReactDoctorScore = (issues: ReactDoctorIssue[]): ReactDoctorScore => { + const value = calculateScore(collectScoreDiagnostics(issues)); + return { value, label: getScoreLabel(value) }; +}; + +export const summarizeReactDoctorResult = ( + result: ReactDoctorResult, +): ReactDoctorJsonReportSummary => { + const affectedFiles = new Set( + result.issues.flatMap((issue) => (issue.location?.filePath ? [issue.location.filePath] : [])), + ); + return { + errorCount: result.issues.filter((issue) => issue.severity === "error").length, + warningCount: result.issues.filter((issue) => issue.severity === "warning").length, + affectedFileCount: affectedFiles.size, + totalIssueCount: result.issues.length, + score: result.score?.value ?? null, + scoreLabel: result.score?.label ?? null, + }; +}; + +export const buildReactDoctorJsonReport = (result: ReactDoctorResult): ReactDoctorJsonReport => ({ + schemaVersion: 1, + ok: result.status === "completed" && !result.issues.some((issue) => issue.severity === "error"), + project: result.project, + issues: result.issues, + checks: result.checks, + summary: summarizeReactDoctorResult(result), + startedAt: result.startedAt, + completedAt: result.completedAt, + durationMilliseconds: result.durationMilliseconds, +}); diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/config.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/config.ts new file mode 100644 index 0000000000..286e2d5307 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/config.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { DEFAULT_CONDITION_NAMES, DEFAULT_INCLUDE_PATHS } from "./constants.js"; +import type { CodebaseAnalysisConfig, CodebaseAnalysisOptions } from "./types.js"; + +export const createCodebaseAnalysisConfig = ( + options: CodebaseAnalysisOptions, +): CodebaseAnalysisConfig => ({ + rootDirectory: path.resolve(options.rootDirectory), + includePaths: options.includePaths?.length ? options.includePaths : DEFAULT_INCLUDE_PATHS, + excludePatterns: options.excludePatterns ?? [], + conditionNames: DEFAULT_CONDITION_NAMES, + production: false, +}); diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/constants.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/constants.ts new file mode 100644 index 0000000000..bd57b6f27f --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/constants.ts @@ -0,0 +1,203 @@ +export const SOURCE_FILE_EXTENSIONS = [ + ".js", + ".mjs", + ".cjs", + ".jsx", + ".ts", + ".tsx", + ".mts", + ".cts", +] as const; + +export const ASSET_FILE_EXTENSIONS = new Set([ + ".avif", + ".css", + ".gif", + ".jpeg", + ".jpg", + ".less", + ".module.css", + ".module.scss", + ".png", + ".sass", + ".scss", + ".svg", + ".webp", + ".woff", + ".woff2", +]); + +export const DECLARATION_FILE_EXTENSION = ".d.ts"; + +export const TYPESCRIPT_DECLARATION_EXTENSIONS = [".d.ts", ".d.mts", ".d.cts"]; + +export const IGNORED_DIRECTORY_NAMES = new Set([ + ".cache", + ".git", + ".next", + ".turbo", + "build", + "coverage", + "dist", + "node_modules", + "out", +]); + +export const PACKAGE_JSON_FILENAME = "package.json"; +export const REACT_CLIENT_DIRECTIVE = "use client"; +export const REACT_SERVER_DIRECTIVE = "use server"; +export const SERVER_ONLY_PACKAGE_NAME = "server-only"; +export const DEFINITELY_TYPED_SCOPE = "@types"; + +export const DEFAULT_CONDITION_NAMES = ["types", "import", "module", "browser", "node", "default"]; + +export const RESOLVE_EXTENSIONS = [ + ".tsx", + ".ts", + ".mts", + ".cts", + ".jsx", + ".js", + ".mjs", + ".cjs", + ".json", +]; + +export const BARREL_EXPORT_THRESHOLD_COUNT = 5; +export const BARREL_IMPORTER_THRESHOLD_COUNT = 3; +export const POSITION_BASE_OFFSET = 1; + +export const COMMON_ENTRY_STEMS = new Set(["App", "index", "main"]); + +export const FRAMEWORK_ROUTE_ENTRY_STEMS = new Set([ + "_app", + "_document", + "apple-icon", + "default", + "error", + "global-error", + "icon", + "layout", + "loading", + "manifest", + "not-found", + "opengraph-image", + "page", + "robots", + "route", + "sitemap", + "template", + "twitter-image", +]); + +export const TEST_ENTRY_MARKERS = [".test.", ".spec.", ".testcase.", ".stories.", ".story."]; + +// Top-level directories whose .ts/.js files are conventionally CLI scripts / +// build tooling entrypoints — they're invoked via `tsx`, `bun run`, `node`, +// or directly from package.json scripts. Files at the root of these +// directories register as `support` entrypoints so their dependency +// closures aren't reported as "Unused file" and their top-level exports +// (which aren't a public API) aren't reported as "Unused export". +export const SCRIPT_ENTRY_DIRECTORY_NAMES = new Set(["bin", "internal-tools", "scripts", "tools"]); + +export const SUPPORT_ENTRY_PATTERNS = [ + "**/*.eval.{js,jsx,ts,tsx}", + "evalite.config.{js,mjs,cjs,ts,mts,cts}", + // Build / lint / DB / styling / instrumentation config files. Conventional + // root-level (or src/) configs that frameworks / tools load via the CLI; + // the project module graph can't see those imports, so flag them as + // support entrypoints. + "{src/,}*.config.{js,jsx,mjs,cjs,ts,tsx,mts,cts}", + "sentry.*.config.{js,mjs,cjs,ts,mts,cts}", + "{src/,}instrumentation.{js,ts}", + "{src/,}instrumentation-client.{js,ts}", + "{src/,}middleware.{js,ts}", +]; + +export const WHOLE_OBJECT_MEMBER_METHODS = new Set([ + "entries", + "getOwnPropertyNames", + "keys", + "values", +]); + +export const CHILD_PROCESS_ENTRY_METHODS = new Set(["execFile", "fork", "spawn"]); +export const CHILD_PROCESS_MODULE_SPECIFIERS = new Set(["child_process", "node:child_process"]); +export const NODE_MODULE_SPECIFIERS = new Set(["module", "node:module"]); +export const PATH_MODULE_SPECIFIERS = new Set(["node:path", "path"]); +export const PATH_ENTRY_HELPER_METHODS = new Set(["join", "resolve"]); +export const WORKER_THREADS_MODULE_SPECIFIERS = new Set(["node:worker_threads", "worker_threads"]); + +export const PUBLIC_VISIBILITY_TAGS = new Set(["public", "alpha", "beta"]); +export const INTERNAL_VISIBILITY_TAG = "internal"; +export const EXPECTED_UNUSED_VISIBILITY_TAG = "expected-unused"; + +export const CODEBASE_RULE_CATEGORY = "codebase"; + +export const DEAD_CODE_CHECK_ID = "react-doctor/codebase/dead-code"; +export const REACT_ARCHITECTURE_CHECK_ID = "react-doctor/codebase/react-architecture"; +export const DEPENDENCIES_CHECK_ID = "react-doctor/codebase/dependencies"; + +export const SOURCE_ENTRY_FIELDS = ["source", "main", "module", "browser", "types", "typings"]; + +export const MANIFEST_CONFIG_DEPENDENCY_FIELDS = [ + "babel", + "commitlint", + "eslintConfig", + "jest", + "lint-staged", + "prettier", + "release", + "semantic-release", + "simple-git-hooks", + "vitest", +]; + +export const SCRIPT_COMMAND_SEPARATORS = new Set(["&&", "||", ";", "|"]); + +export const SCRIPT_IGNORED_COMMANDS = new Set([ + "bun", + "cd", + "echo", + "exit", + "export", + "mkdir", + "node", + "npm", + "pnpm", + "rm", + "yarn", +]); + +export const SCRIPT_WRAPPER_COMMANDS = new Set(["cross-env", "dotenv", "env-cmd"]); + +export const SCRIPT_RUNNER_COMMANDS = new Set(["bunx", "npx"]); + +export const SCRIPT_PACKAGE_MANAGER_RUNNER_SUBCOMMANDS: Record> = { + bun: new Set(["x"]), + npm: new Set(["exec", "x"]), + pnpm: new Set(["dlx", "exec"]), + yarn: new Set(["dlx", "exec"]), +}; + +export const SCRIPT_BINARY_PACKAGE_NAME_ALIASES: Record = { + eslint: ["eslint"], + jest: ["jest"], + "lint-staged": ["lint-staged"], + next: ["next"], + playwright: ["@playwright/test", "playwright"], + prettier: ["prettier"], + "run-p": ["npm-run-all"], + "run-s": ["npm-run-all"], + storybook: ["storybook", "@storybook/react", "@storybook/nextjs"], + tsc: ["typescript"], + tsup: ["tsup"], + tsx: ["tsx"], + turbo: ["turbo"], + vite: ["vite"], + vitest: ["vitest"], +}; + +export const IGNORED_DEFINITELY_TYPED_PACKAGE_NAMES = new Set(["node", "bun", "jest"]); + +export const DEFAULT_INCLUDE_PATHS = ["."]; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/discovery.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/discovery.ts new file mode 100644 index 0000000000..27e99b093b --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/discovery.ts @@ -0,0 +1,165 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { IGNORED_DIRECTORY_NAMES } from "./constants.js"; +import { buildLineStarts, isSourceFilePath, matchesGlob, toRelativePath } from "./path-utils.js"; +import { findWorkspaceForFile } from "./workspace.js"; +import type { CodebaseAnalysisConfig, ProjectFile, WorkspaceInfo } from "./types.js"; + +interface GitignorePattern { + pattern: string; + isNegated: boolean; +} + +const shouldSkipDirectory = (directoryName: string): boolean => + IGNORED_DIRECTORY_NAMES.has(directoryName); + +const discoverSourceFilePaths = async ( + directoryPath: string, + config: CodebaseAnalysisConfig, + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); + let entries: Dirent[]; + try { + entries = await fs.readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } + + const filePaths: string[] = []; + for (const entry of entries) { + signal?.throwIfAborted(); + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + if (!shouldSkipDirectory(entry.name)) { + filePaths.push(...(await discoverSourceFilePaths(entryPath, config, signal))); + } + continue; + } + if (entry.isFile() && isSourceFilePath(entryPath)) { + filePaths.push(entryPath); + } + } + + return filePaths; +}; + +const patternToRegExp = (pattern: string): RegExp => { + const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`(^|/)${escapedPattern}($|/)`); +}; + +const matchesAnyPattern = (relativePath: string, patterns: string[]): boolean => + patterns.some((pattern) => patternToRegExp(pattern).test(relativePath)); + +const toExtendedGitignorePatterns = (pattern: string): string[] => { + if (pattern === "*" || pattern === "**" || pattern.endsWith("/*")) return [pattern]; + return [pattern, `${pattern}/**`]; +}; + +const toGitignorePattern = (line: string): GitignorePattern | null => { + const trimmedLine = line.trim(); + if (!trimmedLine || (trimmedLine.startsWith("#") && !trimmedLine.startsWith("\\#"))) return null; + const unescapedLine = trimmedLine.replace(/^\\(?=#)/, ""); + const isNegated = unescapedLine.startsWith("!"); + let pattern = isNegated ? unescapedLine.slice(1) : unescapedLine; + if (!pattern) return null; + if (pattern.endsWith("/")) pattern = pattern.slice(0, -1); + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } else if (!pattern.startsWith("**/")) { + pattern = `**/${pattern}`; + } + return { pattern, isNegated }; +}; + +const readGitignorePatterns = async (rootDirectory: string): Promise => { + try { + const sourceText = await fs.readFile(path.join(rootDirectory, ".gitignore"), "utf8"); + return sourceText + .split(/\r?\n/) + .map(toGitignorePattern) + .filter((pattern): pattern is GitignorePattern => Boolean(pattern)); + } catch { + return []; + } +}; + +const isGitignored = (relativePath: string, patterns: GitignorePattern[]): boolean => { + let isIgnored = false; + for (const item of patterns) { + if ( + toExtendedGitignorePatterns(item.pattern).some((pattern) => + matchesGlob(relativePath, pattern), + ) + ) { + isIgnored = !item.isNegated; + } + } + return isIgnored; +}; + +const isIncluded = (relativePath: string, includePaths: string[]): boolean => + includePaths.some((includePath) => { + if (includePath === ".") return true; + return ( + relativePath === includePath || relativePath.startsWith(`${includePath.replace(/\/$/, "")}/`) + ); + }); + +const isUnderDirectory = (filePath: string, directory: string): boolean => { + const relativePath = path.relative(directory, filePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +}; + +const isGeneratedOutputFile = (filePath: string, workspaces: WorkspaceInfo[]): boolean => + workspaces.some((workspace) => + workspace.sourceMaps.some( + (sourceMap) => + isUnderDirectory(filePath, sourceMap.outputDirectory) && + !isUnderDirectory(filePath, sourceMap.sourceDirectory), + ), + ); + +export const discoverSourceFiles = async ( + config: CodebaseAnalysisConfig, + workspaces: WorkspaceInfo[], + signal?: AbortSignal, +): Promise => { + const filePathSet = new Set(); + const gitignorePatterns = await readGitignorePatterns(config.rootDirectory); + for (const includePath of config.includePaths) { + const directoryPath = path.resolve(config.rootDirectory, includePath); + for (const filePath of await discoverSourceFilePaths(directoryPath, config, signal)) { + const relativePath = toRelativePath(config.rootDirectory, filePath); + if ( + isIncluded(relativePath, config.includePaths) && + !isGitignored(relativePath, gitignorePatterns) && + !isGeneratedOutputFile(filePath, workspaces) && + !matchesAnyPattern(relativePath, config.excludePatterns) + ) { + filePathSet.add(filePath); + } + } + } + const filePaths = [...filePathSet].sort((first, second) => first.localeCompare(second)); + const sourceFiles: ProjectFile[] = []; + + for (const filePath of filePaths) { + signal?.throwIfAborted(); + const sourceText = await fs.readFile(filePath, "utf8"); + const workspace = findWorkspaceForFile(workspaces, filePath); + sourceFiles.push({ + id: sourceFiles.length, + filePath, + relativePath: toRelativePath(config.rootDirectory, filePath), + extension: path.extname(filePath), + sourceText, + workspaceId: workspace.id, + lineStarts: buildLineStarts(sourceText), + }); + } + + return sourceFiles; +}; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/entrypoints.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/entrypoints.ts new file mode 100644 index 0000000000..12f1dfae94 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/entrypoints.ts @@ -0,0 +1,295 @@ +import path from "node:path"; +import { + COMMON_ENTRY_STEMS, + FRAMEWORK_ROUTE_ENTRY_STEMS, + SCRIPT_ENTRY_DIRECTORY_NAMES, + SOURCE_FILE_EXTENSIONS, + SUPPORT_ENTRY_PATTERNS, + TEST_ENTRY_MARKERS, + TYPESCRIPT_DECLARATION_EXTENSIONS, +} from "./constants.js"; +import { + collectManifestEntrySpecifiers, + collectManifestSupportSpecifiers, + collectScriptFileEntryPaths, +} from "./manifest.js"; +import { getFileStem, matchesAnyGlob, toRelativePath } from "./path-utils.js"; +import type { + CodebaseAnalysisConfig, + EntryPoint, + EntryPointRole, + ProjectFile, + WorkspaceInfo, +} from "./types.js"; +import type { CodebasePluginResult } from "./plugins/types.js"; + +const toPathLookup = (files: ProjectFile[]): Map => + new Map(files.map((file) => [file.filePath, file])); + +const extensionCandidates = (specifier: string): string[] => { + const extension = path.extname(specifier); + if (extension) return [specifier]; + return [ + specifier, + ...SOURCE_FILE_EXTENSIONS.map((item) => `${specifier}${item}`), + ...SOURCE_FILE_EXTENSIONS.map((item) => path.join(specifier, `index${item}`)), + ]; +}; + +const SOURCE_EXTENSION_CANDIDATES: Record = { + ".cjs": [".cts", ".cjs", ".ts", ".js"], + ".js": [".ts", ".tsx", ".js", ".jsx"], + ".jsx": [".tsx", ".jsx"], + ".mjs": [".mts", ".mjs", ".ts", ".js"], +}; + +const isUnderDirectory = (filePath: string, directory: string): boolean => { + const relativePath = path.relative(directory, filePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +}; + +const toConfiguredSourceMappedPath = ( + filePath: string, + workspace: WorkspaceInfo, +): string | null => { + const sourceMap = [...workspace.sourceMaps] + .sort((first, second) => second.outputDirectory.length - first.outputDirectory.length) + .find((item) => isUnderDirectory(filePath, item.outputDirectory)); + if (!sourceMap) return null; + return path.join(sourceMap.sourceDirectory, path.relative(sourceMap.outputDirectory, filePath)); +}; + +const toConventionalSourceMappedPath = (filePath: string): string | null => + filePath.includes(`${path.sep}dist${path.sep}`) + ? filePath.replace(`${path.sep}dist${path.sep}`, `${path.sep}src${path.sep}`) + : null; + +const toSourceMappedPath = (filePath: string, workspace: WorkspaceInfo): string | null => + toConfiguredSourceMappedPath(filePath, workspace) ?? toConventionalSourceMappedPath(filePath); + +const toAlternativeSourcePaths = (filePath: string): string[] => { + const declarationExtension = TYPESCRIPT_DECLARATION_EXTENSIONS.find((extension) => + filePath.endsWith(extension), + ); + if (declarationExtension) { + const basePath = filePath.slice(0, -declarationExtension.length); + return [`${basePath}.mts`, `${basePath}.cts`, `${basePath}.ts`, `${basePath}.tsx`]; + } + + const extension = path.extname(filePath); + const sourceExtensions = SOURCE_EXTENSION_CANDIDATES[extension]; + if (!sourceExtensions) return []; + const basePath = filePath.slice(0, -extension.length); + return sourceExtensions.map((sourceExtension) => `${basePath}${sourceExtension}`); +}; + +const resolveEntrySpecifier = ( + config: CodebaseAnalysisConfig, + workspace: WorkspaceInfo, + filesByPath: ReadonlyMap, + specifier: string, +): ProjectFile | null => { + for (const candidate of extensionCandidates(specifier)) { + const absolutePath = path.resolve(workspace.directory, candidate); + const sourceMappedPath = toSourceMappedPath(absolutePath, workspace); + const candidatePaths = new Set([ + absolutePath, + ...toAlternativeSourcePaths(absolutePath), + ...(sourceMappedPath + ? [sourceMappedPath, ...toAlternativeSourcePaths(sourceMappedPath)] + : []), + ]); + for (const candidatePath of candidatePaths) { + const file = filesByPath.get(candidatePath); + if (file) return file; + } + } + const rootRelativePath = path.resolve(config.rootDirectory, specifier); + return filesByPath.get(rootRelativePath) ?? null; +}; + +const isConventionalRuntimeEntry = (relativePath: string): boolean => { + const fileStem = getFileStem(relativePath); + const pathParts = relativePath.split("/"); + const isTopLevelSourceEntry = + pathParts.length === 1 || (pathParts.length === 2 && pathParts[0] === "src"); + return ( + (COMMON_ENTRY_STEMS.has(fileStem) && isTopLevelSourceEntry) || + (FRAMEWORK_ROUTE_ENTRY_STEMS.has(fileStem) && + (pathParts.includes("app") || pathParts.includes("pages") || pathParts.includes("routes"))) + ); +}; + +// Top-level files inside conventional CLI script directories +// (`scripts/foo.ts`, `tools/foo.ts`, `internal-tools/foo.ts`, `bin/foo.ts`) +// and `internal-tools/foo/index.{ts,tsx,...}` are SUPPORT entrypoints +// (not runtime). They're invoked directly by `tsx` / `bun` / `node`, and +// their top-level exports aren't a public API — flagging them as +// `unused-export` is noise. Helper files in deeper folders like +// `scripts/_lib/` and `scripts/foo/helpers/` are NOT entries — they become +// reachable through the script files that import them. +const isScriptDirectoryEntry = (relativePath: string): boolean => { + const pathParts = relativePath.split("/"); + if (pathParts.length < 2) return false; + if (!SCRIPT_ENTRY_DIRECTORY_NAMES.has(pathParts[0])) return false; + if (pathParts.length === 2) return true; + if (pathParts.length !== 3) return false; + if (pathParts[1].startsWith("_")) return false; + const fileStem = getFileStem(pathParts[pathParts.length - 1]); + return fileStem === "index"; +}; + +const isTestEntry = (relativePath: string): boolean => + TEST_ENTRY_MARKERS.some((marker) => relativePath.includes(marker)); + +const isSupportEntry = (relativePath: string): boolean => + matchesAnyGlob(relativePath, SUPPORT_ENTRY_PATTERNS); + +const hasGlobSyntax = (value: string): boolean => value.includes("*") || value.includes("{"); + +const stripRelativePrefix = (value: string): string => value.replace(/^\.\//, ""); + +const JS_LEAF_EXTENSIONS: ReadonlyArray = [".cjs", ".js", ".jsx", ".mjs"]; +const DECLARATION_SOURCE_EXTENSIONS: ReadonlyArray = [".mts", ".cts", ".ts", ".tsx"]; + +const expandSourceExtensionGlob = (pattern: string): string[] => { + const declarationExtension = TYPESCRIPT_DECLARATION_EXTENSIONS.find((extension) => + pattern.endsWith(extension), + ); + if (declarationExtension) { + const basePattern = pattern.slice(0, -declarationExtension.length); + return [`${basePattern}{${DECLARATION_SOURCE_EXTENSIONS.join(",")}}`, pattern]; + } + const extension = path.extname(pattern); + if (!JS_LEAF_EXTENSIONS.includes(extension)) return [pattern]; + const basePattern = pattern.slice(0, -extension.length); + return [`${basePattern}{${SOURCE_FILE_EXTENSIONS.join(",")}}`, pattern]; +}; + +const toConventionalSourceMappedGlobPattern = (pattern: string): string | null => + pattern.startsWith("dist/") ? `src/${pattern.slice("dist/".length)}` : null; + +const toSourceMappedGlobPatterns = (entry: string, workspace: WorkspaceInfo): string[] => { + const normalizedEntry = stripRelativePrefix(entry); + const patterns = new Set(expandSourceExtensionGlob(normalizedEntry)); + const conventionalSourcePattern = toConventionalSourceMappedGlobPattern(normalizedEntry); + if (conventionalSourcePattern) { + for (const pattern of expandSourceExtensionGlob(conventionalSourcePattern)) { + patterns.add(pattern); + } + } + for (const sourceMap of workspace.sourceMaps) { + const outputDirectory = toRelativePath(workspace.directory, sourceMap.outputDirectory); + const sourceDirectory = toRelativePath(workspace.directory, sourceMap.sourceDirectory); + if (!normalizedEntry.startsWith(`${outputDirectory}/`)) continue; + const sourcePattern = `${sourceDirectory}/${normalizedEntry.slice(outputDirectory.length + 1)}`; + for (const pattern of expandSourceExtensionGlob(sourcePattern)) { + patterns.add(pattern); + } + } + return [...patterns]; +}; + +const matchesManifestEntryGlob = ( + workspaceRelativePath: string, + entry: string, + workspace: WorkspaceInfo, +): boolean => matchesAnyGlob(workspaceRelativePath, toSourceMappedGlobPatterns(entry, workspace)); + +const pushEntryPoint = ( + entryPoints: EntryPoint[], + file: ProjectFile | null, + role: EntryPointRole, + source: string, +): void => { + if (!file) return; + if ( + entryPoints.some( + (entryPoint) => + entryPoint.fileId === file.id && entryPoint.role === role && entryPoint.source === source, + ) + ) + return; + entryPoints.push({ fileId: file.id, role, source }); +}; + +export const discoverEntryPoints = ( + config: CodebaseAnalysisConfig, + workspaces: WorkspaceInfo[], + files: ProjectFile[], + pluginResults: ReadonlyMap, +): EntryPoint[] => { + const filesByPath = toPathLookup(files); + const entryPoints: EntryPoint[] = []; + + for (const workspace of workspaces) { + const manifestEntries = collectManifestEntrySpecifiers(workspace.manifest); + for (const entry of manifestEntries.filter((manifestEntry) => !hasGlobSyntax(manifestEntry))) { + pushEntryPoint( + entryPoints, + resolveEntrySpecifier(config, workspace, filesByPath, entry), + "runtime", + "package.json", + ); + } + for (const entry of collectScriptFileEntryPaths(workspace.manifest)) { + pushEntryPoint( + entryPoints, + resolveEntrySpecifier(config, workspace, filesByPath, entry), + "support", + "script-file", + ); + } + const manifestSupportEntries = collectManifestSupportSpecifiers(workspace.manifest); + for (const entry of manifestSupportEntries.filter( + (supportEntry) => !hasGlobSyntax(supportEntry), + )) { + pushEntryPoint( + entryPoints, + resolveEntrySpecifier(config, workspace, filesByPath, entry), + "support", + "package.json:sideEffects", + ); + } + + const workspaceFiles = files.filter((file) => file.workspaceId === workspace.id); + const pluginResult = pluginResults.get(workspace.id); + for (const file of workspaceFiles) { + const workspaceRelativePath = toRelativePath(workspace.directory, file.filePath); + for (const entry of manifestSupportEntries.filter(hasGlobSyntax)) { + if (matchesAnyGlob(workspaceRelativePath, [stripRelativePrefix(entry)])) { + pushEntryPoint(entryPoints, file, "support", "package.json:sideEffects"); + } + } + for (const entry of manifestEntries.filter(hasGlobSyntax)) { + if (matchesManifestEntryGlob(workspaceRelativePath, entry, workspace)) { + pushEntryPoint(entryPoints, file, "runtime", "package.json"); + } + } + for (const entryPattern of pluginResult?.entryPatterns ?? []) { + if (matchesAnyGlob(workspaceRelativePath, [entryPattern.pattern])) { + pushEntryPoint(entryPoints, file, entryPattern.role, "plugin"); + } + } + if (isConventionalRuntimeEntry(workspaceRelativePath)) { + pushEntryPoint(entryPoints, file, "runtime", "convention"); + } + if (isScriptDirectoryEntry(workspaceRelativePath)) { + pushEntryPoint(entryPoints, file, "support", "script-directory"); + } + if (isTestEntry(workspaceRelativePath)) { + pushEntryPoint(entryPoints, file, "test", "test-pattern"); + } + if (isSupportEntry(workspaceRelativePath)) { + pushEntryPoint(entryPoints, file, "support", "support-pattern"); + } + if (pluginResult && matchesAnyGlob(workspaceRelativePath, pluginResult.alwaysUsedPatterns)) { + pushEntryPoint(entryPoints, file, "support", "plugin-always-used"); + } + } + } + + return entryPoints.sort( + (first, second) => first.fileId - second.fileId || first.role.localeCompare(second.role), + ); +}; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/extract/index.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/extract/index.ts new file mode 100644 index 0000000000..50243eca32 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/extract/index.ts @@ -0,0 +1,2057 @@ +import { parseSync } from "oxc-parser"; +import type { StaticExportEntry, StaticImport, StaticImportEntry } from "oxc-parser"; +import { + CHILD_PROCESS_ENTRY_METHODS, + CHILD_PROCESS_MODULE_SPECIFIERS, + NODE_MODULE_SPECIFIERS, + PATH_ENTRY_HELPER_METHODS, + PATH_MODULE_SPECIFIERS, + REACT_CLIENT_DIRECTIVE, + REACT_SERVER_DIRECTIVE, + WHOLE_OBJECT_MEMBER_METHODS, + WORKER_THREADS_MODULE_SPECIFIERS, +} from "../constants.js"; +import { getSourcePositionFromLineStarts } from "../path-utils.js"; +import type { EsTreeNode } from "../../../lint/utils/es-tree-node.js"; +import { isAstNode } from "../../../lint/utils/is-ast-node.js"; +import { isNodeOfType } from "../../../lint/utils/is-node-of-type.js"; +import { walkAst } from "../../../lint/utils/walk-ast.js"; +import type { + CodebaseModule, + ContextImportOptions, + ExportMemberRecord, + ExportRecord, + ImportedBinding, + ImportRecord, + MemberObjectReference, + NamespaceLocalAlias, + NamespaceLocalObjectAlias, + NamespaceMemberReference, + NamespaceObjectAlias, + ProjectFile, + ShadowRange, +} from "../types.js"; + +interface CommentRecord { + value: string; + start: number; + end: number; +} + +interface CommonJsStarReExportRecord { + source: string; + start: number; + end: number; +} + +interface RuntimeEntryLocals { + childProcessMethodNames: Set; + childProcessNamespaceNames: Set; + nodeModuleNamespaceNames: Set; + nodeModuleRegisterNames: Set; + pathHelperMethodNames: Map; + pathNamespaceNames: Set; + shadowRangesByName: Map; + workerThreadConstructorNames: Set; + workerThreadNamespaceNames: Set; +} + +const isIdentifierWithName = (value: unknown): value is EsTreeNode & { name: string } => + isAstNode(value) && value.type === "Identifier" && typeof value.name === "string"; + +const getStringLiteralValue = (node: unknown): string | null => { + if (!isAstNode(node)) return null; + if ( + (node.type === "Literal" || node.type === "StringLiteral") && + typeof node.value === "string" + ) { + return node.value; + } + if (node.type === "TemplateLiteral" && Array.isArray(node.quasis) && node.quasis.length === 1) { + const quasi = node.quasis[0]; + if (isAstNode(quasi) && typeof quasi.value === "object" && quasi.value !== null) { + const value = quasi.value as { cooked?: unknown }; + return typeof value.cooked === "string" ? value.cooked : null; + } + } + return null; +}; + +const getNodeStart = (node: EsTreeNode): number => { + if (typeof node.start === "number") return node.start; + if (Array.isArray(node.range) && typeof node.range[0] === "number") return node.range[0]; + return 0; +}; + +const getNodeEnd = (node: EsTreeNode): number => { + if (typeof node.end === "number") return node.end; + if (Array.isArray(node.range) && typeof node.range[1] === "number") return node.range[1]; + return getNodeStart(node); +}; + +const collectBindingIdentifierNames = (node: unknown): string[] => { + if (!isAstNode(node)) return []; + if (isIdentifierWithName(node)) return [node.name]; + if (node.type === "ObjectPattern") { + return (node.properties ?? []).flatMap((property: unknown) => { + if (!isAstNode(property)) return []; + if (property.type === "Property") return collectBindingIdentifierNames(property.value); + if (property.type === "RestElement") return collectBindingIdentifierNames(property.argument); + return []; + }); + } + if (node.type === "ArrayPattern") { + return (node.elements ?? []).flatMap(collectBindingIdentifierNames); + } + if (node.type === "AssignmentPattern") { + return collectBindingIdentifierNames(node.left); + } + if (node.type === "RestElement") { + return collectBindingIdentifierNames(node.argument); + } + return []; +}; + +const findNearestScopeEnd = (node: EsTreeNode): number => { + let currentNode = node.parent; + while (currentNode) { + if ( + currentNode.type === "BlockStatement" || + currentNode.type === "Program" || + currentNode.type === "StaticBlock" + ) { + return getNodeEnd(currentNode); + } + currentNode = currentNode.parent; + } + return getNodeEnd(node); +}; + +const addShadowRange = ( + runtimeEntryLocals: RuntimeEntryLocals, + name: string, + range: ShadowRange, +): void => { + const ranges = runtimeEntryLocals.shadowRangesByName.get(name) ?? []; + ranges.push(range); + runtimeEntryLocals.shadowRangesByName.set(name, ranges); +}; + +const addUsedIdentifierRange = ( + rangesByName: Map, + name: string, + position: number, +): void => { + const ranges = rangesByName.get(name) ?? []; + ranges.push(position); + rangesByName.set(name, ranges); +}; + +const isRuntimeLocalShadowed = ( + runtimeEntryLocals: RuntimeEntryLocals, + name: string, + position: number, +): boolean => + runtimeEntryLocals.shadowRangesByName + .get(name) + ?.some((range) => position >= range.start && position <= range.end) ?? false; + +const position = (file: ProjectFile, start: number) => + getSourcePositionFromLineStarts(file.lineStarts, start); + +const getDirectiveValue = (statement: unknown): string | null => { + if (!isAstNode(statement) || statement.type !== "ExpressionStatement") return null; + return getStringLiteralValue(statement.expression); +}; + +const collectDirectives = (program: EsTreeNode): Set => { + const directives = new Set(); + if (!Array.isArray(program.body)) return directives; + for (const statement of program.body) { + const directive = getDirectiveValue(statement); + if (!directive) break; + if (directive === REACT_CLIENT_DIRECTIVE || directive === REACT_SERVER_DIRECTIVE) { + directives.add(directive); + } + } + return directives; +}; + +const toImportedName = (entry: StaticImportEntry): string => { + if (entry.importName.kind === "Default") return "default"; + if (entry.importName.kind === "NamespaceObject") return "*"; + return entry.importName.name ?? entry.localName.value; +}; + +const toImportedBinding = (entry: StaticImportEntry): ImportedBinding => ({ + importedName: toImportedName(entry), + localName: entry.localName.value, + isTypeOnly: entry.isType, + isNamespace: entry.importName.kind === "NamespaceObject", + start: entry.localName.start, + end: entry.localName.end, +}); + +const createImportRecord = ( + file: ProjectFile, + source: string, + kind: ImportRecord["kind"], + bindings: ImportedBinding[], + start: number, + end: number, + isOptional = false, + context?: ContextImportOptions, + isTypeOnlyOverride?: boolean, +): ImportRecord => ({ + source, + bindings, + kind, + context, + isTypeOnly: + isTypeOnlyOverride ?? (bindings.length > 0 && bindings.every((binding) => binding.isTypeOnly)), + isSideEffectOnly: bindings.length === 0, + isOptional, + start, + end, + position: position(file, start), +}); + +const toPropertyName = (node: unknown): string | null => { + if (!isAstNode(node)) return null; + if (typeof node.name === "string") return node.name; + return getStringLiteralValue(node); +}; + +const toMemberExpressionPath = ( + node: EsTreeNode, +): { namespace: string; memberPath: string[] } | null => { + if (isIdentifierWithName(node)) return { namespace: node.name, memberPath: [] }; + if (node.type !== "MemberExpression" || !isAstNode(node.object) || !isAstNode(node.property)) { + return null; + } + const parentPath = toMemberExpressionPath(node.object); + if (!parentPath) return null; + const propertyName = + node.computed === true ? getStringLiteralValue(node.property) : toPropertyName(node.property); + if (!propertyName) return null; + return { + namespace: parentPath.namespace, + memberPath: [...parentPath.memberPath, propertyName], + }; +}; + +const toQualifiedNamePath = ( + node: EsTreeNode, +): { namespace: string; memberPath: string[] } | null => { + if (isIdentifierWithName(node)) return { namespace: node.name, memberPath: [] }; + if ( + node.type !== "TSQualifiedName" || + !isAstNode(node.left) || + !isIdentifierWithName(node.right) + ) { + return null; + } + const parentPath = toQualifiedNamePath(node.left); + if (!parentPath) return null; + return { + namespace: parentPath.namespace, + memberPath: [...parentPath.memberPath, node.right.name], + }; +}; + +const toRequireBinding = (property: EsTreeNode): ImportedBinding | null => { + const importedName = toPropertyName(property.key); + if (!importedName) return null; + const value = isAstNode(property.value) ? property.value : property.key; + if (isIdentifierWithName(value)) { + return { + importedName, + localName: value.name, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(value), + end: getNodeEnd(value), + }; + } + if (isAstNode(value) && value.type === "AssignmentPattern" && isIdentifierWithName(value.left)) { + return { + importedName, + localName: value.left.name, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(value.left), + end: getNodeEnd(value.left), + }; + } + return null; +}; + +const collectObjectPatternRequireBindings = (pattern: EsTreeNode): ImportedBinding[] => { + if (!Array.isArray(pattern.properties)) return []; + return pattern.properties + .filter( + (property): property is EsTreeNode => isAstNode(property) && property.type === "Property", + ) + .map(toRequireBinding) + .filter((binding): binding is ImportedBinding => Boolean(binding)); +}; + +const collectRequireBindings = (requireCall: EsTreeNode): ImportedBinding[] => { + const parent = requireCall.parent; + if (!parent) return []; + if ( + parent.type === "MemberExpression" && + parent.object === requireCall && + isAstNode(parent.property) + ) { + const grandparent = parent.parent; + const importedName = toPropertyName(parent.property); + if ( + importedName && + grandparent?.type === "VariableDeclarator" && + grandparent.init === parent && + isIdentifierWithName(grandparent.id) + ) { + return [ + { + importedName, + localName: grandparent.id.name, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(grandparent.id), + end: getNodeEnd(grandparent.id), + }, + ]; + } + } + if ( + parent.type !== "VariableDeclarator" || + parent.init !== requireCall || + !isAstNode(parent.id) + ) { + return []; + } + if (isIdentifierWithName(parent.id)) { + return [ + { + importedName: "*", + localName: parent.id.name, + isTypeOnly: false, + isNamespace: true, + start: getNodeStart(parent.id), + end: getNodeEnd(parent.id), + }, + ]; + } + if (parent.id.type === "ObjectPattern") { + return collectObjectPatternRequireBindings(parent.id); + } + return []; +}; + +const getImportUseExpression = (importCall: EsTreeNode): EsTreeNode => { + let expression = importCall; + let parent = expression.parent; + if (isAstNode(parent) && parent.type === "AwaitExpression" && parent.argument === expression) { + expression = parent; + parent = expression.parent; + } + while ( + isAstNode(parent) && + (parent.type === "ParenthesizedExpression" || parent.type === "ChainExpression") && + parent.expression === expression + ) { + expression = parent; + parent = expression.parent; + } + return expression; +}; + +const toDynamicImportThenBinding = ( + importedName: string, + localName: string, + declarationNode: EsTreeNode, + referenceNode: EsTreeNode, +): ImportedBinding => ({ + importedName, + localName, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(declarationNode), + end: getNodeEnd(declarationNode), + referenceStart: getNodeStart(referenceNode), +}); + +const collectDynamicImportThenBindings = (importUseExpression: EsTreeNode): ImportedBinding[] => { + const thenMemberExpression = importUseExpression.parent; + if ( + !isAstNode(thenMemberExpression) || + thenMemberExpression.type !== "MemberExpression" || + thenMemberExpression.object !== importUseExpression || + !isIdentifierWithName(thenMemberExpression.property) || + thenMemberExpression.property.name !== "then" + ) { + return []; + } + const thenCallExpression = thenMemberExpression.parent; + if ( + !isAstNode(thenCallExpression) || + thenCallExpression.type !== "CallExpression" || + thenCallExpression.callee !== thenMemberExpression || + !Array.isArray(thenCallExpression.arguments) + ) { + return []; + } + const callback = thenCallExpression.arguments[0]; + if ( + !isAstNode(callback) || + (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") || + !Array.isArray(callback.params) + ) { + return []; + } + const moduleParameter = callback.params[0]; + if (!isAstNode(moduleParameter)) return []; + if (moduleParameter.type === "ObjectPattern") { + return collectObjectPatternRequireBindings(moduleParameter); + } + if (!isIdentifierWithName(moduleParameter) || !isAstNode(callback.body)) return []; + const importedNamesByName = new Map(); + walkAst(callback.body, (node) => { + if (node.type !== "MemberExpression") return; + const memberExpressionPath = toMemberExpressionPath(node); + if ( + !memberExpressionPath || + memberExpressionPath.namespace !== moduleParameter.name || + memberExpressionPath.memberPath.length === 0 + ) { + return; + } + const importedName = memberExpressionPath.memberPath[0]; + if (importedName && !importedNamesByName.has(importedName)) { + importedNamesByName.set( + importedName, + toDynamicImportThenBinding(importedName, moduleParameter.name, moduleParameter, node), + ); + } + }); + return [...importedNamesByName.values()]; +}; + +const isPromiseAllCall = (node: EsTreeNode): boolean => + node.type === "CallExpression" && + isAstNode(node.callee) && + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + node.callee.object.name === "Promise" && + isIdentifierWithName(node.callee.property) && + node.callee.property.name === "all"; + +const collectDynamicImportPromiseAllBindings = ( + importUseExpression: EsTreeNode, +): ImportedBinding[] => { + const importElements = importUseExpression.parent; + if (!isAstNode(importElements) || importElements.type !== "ArrayExpression") return []; + const promiseAllCall = importElements.parent; + if (!isAstNode(promiseAllCall) || !isPromiseAllCall(promiseAllCall)) return []; + const awaitExpression = promiseAllCall.parent; + if ( + !isAstNode(awaitExpression) || + awaitExpression.type !== "AwaitExpression" || + awaitExpression.argument !== promiseAllCall + ) { + return []; + } + const declarator = awaitExpression.parent; + if ( + !isAstNode(declarator) || + declarator.type !== "VariableDeclarator" || + declarator.init !== awaitExpression || + !isAstNode(declarator.id) || + declarator.id.type !== "ArrayPattern" || + !Array.isArray(importElements.elements) || + !Array.isArray(declarator.id.elements) + ) { + return []; + } + const importIndex = importElements.elements.findIndex( + (element) => element === importUseExpression, + ); + const bindingElement = declarator.id.elements[importIndex]; + if (!isAstNode(bindingElement)) return []; + if (bindingElement.type === "ObjectPattern") { + return collectObjectPatternRequireBindings(bindingElement); + } + if (isIdentifierWithName(bindingElement)) { + return [ + { + importedName: "*", + localName: bindingElement.name, + isTypeOnly: false, + isNamespace: true, + start: getNodeStart(bindingElement), + end: getNodeEnd(bindingElement), + }, + ]; + } + return []; +}; + +const collectDynamicImportBindings = (importCall: EsTreeNode): ImportedBinding[] => { + const importUseExpression = getImportUseExpression(importCall); + const thenBindings = collectDynamicImportThenBindings(importUseExpression); + if (thenBindings.length > 0) return thenBindings; + const promiseAllBindings = collectDynamicImportPromiseAllBindings(importUseExpression); + if (promiseAllBindings.length > 0) return promiseAllBindings; + const parent = importUseExpression.parent; + if (!parent) return []; + if ( + parent.type === "MemberExpression" && + parent.object === importUseExpression && + isAstNode(parent.property) + ) { + const importedName = toPropertyName(parent.property); + if (!importedName) return []; + const declarator = parent.parent; + if ( + isNodeOfType(declarator, "VariableDeclarator") && + declarator.init === parent && + isIdentifierWithName(declarator.id) + ) { + return [ + { + importedName, + localName: declarator.id.name, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(declarator.id), + end: getNodeEnd(declarator.id), + referenceStart: getNodeStart(parent.property), + }, + ]; + } + return [ + { + importedName, + localName: importedName, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(parent.property), + end: getNodeEnd(parent.property), + }, + ]; + } + if ( + parent.type !== "VariableDeclarator" || + parent.init !== importUseExpression || + !isAstNode(parent.id) + ) { + return []; + } + if (isIdentifierWithName(parent.id)) { + return [ + { + importedName: "*", + localName: parent.id.name, + isTypeOnly: false, + isNamespace: true, + start: getNodeStart(parent.id), + end: getNodeEnd(parent.id), + }, + ]; + } + if (parent.id.type === "ObjectPattern") { + return collectObjectPatternRequireBindings(parent.id); + } + return []; +}; + +const getStringArrayLiteralValues = (node: unknown): string[] => { + const stringValue = getStringLiteralValue(node); + if (stringValue) return [stringValue]; + if (!isAstNode(node) || node.type !== "ArrayExpression" || !Array.isArray(node.elements)) { + return []; + } + return node.elements + .map(getStringLiteralValue) + .filter((value): value is string => Boolean(value)); +}; + +const getTemplateGlobValue = (node: unknown): string | null => { + if (!isAstNode(node) || node.type !== "TemplateLiteral" || !Array.isArray(node.quasis)) { + return null; + } + const parts = node.quasis.map((quasi) => { + if (!isAstNode(quasi) || typeof quasi.value !== "object" || quasi.value === null) { + return ""; + } + const value = quasi.value as { cooked?: unknown }; + return typeof value.cooked === "string" ? value.cooked : ""; + }); + if (parts.length < 2) return null; + return parts.reduce((pattern, part, index) => `${pattern}${index > 0 ? "*" : ""}${part}`, ""); +}; + +const getStringConcatenationGlobValue = (node: unknown): string | null => { + if (!isAstNode(node)) return null; + const literalValue = getStringLiteralValue(node); + if (literalValue !== null) return literalValue; + if (node.type !== "BinaryExpression" || node.operator !== "+") return "*"; + const leftValue = getStringConcatenationGlobValue(node.left); + const rightValue = getStringConcatenationGlobValue(node.right); + if (leftValue === null || rightValue === null) return null; + return `${leftValue}${rightValue}`; +}; + +const getDynamicImportGlobValue = (node: unknown): string | null => { + const templatePattern = getTemplateGlobValue(node); + if (templatePattern) return templatePattern; + const concatenationPattern = getStringConcatenationGlobValue(node); + if (!concatenationPattern || !concatenationPattern.includes("*")) return null; + return concatenationPattern; +}; + +const getBooleanLiteralValue = (node: unknown): boolean | null => { + if (!isAstNode(node) || node.type !== "Literal" || typeof node.value !== "boolean") return null; + return node.value; +}; + +const getRegexLiteral = ( + node: unknown, +): Pick => { + if (!isAstNode(node) || node.type !== "Literal") return {}; + const regex = node.regex; + if (!regex || typeof regex !== "object") return {}; + const pattern = + "pattern" in regex && typeof regex.pattern === "string" ? regex.pattern : undefined; + const flags = "flags" in regex && typeof regex.flags === "string" ? regex.flags : undefined; + return { + regexPattern: pattern, + regexFlags: flags, + }; +}; + +const isImportMetaGlobCall = (node: EsTreeNode): boolean => { + if (node.type !== "CallExpression" || !isAstNode(node.callee)) return false; + const callee = node.callee; + if ( + callee.type !== "MemberExpression" || + callee.computed === true || + !isAstNode(callee.object) || + !isAstNode(callee.property) + ) { + return false; + } + return ( + callee.object.type === "MetaProperty" && + isIdentifierWithName(callee.property) && + callee.property.name === "glob" + ); +}; + +const isRequireContextCall = (node: EsTreeNode): boolean => { + if (node.type !== "CallExpression" || !isAstNode(node.callee)) return false; + const callee = node.callee; + return ( + callee.type === "MemberExpression" && + callee.computed !== true && + isIdentifierWithName(callee.object) && + callee.object.name === "require" && + isIdentifierWithName(callee.property) && + callee.property.name === "context" + ); +}; + +const collectContextImportRecords = (file: ProjectFile, node: EsTreeNode): ImportRecord[] => { + if (!Array.isArray(node.arguments)) return []; + if (isImportMetaGlobCall(node)) { + return getStringArrayLiteralValues(node.arguments[0]).map((source) => + createImportRecord(file, source, "context", [], getNodeStart(node), getNodeEnd(node), false, { + kind: "glob", + }), + ); + } + if (!isRequireContextCall(node)) return []; + const source = getStringLiteralValue(node.arguments[0]); + if (!source) return []; + const recursive = getBooleanLiteralValue(node.arguments[1]) ?? true; + return [ + createImportRecord(file, source, "context", [], getNodeStart(node), getNodeEnd(node), false, { + kind: "require-context", + recursive, + ...getRegexLiteral(node.arguments[2]), + }), + ]; +}; + +const createDynamicImportRecord = ( + file: ProjectFile, + node: EsTreeNode, + sourceNode: unknown, +): ImportRecord | null => { + const source = getStringLiteralValue(sourceNode); + if (source) { + return createImportRecord( + file, + source, + "dynamic", + collectDynamicImportBindings(node), + getNodeStart(node), + getNodeEnd(node), + ); + } + const templatePattern = getDynamicImportGlobValue(sourceNode); + if (!templatePattern) return null; + return createImportRecord( + file, + templatePattern, + "context", + [], + getNodeStart(node), + getNodeEnd(node), + false, + { + kind: "glob", + }, + ); +}; + +const toStaticImportRecord = (file: ProjectFile, staticImport: StaticImport): ImportRecord => + createImportRecord( + file, + staticImport.moduleRequest.value, + "static", + staticImport.entries.map(toImportedBinding), + staticImport.start, + staticImport.end, + ); + +const toExportedName = (entry: StaticExportEntry): string => { + if (entry.exportName.kind === "Default") return "default"; + if (entry.exportName.kind === "None") return "*"; + return entry.exportName.name ?? "*"; +}; + +const toLocalName = (entry: StaticExportEntry): string | null => { + if (entry.localName.kind === "Default") return "default"; + return entry.localName.name; +}; + +const getExportKind = (entry: StaticExportEntry): ExportRecord["symbolKind"] => { + if (entry.isType) return "type"; + return "unknown"; +}; + +const isReactComponentLikeName = (name: string): boolean => { + const firstCharacter = name.at(0); + return Boolean(firstCharacter && firstCharacter.toUpperCase() === firstCharacter); +}; + +const collectJSDocTags = (comments: CommentRecord[], exportStart: number): Set => { + const precedingComment = [...comments] + .filter((comment) => comment.end <= exportStart) + .sort((first, second) => second.end - first.end)[0]; + if (!precedingComment || exportStart - precedingComment.end > 8) return new Set(); + const tags = [ + ...[...precedingComment.value.matchAll(/@([a-zA-Z][\w-]*)/g)].map((match) => match[1]), + ...[...precedingComment.value.matchAll(/@api\s+([a-zA-Z][\w-]*)/g)].map((match) => match[1]), + ].filter((tag): tag is string => Boolean(tag)); + return new Set(tags); +}; + +const toCommentImportedBinding = ( + importedName: string, + start: number, + end: number, +): ImportedBinding => ({ + importedName, + localName: importedName, + isTypeOnly: true, + isNamespace: false, + start, + end, +}); + +const collectCommentImportRecords = ( + file: ProjectFile, + comments: CommentRecord[], +): ImportRecord[] => { + const imports: ImportRecord[] = []; + for (const comment of comments) { + for (const match of comment.value.matchAll(/ { + const exportedName = toExportedName(entry); + const localName = toLocalName(entry); + return { + exportedName, + localName, + source: entry.moduleRequest?.value ?? null, + importedName: entry.importName.name ?? (entry.importName.kind === "AllButDefault" ? "*" : null), + symbolKind: getExportKind(entry), + isTypeOnly: entry.isType, + isReExport: Boolean(entry.moduleRequest), + isCommonJs: false, + isNamespace: entry.importName.kind === "All" || entry.importName.kind === "AllButDefault", + isReactComponentLike: isReactComponentLikeName(exportedName), + jsDocTags: collectJSDocTags(comments, entry.start), + members: [], + hasLocalReferences: false, + start: entry.start, + end: entry.end, + position: position(file, entry.start), + }; +}; + +const toReExportImportRecord = ( + file: ProjectFile, + entry: StaticExportEntry, +): ImportRecord | null => { + const source = entry.moduleRequest?.value; + if (!source) return null; + const exportedName = toExportedName(entry); + return createImportRecord( + file, + source, + "re-export", + [ + { + importedName: entry.importName.name ?? exportedName, + localName: exportedName, + isTypeOnly: entry.isType, + isNamespace: entry.importName.kind === "All" || entry.importName.kind === "AllButDefault", + start: entry.start, + end: entry.end, + }, + ], + entry.start, + entry.end, + ); +}; + +const isFunctionParameterDeclaration = (node: EsTreeNode): boolean => { + let currentNode = node; + let parent = node.parent; + while (parent) { + if ( + (isNodeOfType(parent, "FunctionDeclaration") || + isNodeOfType(parent, "FunctionExpression") || + isNodeOfType(parent, "ArrowFunctionExpression")) && + Array.isArray(parent.params) && + parent.params.includes(currentNode) + ) { + return true; + } + if ( + !isNodeOfType(parent, "ObjectPattern") && + !isNodeOfType(parent, "ArrayPattern") && + !isNodeOfType(parent, "AssignmentPattern") && + !isNodeOfType(parent, "RestElement") && + !isNodeOfType(parent, "Property") + ) { + return false; + } + currentNode = parent; + parent = parent.parent; + } + return false; +}; + +const isIdentifierDeclaration = (node: EsTreeNode): boolean => { + const parent = node.parent; + if (!parent) return false; + if (isNodeOfType(parent, "VariableDeclarator") && parent.id === node) return true; + if ( + (isNodeOfType(parent, "FunctionDeclaration") || isNodeOfType(parent, "ClassDeclaration")) && + parent.id === node + ) + return true; + if (isFunctionParameterDeclaration(node)) return true; + if ( + (isNodeOfType(parent, "TSTypeAliasDeclaration") || + isNodeOfType(parent, "TSInterfaceDeclaration")) && + parent.id === node + ) + return true; + if ( + isNodeOfType(parent, "MemberExpression") && + parent.property === node && + parent.computed !== true + ) { + return true; + } + if (isNodeOfType(parent, "Property") && parent.key === node && parent.computed !== true) { + if (isNodeOfType(parent.parent, "ObjectPattern")) return true; + return parent.shorthand !== true; + } + if ( + isNodeOfType(parent, "TSPropertySignature") && + parent.key === node && + parent.computed !== true + ) { + return true; + } + if ( + isNodeOfType(parent, "ImportSpecifier") || + isNodeOfType(parent, "ImportDefaultSpecifier") || + isNodeOfType(parent, "ImportNamespaceSpecifier") + ) + return true; + if (isNodeOfType(parent, "ExportSpecifier")) return true; + return false; +}; + +const isExportedVariableDeclarator = (node: EsTreeNode): boolean => + node.parent?.type === "VariableDeclaration" && + node.parent.parent?.type === "ExportNamedDeclaration"; + +const collectObjectNamespaceAliases = (node: EsTreeNode): NamespaceObjectAlias[] => { + if ( + node.type !== "VariableDeclarator" || + !isExportedVariableDeclarator(node) || + !isIdentifierWithName(node.id) || + !isAstNode(node.init) || + node.init.type !== "ObjectExpression" || + !Array.isArray(node.init.properties) + ) { + return []; + } + return node.init.properties.flatMap((property) => { + if (!isAstNode(property) || property.type !== "Property") return []; + const propertyName = toPropertyName(property.key); + const value = isAstNode(property.value) ? property.value : property.key; + if (!propertyName || !isIdentifierWithName(value)) return []; + return [ + { + exportName: node.id.name, + propertyName, + namespaceLocalName: value.name, + }, + ]; + }); +}; + +const collectNamespaceLocalAliases = (node: EsTreeNode): NamespaceLocalAlias[] => { + if ( + node.type !== "VariableDeclarator" || + !isIdentifierWithName(node.id) || + !isAstNode(node.init) + ) { + return []; + } + if (isIdentifierWithName(node.init)) { + return [ + { + aliasName: node.id.name, + namespaceLocalName: node.init.name, + start: getNodeStart(node.id), + end: getNodeEnd(node.id), + }, + ]; + } + if ( + node.init.type === "ConditionalExpression" && + isIdentifierWithName(node.init.consequent) && + isIdentifierWithName(node.init.alternate) + ) { + return [ + { + aliasName: node.id.name, + namespaceLocalName: node.init.consequent.name, + start: getNodeStart(node.id), + end: getNodeEnd(node.id), + }, + { + aliasName: node.id.name, + namespaceLocalName: node.init.alternate.name, + start: getNodeStart(node.id), + end: getNodeEnd(node.id), + }, + ]; + } + if (node.init.type === "ObjectExpression" && Array.isArray(node.init.properties)) { + return node.init.properties.flatMap((property) => { + if ( + !isAstNode(property) || + property.type !== "SpreadElement" || + !isIdentifierWithName(property.argument) + ) { + return []; + } + return [ + { + aliasName: node.id.name, + namespaceLocalName: property.argument.name, + start: getNodeStart(node.id), + end: getNodeEnd(node.id), + }, + ]; + }); + } + return []; +}; + +const collectNamespaceLocalObjectAliases = (node: EsTreeNode): NamespaceLocalObjectAlias[] => { + if ( + node.type !== "VariableDeclarator" || + !isIdentifierWithName(node.id) || + !isAstNode(node.init) || + node.init.type !== "ObjectExpression" || + !Array.isArray(node.init.properties) + ) { + return []; + } + return node.init.properties.flatMap((property) => { + if (!isAstNode(property) || property.type !== "Property") return []; + const propertyName = toPropertyName(property.key); + const value = isAstNode(property.value) ? property.value : property.key; + if (!propertyName || !isIdentifierWithName(value)) return []; + return [ + { + objectLocalName: node.id.name, + propertyName, + namespaceLocalName: value.name, + }, + ]; + }); +}; + +const collectDestructuredNamespaceReferences = (node: EsTreeNode): NamespaceMemberReference[] => { + if ( + node.type !== "VariableDeclarator" || + !isAstNode(node.id) || + node.id.type !== "ObjectPattern" || + !isAstNode(node.init) || + !Array.isArray(node.id.properties) + ) { + return []; + } + const initPath = toMemberExpressionPath(node.init); + if (!initPath) return []; + return node.id.properties.flatMap((property) => { + if (!isAstNode(property) || property.type !== "Property") return []; + const propertyName = toPropertyName(property.key); + if (!propertyName) return []; + return [ + { + namespace: initPath.namespace, + memberName: propertyName, + memberPath: [...initPath.memberPath, propertyName], + start: getNodeStart(property), + end: getNodeEnd(property), + }, + ]; + }); +}; + +const collectObjectExportNames = (node: unknown): string[] => { + if (!isAstNode(node) || node.type !== "ObjectExpression" || !Array.isArray(node.properties)) { + return []; + } + return node.properties.flatMap((property) => { + if (!isAstNode(property) || property.type !== "Property") return []; + const propertyName = toPropertyName(property.key); + return propertyName ? [propertyName] : []; + }); +}; + +const getRequireCallSource = (node: unknown): string | null => { + if ( + !isAstNode(node) || + node.type !== "CallExpression" || + !isIdentifierWithName(node.callee) || + node.callee.name !== "require" || + !Array.isArray(node.arguments) + ) { + return null; + } + return getStringLiteralValue(node.arguments[0]); +}; + +const createRuntimeEntryLocals = (): RuntimeEntryLocals => ({ + childProcessMethodNames: new Set(), + childProcessNamespaceNames: new Set(), + nodeModuleNamespaceNames: new Set(), + nodeModuleRegisterNames: new Set(), + pathHelperMethodNames: new Map(), + pathNamespaceNames: new Set(), + shadowRangesByName: new Map(), + workerThreadConstructorNames: new Set(), + workerThreadNamespaceNames: new Set(), +}); + +const addRuntimeImportDeclarationLocals = ( + node: EsTreeNode, + runtimeEntryLocals: RuntimeEntryLocals, +): void => { + if (node.type !== "ImportDeclaration" || !Array.isArray(node.specifiers)) return; + const source = getStringLiteralValue(node.source); + if (!source) return; + for (const specifier of node.specifiers) { + if (!isAstNode(specifier) || !isIdentifierWithName(specifier.local)) continue; + if (CHILD_PROCESS_MODULE_SPECIFIERS.has(source)) { + if (specifier.type === "ImportNamespaceSpecifier") { + runtimeEntryLocals.childProcessNamespaceNames.add(specifier.local.name); + } else if (specifier.type === "ImportSpecifier") { + const importedName = toPropertyName(specifier.imported); + if (importedName && CHILD_PROCESS_ENTRY_METHODS.has(importedName)) { + runtimeEntryLocals.childProcessMethodNames.add(specifier.local.name); + } + } + } + if (NODE_MODULE_SPECIFIERS.has(source)) { + if (specifier.type === "ImportNamespaceSpecifier") { + runtimeEntryLocals.nodeModuleNamespaceNames.add(specifier.local.name); + } else if (specifier.type === "ImportSpecifier") { + const importedName = toPropertyName(specifier.imported); + if (importedName === "register") { + runtimeEntryLocals.nodeModuleRegisterNames.add(specifier.local.name); + } + } + } + if (PATH_MODULE_SPECIFIERS.has(source)) { + if ( + specifier.type === "ImportNamespaceSpecifier" || + specifier.type === "ImportDefaultSpecifier" + ) { + runtimeEntryLocals.pathNamespaceNames.add(specifier.local.name); + } else if (specifier.type === "ImportSpecifier") { + const importedName = toPropertyName(specifier.imported); + if (importedName && PATH_ENTRY_HELPER_METHODS.has(importedName)) { + runtimeEntryLocals.pathHelperMethodNames.set(specifier.local.name, importedName); + } + } + } + if (WORKER_THREADS_MODULE_SPECIFIERS.has(source)) { + if (specifier.type === "ImportNamespaceSpecifier") { + runtimeEntryLocals.workerThreadNamespaceNames.add(specifier.local.name); + } else if (specifier.type === "ImportSpecifier") { + const importedName = toPropertyName(specifier.imported); + if (importedName === "Worker") { + runtimeEntryLocals.workerThreadConstructorNames.add(specifier.local.name); + } + } + } + } +}; + +const addRuntimeRequireLocals = ( + node: EsTreeNode, + runtimeEntryLocals: RuntimeEntryLocals, +): void => { + if (node.type !== "VariableDeclarator" || !isAstNode(node.id) || !isAstNode(node.init)) { + return; + } + const source = getRequireCallSource(node.init); + if (!source) return; + if (isIdentifierWithName(node.id)) { + if (CHILD_PROCESS_MODULE_SPECIFIERS.has(source)) { + runtimeEntryLocals.childProcessNamespaceNames.add(node.id.name); + } + if (NODE_MODULE_SPECIFIERS.has(source)) { + runtimeEntryLocals.nodeModuleNamespaceNames.add(node.id.name); + } + if (PATH_MODULE_SPECIFIERS.has(source)) { + runtimeEntryLocals.pathNamespaceNames.add(node.id.name); + } + if (WORKER_THREADS_MODULE_SPECIFIERS.has(source)) { + runtimeEntryLocals.workerThreadNamespaceNames.add(node.id.name); + } + return; + } + if (node.id.type !== "ObjectPattern") return; + for (const binding of collectObjectPatternRequireBindings(node.id)) { + if ( + CHILD_PROCESS_MODULE_SPECIFIERS.has(source) && + CHILD_PROCESS_ENTRY_METHODS.has(binding.importedName) + ) { + runtimeEntryLocals.childProcessMethodNames.add(binding.localName); + } + if (NODE_MODULE_SPECIFIERS.has(source) && binding.importedName === "register") { + runtimeEntryLocals.nodeModuleRegisterNames.add(binding.localName); + } + if (PATH_MODULE_SPECIFIERS.has(source) && PATH_ENTRY_HELPER_METHODS.has(binding.importedName)) { + runtimeEntryLocals.pathHelperMethodNames.set(binding.localName, binding.importedName); + } + if (WORKER_THREADS_MODULE_SPECIFIERS.has(source) && binding.importedName === "Worker") { + runtimeEntryLocals.workerThreadConstructorNames.add(binding.localName); + } + } +}; + +const getRuntimeRequireBindingNames = (node: EsTreeNode): Set => { + const bindingNames = new Set(); + if (node.type !== "VariableDeclarator" || !isAstNode(node.id) || !isAstNode(node.init)) { + return bindingNames; + } + const source = getRequireCallSource(node.init); + if ( + !source || + (!CHILD_PROCESS_MODULE_SPECIFIERS.has(source) && + !NODE_MODULE_SPECIFIERS.has(source) && + !PATH_MODULE_SPECIFIERS.has(source) && + !WORKER_THREADS_MODULE_SPECIFIERS.has(source)) + ) { + return bindingNames; + } + for (const bindingName of collectBindingIdentifierNames(node.id)) { + bindingNames.add(bindingName); + } + return bindingNames; +}; + +const addRuntimeShadowRanges = (node: EsTreeNode, runtimeEntryLocals: RuntimeEntryLocals): void => { + if ( + (node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression") && + isAstNode(node.body) + ) { + const range = { start: getNodeStart(node), end: getNodeEnd(node.body) }; + for (const bindingName of (node.params ?? []).flatMap(collectBindingIdentifierNames)) { + addShadowRange(runtimeEntryLocals, bindingName, range); + } + if ( + (node.type === "FunctionDeclaration" || node.type === "FunctionExpression") && + isIdentifierWithName(node.id) + ) { + addShadowRange(runtimeEntryLocals, node.id.name, range); + } + return; + } + if (node.type === "VariableDeclarator" && isAstNode(node.id)) { + const runtimeRequireBindingNames = getRuntimeRequireBindingNames(node); + const range = { start: getNodeStart(node), end: findNearestScopeEnd(node) }; + for (const bindingName of collectBindingIdentifierNames(node.id)) { + if (!runtimeRequireBindingNames.has(bindingName)) { + addShadowRange(runtimeEntryLocals, bindingName, range); + } + } + return; + } + if (node.type === "CatchClause" && isAstNode(node.param) && isAstNode(node.body)) { + const range = { start: getNodeStart(node), end: getNodeEnd(node.body) }; + for (const bindingName of collectBindingIdentifierNames(node.param)) { + addShadowRange(runtimeEntryLocals, bindingName, range); + } + } +}; + +const getInlineDirnamePath = ( + node: unknown, + runtimeEntryLocals: RuntimeEntryLocals, +): string | null => { + if ( + !isAstNode(node) || + node.type !== "CallExpression" || + !isAstNode(node.callee) || + !Array.isArray(node.arguments) || + node.arguments.length < 2 + ) { + return null; + } + let helperName: string | null = null; + if ( + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + runtimeEntryLocals.pathNamespaceNames.has(node.callee.object.name) && + !isRuntimeLocalShadowed( + runtimeEntryLocals, + node.callee.object.name, + getNodeStart(node.callee.object), + ) && + isIdentifierWithName(node.callee.property) && + PATH_ENTRY_HELPER_METHODS.has(node.callee.property.name) + ) { + helperName = node.callee.property.name; + } else if ( + isIdentifierWithName(node.callee) && + !isRuntimeLocalShadowed(runtimeEntryLocals, node.callee.name, getNodeStart(node.callee)) + ) { + helperName = runtimeEntryLocals.pathHelperMethodNames.get(node.callee.name) ?? null; + } + if (!helperName) return null; + const firstArgument = node.arguments[0]; + if (!isIdentifierWithName(firstArgument) || firstArgument.name !== "__dirname") return null; + const pathParts = node.arguments.slice(1).map(getStringLiteralValue); + if (pathParts.some((pathPart) => pathPart === null)) return null; + const joinedPath = pathParts.join("/").replace(/\/+/g, "/"); + return joinedPath.startsWith(".") || joinedPath.startsWith("/") ? joinedPath : `./${joinedPath}`; +}; + +const isImportMetaUrlExpression = (node: unknown): boolean => + isAstNode(node) && + node.type === "MemberExpression" && + isAstNode(node.object) && + node.object.type === "MetaProperty" && + isIdentifierWithName(node.property) && + node.property.name === "url"; + +const createEntryImportRecord = (file: ProjectFile, sourceNode: unknown): ImportRecord | null => { + if (!isAstNode(sourceNode)) return null; + const source = getStringLiteralValue(sourceNode); + if (!source) return null; + return createImportRecord( + file, + source, + "require-resolve", + [], + getNodeStart(sourceNode), + getNodeEnd(sourceNode), + ); +}; + +const collectResolverEntryImportRecords = ( + file: ProjectFile, + node: EsTreeNode, + runtimeEntryLocals: RuntimeEntryLocals, +): ImportRecord[] => { + if (node.type !== "CallExpression" || !isAstNode(node.callee) || !Array.isArray(node.arguments)) { + return []; + } + const firstArgument = node.arguments[0]; + if ( + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + node.callee.object.name === "require" && + !isRuntimeLocalShadowed(runtimeEntryLocals, "require", getNodeStart(node.callee.object)) && + isIdentifierWithName(node.callee.property) && + node.callee.property.name === "resolve" + ) { + const importRecord = createEntryImportRecord(file, firstArgument); + return importRecord ? [importRecord] : []; + } + if ( + node.callee.type === "MemberExpression" && + isAstNode(node.callee.object) && + node.callee.object.type === "MetaProperty" && + isIdentifierWithName(node.callee.property) && + node.callee.property.name === "resolve" + ) { + const importRecord = createEntryImportRecord(file, firstArgument); + return importRecord ? [importRecord] : []; + } + let isNodeModuleRegisterCall = false; + if ( + isIdentifierWithName(node.callee) && + runtimeEntryLocals.nodeModuleRegisterNames.has(node.callee.name) && + !isRuntimeLocalShadowed(runtimeEntryLocals, node.callee.name, getNodeStart(node.callee)) + ) { + isNodeModuleRegisterCall = true; + } else if ( + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + runtimeEntryLocals.nodeModuleNamespaceNames.has(node.callee.object.name) && + !isRuntimeLocalShadowed( + runtimeEntryLocals, + node.callee.object.name, + getNodeStart(node.callee.object), + ) && + isIdentifierWithName(node.callee.property) && + node.callee.property.name === "register" + ) { + isNodeModuleRegisterCall = true; + } + if (!isNodeModuleRegisterCall) return []; + const source = getStringLiteralValue(firstArgument); + const secondArgument = node.arguments[1]; + if (!source || (source.startsWith(".") && !isImportMetaUrlExpression(secondArgument))) { + return []; + } + const importRecord = createEntryImportRecord(file, firstArgument); + return importRecord ? [importRecord] : []; +}; + +const collectRuntimeEntryImportRecords = ( + file: ProjectFile, + node: EsTreeNode, + runtimeEntryLocals: RuntimeEntryLocals, +): ImportRecord[] => { + if (node.type !== "CallExpression" || !isAstNode(node.callee) || !Array.isArray(node.arguments)) { + return []; + } + let isChildProcessEntryCall = false; + if ( + isIdentifierWithName(node.callee) && + runtimeEntryLocals.childProcessMethodNames.has(node.callee.name) && + !isRuntimeLocalShadowed(runtimeEntryLocals, node.callee.name, getNodeStart(node.callee)) + ) { + isChildProcessEntryCall = true; + } else if ( + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + runtimeEntryLocals.childProcessNamespaceNames.has(node.callee.object.name) && + !isRuntimeLocalShadowed( + runtimeEntryLocals, + node.callee.object.name, + getNodeStart(node.callee.object), + ) && + isIdentifierWithName(node.callee.property) && + CHILD_PROCESS_ENTRY_METHODS.has(node.callee.property.name) + ) { + isChildProcessEntryCall = true; + } + if (!isChildProcessEntryCall) return []; + const source = getInlineDirnamePath(node.arguments[0], runtimeEntryLocals); + if (!source) return []; + return [ + createImportRecord( + file, + source, + "require-resolve", + [], + getNodeStart(node.arguments[0]), + getNodeEnd(node.arguments[0]), + ), + ]; +}; + +const collectWorkerThreadEntryImportRecords = ( + file: ProjectFile, + node: EsTreeNode, + runtimeEntryLocals: RuntimeEntryLocals, +): ImportRecord[] => { + if (node.type !== "NewExpression" || !isAstNode(node.callee) || !Array.isArray(node.arguments)) { + return []; + } + let isWorkerThreadConstructor = false; + if ( + isIdentifierWithName(node.callee) && + runtimeEntryLocals.workerThreadConstructorNames.has(node.callee.name) && + !isRuntimeLocalShadowed(runtimeEntryLocals, node.callee.name, getNodeStart(node.callee)) + ) { + isWorkerThreadConstructor = true; + } else if ( + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + runtimeEntryLocals.workerThreadNamespaceNames.has(node.callee.object.name) && + !isRuntimeLocalShadowed( + runtimeEntryLocals, + node.callee.object.name, + getNodeStart(node.callee.object), + ) && + isIdentifierWithName(node.callee.property) && + node.callee.property.name === "Worker" + ) { + isWorkerThreadConstructor = true; + } + if (!isWorkerThreadConstructor) return []; + const source = getInlineDirnamePath(node.arguments[0], runtimeEntryLocals); + if (!source) return []; + return [ + createImportRecord( + file, + source, + "require-resolve", + [], + getNodeStart(node.arguments[0]), + getNodeEnd(node.arguments[0]), + ), + ]; +}; + +const collectRequireSpreadSources = (node: unknown): CommonJsStarReExportRecord[] => { + if (!isAstNode(node) || node.type !== "ObjectExpression" || !Array.isArray(node.properties)) { + return []; + } + return node.properties.flatMap((property) => { + if (!isAstNode(property) || property.type !== "SpreadElement") return []; + const source = getRequireCallSource(property.argument); + return source ? [{ source, start: getNodeStart(property), end: getNodeEnd(property) }] : []; + }); +}; + +const collectCommonJsExportNames = (node: EsTreeNode): string[] => { + if (node.type !== "AssignmentExpression" || node.operator !== "=" || !isAstNode(node.left)) { + return []; + } + const leftPath = toMemberExpressionPath(node.left); + if (!leftPath) return []; + if (leftPath.namespace === "exports" && leftPath.memberPath.length === 1) { + const exportName = leftPath.memberPath[0]; + return exportName ? [exportName] : []; + } + if (leftPath.namespace === "module" && leftPath.memberPath[0] === "exports") { + if (leftPath.memberPath.length === 2) { + const exportName = leftPath.memberPath[1]; + return exportName ? [exportName] : []; + } + if (leftPath.memberPath.length === 1) { + const objectExportNames = collectObjectExportNames(node.right); + if (collectRequireSpreadSources(node.right).length > 0) return objectExportNames; + if (getRequireCallSource(node.right)) return []; + return objectExportNames.length > 0 ? objectExportNames : ["default"]; + } + } + return []; +}; + +const collectCommonJsStarReExports = (node: EsTreeNode): CommonJsStarReExportRecord[] => { + if (node.type !== "AssignmentExpression" || node.operator !== "=" || !isAstNode(node.left)) { + return []; + } + const leftPath = toMemberExpressionPath(node.left); + if ( + !leftPath || + leftPath.namespace !== "module" || + leftPath.memberPath[0] !== "exports" || + leftPath.memberPath.length !== 1 + ) { + return []; + } + const directSource = getRequireCallSource(node.right); + if (directSource) { + return [{ source: directSource, start: getNodeStart(node), end: getNodeEnd(node) }]; + } + return collectRequireSpreadSources(node.right); +}; + +const collectCommonJsReExportRecords = (file: ProjectFile, node: EsTreeNode): ImportRecord[] => { + if ( + node.type !== "AssignmentExpression" || + node.operator !== "=" || + !isAstNode(node.left) || + !isAstNode(node.right) + ) { + return []; + } + const leftPath = toMemberExpressionPath(node.left); + if ( + !leftPath || + leftPath.namespace !== "module" || + leftPath.memberPath[0] !== "exports" || + leftPath.memberPath.length !== 2 + ) { + return []; + } + const exportName = leftPath.memberPath[1]; + if ( + !exportName || + node.right.type !== "MemberExpression" || + !isAstNode(node.right.object) || + node.right.object.type !== "CallExpression" || + !isIdentifierWithName(node.right.object.callee) || + node.right.object.callee.name !== "require" || + !isAstNode(node.right.property) || + !Array.isArray(node.right.object.arguments) + ) { + return []; + } + const source = getStringLiteralValue(node.right.object.arguments[0]); + const importedName = toPropertyName(node.right.property); + if (!source || !importedName) return []; + return [ + createImportRecord( + file, + source, + "re-export", + [ + { + importedName, + localName: exportName, + isTypeOnly: false, + isNamespace: false, + start: getNodeStart(node.right.property), + end: getNodeEnd(node.right.property), + }, + ], + getNodeStart(node), + getNodeEnd(node), + ), + ]; +}; + +const toMemberObjectReference = (node: unknown): MemberObjectReference | null => { + if (!isAstNode(node)) return null; + const memberExpressionPath = toMemberExpressionPath(node); + return memberExpressionPath + ? { + namespace: memberExpressionPath.namespace, + memberPath: memberExpressionPath.memberPath, + start: getNodeStart(node), + end: getNodeEnd(node), + } + : null; +}; + +const collectWholeObjectMemberReferences = (node: EsTreeNode): MemberObjectReference[] => { + if ( + node.type === "CallExpression" && + isAstNode(node.callee) && + node.callee.type === "MemberExpression" && + isIdentifierWithName(node.callee.object) && + node.callee.object.name === "Object" && + isIdentifierWithName(node.callee.property) && + WHOLE_OBJECT_MEMBER_METHODS.has(node.callee.property.name) && + Array.isArray(node.arguments) + ) { + return node.arguments.flatMap((argument) => { + const reference = toMemberObjectReference(argument); + return reference ? [reference] : []; + }); + } + if (node.type === "SpreadElement") { + const reference = toMemberObjectReference(node.argument); + return reference ? [reference] : []; + } + return []; +}; + +const toTypeImportQualifierName = (node: unknown): string | null => { + if (!isAstNode(node)) return null; + if (isIdentifierWithName(node)) return node.name; + if (node.type !== "TSQualifiedName") return null; + return toTypeImportQualifierName(node.left); +}; + +const collectTypeImportRecords = (file: ProjectFile, node: EsTreeNode): ImportRecord[] => { + if (node.type !== "TSImportType") return []; + const source = getStringLiteralValue(node.source); + if (!source) return []; + const qualifierName = toTypeImportQualifierName(node.qualifier); + return [ + createImportRecord( + file, + source, + "comment", + qualifierName + ? [toCommentImportedBinding(qualifierName, getNodeStart(node), getNodeEnd(node))] + : [], + getNodeStart(node), + getNodeEnd(node), + false, + undefined, + true, + ), + ]; +}; + +const collectTypeScriptImportEqualsRecords = ( + file: ProjectFile, + node: EsTreeNode, +): ImportRecord[] => { + if ( + node.type !== "TSImportEqualsDeclaration" || + !isIdentifierWithName(node.id) || + !isAstNode(node.moduleReference) || + node.moduleReference.type !== "TSExternalModuleReference" + ) { + return []; + } + const source = getStringLiteralValue(node.moduleReference.expression); + if (!source) return []; + return [ + createImportRecord( + file, + source, + "require", + [ + { + importedName: "*", + localName: node.id.name, + isTypeOnly: node.importKind === "type", + isNamespace: true, + start: getNodeStart(node.id), + end: getNodeEnd(node.id), + }, + ], + getNodeStart(node), + getNodeEnd(node), + false, + undefined, + node.importKind === "type", + ), + ]; +}; + +const collectAstFacts = ( + file: ProjectFile, + program: EsTreeNode, +): { + imports: ImportRecord[]; + usedIdentifiers: Set; + usedIdentifierRanges: Map; + shadowRangesByName: Map; + namespaceMemberReferences: NamespaceMemberReference[]; + memberObjectReferences: MemberObjectReference[]; + namespaceObjectAliases: NamespaceObjectAlias[]; + namespaceLocalAliases: NamespaceLocalAlias[]; + namespaceLocalObjectAliases: NamespaceLocalObjectAlias[]; + cjsExportNames: Set; + cjsStarReExports: CommonJsStarReExportRecord[]; + membersByExportName: Map; +} => { + const imports: ImportRecord[] = []; + const usedIdentifiers = new Set(); + const usedIdentifierRanges = new Map(); + const namespaceMemberReferences: NamespaceMemberReference[] = []; + const memberObjectReferences: MemberObjectReference[] = []; + const namespaceObjectAliases: NamespaceObjectAlias[] = []; + const namespaceLocalAliases: NamespaceLocalAlias[] = []; + const namespaceLocalObjectAliases: NamespaceLocalObjectAlias[] = []; + const cjsExportNames = new Set(); + const cjsStarReExports: CommonJsStarReExportRecord[] = []; + const runtimeEntryLocals = createRuntimeEntryLocals(); + const membersByExportName = new Map(); + + walkAst(program, (node) => { + addRuntimeImportDeclarationLocals(node, runtimeEntryLocals); + addRuntimeShadowRanges(node, runtimeEntryLocals); + + if ( + node.type === "Identifier" && + typeof node.name === "string" && + !isIdentifierDeclaration(node) + ) { + usedIdentifiers.add(node.name); + addUsedIdentifierRange(usedIdentifierRanges, node.name, getNodeStart(node)); + } + + if (node.type === "JSXIdentifier" && typeof node.name === "string") { + usedIdentifiers.add(node.name); + addUsedIdentifierRange(usedIdentifierRanges, node.name, getNodeStart(node)); + } + + memberObjectReferences.push(...collectWholeObjectMemberReferences(node)); + imports.push(...collectTypeImportRecords(file, node)); + imports.push(...collectTypeScriptImportEqualsRecords(file, node)); + + if (node.type === "CallExpression" && isAstNode(node.callee)) { + imports.push(...collectResolverEntryImportRecords(file, node, runtimeEntryLocals)); + imports.push(...collectRuntimeEntryImportRecords(file, node, runtimeEntryLocals)); + imports.push(...collectContextImportRecords(file, node)); + if (node.callee.type === "Import" && Array.isArray(node.arguments)) { + const dynamicImportRecord = createDynamicImportRecord(file, node, node.arguments[0]); + if (dynamicImportRecord) imports.push(dynamicImportRecord); + } + if ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + Array.isArray(node.arguments) + ) { + const source = getStringLiteralValue(node.arguments[0]); + if (source) + imports.push( + createImportRecord( + file, + source, + "require", + collectRequireBindings(node), + getNodeStart(node), + getNodeEnd(node), + ), + ); + } + } + + if (node.type === "ImportExpression") { + const dynamicImportRecord = createDynamicImportRecord(file, node, node.source); + if (dynamicImportRecord) imports.push(dynamicImportRecord); + } + + if ( + node.type === "NewExpression" && + isAstNode(node.callee) && + node.callee.type === "Identifier" && + node.callee.name === "URL" && + Array.isArray(node.arguments) && + isImportMetaUrlExpression(node.arguments[1]) + ) { + const source = getStringLiteralValue(node.arguments[0]); + if (source) + imports.push( + createImportRecord(file, source, "asset", [], getNodeStart(node), getNodeEnd(node)), + ); + } + + imports.push(...collectWorkerThreadEntryImportRecords(file, node, runtimeEntryLocals)); + + if (node.type === "VariableDeclarator") { + addRuntimeRequireLocals(node, runtimeEntryLocals); + namespaceObjectAliases.push(...collectObjectNamespaceAliases(node)); + namespaceLocalAliases.push(...collectNamespaceLocalAliases(node)); + namespaceLocalObjectAliases.push(...collectNamespaceLocalObjectAliases(node)); + namespaceMemberReferences.push(...collectDestructuredNamespaceReferences(node)); + } + + if (node.type === "TSQualifiedName") { + const qualifiedNamePath = toQualifiedNamePath(node); + if (qualifiedNamePath && qualifiedNamePath.memberPath.length > 0) { + namespaceMemberReferences.push({ + namespace: qualifiedNamePath.namespace, + memberName: qualifiedNamePath.memberPath.at(-1) ?? "", + memberPath: qualifiedNamePath.memberPath, + start: getNodeStart(node), + end: getNodeEnd(node), + }); + } + } + + if (node.type === "AssignmentExpression") { + imports.push(...collectCommonJsReExportRecords(file, node)); + cjsStarReExports.push(...collectCommonJsStarReExports(node)); + for (const exportName of collectCommonJsExportNames(node)) { + cjsExportNames.add(exportName); + } + } + + if (node.type === "MemberExpression" && isAstNode(node.object) && isAstNode(node.property)) { + const memberExpressionPath = toMemberExpressionPath(node); + if (memberExpressionPath && memberExpressionPath.memberPath.length > 0) { + namespaceMemberReferences.push({ + namespace: memberExpressionPath.namespace, + memberName: memberExpressionPath.memberPath.at(-1) ?? "", + memberPath: memberExpressionPath.memberPath, + start: getNodeStart(node), + end: getNodeEnd(node), + }); + if ( + memberExpressionPath.namespace === "exports" && + memberExpressionPath.memberPath.length === 1 + ) { + cjsExportNames.add(memberExpressionPath.memberPath[0] ?? ""); + } + } + } + + if ( + (node.type === "TSEnumDeclaration" || node.type === "ClassDeclaration") && + isAstNode(node.id) && + typeof node.id.name === "string" + ) { + const members: ExportMemberRecord[] = []; + const rawMembers = + node.type === "TSEnumDeclaration" && + isAstNode(node.body) && + Array.isArray(node.body.members) + ? node.body.members + : node.type === "ClassDeclaration" && + isAstNode(node.body) && + Array.isArray(node.body.body) + ? node.body.body + : []; + for (const member of rawMembers) { + if (!isAstNode(member)) continue; + if (node.type === "ClassDeclaration" && member.static !== true) continue; + const key = isAstNode(member.id) ? member.id : isAstNode(member.key) ? member.key : null; + const name = key && typeof key.name === "string" ? key.name : getStringLiteralValue(key); + if (name) { + members.push({ + name, + kind: node.type === "TSEnumDeclaration" ? "enum" : "class", + start: getNodeStart(member), + end: getNodeEnd(member), + position: position(file, getNodeStart(member)), + jsDocTags: new Set(), + hasLocalReferences: false, + }); + } + } + membersByExportName.set(node.id.name, members); + } + }); + + return { + imports, + usedIdentifiers, + usedIdentifierRanges, + shadowRangesByName: runtimeEntryLocals.shadowRangesByName, + namespaceMemberReferences, + memberObjectReferences, + namespaceObjectAliases, + namespaceLocalAliases, + namespaceLocalObjectAliases, + cjsExportNames, + cjsStarReExports, + membersByExportName, + }; +}; + +const enrichExportsFromAst = ( + exports: ExportRecord[], + membersByExportName: ReadonlyMap, + usedIdentifiers: ReadonlySet, +): ExportRecord[] => + exports.map((exportRecord) => { + const localName = exportRecord.localName ?? exportRecord.exportedName; + return { + ...exportRecord, + symbolKind: + exportRecord.symbolKind === "unknown" && membersByExportName.has(localName) + ? membersByExportName.get(localName)?.[0]?.kind === "enum" + ? "enum" + : "class" + : exportRecord.symbolKind, + members: membersByExportName.get(localName) ?? [], + hasLocalReferences: exportRecord.isCommonJs ? false : usedIdentifiers.has(localName), + }; + }); + +export const extractModule = (file: ProjectFile): CodebaseModule => { + const parseResult = parseSync(file.filePath, file.sourceText, { + sourceType: "unambiguous", + range: true, + }); + const comments = parseResult.comments as CommentRecord[]; + const program = parseResult.program as EsTreeNode; + const astFacts = collectAstFacts(file, program); + const commentImports = collectCommentImportRecords(file, comments); + const staticImports = parseResult.module.staticImports.map((staticImport) => + toStaticImportRecord(file, staticImport), + ); + const reExportImports = parseResult.module.staticExports + .flatMap((staticExport) => staticExport.entries) + .map((entry) => toReExportImportRecord(file, entry)) + .filter((importRecord): importRecord is ImportRecord => Boolean(importRecord)); + const rawExports = parseResult.module.staticExports + .flatMap((staticExport) => staticExport.entries) + .map((entry) => toExportRecord(file, entry, comments)); + for (const cjsExportName of astFacts.cjsExportNames) { + rawExports.push({ + exportedName: cjsExportName, + localName: cjsExportName, + source: null, + importedName: null, + symbolKind: "value", + isTypeOnly: false, + isReExport: false, + isCommonJs: true, + isNamespace: false, + isReactComponentLike: isReactComponentLikeName(cjsExportName), + jsDocTags: new Set(), + members: [], + hasLocalReferences: false, + start: 0, + end: 0, + position: { line: 1, column: 1 }, + }); + } + const cjsStarReExportImports = astFacts.cjsStarReExports.map((record) => + createImportRecord( + file, + record.source, + "re-export", + [ + { + importedName: "*", + localName: "*", + isTypeOnly: false, + isNamespace: true, + start: record.start, + end: record.end, + }, + ], + record.start, + record.end, + ), + ); + for (const record of astFacts.cjsStarReExports) { + rawExports.push({ + exportedName: "*", + localName: null, + source: record.source, + importedName: "*", + symbolKind: "unknown", + isTypeOnly: false, + isReExport: true, + isCommonJs: true, + isNamespace: true, + isReactComponentLike: false, + jsDocTags: new Set(), + members: [], + hasLocalReferences: false, + start: record.start, + end: record.end, + position: position(file, record.start), + }); + } + + return { + file, + imports: [ + ...staticImports, + ...commentImports, + ...astFacts.imports, + ...reExportImports, + ...cjsStarReExportImports, + ], + exports: enrichExportsFromAst( + rawExports, + astFacts.membersByExportName, + astFacts.usedIdentifiers, + ), + directives: collectDirectives(program), + usedIdentifiers: astFacts.usedIdentifiers, + usedIdentifierRanges: astFacts.usedIdentifierRanges, + shadowRangesByName: astFacts.shadowRangesByName, + namespaceMemberReferences: astFacts.namespaceMemberReferences, + memberObjectReferences: astFacts.memberObjectReferences, + namespaceObjectAliases: astFacts.namespaceObjectAliases, + namespaceLocalAliases: astFacts.namespaceLocalAliases, + namespaceLocalObjectAliases: astFacts.namespaceLocalObjectAliases, + cjsExportNames: astFacts.cjsExportNames, + parseErrors: parseResult.errors.map((error) => error.message), + }; +}; + +export const extractModules = (files: ProjectFile[]): CodebaseModule[] => files.map(extractModule); diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/graph.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/graph.ts new file mode 100644 index 0000000000..5a2c16fd99 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/graph.ts @@ -0,0 +1,1092 @@ +import { + INTERNAL_VISIBILITY_TAG, + PACKAGE_JSON_FILENAME, + PUBLIC_VISIBILITY_TAGS, +} from "./constants.js"; +import { matchesAnyGlob, toRelativePath } from "./path-utils.js"; +import type { CodebasePluginResult } from "./plugins/types.js"; +import type { + CodebaseAnalysisConfig, + EntryPoint, + EntryPointRole, + GraphExportSymbol, + ImportedBinding, + ModuleGraph, + ModuleGraphNode, + ResolvedImport, + ResolvedModule, + WorkspaceInfo, +} from "./types.js"; + +interface ReachabilityWorkItem { + fileId: number; + role: EntryPointRole | "type"; +} + +const createGraphNode = ( + resolvedModule: ResolvedModule, + entryPoints: EntryPoint[], +): ModuleGraphNode => ({ + file: resolvedModule.module.file, + imports: resolvedModule.imports, + importedBy: new Set(), + exports: new Map( + resolvedModule.module.exports.map((exportRecord) => [ + exportRecord.exportedName, + { + ...exportRecord, + references: [], + isPluginUsed: false, + isReferencedByNamespace: false, + referencedMemberNames: new Set(), + }, + ]), + ), + directives: resolvedModule.module.directives, + parseErrors: resolvedModule.module.parseErrors, + usedIdentifiers: resolvedModule.module.usedIdentifiers, + usedIdentifierRanges: resolvedModule.module.usedIdentifierRanges, + shadowRangesByName: resolvedModule.module.shadowRangesByName, + namespaceMemberReferences: resolvedModule.module.namespaceMemberReferences, + memberObjectReferences: resolvedModule.module.memberObjectReferences, + namespaceObjectAliases: resolvedModule.module.namespaceObjectAliases, + namespaceLocalAliases: resolvedModule.module.namespaceLocalAliases, + namespaceLocalObjectAliases: resolvedModule.module.namespaceLocalObjectAliases, + entryRoles: new Set( + entryPoints + .filter((entryPoint) => entryPoint.fileId === resolvedModule.module.file.id) + .map((entryPoint) => entryPoint.role), + ), + entrySources: new Set( + entryPoints + .filter((entryPoint) => entryPoint.fileId === resolvedModule.module.file.id) + .map((entryPoint) => entryPoint.source), + ), + isReachable: false, + isRuntimeReachable: false, + isTestReachable: false, + isTypeReachable: false, + hasCjsExports: resolvedModule.module.cjsExportNames.size > 0, +}); + +const createPathToNodeMap = (nodes: Map): Map => + new Map([...nodes.values()].map((node) => [node.file.filePath, node])); + +const connectReverseImports = (nodes: Map): void => { + const pathToNode = createPathToNodeMap(nodes); + for (const node of nodes.values()) { + for (const resolvedImport of node.imports) { + if (resolvedImport.targetKind !== "internal" || !resolvedImport.targetFilePath) continue; + const targetNode = pathToNode.get(resolvedImport.targetFilePath); + targetNode?.importedBy.add(node.file.id); + } + } +}; + +const markReachableFiles = ( + nodes: Map, + entryPoints: EntryPoint[], +): void => { + const pending: ReachabilityWorkItem[] = entryPoints.map((entryPoint) => ({ + fileId: entryPoint.fileId, + role: entryPoint.role, + })); + const visitedKeys = new Set(); + const pathToNode = createPathToNodeMap(nodes); + + while (pending.length > 0) { + const item = pending.pop(); + if (!item) continue; + const key = `${item.fileId}:${item.role}`; + if (visitedKeys.has(key)) continue; + visitedKeys.add(key); + const node = nodes.get(item.fileId); + if (!node) continue; + node.isReachable = true; + if (item.role === "runtime") node.isRuntimeReachable = true; + if (item.role === "test") node.isTestReachable = true; + if (item.role === "type") node.isTypeReachable = true; + for (const resolvedImport of node.imports) { + if (resolvedImport.targetKind === "internal" && resolvedImport.targetFilePath) { + const targetNode = pathToNode.get(resolvedImport.targetFilePath); + const role = resolvedImport.importRecord.isTypeOnly ? "type" : item.role; + if (targetNode) pending.push({ fileId: targetNode.file.id, role }); + } + } + } +}; + +const findExportSymbol = ( + node: ModuleGraphNode, + importedName: string, +): GraphExportSymbol | undefined => + node.exports.get(importedName) ?? + (importedName === "default" ? node.exports.get("default") : undefined); + +const addExportReference = ( + exportSymbol: GraphExportSymbol, + reference: GraphExportSymbol["references"][number], +): boolean => { + if ( + exportSymbol.references.some( + (existingReference) => + existingReference.fromFileId === reference.fromFileId && + existingReference.importRecord.source === reference.importRecord.source && + existingReference.kind === reference.kind, + ) + ) { + return false; + } + exportSymbol.references.push(reference); + return true; +}; + +const addExportMemberReferences = ( + exportSymbol: GraphExportSymbol, + memberNames: Iterable, +): void => { + for (const memberName of memberNames) { + if (exportSymbol.members.some((member) => member.name === memberName)) { + exportSymbol.referencedMemberNames.add(memberName); + } + } +}; + +const addAllExportMemberReferences = (exportSymbol: GraphExportSymbol): void => { + addExportMemberReferences( + exportSymbol, + exportSymbol.members.map((member) => member.name), + ); +}; + +const isPositionShadowed = ( + node: ModuleGraphNode, + localName: string, + position: number, + binding?: { start: number; end: number }, +): boolean => + (node.shadowRangesByName ?? new Map()) + .get(localName) + ?.some( + (range) => + (!binding || binding.start < range.start || binding.start > range.end) && + position >= range.start && + position <= range.end, + ) ?? false; + +const isReferenceShadowed = ( + node: ModuleGraphNode, + localName: string, + reference: { start: number }, + binding?: { start: number; end: number }, +): boolean => isPositionShadowed(node, localName, reference.start, binding); + +const isLocalNameUsed = ( + node: ModuleGraphNode, + localName: string, + binding?: { start: number; end: number }, +): boolean => + (node.usedIdentifierRanges ?? new Map()) + .get(localName) + ?.some((position) => !isPositionShadowed(node, localName, position, binding)) ?? false; + +const getMemberReferencesForLocalName = (node: ModuleGraphNode, localName: string): string[] => + node.namespaceMemberReferences + .filter( + (reference) => + reference.namespace === localName && + reference.memberPath.length >= 1 && + !isReferenceShadowed(node, localName, reference), + ) + .map((reference) => reference.memberPath[0]) + .filter((memberName): memberName is string => Boolean(memberName)); + +const getMemberObjectReferencesForLocalName = (node: ModuleGraphNode, localName: string) => + node.memberObjectReferences.filter( + (reference) => + reference.namespace === localName && !isReferenceShadowed(node, localName, reference), + ); + +const getNamespaceMemberReferencesForLocalName = ( + node: ModuleGraphNode, + localName: string, + binding?: ImportedBinding, +) => [ + ...node.namespaceMemberReferences.filter( + (reference) => + reference.namespace === localName && + !isReferenceShadowed(node, localName, reference, binding), + ), + ...node.namespaceLocalAliases + .filter( + (alias) => + alias.namespaceLocalName === localName && isLocalNameUsed(node, alias.aliasName, alias), + ) + .flatMap((alias) => + node.namespaceMemberReferences.filter( + (reference) => + reference.namespace === alias.aliasName && + !isReferenceShadowed(node, alias.aliasName, reference, alias), + ), + ), +]; + +const getNamespaceMemberObjectReferencesForLocalName = ( + node: ModuleGraphNode, + localName: string, + binding?: ImportedBinding, +) => [ + ...node.memberObjectReferences.filter( + (reference) => + reference.namespace === localName && + !isReferenceShadowed(node, localName, reference, binding), + ), + ...node.namespaceLocalAliases + .filter( + (alias) => + alias.namespaceLocalName === localName && isLocalNameUsed(node, alias.aliasName, alias), + ) + .flatMap((alias) => + node.memberObjectReferences.filter( + (reference) => + reference.namespace === alias.aliasName && + !isReferenceShadowed(node, alias.aliasName, reference, alias), + ), + ), +]; + +const isImportBindingUsed = ( + node: ModuleGraphNode, + binding: ImportedBinding, + importKind: ResolvedImport["importRecord"]["kind"], +): boolean => { + if ( + importKind === "dynamic" && + !binding.isNamespace && + binding.localName !== binding.importedName + ) { + return !isPositionShadowed( + node, + binding.localName, + binding.referenceStart ?? binding.start, + binding, + ); + } + return isLocalNameUsed(node, binding.localName, binding); +}; + +const addImportReferences = (nodes: Map): void => { + const pathToNode = createPathToNodeMap(nodes); + for (const node of nodes.values()) { + for (const resolvedImport of node.imports) { + if (resolvedImport.targetKind !== "internal" || !resolvedImport.targetFilePath) continue; + const targetNode = pathToNode.get(resolvedImport.targetFilePath); + if (!targetNode) continue; + if (resolvedImport.importRecord.bindings.length === 0) { + continue; + } + for (const binding of resolvedImport.importRecord.bindings) { + if (resolvedImport.importRecord.kind === "re-export" && binding.isNamespace) { + continue; + } + const isReExport = resolvedImport.importRecord.kind === "re-export"; + const isCommentReference = resolvedImport.importRecord.kind === "comment"; + if ( + !isReExport && + !isCommentReference && + !isImportBindingUsed(node, binding, resolvedImport.importRecord.kind) + ) { + continue; + } + if (binding.isNamespace) { + const namespaceReferences = getNamespaceMemberReferencesForLocalName( + node, + binding.localName, + binding, + ); + const namespaceObjectReferences = getNamespaceMemberObjectReferencesForLocalName( + node, + binding.localName, + binding, + ); + const referencedMemberNames = new Set( + [...namespaceReferences, ...namespaceObjectReferences] + .map((reference) => reference.memberPath[0]) + .filter((memberName): memberName is string => Boolean(memberName)), + ); + if ( + referencedMemberNames.size === 0 && + (node.namespaceObjectAliases.some( + (alias) => alias.namespaceLocalName === binding.localName, + ) || + node.namespaceLocalAliases.some( + (alias) => alias.namespaceLocalName === binding.localName, + ) || + node.namespaceLocalObjectAliases.some( + (alias) => alias.namespaceLocalName === binding.localName, + )) + ) { + continue; + } + const referencedExportSymbols = + referencedMemberNames.size > 0 + ? [...referencedMemberNames].flatMap((memberName) => { + const exportSymbol = targetNode.exports.get(memberName); + return exportSymbol ? [exportSymbol] : []; + }) + : [...targetNode.exports.values()]; + for (const exportSymbol of referencedExportSymbols) { + exportSymbol.isReferencedByNamespace = true; + addExportMemberReferences( + exportSymbol, + namespaceReferences + .filter((reference) => reference.memberPath[0] === exportSymbol.exportedName) + .map((reference) => reference.memberPath[1]) + .filter((memberName): memberName is string => Boolean(memberName)), + ); + for (const objectReference of namespaceObjectReferences) { + if (objectReference.memberPath.length === 0) { + addAllExportMemberReferences(exportSymbol); + continue; + } + if (objectReference.memberPath[0] === exportSymbol.exportedName) { + addAllExportMemberReferences(exportSymbol); + } + } + addExportReference(exportSymbol, { + fromFileId: node.file.id, + kind: referencedMemberNames.size > 0 ? "namespace-member" : "namespace", + importRecord: resolvedImport.importRecord, + }); + } + continue; + } + const exportSymbol = findExportSymbol(targetNode, binding.importedName); + if (exportSymbol && node.file.id !== targetNode.file.id) { + addExportMemberReferences( + exportSymbol, + getMemberReferencesForLocalName(node, binding.localName), + ); + if ( + getMemberObjectReferencesForLocalName(node, binding.localName).some( + (reference) => reference.memberPath.length === 0, + ) + ) { + addAllExportMemberReferences(exportSymbol); + } + addExportReference(exportSymbol, { + fromFileId: node.file.id, + kind: binding.importedName === "default" ? "default" : "named", + importRecord: resolvedImport.importRecord, + }); + } + } + } + } +}; + +const addLocalExportMemberReferences = (nodes: Map): void => { + for (const node of nodes.values()) { + for (const exportSymbol of node.exports.values()) { + const localName = exportSymbol.localName ?? exportSymbol.exportedName; + addExportMemberReferences(exportSymbol, getMemberReferencesForLocalName(node, localName)); + if ( + getMemberObjectReferencesForLocalName(node, localName).some( + (reference) => reference.memberPath.length === 0, + ) + ) { + addAllExportMemberReferences(exportSymbol); + } + } + } +}; + +const propagateNamespaceLocalObjectAliases = (nodes: Map): void => { + const pathToNode = createPathToNodeMap(nodes); + for (const node of nodes.values()) { + if (node.namespaceLocalObjectAliases.length === 0) continue; + for (const memberReference of node.namespaceMemberReferences.filter( + (reference) => !isPrefixMemberReference(reference, node.namespaceMemberReferences), + )) { + const alias = node.namespaceLocalObjectAliases.find( + (item) => + item.objectLocalName === memberReference.namespace && + item.propertyName === memberReference.memberPath[0], + ); + if (!alias) continue; + const namespaceTargetNode = findNamespaceImportTarget( + node, + pathToNode, + alias.namespaceLocalName, + ); + if (!namespaceTargetNode) continue; + const targetMemberPath = memberReference.memberPath.slice(1); + if (targetMemberPath.length === 0) { + const importRecord = findNamespaceImportRecord(node, alias.namespaceLocalName); + if (importRecord) + addNamespaceObjectReference(namespaceTargetNode, node.file.id, importRecord); + continue; + } + const importRecord = findNamespaceImportRecord(node, alias.namespaceLocalName); + if (importRecord) { + addNamespaceMemberPathReference( + namespaceTargetNode, + targetMemberPath, + node.file.id, + importRecord, + ); + } + } + } +}; + +const findNamespaceImportTarget = ( + node: ModuleGraphNode, + pathToNode: ReadonlyMap, + namespaceLocalName: string, +): ModuleGraphNode | null => { + for (const resolvedImport of node.imports) { + if (resolvedImport.targetKind !== "internal" || !resolvedImport.targetFilePath) continue; + if ( + resolvedImport.importRecord.bindings.some( + (binding) => binding.isNamespace && binding.localName === namespaceLocalName, + ) + ) { + return pathToNode.get(resolvedImport.targetFilePath) ?? null; + } + } + return null; +}; + +const findNamespaceImportRecord = ( + node: ModuleGraphNode, + namespaceLocalName: string, +): ResolvedImport["importRecord"] | null => { + const resolvedImport = node.imports.find((item) => + item.importRecord.bindings.some( + (binding) => binding.isNamespace && binding.localName === namespaceLocalName, + ), + ); + return resolvedImport?.importRecord ?? null; +}; + +const addNamespaceMemberReference = ( + targetNode: ModuleGraphNode, + exportName: string, + fromFileId: number, + importRecord: ResolvedImport["importRecord"], + exportedMemberNames: Iterable = [], +): void => { + const exportSymbol = targetNode.exports.get(exportName); + if (!exportSymbol) return; + exportSymbol.isReferencedByNamespace = true; + addExportMemberReferences(exportSymbol, exportedMemberNames); + addExportReference(exportSymbol, { + fromFileId, + kind: "namespace-member", + importRecord, + }); +}; + +const addNamespaceMemberPathReference = ( + targetNode: ModuleGraphNode, + memberPath: string[], + fromFileId: number, + importRecord: ResolvedImport["importRecord"], +): void => { + const exportName = memberPath[0]; + if (!exportName) return; + addNamespaceMemberReference( + targetNode, + exportName, + fromFileId, + importRecord, + memberPath.slice(1), + ); +}; + +const addNamespaceObjectReference = ( + targetNode: ModuleGraphNode, + fromFileId: number, + importRecord: ResolvedImport["importRecord"], +): void => { + for (const exportSymbol of targetNode.exports.values()) { + exportSymbol.isReferencedByNamespace = true; + addExportReference(exportSymbol, { + fromFileId, + kind: "namespace", + importRecord, + }); + } +}; + +const isPrefixMemberReference = ( + reference: ModuleGraphNode["namespaceMemberReferences"][number], + references: ModuleGraphNode["namespaceMemberReferences"], +): boolean => + references.some( + (candidate) => + candidate !== reference && + candidate.namespace === reference.namespace && + candidate.memberPath.length > reference.memberPath.length && + reference.memberPath.every((memberName, index) => candidate.memberPath[index] === memberName), + ); + +const propagateNamespaceObjectAliases = (nodes: Map): void => { + const pathToNode = createPathToNodeMap(nodes); + for (const consumerNode of nodes.values()) { + for (const resolvedImport of consumerNode.imports) { + if (resolvedImport.targetKind !== "internal" || !resolvedImport.targetFilePath) continue; + const aliasNode = pathToNode.get(resolvedImport.targetFilePath); + if (!aliasNode || aliasNode.namespaceObjectAliases.length === 0) continue; + for (const binding of resolvedImport.importRecord.bindings) { + if (!consumerNode.usedIdentifiers.has(binding.localName)) continue; + const bindingMemberReferences = consumerNode.namespaceMemberReferences.filter( + (reference) => reference.namespace === binding.localName, + ); + if (bindingMemberReferences.length === 0) { + const aliases = aliasNode.namespaceObjectAliases.filter( + (alias) => binding.isNamespace || alias.exportName === binding.importedName, + ); + for (const alias of aliases) { + const namespaceTargetNode = findNamespaceImportTarget( + aliasNode, + pathToNode, + alias.namespaceLocalName, + ); + if (namespaceTargetNode) { + addNamespaceObjectReference( + namespaceTargetNode, + consumerNode.file.id, + resolvedImport.importRecord, + ); + } + } + continue; + } + for (const memberReference of bindingMemberReferences.filter( + (reference) => !isPrefixMemberReference(reference, bindingMemberReferences), + )) { + const exportNameOffset = binding.isNamespace ? 1 : 0; + const alias = aliasNode.namespaceObjectAliases.find( + (item) => + item.exportName === + (binding.isNamespace ? memberReference.memberPath[0] : binding.importedName) && + item.propertyName === memberReference.memberPath[exportNameOffset], + ); + if (!alias) continue; + const namespaceTargetNode = findNamespaceImportTarget( + aliasNode, + pathToNode, + alias.namespaceLocalName, + ); + if (!namespaceTargetNode) continue; + const targetMemberPath = memberReference.memberPath.slice(exportNameOffset + 1); + if (targetMemberPath.length === 0) { + addNamespaceObjectReference( + namespaceTargetNode, + consumerNode.file.id, + resolvedImport.importRecord, + ); + continue; + } + addNamespaceMemberPathReference( + namespaceTargetNode, + targetMemberPath, + consumerNode.file.id, + resolvedImport.importRecord, + ); + } + } + } + } +}; + +interface NamespaceReExportReference { + kind: "member" | "namespace"; + memberPath?: string[]; + importRecord: ResolvedImport["importRecord"]; +} + +const collectNamespaceReExportReferences = ( + consumerNode: ModuleGraphNode, + targetNode: ModuleGraphNode, + exportName: string, +): NamespaceReExportReference[] => { + const references: NamespaceReExportReference[] = []; + for (const resolvedImport of consumerNode.imports) { + if ( + resolvedImport.targetKind !== "internal" || + resolvedImport.targetFilePath !== targetNode.file.filePath + ) { + continue; + } + for (const binding of resolvedImport.importRecord.bindings) { + if (!consumerNode.usedIdentifiers.has(binding.localName)) continue; + if (binding.isNamespace) { + const bindingReferences = consumerNode.namespaceMemberReferences.filter( + (item) => item.namespace === binding.localName, + ); + const exportReferences = bindingReferences.filter( + (item) => item.memberPath[0] === exportName, + ); + const standaloneExportReferences = exportReferences.filter( + (item) => !isPrefixMemberReference(item, bindingReferences), + ); + if ( + bindingReferences.length === 0 || + standaloneExportReferences.some((item) => item.memberPath.length === 1) + ) { + references.push({ kind: "namespace", importRecord: resolvedImport.importRecord }); + continue; + } + for (const reference of standaloneExportReferences.filter( + (item) => item.memberPath.length >= 2, + )) { + references.push({ + kind: "member", + memberPath: reference.memberPath.slice(1), + importRecord: resolvedImport.importRecord, + }); + } + continue; + } + if (binding.importedName !== exportName) continue; + const bindingReferences = consumerNode.namespaceMemberReferences.filter( + (item) => item.namespace === binding.localName, + ); + if (bindingReferences.length === 0) { + references.push({ kind: "namespace", importRecord: resolvedImport.importRecord }); + continue; + } + for (const reference of bindingReferences.filter( + (item) => !isPrefixMemberReference(item, bindingReferences), + )) { + references.push({ + kind: "member", + memberPath: reference.memberPath, + importRecord: resolvedImport.importRecord, + }); + } + } + } + return references; +}; + +interface ReachableNamespaceReExport { + node: ModuleGraphNode; + exportName: string; +} + +const enumerateReachableNamespaceReExports = ( + nodes: Map, + pathToNode: ReadonlyMap, + seedNode: ModuleGraphNode, + seedExportName: string, +): ReachableNamespaceReExport[] => { + const reachableByKey = new Map(); + const pending: ReachableNamespaceReExport[] = [{ node: seedNode, exportName: seedExportName }]; + for (const item of pending) { + const key = `${item.node.file.id}:${item.exportName}`; + if (reachableByKey.has(key)) continue; + reachableByKey.set(key, item); + for (const candidateNode of nodes.values()) { + for (const candidateExport of candidateNode.exports.values()) { + if (!candidateExport.isReExport || !candidateExport.source) continue; + const candidateSourceNode = getInternalImportTarget( + candidateNode, + pathToNode, + candidateExport.source, + ); + if (candidateSourceNode?.file.id !== item.node.file.id) continue; + if (candidateExport.isNamespace && candidateExport.exportedName !== "*") continue; + if (candidateExport.importedName === item.exportName) { + pending.push({ node: candidateNode, exportName: candidateExport.exportedName }); + } else if (candidateExport.importedName === "*" && candidateExport.exportedName === "*") { + pending.push({ node: candidateNode, exportName: item.exportName }); + } + } + } + } + return [...reachableByKey.values()]; +}; + +const propagateNamespaceReExportReferences = (nodes: Map): void => { + const pathToNode = createPathToNodeMap(nodes); + for (const node of nodes.values()) { + for (const exportSymbol of node.exports.values()) { + if ( + !exportSymbol.isReExport || + !exportSymbol.isNamespace || + !exportSymbol.source || + exportSymbol.exportedName === "*" + ) { + continue; + } + const sourceNode = getInternalImportTarget(node, pathToNode, exportSymbol.source); + if (!sourceNode) continue; + const reachableExports = enumerateReachableNamespaceReExports( + nodes, + pathToNode, + node, + exportSymbol.exportedName, + ); + if (reachableExports.some((item) => isPackageEntrypoint(item.node))) { + for (const sourceExport of sourceNode.exports.values()) { + if (sourceExport.exportedName === "default" || sourceExport.exportedName === "*") + continue; + addExportReference(sourceExport, { + fromFileId: node.file.id, + kind: "re-export", + importRecord: getInternalImportRecord(node, exportSymbol.source) ?? + node.imports[0]?.importRecord ?? { + source: exportSymbol.source, + bindings: [], + kind: "re-export", + isTypeOnly: exportSymbol.isTypeOnly, + isSideEffectOnly: false, + isOptional: false, + start: exportSymbol.start, + end: exportSymbol.end, + position: exportSymbol.position, + }, + }); + } + continue; + } + for (const reachableExport of reachableExports) { + for (const consumerNode of nodes.values()) { + for (const reference of collectNamespaceReExportReferences( + consumerNode, + reachableExport.node, + reachableExport.exportName, + )) { + if (reference.kind === "namespace") { + addNamespaceObjectReference(sourceNode, consumerNode.file.id, reference.importRecord); + continue; + } + if (reference.memberPath) { + addNamespaceMemberPathReference( + sourceNode, + reference.memberPath, + consumerNode.file.id, + reference.importRecord, + ); + } + } + } + } + } + } +}; + +const isPackageEntrypoint = (node: ModuleGraphNode): boolean => + node.entrySources.has("package.json"); + +const getInternalImportTarget = ( + node: ModuleGraphNode, + pathToNode: ReadonlyMap, + source: string, +): ModuleGraphNode | null => { + const sourceImport = node.imports.find( + (resolvedImport) => resolvedImport.importRecord.source === source, + ); + if (sourceImport?.targetKind !== "internal" || !sourceImport.targetFilePath) return null; + return pathToNode.get(sourceImport.targetFilePath) ?? null; +}; + +const getInternalImportRecord = ( + node: ModuleGraphNode, + source: string, +): ResolvedImport["importRecord"] | null => + node.imports.find((resolvedImport) => resolvedImport.importRecord.source === source) + ?.importRecord ?? null; + +const propagateStarReferenceToSource = ( + sourceNode: ModuleGraphNode, + pathToNode: ReadonlyMap, + exportName: string, + reference: GraphExportSymbol["references"][number], + visitedNodeIds = new Set(), +): boolean => { + if (visitedNodeIds.has(sourceNode.file.id)) return false; + visitedNodeIds.add(sourceNode.file.id); + const sourceExport = sourceNode.exports.get(exportName); + if (sourceExport) return addExportReference(sourceExport, { ...reference, kind: "re-export" }); + let didChange = false; + for (const starExport of sourceNode.exports.values()) { + if (!starExport.isReExport || !starExport.source || starExport.exportedName !== "*") continue; + const nextSourceNode = getInternalImportTarget(sourceNode, pathToNode, starExport.source); + if (!nextSourceNode) continue; + didChange = + propagateStarReferenceToSource( + nextSourceNode, + pathToNode, + exportName, + reference, + visitedNodeIds, + ) || didChange; + } + return didChange; +}; + +const collectNamedImportReferencesToNode = ( + nodes: ReadonlyMap, + targetNode: ModuleGraphNode, +): Map => { + const referencesByName = new Map(); + for (const importerNode of nodes.values()) { + for (const resolvedImport of importerNode.imports) { + if ( + resolvedImport.targetKind !== "internal" || + resolvedImport.targetFilePath !== targetNode.file.filePath + ) { + continue; + } + for (const binding of resolvedImport.importRecord.bindings) { + if ( + binding.isNamespace || + binding.importedName === "default" || + binding.importedName === "*" + ) { + continue; + } + if ( + resolvedImport.importRecord.kind !== "re-export" && + !isImportBindingUsed(importerNode, binding, resolvedImport.importRecord.kind) + ) { + continue; + } + const references = referencesByName.get(binding.importedName) ?? []; + references.push({ + fromFileId: importerNode.file.id, + kind: "re-export", + importRecord: resolvedImport.importRecord, + }); + referencesByName.set(binding.importedName, references); + } + } + } + return referencesByName; +}; + +const collectReferencedExports = ( + node: ModuleGraphNode, +): Map => { + const referencesByName = new Map(); + for (const exportSymbol of node.exports.values()) { + if (exportSymbol.references.length === 0) continue; + referencesByName.set(exportSymbol.exportedName, [...exportSymbol.references]); + } + return referencesByName; +}; + +const propagateStarReExportReferences = ( + nodes: ReadonlyMap, + node: ModuleGraphNode, + pathToNode: ReadonlyMap, +): boolean => { + let didChange = false; + const namedImportReferences = collectNamedImportReferencesToNode(nodes, node); + const referencedExports = collectReferencedExports(node); + for (const exportSymbol of node.exports.values()) { + if (!exportSymbol.isReExport || !exportSymbol.source || exportSymbol.exportedName !== "*") { + continue; + } + const sourceNode = getInternalImportTarget(node, pathToNode, exportSymbol.source); + if (!sourceNode) continue; + const importRecord = getInternalImportRecord(node, exportSymbol.source); + if (!importRecord) continue; + if (isPackageEntrypoint(node)) { + for (const sourceExport of sourceNode.exports.values()) { + if (sourceExport.exportedName === "default" || sourceExport.exportedName === "*") continue; + didChange = + addExportReference(sourceExport, { + fromFileId: node.file.id, + kind: "re-export", + importRecord, + }) || didChange; + } + continue; + } + for (const [exportName, references] of [...namedImportReferences, ...referencedExports]) { + if (exportName === "default" || exportName === "*") continue; + for (const reference of references) { + didChange = + propagateStarReferenceToSource(sourceNode, pathToNode, exportName, reference) || + didChange; + } + } + } + return didChange; +}; + +const propagateNamedReExportReferences = ( + node: ModuleGraphNode, + pathToNode: ReadonlyMap, + exportSymbol: GraphExportSymbol, +): boolean => { + if (!exportSymbol.isReExport || !exportSymbol.source || exportSymbol.exportedName === "*") { + return false; + } + const sourceNode = getInternalImportTarget(node, pathToNode, exportSymbol.source); + if (!sourceNode) return false; + const importRecord = getInternalImportRecord(node, exportSymbol.source); + if (!importRecord) return false; + const targetExportName = exportSymbol.importedName ?? exportSymbol.exportedName; + const targetExport = sourceNode.exports.get(targetExportName); + if (!targetExport) return false; + const references = + exportSymbol.references.length > 0 + ? exportSymbol.references + : isPackageEntrypoint(node) + ? [ + { + fromFileId: node.file.id, + kind: "re-export" as const, + importRecord, + }, + ] + : []; + let didChange = false; + for (const reference of references) { + didChange = addExportReference(targetExport, { ...reference, kind: "re-export" }) || didChange; + } + return didChange; +}; + +const propagateReExportReferences = (nodes: Map): void => { + const pathToNode = createPathToNodeMap(nodes); + let didChange = true; + while (didChange) { + didChange = false; + for (const node of nodes.values()) { + didChange = propagateStarReExportReferences(nodes, node, pathToNode) || didChange; + for (const exportSymbol of node.exports.values()) { + didChange = propagateNamedReExportReferences(node, pathToNode, exportSymbol) || didChange; + } + } + } +}; + +const applyPluginUsedExports = ( + nodes: Map, + pluginResults: ReadonlyMap, + workspaces: WorkspaceInfo[], +): void => { + for (const node of nodes.values()) { + const workspace = workspaces[node.file.workspaceId]; + const pluginResult = pluginResults.get(node.file.workspaceId); + if (!workspace || !pluginResult) continue; + const workspaceRelativePath = toRelativePath(workspace.directory, node.file.filePath); + for (const [pattern, exportNames] of pluginResult.usedExports) { + if (!matchesAnyGlob(workspaceRelativePath, [pattern])) continue; + for (const exportName of exportNames) { + const exportSymbol = node.exports.get(exportName); + if (exportSymbol) exportSymbol.isPluginUsed = true; + } + } + } +}; + +const collectUnresolvedImports = (nodes: Map): ResolvedImport[] => { + const unresolvedImports: ResolvedImport[] = []; + for (const node of nodes.values()) { + unresolvedImports.push( + ...node.imports.filter((resolvedImport) => resolvedImport.targetKind === "unresolved"), + ); + } + return unresolvedImports; +}; + +const isLoaderPackageUsage = (resolvedImport: ResolvedImport): boolean => + resolvedImport.importRecord.source.includes("!") && + Boolean( + resolvedImport.packageName && + resolvedImport.importRecord.source + .split("!") + .slice(0, -1) + .some((loader) => loader.includes(resolvedImport.packageName ?? "")), + ); + +const collectPackageUsages = (nodes: Map, workspaces: WorkspaceInfo[]) => { + const workspaceNames = new Set(workspaces.map((workspace) => workspace.name)); + const pathToNode = createPathToNodeMap(nodes); + return [...nodes.values()].flatMap((node) => + node.imports + .filter((resolvedImport) => { + if (!resolvedImport.packageName) return false; + if ( + resolvedImport.targetKind === "external" || + resolvedImport.targetKind === "unresolved" + ) { + return true; + } + if (!workspaceNames.has(resolvedImport.packageName)) return false; + if (resolvedImport.targetKind !== "internal" || !resolvedImport.targetFilePath) { + return false; + } + const targetNode = pathToNode.get(resolvedImport.targetFilePath); + return Boolean(targetNode && targetNode.file.workspaceId !== node.file.workspaceId); + }) + .map((resolvedImport) => ({ + packageName: resolvedImport.packageName ?? "", + workspaceId: node.file.workspaceId, + fromFileId: node.file.id, + specifier: resolvedImport.importRecord.source, + isTypeOnly: resolvedImport.importRecord.isTypeOnly, + isRuntime: node.isRuntimeReachable && !isLoaderPackageUsage(resolvedImport), + isTestOnly: node.isTestReachable && !node.isRuntimeReachable, + })), + ); +}; + +export const buildModuleGraph = ( + config: CodebaseAnalysisConfig, + workspaces: WorkspaceInfo[], + resolvedModules: ResolvedModule[], + entryPoints: EntryPoint[], + pluginResults: ReadonlyMap, +): ModuleGraph => { + const nodes = new Map(); + + for (const resolvedModule of resolvedModules) { + nodes.set(resolvedModule.module.file.id, createGraphNode(resolvedModule, entryPoints)); + } + + connectReverseImports(nodes); + markReachableFiles(nodes, entryPoints); + addImportReferences(nodes); + addLocalExportMemberReferences(nodes); + propagateNamespaceLocalObjectAliases(nodes); + propagateNamespaceObjectAliases(nodes); + propagateNamespaceReExportReferences(nodes); + propagateReExportReferences(nodes); + applyPluginUsedExports(nodes, pluginResults, workspaces); + + return { + rootDirectory: config.rootDirectory, + config, + workspaces, + files: resolvedModules.map((resolvedModule) => resolvedModule.module.file), + nodes, + pathToFileId: new Map( + resolvedModules.map((resolvedModule) => [ + resolvedModule.module.file.filePath, + resolvedModule.module.file.id, + ]), + ), + entryPoints, + packageUsages: collectPackageUsages(nodes, workspaces), + unresolvedImports: collectUnresolvedImports(nodes), + pluginResults, + }; +}; + +export const getPackageJsonPath = (rootDirectory: string): string => + `${rootDirectory}/${PACKAGE_JSON_FILENAME}`; + +export const isVisibilityProtected = (exportSymbol: GraphExportSymbol): boolean => + [...exportSymbol.jsDocTags].some( + (tag) => PUBLIC_VISIBILITY_TAGS.has(tag) || tag === INTERNAL_VISIBILITY_TAG, + ); diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/index.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/index.ts new file mode 100644 index 0000000000..651505ee37 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/index.ts @@ -0,0 +1,51 @@ +import { createCodebaseAnalysisConfig } from "./config.js"; +import { discoverSourceFiles } from "./discovery.js"; +import { discoverEntryPoints } from "./entrypoints.js"; +import { extractModules } from "./extract/index.js"; +import { buildModuleGraph } from "./graph.js"; +import { runCodebasePlugins } from "./plugins/index.js"; +import { resolveModules } from "./resolve.js"; +import { discoverWorkspaces } from "./workspace.js"; +import type { CodebaseAnalysisOptions, CodebaseAnalysisResult } from "./types.js"; + +export type { + CodebaseAnalysisOptions, + CodebaseAnalysisResult, + CodebaseModule, + DependencyBuckets, + DiscoveredSourceFile, + EntryPoint, + EntryPointRole, + ExportMemberRecord, + ExportRecord, + GraphExportSymbol, + ImportedBinding, + ImportRecord, + ModuleGraph, + ModuleGraphNode, + PackageJsonObject, + PackageUsage, + ProjectFile, + ResolvedImport, + ResolvedModule, + SourcePosition, + WorkspaceInfo, +} from "./types.js"; + +export const runCodebaseAnalysis = async ( + options: CodebaseAnalysisOptions, +): Promise => { + options.signal?.throwIfAborted(); + const config = createCodebaseAnalysisConfig(options); + const workspaces = await discoverWorkspaces(config); + const pluginResults = runCodebasePlugins(workspaces); + const sourceFiles = await discoverSourceFiles(config, workspaces, options.signal); + options.signal?.throwIfAborted(); + const modules = extractModules(sourceFiles); + options.signal?.throwIfAborted(); + const resolvedModules = resolveModules(config.rootDirectory, modules, workspaces, pluginResults); + const entryPoints = discoverEntryPoints(config, workspaces, sourceFiles, pluginResults); + const graph = buildModuleGraph(config, workspaces, resolvedModules, entryPoints, pluginResults); + + return { graph }; +}; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/manifest.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/manifest.ts new file mode 100644 index 0000000000..c3e96c8125 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/manifest.ts @@ -0,0 +1,340 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + MANIFEST_CONFIG_DEPENDENCY_FIELDS, + PACKAGE_JSON_FILENAME, + SCRIPT_BINARY_PACKAGE_NAME_ALIASES, + SCRIPT_COMMAND_SEPARATORS, + SCRIPT_IGNORED_COMMANDS, + SCRIPT_PACKAGE_MANAGER_RUNNER_SUBCOMMANDS, + SCRIPT_RUNNER_COMMANDS, + SCRIPT_WRAPPER_COMMANDS, + SOURCE_ENTRY_FIELDS, + SOURCE_FILE_EXTENSIONS, +} from "./constants.js"; +import type { DependencyBuckets, PackageJsonObject, WorkspaceInfo } from "./types.js"; + +const EMPTY_OBJECT: Record = {}; + +const toStringMap = (value: unknown): Map => { + if (!value || typeof value !== "object" || Array.isArray(value)) return new Map(); + return new Map( + Object.entries(value as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +}; + +export const readPackageJson = async (directory: string): Promise => { + const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME); + try { + return JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackageJsonObject; + } catch { + return null; + } +}; + +export const createDependencyBuckets = (manifest: PackageJsonObject): DependencyBuckets => ({ + dependencies: toStringMap(manifest.dependencies ?? EMPTY_OBJECT), + devDependencies: toStringMap(manifest.devDependencies ?? EMPTY_OBJECT), + peerDependencies: toStringMap(manifest.peerDependencies ?? EMPTY_OBJECT), + optionalDependencies: toStringMap(manifest.optionalDependencies ?? EMPTY_OBJECT), +}); + +export const collectDependencyNames = (dependencyBuckets: DependencyBuckets): Set => + new Set(Object.values(dependencyBuckets).flatMap((bucket) => [...bucket.keys()])); + +const stripShellTokenQuotes = (token: string): string => token.replace(/^["']|["']$/g, ""); + +const isEnvironmentAssignment = (token: string): boolean => + /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); + +const toCommandName = (token: string): string => { + const command = stripShellTokenQuotes(token).split("/").at(-1) ?? ""; + return command.replace(/\.(cmd|ps1|sh)$/, ""); +}; + +const isCommandToken = (token: string): boolean => + Boolean(token) && !isEnvironmentAssignment(token) && !token.startsWith("-"); + +const findNextCommandTokenIndex = (tokens: string[], startIndex: number): number => { + for (let index = startIndex; index < tokens.length; index++) { + const token = stripShellTokenQuotes(tokens[index] ?? ""); + if (SCRIPT_COMMAND_SEPARATORS.has(token)) return -1; + if (token === "--") continue; + if (isCommandToken(token)) return index; + } + return -1; +}; + +const findRunnerCommandIndex = ( + commandName: string, + tokens: string[], + startIndex: number, +): number => { + if (SCRIPT_RUNNER_COMMANDS.has(commandName)) { + return findNextCommandTokenIndex(tokens, startIndex); + } + const runnerSubcommands = SCRIPT_PACKAGE_MANAGER_RUNNER_SUBCOMMANDS[commandName]; + if (!runnerSubcommands) return -1; + const subcommandIndex = findNextCommandTokenIndex(tokens, startIndex); + if (subcommandIndex < 0) return -1; + const subcommand = toCommandName(stripShellTokenQuotes(tokens[subcommandIndex] ?? "")); + if (!runnerSubcommands.has(subcommand)) return -1; + return findNextCommandTokenIndex(tokens, subcommandIndex + 1); +}; + +const resolveCommandPackageNames = ( + commandName: string, + dependencyNames: ReadonlySet, +): string[] => { + const aliases = SCRIPT_BINARY_PACKAGE_NAME_ALIASES[commandName] ?? [commandName]; + const declaredAliases = aliases.filter((packageName) => dependencyNames.has(packageName)); + return declaredAliases.length > 0 ? declaredAliases : aliases.slice(0, 1); +}; + +const collectScriptCommands = (script: string): string[] => { + const commands: string[] = []; + const tokens = script.match(/[^\s]+/g) ?? []; + let isExpectingCommand = true; + let environmentAssignmentQuote: string | null = null; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index] ?? ""; + if (environmentAssignmentQuote) { + if (token.endsWith(environmentAssignmentQuote)) environmentAssignmentQuote = null; + continue; + } + const strippedToken = stripShellTokenQuotes(token); + if (SCRIPT_COMMAND_SEPARATORS.has(strippedToken)) { + isExpectingCommand = true; + continue; + } + if (isEnvironmentAssignment(strippedToken)) { + const assignmentValue = token.slice(token.indexOf("=") + 1); + const openingQuote = assignmentValue[0]; + if ( + (openingQuote === '"' || openingQuote === "'") && + !assignmentValue.endsWith(openingQuote) + ) { + environmentAssignmentQuote = openingQuote; + } + continue; + } + if (!isExpectingCommand || strippedToken.startsWith("-")) { + continue; + } + const commandName = toCommandName(strippedToken); + const runnerCommandIndex = findRunnerCommandIndex(commandName, tokens, index + 1); + if (runnerCommandIndex >= 0) { + commands.push(toCommandName(stripShellTokenQuotes(tokens[runnerCommandIndex] ?? ""))); + index = runnerCommandIndex; + isExpectingCommand = false; + continue; + } + if (!commandName || SCRIPT_IGNORED_COMMANDS.has(commandName)) { + isExpectingCommand = false; + continue; + } + commands.push(commandName); + isExpectingCommand = SCRIPT_WRAPPER_COMMANDS.has(commandName); + } + + return commands; +}; + +export const collectScriptDependencyNames = ( + manifest: PackageJsonObject, + dependencyNames: ReadonlySet, +): Set => { + const scriptDependencyNames = new Set(); + for (const script of Object.values(manifest.scripts ?? EMPTY_OBJECT)) { + for (const packageName of collectNodeOptionsDependencyNames(script)) { + scriptDependencyNames.add(packageName); + } + for (const commandName of collectScriptCommands(script)) { + for (const packageName of resolveCommandPackageNames(commandName, dependencyNames)) { + scriptDependencyNames.add(packageName); + } + } + } + return scriptDependencyNames; +}; + +const SCRIPT_FILE_RUNNER_COMMANDS = new Set([ + ...SCRIPT_IGNORED_COMMANDS, + ...SCRIPT_RUNNER_COMMANDS, + ...Object.keys(SCRIPT_BINARY_PACKAGE_NAME_ALIASES), +]); + +const isSourceFilePath = (token: string): boolean => + SOURCE_FILE_EXTENSIONS.some((extension) => token.endsWith(extension)); + +const collectScriptFileEntries = (script: string): string[] => { + const entries: string[] = []; + const tokens = (script.match(/[^\s]+/g) ?? []).map(stripShellTokenQuotes); + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index] ?? ""; + if (SCRIPT_COMMAND_SEPARATORS.has(token)) continue; + if (token.startsWith("-")) continue; + + if (isSourceFilePath(token) && !token.startsWith("-")) { + entries.push(token); + continue; + } + + const commandName = toCommandName(token); + if (!SCRIPT_FILE_RUNNER_COMMANDS.has(commandName)) continue; + + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex++) { + const argument = tokens[argumentIndex] ?? ""; + if (SCRIPT_COMMAND_SEPARATORS.has(argument)) break; + if (argument.startsWith("-")) continue; + if (argument === "run" || argument === "exec") continue; + if (isSourceFilePath(argument)) { + entries.push(argument); + break; + } + break; + } + } + + return entries; +}; + +export const collectScriptFileEntryPaths = (manifest: PackageJsonObject): string[] => { + const entries: string[] = []; + for (const script of Object.values(manifest.scripts ?? EMPTY_OBJECT)) { + entries.push(...collectScriptFileEntries(script)); + } + return entries; +}; + +const collectManifestDependencyNamesFromValue = ( + value: unknown, + dependencyNames: ReadonlySet, + references: Set, +): void => { + if (typeof value === "string") { + const packageName = toManifestPackageName(value); + if (packageName) references.add(packageName); + for (const dependencyName of dependencyNames) { + if (value === dependencyName || value.startsWith(`${dependencyName}/`)) { + references.add(dependencyName); + } + } + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) { + collectManifestDependencyNamesFromValue(item, dependencyNames, references); + } + return; + } + for (const item of Object.values(value)) { + collectManifestDependencyNamesFromValue(item, dependencyNames, references); + } +}; + +const isLikelyPackageName = (value: string): boolean => + value.startsWith("@") || value.includes("-") || value.includes("/"); + +const toManifestPackageName = (value: string): string | null => { + if (value.startsWith(".") || value.startsWith("/") || value.includes(" ")) return null; + if (!isLikelyPackageName(value)) return null; + const parts = value.split("/"); + const firstPart = parts[0]; + if (!firstPart) return null; + if (firstPart.startsWith("@")) { + const secondPart = parts[1]; + return secondPart ? `${firstPart}/${secondPart}` : null; + } + return firstPart; +}; + +const toNodeOptionsPackageName = (value: string): string | null => { + if (value.startsWith(".") || value.startsWith("/") || value.includes(" ")) return null; + const parts = value.split("/"); + const firstPart = parts[0]; + if (!firstPart) return null; + if (firstPart.startsWith("@")) { + const secondPart = parts[1]; + return secondPart ? `${firstPart}/${secondPart}` : null; + } + return firstPart; +}; + +const collectNodeOptionsDependencyNames = (script: string): Set => { + const references = new Set(); + for (const match of script.matchAll(/\bNODE_OPTIONS=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g)) { + const nodeOptions = match[1] ?? match[2] ?? match[3] ?? ""; + for (const optionMatch of nodeOptions.matchAll(/(?:--require|-r|--import)(?:=|\s+)([^\s]+)/g)) { + const packageName = toNodeOptionsPackageName(stripShellTokenQuotes(optionMatch[1] ?? "")); + if (packageName) references.add(packageName); + } + } + return references; +}; + +export const collectManifestDependencyNames = ( + manifest: PackageJsonObject, + dependencyNames: ReadonlySet, +): Set => { + const references = new Set(); + for (const field of MANIFEST_CONFIG_DEPENDENCY_FIELDS) { + collectManifestDependencyNamesFromValue(manifest[field], dependencyNames, references); + } + collectManifestDependencyNamesFromValue(manifest.imports, dependencyNames, references); + return references; +}; + +const collectExportEntryValues = (value: unknown, entries: Set): void => { + if (typeof value === "string") { + entries.add(value); + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) collectExportEntryValues(item, entries); + return; + } + for (const item of Object.values(value)) { + collectExportEntryValues(item, entries); + } +}; + +const collectBinEntries = (manifest: PackageJsonObject, entries: Set): void => { + if (typeof manifest.bin === "string") { + entries.add(manifest.bin); + return; + } + if (!manifest.bin || typeof manifest.bin !== "object") return; + for (const value of Object.values(manifest.bin)) { + if (typeof value === "string") entries.add(value); + } +}; + +export const collectManifestEntrySpecifiers = (manifest: PackageJsonObject): string[] => { + const entries = new Set(); + for (const field of SOURCE_ENTRY_FIELDS) { + const value = manifest[field]; + if (typeof value === "string") entries.add(value); + } + collectBinEntries(manifest, entries); + collectExportEntryValues(manifest.exports, entries); + collectExportEntryValues(manifest.imports, entries); + return [...entries].filter((entry) => entry.startsWith(".") || entry.startsWith("/")).sort(); +}; + +export const collectManifestSupportSpecifiers = (manifest: PackageJsonObject): string[] => { + if (!Array.isArray(manifest.sideEffects)) return []; + return manifest.sideEffects + .filter((entry): entry is string => typeof entry === "string") + .filter((entry) => entry.startsWith(".") || entry.startsWith("/")) + .sort(); +}; + +export const isOptionalPeerDependency = (workspace: WorkspaceInfo, packageName: string): boolean => + Boolean(workspace.manifest.peerDependenciesMeta?.[packageName]?.optional); diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/path-utils.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/path-utils.ts new file mode 100644 index 0000000000..224961d0ca --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/path-utils.ts @@ -0,0 +1,124 @@ +import path from "node:path"; +import { + POSITION_BASE_OFFSET, + SOURCE_FILE_EXTENSIONS, + TYPESCRIPT_DECLARATION_EXTENSIONS, +} from "./constants.js"; +import type { SourcePosition } from "./types.js"; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export const toPortablePath = (filePath: string): string => filePath.split(path.sep).join("/"); + +export const toRelativePath = (rootDirectory: string, filePath: string): string => + toPortablePath(path.relative(rootDirectory, filePath)); + +export const isSourceFilePath = (filePath: string): boolean => + SOURCE_FILE_EXTENSIONS.some((extension) => filePath.endsWith(extension)) && + !TYPESCRIPT_DECLARATION_EXTENSIONS.some((extension) => filePath.endsWith(extension)); + +export const buildLineStarts = (sourceText: string): number[] => { + const lineStarts = [0]; + for (let index = 0; index < sourceText.length; index++) { + if (sourceText[index] === "\n") lineStarts.push(index + 1); + } + return lineStarts; +}; + +export const getSourcePositionFromLineStarts = ( + lineStarts: number[], + index: number, +): SourcePosition => { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const lineStart = lineStarts[middle] ?? 0; + const nextLineStart = lineStarts[middle + 1] ?? Number.POSITIVE_INFINITY; + if (index < lineStart) high = middle - 1; + else if (index >= nextLineStart) low = middle + 1; + else { + return { + line: middle + POSITION_BASE_OFFSET, + column: index - lineStart + POSITION_BASE_OFFSET, + }; + } + } + return { + line: POSITION_BASE_OFFSET, + column: POSITION_BASE_OFFSET, + }; +}; + +export const getSourcePosition = (sourceText: string, index: number): SourcePosition => + getSourcePositionFromLineStarts(buildLineStarts(sourceText), index); + +export const isBareSpecifier = (specifier: string): boolean => + !specifier.startsWith(".") && + !specifier.startsWith("/") && + !specifier.startsWith("#") && + !/^[A-Za-z][A-Za-z\d+.-]*:/.test(specifier); + +export const isUrlLikeSpecifier = (specifier: string): boolean => + /^[A-Za-z][A-Za-z\d+.-]*:/.test(specifier); + +export const getPackageNameFromSpecifier = (specifier: string): string | null => { + if (!isBareSpecifier(specifier)) return null; + const parts = specifier.split("/"); + const firstPart = parts[0]; + if (!firstPart) return null; + if (firstPart.startsWith("@")) { + const secondPart = parts[1]; + return secondPart ? `${firstPart}/${secondPart}` : firstPart; + } + return firstPart; +}; + +export const getFileStem = (relativePath: string): string => { + const basename = path.basename(relativePath); + for (const extension of SOURCE_FILE_EXTENSIONS) { + if (basename.endsWith(extension)) { + return basename.slice(0, -extension.length); + } + } + return basename; +}; + +export const createGlobMatcher = (pattern: string): RegExp => { + let source = ""; + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index]; + const nextCharacter = pattern[index + 1]; + const characterAfterNext = pattern[index + 2]; + if (character === "*" && nextCharacter === "*" && characterAfterNext === "/") { + source += "(?:.*/)?"; + index += 2; + } else if (character === "*" && nextCharacter === "*") { + source += ".*"; + index++; + } else if (character === "*") { + source += "[^/]*"; + } else if (character === "{") { + const endIndex = pattern.indexOf("}", index); + if (endIndex > index) { + source += `(${pattern + .slice(index + 1, endIndex) + .split(",") + .map(escapeRegExp) + .join("|")})`; + index = endIndex; + } else { + source += "\\{"; + } + } else { + source += escapeRegExp(character); + } + } + return new RegExp(`^${source}$`); +}; + +export const matchesGlob = (relativePath: string, pattern: string): boolean => + createGlobMatcher(pattern).test(relativePath); + +export const matchesAnyGlob = (relativePath: string, patterns: string[]): boolean => + patterns.some((pattern) => matchesGlob(relativePath, pattern)); diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/plugins/index.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/plugins/index.ts new file mode 100644 index 0000000000..76b22dea7a --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/plugins/index.ts @@ -0,0 +1,291 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { CodebasePlugin, CodebasePluginResult } from "./types.js"; +import type { WorkspaceInfo } from "../types.js"; + +const createEmptyPluginResult = (): CodebasePluginResult => ({ + entryPatterns: [], + alwaysUsedPatterns: [], + usedExports: new Map(), + toolingDependencies: new Set(), + virtualModulePrefixes: [], + generatedImportSuffixes: [], +}); + +const builtInPlugins: CodebasePlugin[] = [ + { + name: "nextjs", + enablers: ["next"], + // `next.config.{...}` is intentionally NOT a runtime entry — it is + // already captured as a `support` entry via SUPPORT_ENTRY_PATTERNS in + // `analyzer/constants.ts`. Listing it as `runtime` here makes its + // build-time imports (`@next/bundle-analyzer`, MDX plugins, etc.) + // appear as runtime usage and falsely triggers `runtime-dev-dependency` + // for packages that are correctly declared in devDependencies. + entryPatterns: [ + "{src/,}app/**/page.{js,jsx,ts,tsx}", + "{src/,}app/**/layout.{js,jsx,ts,tsx}", + "{src/,}app/**/route.{js,ts}", + "{src/,}app/**/not-found.{js,jsx,ts,tsx}", + "{src/,}app/**/error.{js,jsx,ts,tsx}", + "{src/,}app/**/global-error.{js,jsx,ts,tsx}", + "{src/,}app/**/loading.{js,jsx,ts,tsx}", + "{src/,}app/**/template.{js,jsx,ts,tsx}", + "{src/,}app/**/default.{js,jsx,ts,tsx}", + "{src/,}app/**/opengraph-image.{js,jsx,ts,tsx}", + "{src/,}app/**/twitter-image.{js,jsx,ts,tsx}", + "{src/,}app/**/icon.{js,jsx,ts,tsx}", + "{src/,}app/**/apple-icon.{js,jsx,ts,tsx}", + "{src/,}app/**/sitemap.{js,ts}", + "{src/,}app/**/robots.{js,ts}", + "{src/,}app/**/manifest.{js,ts}", + "{src/,}pages/**/*.{js,jsx,ts,tsx}", + ], + entryRole: "runtime", + alwaysUsedPatterns: [ + "{src/,}middleware.{js,ts}", + "{src/,}instrumentation.{js,ts}", + "{src/,}instrumentation-client.{js,ts}", + "{src/,}mdx-components.{js,jsx,ts,tsx}", + // Next.js loads `sentry.{client,server,edge}.config.{js,ts}` automatically + // when @sentry/nextjs is configured; user code never imports them. + "sentry.{client,server,edge}.config.{js,mjs,cjs,ts}", + ], + toolingDependencies: ["next", "react", "react-dom"], + usedExports: [ + { + pattern: + "{src/,}app/**/{page,layout,route,not-found,error,global-error,loading,template,default}.{js,jsx,ts,tsx}", + exports: [ + "default", + "metadata", + "generateMetadata", + "generateStaticParams", + "generateViewport", + "viewport", + "config", + "dynamic", + "dynamicParams", + "fetchCache", + "maxDuration", + "preferredRegion", + "revalidate", + "runtime", + "experimental_ppr", + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + ], + }, + { + pattern: "{src/,}app/**/{opengraph-image,twitter-image,icon,apple-icon}.{js,jsx,ts,tsx}", + exports: ["default", "alt", "size", "contentType", "generateImageMetadata"], + }, + { + pattern: "{src/,}app/**/{sitemap,robots,manifest}.{js,ts}", + exports: ["default"], + }, + { + pattern: "{src/,}pages/**/*.{js,jsx,ts,tsx}", + exports: [ + "default", + "getStaticProps", + "getStaticPaths", + "getServerSideProps", + "config", + "reportWebVitals", + ], + }, + { + pattern: "{src/,}middleware.{js,ts}", + exports: ["default", "middleware", "config"], + }, + { + pattern: "{src/,}instrumentation.{js,ts}", + exports: ["register", "onRequestError"], + }, + { + pattern: "{src/,}instrumentation-client.{js,ts}", + exports: ["onRouterTransitionStart"], + }, + { + pattern: "next.config.{js,mjs,cjs,ts}", + exports: ["default"], + }, + ], + generatedImportSuffixes: ["/$types"], + virtualModulePrefixes: ["@/."], + }, + { + name: "vite", + enablers: ["vite"], + // `vite.config.{...}` is handled by SUPPORT_ENTRY_PATTERNS so it stays + // reachable for dependency tracking without being treated as runtime + // code — otherwise plugin imports (`@vitejs/plugin-react`, + // `@tailwindcss/vite`, etc.) get flagged as `runtime-dev-dependency` + // even though they're correctly listed in devDependencies. + entryPatterns: ["index.html", "src/main.{js,jsx,ts,tsx}"], + entryRole: "runtime", + toolingDependencies: ["vite"], + virtualModulePrefixes: ["virtual:"], + }, + { + name: "vitest", + enablers: ["vitest"], + entryPatterns: ["**/*.{test,spec}.{js,jsx,ts,tsx}", "vitest.config.{js,mjs,cjs,ts}"], + entryRole: "test", + toolingDependencies: ["vitest"], + }, + { + name: "jest", + enablers: ["jest", "ts-jest", "@jest/"], + entryPatterns: ["**/*.{test,spec}.{js,jsx,ts,tsx}", "jest.config.{js,mjs,cjs,ts}"], + entryRole: "test", + toolingDependencies: ["jest", "ts-jest"], + }, + { + name: "eslint", + enablers: ["eslint", "@eslint/"], + entryPatterns: ["eslint.config.{js,mjs,cjs,ts}"], + entryRole: "support", + toolingDependencies: ["eslint"], + usedExports: [{ pattern: "eslint.config.{js,mjs,cjs,ts}", exports: ["default"] }], + }, + { + name: "tailwindcss", + enablers: ["tailwindcss", "@tailwindcss/postcss", "@tailwindcss/vite", "@tailwindcss/cli"], + entryPatterns: ["tailwind.config.{js,mjs,cjs,ts}"], + entryRole: "support", + toolingDependencies: [ + "tailwindcss", + "@tailwindcss/postcss", + "@tailwindcss/vite", + "@tailwindcss/cli", + ], + usedExports: [{ pattern: "tailwind.config.{js,mjs,cjs,ts}", exports: ["default"] }], + }, + { + name: "postcss", + enablers: ["postcss", "@tailwindcss/postcss"], + entryPatterns: ["postcss.config.{js,mjs,cjs,ts}"], + entryRole: "support", + toolingDependencies: ["postcss"], + usedExports: [{ pattern: "postcss.config.{js,mjs,cjs,ts}", exports: ["default"] }], + }, + { + name: "playwright", + enablers: ["@playwright/test", "playwright"], + entryPatterns: ["playwright.config.{js,mjs,cjs,ts}"], + entryRole: "support", + toolingDependencies: ["@playwright/test", "playwright"], + usedExports: [{ pattern: "playwright.config.{js,mjs,cjs,ts}", exports: ["default"] }], + }, + { + name: "tsup", + enablers: ["tsup"], + entryPatterns: ["tsup.config.{js,mjs,cjs,ts}"], + entryRole: "support", + toolingDependencies: ["tsup"], + usedExports: [{ pattern: "tsup.config.{js,mjs,cjs,ts}", exports: ["default"] }], + }, + { + // shadcn/ui generates registry-installed component files under + // `components/ui/` (or the user-configured alias). The user only + // imports the top-level component (`Sidebar`, `Dialog`, ...) — many of + // the named sub-component exports (`SidebarHeader`, `DialogTrigger`, + // ...) ship intentionally over-exported as part of the design-system + // surface, so we exempt the whole directory from dead-code analysis. + name: "shadcn", + enablers: [], + isEnabled: (workspace: WorkspaceInfo) => + existsSync(path.join(workspace.directory, "components.json")), + entryPatterns: [], + entryRole: "support", + alwaysUsedPatterns: ["{src/,}components/ui/**/*.{js,jsx,ts,tsx}"], + toolingDependencies: [], + }, + { + name: "storybook", + enablers: ["storybook", "@storybook/"], + entryPatterns: ["**/*.stories.{js,jsx,ts,tsx}", ".storybook/**/*.{js,jsx,ts,tsx}"], + entryRole: "support", + toolingDependencies: ["storybook"], + }, + { + name: "tanstack-start", + enablers: ["@tanstack/react-start", "@tanstack/start"], + entryPatterns: ["app/routes/**/*.{js,jsx,ts,tsx}", "src/routes/**/*.{js,jsx,ts,tsx}"], + entryRole: "runtime", + toolingDependencies: ["@tanstack/react-start"], + }, + { + name: "react-native", + enablers: ["react-native", "expo"], + entryPatterns: ["App.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "index.{js,jsx,ts,tsx}"], + entryRole: "runtime", + toolingDependencies: ["react-native", "expo"], + }, +]; + +const isPluginEnabled = (plugin: CodebasePlugin, workspace: WorkspaceInfo): boolean => { + if (plugin.isEnabled?.(workspace)) return true; + return plugin.enablers.some((enabler) => { + if (enabler.endsWith("/")) { + return [...workspace.dependencyNames].some((dependencyName) => + dependencyName.startsWith(enabler), + ); + } + return workspace.dependencyNames.has(enabler); + }); +}; + +const mergePluginResult = ( + target: CodebasePluginResult, + plugin: CodebasePlugin, + workspace: WorkspaceInfo, +): void => { + target.entryPatterns.push( + ...plugin.entryPatterns.map((pattern) => ({ pattern, role: plugin.entryRole })), + ); + target.alwaysUsedPatterns.push(...(plugin.alwaysUsedPatterns ?? [])); + target.virtualModulePrefixes.push(...(plugin.virtualModulePrefixes ?? [])); + target.generatedImportSuffixes.push(...(plugin.generatedImportSuffixes ?? [])); + for (const dependencyName of plugin.toolingDependencies ?? []) { + target.toolingDependencies.add(dependencyName); + } + for (const usedExportRule of plugin.usedExports ?? []) { + target.usedExports.set(usedExportRule.pattern, new Set(usedExportRule.exports)); + } + const packageJsonResult = plugin.resolvePackageJson?.(workspace.manifest); + if (!packageJsonResult) return; + target.entryPatterns.push(...packageJsonResult.entryPatterns); + target.alwaysUsedPatterns.push(...packageJsonResult.alwaysUsedPatterns); + target.virtualModulePrefixes.push(...packageJsonResult.virtualModulePrefixes); + target.generatedImportSuffixes.push(...packageJsonResult.generatedImportSuffixes); + for (const dependencyName of packageJsonResult.toolingDependencies) { + target.toolingDependencies.add(dependencyName); + } + for (const [pattern, exportNames] of packageJsonResult.usedExports) { + target.usedExports.set(pattern, exportNames); + } +}; + +export const runCodebasePlugins = ( + workspaces: WorkspaceInfo[], +): Map => { + const results = new Map(); + for (const workspace of workspaces) { + const result = createEmptyPluginResult(); + for (const plugin of builtInPlugins) { + if (isPluginEnabled(plugin, workspace)) { + mergePluginResult(result, plugin, workspace); + } + } + results.set(workspace.id, result); + } + return results; +}; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/plugins/types.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/plugins/types.ts new file mode 100644 index 0000000000..e8a02688fc --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/plugins/types.ts @@ -0,0 +1,29 @@ +import type { EntryPointRole, PackageJsonObject, WorkspaceInfo } from "../types.js"; + +export interface CodebasePluginResult { + entryPatterns: CodebasePluginEntryPattern[]; + alwaysUsedPatterns: string[]; + usedExports: Map>; + toolingDependencies: Set; + virtualModulePrefixes: string[]; + generatedImportSuffixes: string[]; +} + +export interface CodebasePluginEntryPattern { + pattern: string; + role: EntryPointRole; +} + +export interface CodebasePlugin { + name: string; + enablers: string[]; + entryPatterns: string[]; + entryRole: EntryPointRole; + alwaysUsedPatterns?: string[]; + toolingDependencies?: string[]; + virtualModulePrefixes?: string[]; + generatedImportSuffixes?: string[]; + usedExports?: Array<{ pattern: string; exports: string[] }>; + isEnabled?: (workspace: WorkspaceInfo) => boolean; + resolvePackageJson?: (manifest: PackageJsonObject) => CodebasePluginResult | null; +} diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/resolve.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/resolve.ts new file mode 100644 index 0000000000..0de4343f2b --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/resolve.ts @@ -0,0 +1,524 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; +import { ResolverFactory } from "oxc-resolver"; +import { + ASSET_FILE_EXTENSIONS, + DEFAULT_CONDITION_NAMES, + RESOLVE_EXTENSIONS, + SOURCE_FILE_EXTENSIONS, + TYPESCRIPT_DECLARATION_EXTENSIONS, +} from "./constants.js"; +import { collectManifestEntrySpecifiers } from "./manifest.js"; +import { + getPackageNameFromSpecifier, + isUrlLikeSpecifier, + matchesGlob, + toPortablePath, + toRelativePath, +} from "./path-utils.js"; +import type { CodebasePluginResult } from "./plugins/types.js"; +import type { CodebaseModule, ResolvedImport, ResolvedModule, WorkspaceInfo } from "./types.js"; + +const createResolver = (): ResolverFactory => + new ResolverFactory({ + tsconfig: "auto", + conditionNames: DEFAULT_CONDITION_NAMES, + extensions: RESOLVE_EXTENSIONS, + extensionAlias: { + ".js": [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"], + ".jsx": [".tsx", ".jsx"], + ".mjs": [".mts", ".mjs"], + ".cjs": [".cts", ".cjs"], + }, + mainFields: ["module", "browser", "main"], + builtinModules: true, + symlinks: false, + }); + +const isInsideRoot = (rootDirectory: string, filePath: string): boolean => { + const relativePath = path.relative(rootDirectory, filePath); + return Boolean(relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +}; + +const isNodeModulePath = (filePath: string): boolean => + filePath.split(path.sep).includes("node_modules"); + +const getKnownAssetExtension = (specifier: string): string | null => { + const lowerSpecifier = specifier.toLowerCase(); + const extension = [...ASSET_FILE_EXTENSIONS] + .sort((first, second) => second.length - first.length) + .find((item) => lowerSpecifier.endsWith(item)); + return extension ?? null; +}; + +const isKnownAssetSpecifier = (specifier: string): boolean => + Boolean(getKnownAssetExtension(specifier)); + +const findWorkspacePackageTarget = ( + sourceFilePaths: ReadonlyMap, + workspaces: WorkspaceInfo[], + packageName: string | null, + importSource: string, +): string | null => { + if (!packageName) return null; + const workspace = workspaces.find((item) => item.name === packageName); + if (!workspace) return null; + const subpath = importSource === packageName ? "" : importSource.slice(packageName.length + 1); + const exportTargets = + subpath.length > 0 + ? collectManifestExportTargets(workspace.manifest.exports, `./${subpath}`) + : []; + const relativeCandidates = + subpath.length > 0 + ? [...exportTargets, subpath, path.join("src", subpath)] + : [ + ...collectManifestEntrySpecifiers(workspace.manifest), + "src/index", + "index", + "src/main", + "main", + ]; + const candidates = relativeCandidates.flatMap((candidate) => + toWorkspaceSourceCandidates(workspace.directory, candidate), + ); + return findExistingSourcePath(sourceFilePaths, candidates, [workspace]); +}; + +const collectManifestExportTargets = (exportsField: unknown, exportKey: string): string[] => { + if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) return []; + const exportValue = (exportsField as Record)[exportKey]; + return [ + ...collectStringValues(exportValue), + ...collectWildcardManifestExportTargets(exportsField as Record, exportKey), + ].filter((value) => value.startsWith(".") || value.startsWith("/")); +}; + +const collectWildcardManifestExportTargets = ( + exportsField: Record, + exportKey: string, +): string[] => { + const targets: string[] = []; + for (const [pattern, value] of Object.entries(exportsField)) { + if (!pattern.includes("*")) continue; + const matchedValue = matchWildcardExportKey(pattern, exportKey); + if (matchedValue === null) continue; + targets.push( + ...collectStringValues(value).map((target) => target.replaceAll("*", matchedValue)), + ); + } + return targets; +}; + +const matchWildcardExportKey = (pattern: string, exportKey: string): string | null => { + const wildcardIndex = pattern.indexOf("*"); + const prefix = pattern.slice(0, wildcardIndex); + const suffix = pattern.slice(wildcardIndex + 1); + if (!exportKey.startsWith(prefix) || !exportKey.endsWith(suffix)) return null; + return exportKey.slice(prefix.length, exportKey.length - suffix.length); +}; + +const collectStringValues = (value: unknown): string[] => { + if (typeof value === "string") return [value]; + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap(collectStringValues); + return Object.values(value).flatMap(collectStringValues); +}; + +const toWorkspaceSourceCandidates = (workspaceDirectory: string, candidate: string): string[] => { + const absolutePath = path.resolve(workspaceDirectory, candidate); + const extension = path.extname(absolutePath); + if (extension) return [absolutePath, ...toIndexCandidates(absolutePath)]; + return [ + absolutePath, + ...SOURCE_FILE_EXTENSIONS.map((sourceExtension) => `${absolutePath}${sourceExtension}`), + ...toIndexCandidates(absolutePath), + ]; +}; + +const toIndexCandidates = (absolutePath: string): string[] => + SOURCE_FILE_EXTENSIONS.map((sourceExtension) => + path.join(absolutePath, `index${sourceExtension}`), + ); + +const findExistingSourcePath = ( + sourceFilePaths: ReadonlyMap, + candidates: string[], + workspaces: readonly WorkspaceInfo[] = [], +): string | null => { + for (const candidate of candidates) { + if (sourceFilePaths.has(candidate)) return candidate; + const sourceMappedTargetPath = findSourceMappedTarget(sourceFilePaths, candidate, workspaces); + if (sourceMappedTargetPath) return sourceMappedTargetPath; + } + return null; +}; + +const stripCompiledExtension = (filePath: string): string => { + const declarationExtension = TYPESCRIPT_DECLARATION_EXTENSIONS.find((extension) => + filePath.endsWith(extension), + ); + if (declarationExtension) return filePath.slice(0, -declarationExtension.length); + const extension = path.extname(filePath); + if (extension === ".js" || extension === ".mjs" || extension === ".cjs") { + return filePath.slice(0, -extension.length); + } + return filePath; +}; + +const toConventionalSourceMappedPath = (filePath: string): string | null => { + const distSegment = `${path.sep}dist${path.sep}`; + if (!filePath.includes(distSegment)) return null; + const sourcePath = filePath.replace(distSegment, `${path.sep}src${path.sep}`); + return stripCompiledExtension(sourcePath); +}; + +const isUnderDirectory = (filePath: string, directory: string): boolean => { + const relativePath = path.relative(directory, filePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +}; + +const toConfiguredSourceMappedPath = ( + filePath: string, + workspaces: readonly WorkspaceInfo[], +): string | null => { + const sourceMap = workspaces + .flatMap((workspace) => workspace.sourceMaps) + .sort((first, second) => second.outputDirectory.length - first.outputDirectory.length) + .find((item) => isUnderDirectory(filePath, item.outputDirectory)); + if (!sourceMap) return null; + return stripCompiledExtension( + path.join(sourceMap.sourceDirectory, path.relative(sourceMap.outputDirectory, filePath)), + ); +}; + +const findSourceMappedTarget = ( + sourceFilePaths: ReadonlyMap, + filePath: string, + workspaces: readonly WorkspaceInfo[] = [], +): string | null => { + const sourceMappedBasePath = + toConfiguredSourceMappedPath(filePath, workspaces) ?? toConventionalSourceMappedPath(filePath); + if (!sourceMappedBasePath) return null; + const candidates = [ + sourceMappedBasePath, + `${sourceMappedBasePath}.mts`, + `${sourceMappedBasePath}.cts`, + `${sourceMappedBasePath}.ts`, + `${sourceMappedBasePath}.tsx`, + `${sourceMappedBasePath}.mjs`, + `${sourceMappedBasePath}.cjs`, + `${sourceMappedBasePath}.js`, + `${sourceMappedBasePath}.jsx`, + ]; + return candidates.find((candidate) => sourceFilePaths.has(candidate)) ?? null; +}; + +const isVirtualOrGeneratedImport = ( + importSource: string, + pluginResult: CodebasePluginResult | undefined, +): boolean => + Boolean( + pluginResult && + (pluginResult.virtualModulePrefixes.some((prefix) => importSource.startsWith(prefix)) || + pluginResult.generatedImportSuffixes.some((suffix) => importSource.endsWith(suffix))), + ); + +interface NormalizedSpecifier { + resource: string; + loaderPackageNames: string[]; +} + +const stripResourceQuery = (specifier: string): string => { + const queryIndex = specifier.search(/[?#]/); + return queryIndex >= 0 ? specifier.slice(0, queryIndex) : specifier; +}; + +const normalizeLoaderName = (loader: string): string | null => { + const normalizedLoader = loader.replace(/^[-!]+/, "").trim(); + return getPackageNameFromSpecifier(normalizedLoader); +}; + +const normalizeBundlerSpecifier = (specifier: string): NormalizedSpecifier => { + const parts = specifier.split("!"); + const resource = stripResourceQuery(parts.at(-1) ?? specifier); + return { + resource, + loaderPackageNames: parts + .slice(0, -1) + .map(normalizeLoaderName) + .filter((packageName): packageName is string => Boolean(packageName)), + }; +}; + +const toExternalPackageImport = ( + importRecord: CodebaseModule["imports"][number], + packageName: string, +): ResolvedImport => ({ + importRecord, + targetKind: "external", + targetFilePath: null, + packageName, + error: null, +}); + +const toRuntimeUrlAssetImport = ( + module: CodebaseModule, + importRecord: CodebaseModule["imports"][number], + importSource: string, +): ResolvedImport => { + const resolvedFilePath = path.resolve(path.dirname(module.file.filePath), importSource); + const hasExistingTarget = existsSync(resolvedFilePath) && statSync(resolvedFilePath).isFile(); + return { + importRecord, + targetKind: "asset", + targetFilePath: hasExistingTarget ? resolvedFilePath : null, + packageName: null, + error: null, + }; +}; + +const toContextGlobPattern = ( + rootDirectory: string, + module: CodebaseModule, + importRecord: CodebaseModule["imports"][number], +): string => { + if (importRecord.context?.kind === "require-context") { + const baseDirectory = getContextBaseDirectory(rootDirectory, module, importRecord); + return path.join(baseDirectory, importRecord.context.recursive === false ? "*" : "**/*"); + } + if (importRecord.source.startsWith("/")) { + return path.join(rootDirectory, importRecord.source.slice(1)); + } + return path.resolve(path.dirname(module.file.filePath), importRecord.source); +}; + +const getContextBaseDirectory = ( + rootDirectory: string, + module: CodebaseModule, + importRecord: CodebaseModule["imports"][number], +): string => { + if (importRecord.context?.kind === "require-context") { + return path.resolve(path.dirname(module.file.filePath), importRecord.source); + } + if (importRecord.source.startsWith("/")) return rootDirectory; + return path.dirname(path.resolve(path.dirname(module.file.filePath), importRecord.source)); +}; + +const createContextRegex = (importRecord: CodebaseModule["imports"][number]): RegExp | null => { + const pattern = importRecord.context?.regexPattern; + if (!pattern) return null; + try { + return new RegExp(pattern, importRecord.context?.regexFlags ?? ""); + } catch { + return null; + } +}; + +const matchesContextRegex = ( + rootDirectory: string, + module: CodebaseModule, + filePath: string, + importRecord: CodebaseModule["imports"][number], +): boolean => { + const regex = createContextRegex(importRecord); + if (!regex) return true; + const baseDirectory = getContextBaseDirectory(rootDirectory, module, importRecord); + const importerRelativePath = toRelativePath(baseDirectory, filePath); + return regex.test(`./${importerRelativePath}`); +}; + +const resolveContextImports = ( + module: CodebaseModule, + rootDirectory: string, + sourceFilePaths: ReadonlyMap, + importRecord: CodebaseModule["imports"][number], +): ResolvedImport[] => { + const globPattern = toPortablePath(toContextGlobPattern(rootDirectory, module, importRecord)); + return [...sourceFilePaths.keys()] + .filter((filePath) => matchesGlob(toPortablePath(filePath), globPattern)) + .filter((filePath) => matchesContextRegex(rootDirectory, module, filePath, importRecord)) + .map((filePath) => ({ + importRecord, + targetKind: "internal", + targetFilePath: filePath, + packageName: null, + error: null, + })); +}; + +const resolveImport = ( + module: CodebaseModule, + resolver: ResolverFactory, + rootDirectory: string, + sourceFilePaths: ReadonlyMap, + workspaces: WorkspaceInfo[], + pluginResults: ReadonlyMap, + importRecord: CodebaseModule["imports"][number], +): ResolvedImport => { + const normalizedSpecifier = normalizeBundlerSpecifier(importRecord.source); + const importSource = normalizedSpecifier.resource; + const packageName = getPackageNameFromSpecifier(importSource); + const pluginResult = pluginResults.get(module.file.workspaceId); + if (isUrlLikeSpecifier(importSource)) { + return { + importRecord, + targetKind: "asset", + targetFilePath: null, + packageName: null, + error: null, + }; + } + if (isKnownAssetSpecifier(importSource)) { + return { + importRecord, + targetKind: packageName ? "external" : "asset", + targetFilePath: packageName + ? null + : path.resolve(path.dirname(module.file.filePath), importSource), + packageName, + error: null, + }; + } + if (isVirtualOrGeneratedImport(importSource, pluginResult)) { + return { + importRecord, + targetKind: "asset", + targetFilePath: null, + packageName, + error: null, + }; + } + const result = resolver.resolveFileSync(module.file.filePath, importSource); + + if (result.builtin) { + return { + importRecord, + targetKind: "builtin", + targetFilePath: null, + packageName, + error: null, + }; + } + + if (result.path) { + const resolvedPath = path.resolve(result.path); + const sourceMappedTargetPath = findSourceMappedTarget( + sourceFilePaths, + resolvedPath, + workspaces, + ); + const internalTargetPath = sourceFilePaths.has(resolvedPath) + ? resolvedPath + : sourceMappedTargetPath; + if (internalTargetPath) { + return { + importRecord, + targetKind: "internal", + targetFilePath: internalTargetPath, + packageName, + error: null, + }; + } + return { + importRecord, + targetKind: + isInsideRoot(rootDirectory, resolvedPath) && !isNodeModulePath(resolvedPath) + ? "asset" + : "external", + targetFilePath: resolvedPath, + packageName, + error: null, + }; + } + + const workspaceTargetPath = findWorkspacePackageTarget( + sourceFilePaths, + workspaces, + packageName, + importSource, + ); + if (workspaceTargetPath) { + return { + importRecord, + targetKind: "internal", + targetFilePath: workspaceTargetPath, + packageName, + error: null, + }; + } + + if (packageName) { + return toExternalPackageImport(importRecord, packageName); + } + + // `new URL("./x", import.meta.url)` is also commonly used for path + // computation (e.g. wrapped in `fileURLToPath` in Node configs), where + // the target is a directory or a runtime-only file that does not need to + // resolve as a module. Treat unresolved asset URLs as silent assets so + // they don't surface as `unresolved-import` false positives. + if (importRecord.kind === "asset") { + return toRuntimeUrlAssetImport(module, importRecord, importSource); + } + + return { + importRecord, + targetKind: "unresolved", + targetFilePath: null, + packageName, + error: result.error ?? "Unable to resolve import.", + }; +}; + +const resolveImportRecords = ( + module: CodebaseModule, + resolver: ResolverFactory, + rootDirectory: string, + sourceFilePaths: ReadonlyMap, + workspaces: WorkspaceInfo[], + pluginResults: ReadonlyMap, + importRecord: CodebaseModule["imports"][number], +): ResolvedImport[] => { + if (importRecord.kind === "context") { + return resolveContextImports(module, rootDirectory, sourceFilePaths, importRecord); + } + const normalizedSpecifier = normalizeBundlerSpecifier(importRecord.source); + return [ + ...normalizedSpecifier.loaderPackageNames.map((packageName) => + toExternalPackageImport(importRecord, packageName), + ), + resolveImport( + module, + resolver, + rootDirectory, + sourceFilePaths, + workspaces, + pluginResults, + importRecord, + ), + ]; +}; + +export const resolveModules = ( + rootDirectory: string, + modules: CodebaseModule[], + workspaces: WorkspaceInfo[], + pluginResults: ReadonlyMap, +): ResolvedModule[] => { + const resolver = createResolver(); + const sourceFilePaths = new Map(modules.map((module) => [module.file.filePath, module.file.id])); + + return modules.map((module) => ({ + module, + imports: module.imports.flatMap((importRecord) => + resolveImportRecords( + module, + resolver, + rootDirectory, + sourceFilePaths, + workspaces, + pluginResults, + importRecord, + ), + ), + })); +}; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/types.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/types.ts new file mode 100644 index 0000000000..d017d39cfc --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/types.ts @@ -0,0 +1,307 @@ +import type { CodebasePluginResult } from "./plugins/types.js"; + +export interface CodebaseAnalysisOptions { + rootDirectory: string; + includePaths?: string[]; + excludePatterns?: string[]; + signal?: AbortSignal; +} + +export interface CodebaseAnalysisConfig { + rootDirectory: string; + includePaths: string[]; + excludePatterns: string[]; + conditionNames: string[]; + production: boolean; +} + +export interface PackageJsonObject { + name?: string; + version?: string; + type?: string; + main?: string; + module?: string; + browser?: string | Record; + source?: string; + types?: string; + typings?: string; + bin?: string | Record; + exports?: unknown; + imports?: unknown; + files?: string[]; + sideEffects?: boolean | string[]; + scripts?: Record; + workspaces?: string[] | { packages?: string[] }; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + peerDependenciesMeta?: Record; + [key: string]: unknown; +} + +export interface DependencyBuckets { + dependencies: Map; + devDependencies: Map; + peerDependencies: Map; + optionalDependencies: Map; +} + +export interface WorkspaceSourceMap { + sourceDirectory: string; + outputDirectory: string; +} + +export interface WorkspaceInfo { + id: number; + name: string; + directory: string; + relativeDirectory: string; + packageJsonPath: string; + manifest: PackageJsonObject; + dependencyBuckets: DependencyBuckets; + dependencyNames: Set; + manifestDependencyNames: Set; + scriptDependencyNames: Set; + typeScriptConfigDependencyNames: Set; + cssImportDependencyNames: Set; + sourceMaps: WorkspaceSourceMap[]; +} + +export interface ProjectFile { + id: number; + filePath: string; + relativePath: string; + extension: string; + sourceText: string; + workspaceId: number; + lineStarts: number[]; +} + +export interface DiscoveredSourceFile { + filePath: string; + relativePath: string; + extension: string; + sourceText: string; +} + +export interface SourcePosition { + line: number; + column: number; +} + +export interface ImportedBinding { + importedName: string; + localName: string; + isTypeOnly: boolean; + isNamespace: boolean; + start: number; + end: number; + referenceStart?: number; +} + +export interface ImportRecord { + source: string; + bindings: ImportedBinding[]; + kind: + | "static" + | "dynamic" + | "comment" + | "re-export" + | "require" + | "require-resolve" + | "import-meta" + | "context" + | "asset"; + context?: ContextImportOptions; + isTypeOnly: boolean; + isSideEffectOnly: boolean; + isOptional: boolean; + start: number; + end: number; + position: SourcePosition; +} + +export interface ContextImportOptions { + kind: "glob" | "require-context"; + recursive?: boolean; + regexPattern?: string; + regexFlags?: string; +} + +export interface ExportMemberRecord { + name: string; + kind: "class" | "enum" | "namespace"; + start: number; + end: number; + position: SourcePosition; + jsDocTags: Set; + hasLocalReferences: boolean; +} + +export interface ExportRecord { + exportedName: string; + localName: string | null; + source: string | null; + importedName: string | null; + symbolKind: "value" | "type" | "interface" | "enum" | "class" | "namespace" | "unknown"; + isTypeOnly: boolean; + isReExport: boolean; + isCommonJs: boolean; + isNamespace: boolean; + isReactComponentLike: boolean; + jsDocTags: Set; + members: ExportMemberRecord[]; + hasLocalReferences: boolean; + start: number; + end: number; + position: SourcePosition; +} + +export interface NamespaceMemberReference { + namespace: string; + memberName: string; + memberPath: string[]; + start: number; + end: number; +} + +export interface MemberObjectReference { + namespace: string; + memberPath: string[]; + start: number; + end: number; +} + +export interface ShadowRange { + start: number; + end: number; +} + +export interface NamespaceObjectAlias { + exportName: string; + propertyName: string; + namespaceLocalName: string; +} + +export interface NamespaceLocalAlias { + aliasName: string; + namespaceLocalName: string; + start: number; + end: number; +} + +export interface NamespaceLocalObjectAlias { + objectLocalName: string; + propertyName: string; + namespaceLocalName: string; +} + +export interface CodebaseModule { + file: ProjectFile; + imports: ImportRecord[]; + exports: ExportRecord[]; + directives: Set; + usedIdentifiers: Set; + usedIdentifierRanges: Map; + shadowRangesByName: Map; + namespaceMemberReferences: NamespaceMemberReference[]; + memberObjectReferences: MemberObjectReference[]; + namespaceObjectAliases: NamespaceObjectAlias[]; + namespaceLocalAliases: NamespaceLocalAlias[]; + namespaceLocalObjectAliases: NamespaceLocalObjectAlias[]; + cjsExportNames: Set; + parseErrors: string[]; +} + +export interface ResolvedImport { + importRecord: ImportRecord; + targetKind: "internal" | "external" | "builtin" | "asset" | "unresolved"; + targetFilePath: string | null; + packageName: string | null; + error: string | null; +} + +export interface ResolvedModule { + module: CodebaseModule; + imports: ResolvedImport[]; +} + +export interface SymbolReference { + fromFileId: number; + kind: + | "named" + | "default" + | "namespace" + | "namespace-member" + | "re-export" + | "dynamic" + | "side-effect"; + importRecord: ImportRecord; +} + +export interface GraphExportSymbol extends ExportRecord { + references: SymbolReference[]; + isPluginUsed: boolean; + isReferencedByNamespace: boolean; + referencedMemberNames: Set; +} + +export interface ModuleGraphNode { + file: ProjectFile; + imports: ResolvedImport[]; + importedBy: Set; + exports: Map; + directives: Set; + parseErrors: string[]; + usedIdentifiers: Set; + usedIdentifierRanges: Map; + shadowRangesByName: Map; + namespaceMemberReferences: NamespaceMemberReference[]; + memberObjectReferences: MemberObjectReference[]; + namespaceObjectAliases: NamespaceObjectAlias[]; + namespaceLocalAliases: NamespaceLocalAlias[]; + namespaceLocalObjectAliases: NamespaceLocalObjectAlias[]; + entryRoles: Set; + entrySources: Set; + isReachable: boolean; + isRuntimeReachable: boolean; + isTestReachable: boolean; + isTypeReachable: boolean; + hasCjsExports: boolean; +} + +export interface EntryPoint { + fileId: number; + role: EntryPointRole; + source: string; +} + +export interface PackageUsage { + packageName: string; + workspaceId: number; + fromFileId: number; + specifier: string; + isTypeOnly: boolean; + isRuntime: boolean; + isTestOnly: boolean; +} + +export interface ModuleGraph { + rootDirectory: string; + config: CodebaseAnalysisConfig; + workspaces: WorkspaceInfo[]; + files: ProjectFile[]; + nodes: Map; + pathToFileId: Map; + entryPoints: EntryPoint[]; + packageUsages: PackageUsage[]; + unresolvedImports: ResolvedImport[]; + pluginResults: ReadonlyMap; +} + +export interface CodebaseAnalysisResult { + graph: ModuleGraph; +} + +export type EntryPointRole = "runtime" | "test" | "support"; diff --git a/packages/react-doctor/src/core/rules/codebase/analyzer/workspace.ts b/packages/react-doctor/src/core/rules/codebase/analyzer/workspace.ts new file mode 100644 index 0000000000..7ec2da89bd --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/analyzer/workspace.ts @@ -0,0 +1,494 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { IGNORED_DIRECTORY_NAMES, PACKAGE_JSON_FILENAME } from "./constants.js"; +import { + collectDependencyNames, + collectManifestDependencyNames, + collectScriptDependencyNames, + createDependencyBuckets, + readPackageJson, +} from "./manifest.js"; +import { getPackageNameFromSpecifier, matchesGlob, toRelativePath } from "./path-utils.js"; +import type { + CodebaseAnalysisConfig, + PackageJsonObject, + WorkspaceInfo, + WorkspaceSourceMap, +} from "./types.js"; + +interface TypeScriptConfigJson { + extends?: unknown; + compilerOptions?: { + importHelpers?: unknown; + jsxImportSource?: unknown; + outDir?: unknown; + plugins?: unknown; + rootDir?: unknown; + types?: unknown; + }; + references?: Array<{ path?: unknown }>; +} + +interface TypeScriptDirectoryOptions { + dependencyNames: Set; + outDir?: string; + rootDir?: string; +} + +const toWorkspacePatternsFromPackageJson = (manifest: PackageJsonObject | null): string[] => { + if (!manifest?.workspaces) return []; + if (Array.isArray(manifest.workspaces)) return manifest.workspaces; + return manifest.workspaces.packages ?? []; +}; + +const stripYamlComment = (line: string): string => { + let quote: string | null = null; + for (let index = 0; index < line.length; index++) { + const character = line[index]; + if ((character === '"' || character === "'") && line[index - 1] !== "\\") { + quote = quote === character ? null : (quote ?? character); + } + if (character === "#" && !quote) return line.slice(0, index); + } + return line; +}; + +const cleanYamlStringValue = (value: string): string => + stripYamlComment(value) + .trim() + .replace(/^["']|["']$/g, ""); + +const parseYamlInlineStringArray = (value: string): string[] => { + const trimmedValue = cleanYamlStringValue(value); + if (!trimmedValue.startsWith("[") || !trimmedValue.endsWith("]")) return []; + return trimmedValue.slice(1, -1).split(",").map(cleanYamlStringValue).filter(Boolean); +}; + +const parsePnpmWorkspacePatterns = (sourceText: string): string[] => { + const patterns: string[] = []; + let isInPackagesSection = false; + let packagesSectionIndent = 0; + + for (const rawLine of sourceText.split("\n")) { + const line = stripYamlComment(rawLine); + if (line.trim().length === 0) continue; + const indent = line.length - line.trimStart().length; + const trimmedLine = line.trim(); + const sectionMatch = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(trimmedLine); + + if (sectionMatch && indent === 0) { + isInPackagesSection = sectionMatch[1] === "packages"; + packagesSectionIndent = indent; + if (isInPackagesSection && sectionMatch[2]) { + patterns.push(...parseYamlInlineStringArray(sectionMatch[2])); + } + continue; + } + + if (!isInPackagesSection || indent < packagesSectionIndent || !trimmedLine.startsWith("-")) { + continue; + } + + const pattern = cleanYamlStringValue(trimmedLine.slice(1)); + if (pattern.length > 0) patterns.push(pattern); + } + + return patterns; +}; + +const readPnpmWorkspacePatterns = async (rootDirectory: string): Promise => { + try { + const sourceText = await fs.readFile(path.join(rootDirectory, "pnpm-workspace.yaml"), "utf8"); + return parsePnpmWorkspacePatterns(sourceText); + } catch { + return []; + } +}; + +const hasPackageJson = async (directory: string): Promise => { + try { + const stats = await fs.stat(path.join(directory, PACKAGE_JSON_FILENAME)); + return stats.isFile(); + } catch { + return false; + } +}; + +const hasDirectory = async (directory: string): Promise => { + try { + const stats = await fs.stat(directory); + return stats.isDirectory(); + } catch { + return false; + } +}; + +const parseJsonWithComments = (sourceText: string): unknown => + JSON.parse(sourceText.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "").replace(/,\s*([}\]])/g, "$1")); + +const hasFile = async (filePath: string): Promise => { + try { + const stats = await fs.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +}; + +const resolveExtendedTypeScriptConfigPath = async ( + tsconfigPath: string, + extendsValue: unknown, +): Promise => { + if (typeof extendsValue !== "string" || extendsValue.length === 0) return null; + if (!extendsValue.startsWith(".") && !extendsValue.startsWith("/")) return null; + const directory = path.dirname(tsconfigPath); + const resolvedPath = path.resolve(directory, extendsValue); + const candidates = [ + resolvedPath, + `${resolvedPath}.json`, + path.join(resolvedPath, "tsconfig.json"), + ]; + for (const candidate of candidates) { + if (await hasFile(candidate)) return candidate; + } + return null; +}; + +const resolveReferencedTypeScriptConfigPath = async ( + tsconfigPath: string, + referencePath: unknown, +): Promise => { + if (typeof referencePath !== "string" || referencePath.length === 0) return null; + const directory = path.dirname(tsconfigPath); + const resolvedPath = path.resolve(directory, referencePath); + const candidates = [ + resolvedPath, + `${resolvedPath}.json`, + path.join(resolvedPath, "tsconfig.json"), + ]; + for (const candidate of candidates) { + if (await hasFile(candidate)) return candidate; + } + return null; +}; + +const toDirectoryOption = (value: unknown, directory: string): string | undefined => + typeof value === "string" && value.length > 0 ? path.resolve(directory, value) : undefined; + +const toDefinitelyTypedPackageName = (typeName: string): string => { + if (typeName.startsWith("@types/")) return typeName; + if (typeName.startsWith("@")) return `@types/${typeName.slice(1).replace("/", "__")}`; + return `@types/${typeName}`; +}; + +const collectExtendsDependencyNames = (extendsValue: unknown): Set => { + const dependencyNames = new Set(); + const specifiers = Array.isArray(extendsValue) ? extendsValue : [extendsValue]; + for (const specifier of specifiers) { + if (typeof specifier !== "string" || specifier.length === 0) continue; + const packageName = getPackageNameFromSpecifier(specifier); + if (packageName) dependencyNames.add(packageName); + } + return dependencyNames; +}; + +const collectTypeScriptConfigDependencyNames = (config: TypeScriptConfigJson): Set => { + const dependencyNames = collectExtendsDependencyNames(config.extends); + const compilerOptions = config.compilerOptions; + if (!compilerOptions) return dependencyNames; + if ( + typeof compilerOptions.jsxImportSource === "string" && + compilerOptions.jsxImportSource.length > 0 + ) { + dependencyNames.add(compilerOptions.jsxImportSource); + } + if (compilerOptions.importHelpers === true) { + dependencyNames.add("tslib"); + } + if (Array.isArray(compilerOptions.types)) { + for (const typeName of compilerOptions.types) { + if (typeof typeName === "string" && typeName.length > 0) { + dependencyNames.add(toDefinitelyTypedPackageName(typeName)); + } + } + } + if (Array.isArray(compilerOptions.plugins)) { + for (const plugin of compilerOptions.plugins) { + if ( + plugin && + typeof plugin === "object" && + "name" in plugin && + typeof plugin.name === "string" && + plugin.name.length > 0 + ) { + dependencyNames.add(plugin.name); + } + } + } + return dependencyNames; +}; + +const readTypeScriptDirectoryOptions = async ( + tsconfigPath: string, + visitedPaths = new Set(), +): Promise => { + if (visitedPaths.has(tsconfigPath)) return null; + visitedPaths.add(tsconfigPath); + try { + const directory = path.dirname(tsconfigPath); + const config = parseJsonWithComments( + await fs.readFile(tsconfigPath, "utf8"), + ) as TypeScriptConfigJson; + const extendedPath = await resolveExtendedTypeScriptConfigPath(tsconfigPath, config.extends); + const inheritedOptions = extendedPath + ? await readTypeScriptDirectoryOptions(extendedPath, visitedPaths) + : null; + const options = { + ...inheritedOptions, + dependencyNames: new Set([ + ...(inheritedOptions?.dependencyNames ?? []), + ...collectTypeScriptConfigDependencyNames(config), + ]), + rootDir: + toDirectoryOption(config.compilerOptions?.rootDir, directory) ?? inheritedOptions?.rootDir, + outDir: + toDirectoryOption(config.compilerOptions?.outDir, directory) ?? inheritedOptions?.outDir, + }; + if (options.rootDir && options.outDir) return options; + for (const reference of config.references ?? []) { + const referencedPath = await resolveReferencedTypeScriptConfigPath( + tsconfigPath, + reference.path, + ); + if (!referencedPath) continue; + const referencedOptions = await readTypeScriptDirectoryOptions(referencedPath, visitedPaths); + for (const dependencyName of referencedOptions?.dependencyNames ?? []) { + options.dependencyNames.add(dependencyName); + } + options.rootDir ??= referencedOptions?.rootDir; + options.outDir ??= referencedOptions?.outDir; + if (options.rootDir && options.outDir) break; + } + return options; + } catch { + return null; + } +}; + +const readTypeScriptSourceMaps = async (directory: string): Promise => { + const directoryOptions = await readTypeScriptDirectoryOptions( + path.join(directory, "tsconfig.json"), + ); + if (!directoryOptions?.outDir) return []; + const sourceDirectory = + directoryOptions.rootDir ?? + ((await hasDirectory(path.join(directory, "src"))) ? path.join(directory, "src") : directory); + return [ + { + sourceDirectory, + outputDirectory: directoryOptions.outDir, + }, + ]; +}; + +const readTypeScriptConfigDependencyNames = async (directory: string): Promise> => + (await readTypeScriptDirectoryOptions(path.join(directory, "tsconfig.json")))?.dependencyNames ?? + new Set(); + +const CSS_EXTENSIONS = new Set([".css", ".scss", ".less"]); + +const CSS_IMPORT_PATTERN = /@import\s+["']([^"']+)["']/g; + +const isCssFile = (fileName: string): boolean => + CSS_EXTENSIONS.has(path.extname(fileName).toLowerCase()); + +const extractCssImportPackageNames = (sourceText: string): Set => { + const packageNames = new Set(); + for (const match of sourceText.matchAll(CSS_IMPORT_PATTERN)) { + const specifier = match[1]; + if (!specifier) continue; + const packageName = getPackageNameFromSpecifier(specifier); + if (packageName) packageNames.add(packageName); + } + return packageNames; +}; + +const discoverCssFilePaths = async (directory: string): Promise => { + let entries: Dirent[]; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + const filePaths: string[] = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory() && !IGNORED_DIRECTORY_NAMES.has(entry.name)) { + filePaths.push(...(await discoverCssFilePaths(entryPath))); + } else if (entry.isFile() && isCssFile(entry.name)) { + filePaths.push(entryPath); + } + } + return filePaths; +}; + +const readCssImportDependencyNames = async (directory: string): Promise> => { + const dependencyNames = new Set(); + const cssFilePaths = await discoverCssFilePaths(directory); + for (const filePath of cssFilePaths) { + try { + const sourceText = await fs.readFile(filePath, "utf8"); + for (const packageName of extractCssImportPackageNames(sourceText)) { + dependencyNames.add(packageName); + } + } catch { + continue; + } + } + return dependencyNames; +}; + +const expandSimpleWorkspacePattern = async ( + rootDirectory: string, + pattern: string, +): Promise => { + const normalizedPattern = pattern.replace(/\/\*\*$/, "/*"); + const wildcardIndex = normalizedPattern.indexOf("*"); + if (wildcardIndex < 0) { + const directory = path.resolve(rootDirectory, normalizedPattern); + return (await hasPackageJson(directory)) ? [directory] : []; + } + + const prefix = normalizedPattern.slice(0, wildcardIndex).replace(/\/$/, ""); + const suffix = normalizedPattern.slice(wildcardIndex + 1).replace(/^\//, ""); + const baseDirectory = path.resolve(rootDirectory, prefix || "."); + let entries: string[]; + try { + entries = await fs.readdir(baseDirectory); + } catch { + return []; + } + + const directories: string[] = []; + for (const entry of entries) { + const candidateDirectory = path.join(baseDirectory, entry, suffix); + if (await hasPackageJson(candidateDirectory)) directories.push(candidateDirectory); + } + return directories; +}; + +const isNegatedWorkspacePattern = (pattern: string): boolean => pattern.startsWith("!"); + +const toPositiveWorkspacePattern = (pattern: string): string => + isNegatedWorkspacePattern(pattern) ? pattern.slice(1) : pattern; + +const isExcludedWorkspaceDirectory = ( + rootDirectory: string, + directory: string, + negatedPatterns: string[], +): boolean => { + const relativeDirectory = toRelativePath(rootDirectory, directory); + return negatedPatterns + .map(toPositiveWorkspacePattern) + .some( + (pattern) => + matchesGlob(relativeDirectory, pattern) || + matchesGlob( + `${relativeDirectory}/package.json`, + `${pattern.replace(/\/$/, "")}/package.json`, + ), + ); +}; + +const discoverWorkspaceDirectories = async ( + config: CodebaseAnalysisConfig, + rootManifest: PackageJsonObject | null, +): Promise => { + const packagePatterns = toWorkspacePatternsFromPackageJson(rootManifest); + const pnpmPatterns = await readPnpmWorkspacePatterns(config.rootDirectory); + const patterns = [...new Set([...packagePatterns, ...pnpmPatterns])]; + const negatedPatterns = patterns.filter(isNegatedWorkspacePattern); + const positivePatterns = patterns.filter((pattern) => !isNegatedWorkspacePattern(pattern)); + const directories = new Set(); + + if (await hasPackageJson(config.rootDirectory)) directories.add(config.rootDirectory); + for (const pattern of positivePatterns) { + for (const directory of await expandSimpleWorkspacePattern(config.rootDirectory, pattern)) { + if (isExcludedWorkspaceDirectory(config.rootDirectory, directory, negatedPatterns)) continue; + directories.add(directory); + } + } + + return [...directories].sort((first, second) => first.localeCompare(second)); +}; + +export const discoverWorkspaces = async ( + config: CodebaseAnalysisConfig, +): Promise => { + const rootManifest = await readPackageJson(config.rootDirectory); + const directories = await discoverWorkspaceDirectories(config, rootManifest); + const workspaces: WorkspaceInfo[] = []; + + for (const directory of directories) { + const manifest = await readPackageJson(directory); + if (!manifest) continue; + const dependencyBuckets = createDependencyBuckets(manifest); + const dependencyNames = collectDependencyNames(dependencyBuckets); + const relativeDirectory = toRelativePath(config.rootDirectory, directory) || "."; + workspaces.push({ + id: workspaces.length, + name: manifest.name ?? relativeDirectory, + directory, + relativeDirectory, + packageJsonPath: path.join(directory, PACKAGE_JSON_FILENAME), + manifest, + dependencyBuckets, + dependencyNames, + manifestDependencyNames: collectManifestDependencyNames(manifest, dependencyNames), + scriptDependencyNames: collectScriptDependencyNames(manifest, dependencyNames), + typeScriptConfigDependencyNames: await readTypeScriptConfigDependencyNames(directory), + cssImportDependencyNames: await readCssImportDependencyNames(directory), + sourceMaps: await readTypeScriptSourceMaps(directory), + }); + } + + if (workspaces.length > 0) return workspaces; + const fallbackManifest = rootManifest ?? {}; + const dependencyBuckets = createDependencyBuckets(fallbackManifest); + const dependencyNames = collectDependencyNames(dependencyBuckets); + return [ + { + id: 0, + name: path.basename(config.rootDirectory), + directory: config.rootDirectory, + relativeDirectory: ".", + packageJsonPath: path.join(config.rootDirectory, PACKAGE_JSON_FILENAME), + manifest: fallbackManifest, + dependencyBuckets, + dependencyNames, + manifestDependencyNames: collectManifestDependencyNames(fallbackManifest, dependencyNames), + scriptDependencyNames: collectScriptDependencyNames(fallbackManifest, dependencyNames), + typeScriptConfigDependencyNames: await readTypeScriptConfigDependencyNames( + config.rootDirectory, + ), + cssImportDependencyNames: await readCssImportDependencyNames(config.rootDirectory), + sourceMaps: await readTypeScriptSourceMaps(config.rootDirectory), + }, + ]; +}; + +export const findWorkspaceForFile = ( + workspaces: WorkspaceInfo[], + filePath: string, +): WorkspaceInfo => { + const matchingWorkspace = [...workspaces] + .sort((first, second) => second.directory.length - first.directory.length) + .find((workspace) => { + const relativePath = path.relative(workspace.directory, filePath); + return ( + relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); + }); + return matchingWorkspace ?? workspaces[0]; +}; diff --git a/packages/react-doctor/src/core/rules/codebase/dead-code.ts b/packages/react-doctor/src/core/rules/codebase/dead-code.ts new file mode 100644 index 0000000000..bf68125946 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/dead-code.ts @@ -0,0 +1,300 @@ +import { + DEAD_CODE_CHECK_ID, + EXPECTED_UNUSED_VISIBILITY_TAG, + INTERNAL_VISIBILITY_TAG, + PUBLIC_VISIBILITY_TAGS, +} from "./analyzer/constants.js"; +import { isVisibilityProtected } from "./analyzer/graph.js"; +import { runCodebaseAnalysis } from "./analyzer/index.js"; +import type { + ExportMemberRecord, + GraphExportSymbol, + ModuleGraph, + ModuleGraphNode, + ProjectFile, +} from "./analyzer/index.js"; +import { defineRule } from "../registry.js"; +import type { ReactDoctorIssue } from "../../types.js"; + +export const DEAD_CODE_RULE_ID = DEAD_CODE_CHECK_ID; + +interface UnusedFileFinding { + file: ProjectFile; +} + +interface UnusedExportFinding { + file: ProjectFile; + exportSymbol: GraphExportSymbol; +} + +interface UnusedExportMemberFinding { + file: ProjectFile; + exportSymbol: GraphExportSymbol; + member: ExportMemberRecord; +} + +interface DuplicateExportFinding { + exportName: string; + exports: Array<{ file: ProjectFile; exportSymbol: GraphExportSymbol }>; +} + +const DEFAULT_EXPORT_NAME = "default"; +const NAMESPACE_EXPORT_NAME = "*"; + +const createCodebaseIssue = ( + issue: Omit & { + severity?: ReactDoctorIssue["severity"]; + category?: string; + }, +): ReactDoctorIssue => ({ + severity: issue.severity ?? "warning", + category: issue.category ?? "Dead Code", + ...issue, +}); + +const sortIssues = (issues: ReactDoctorIssue[]): ReactDoctorIssue[] => + issues.sort((first, second) => { + const firstPath = first.location?.filePath ?? ""; + const secondPath = second.location?.filePath ?? ""; + return ( + firstPath.localeCompare(secondPath) || + (first.location?.line ?? 0) - (second.location?.line ?? 0) || + first.id.localeCompare(second.id) + ); + }); + +// Framework-conventional exports (`default`, `GET`, `metadata`, ...) on +// `app/**/route.ts`, `pages/**/*.tsx`, etc. are marked as `isPluginUsed` +// by the corresponding framework plugin (`plugins/index.ts`) via its +// `usedExports` rules. No separate name-allowlist is needed here. +const isExportUsed = (exportSymbol: GraphExportSymbol): boolean => + exportSymbol.references.length > 0 || + exportSymbol.isPluginUsed || + isExpectedUnused(exportSymbol) || + isVisibilityProtected(exportSymbol); + +const hasUsageReference = (exportSymbol: GraphExportSymbol): boolean => + exportSymbol.references.length > 0 || exportSymbol.isPluginUsed; + +const isExpectedUnused = (exportSymbol: GraphExportSymbol): boolean => + exportSymbol.jsDocTags.has(EXPECTED_UNUSED_VISIBILITY_TAG); + +const isMemberVisibilityProtected = (member: ExportMemberRecord): boolean => + [...member.jsDocTags].some( + (tag) => PUBLIC_VISIBILITY_TAGS.has(tag) || tag === INTERNAL_VISIBILITY_TAG, + ); + +const isPackageEntrypoint = (entrySources: ReadonlySet): boolean => + entrySources.has("package.json"); + +const isExternalEntrypointExportSurface = (node: ModuleGraphNode): boolean => + isPackageEntrypoint(node.entrySources) || node.entryRoles.has("support"); + +const collectDuplicateExports = (graph: ModuleGraph): DuplicateExportFinding[] => { + const exportsByName = new Map(); + for (const node of graph.nodes.values()) { + if (!node.isReachable) continue; + if (isExternalEntrypointExportSurface(node)) continue; + for (const exportSymbol of node.exports.values()) { + if ( + exportSymbol.exportedName === DEFAULT_EXPORT_NAME || + exportSymbol.exportedName === NAMESPACE_EXPORT_NAME || + exportSymbol.isPluginUsed + ) { + continue; + } + // Type-only exports of the same name in different modules are not a + // public-API conflict — readers can't observe one masking the other + // at runtime, and it's idiomatic to have e.g. `type Config` per + // feature module. Skip them entirely. + if (exportSymbol.isTypeOnly) continue; + // Unused exports are already reported by `unused-export` — counting + // them as duplicates just produces redundant noise about dead code. + if (!hasUsageReference(exportSymbol)) continue; + const exports = exportsByName.get(exportSymbol.exportedName) ?? []; + exports.push({ file: node.file, exportSymbol }); + exportsByName.set(exportSymbol.exportedName, exports); + } + } + return [...exportsByName.entries()] + .filter(([, exports]) => exports.length > 1) + .map(([exportName, exports]) => ({ exportName, exports })); +}; + +const collectUnusedFiles = (graph: ModuleGraph): UnusedFileFinding[] => + [...graph.nodes.values()] + .filter((node) => !node.isReachable) + .map((node) => ({ file: node.file })); + +const collectUnusedExports = (graph: ModuleGraph): UnusedExportFinding[] => + [...graph.nodes.values()] + .filter((node) => node.isReachable) + .flatMap((node) => + [...node.exports.values()] + .filter( + (exportSymbol) => + exportSymbol.exportedName !== NAMESPACE_EXPORT_NAME && + !isExportUsed(exportSymbol) && + !isExternalEntrypointExportSurface(node), + ) + .map((exportSymbol) => ({ file: node.file, exportSymbol })), + ); + +const collectNamespaceOnlyExports = (graph: ModuleGraph): UnusedExportFinding[] => + [...graph.nodes.values()].flatMap((node) => + [...node.exports.values()] + .filter( + (exportSymbol) => + exportSymbol.isReferencedByNamespace && + exportSymbol.references.every((reference) => reference.kind === "namespace"), + ) + .map((exportSymbol) => ({ file: node.file, exportSymbol })), + ); + +const isMemberUsed = (exportSymbol: GraphExportSymbol, member: ExportMemberRecord): boolean => + member.hasLocalReferences || + exportSymbol.referencedMemberNames.has(member.name) || + member.jsDocTags.has(EXPECTED_UNUSED_VISIBILITY_TAG) || + isMemberVisibilityProtected(member); + +const collectUnusedExportMembers = (graph: ModuleGraph): UnusedExportMemberFinding[] => + [...graph.nodes.values()] + .filter((node) => node.isReachable && !isExternalEntrypointExportSurface(node)) + .flatMap((node) => + [...node.exports.values()] + .filter((exportSymbol) => isExportUsed(exportSymbol)) + .flatMap((exportSymbol) => + exportSymbol.members + .filter((member) => !isMemberUsed(exportSymbol, member)) + .map((member) => ({ file: node.file, exportSymbol, member })), + ), + ); + +const collectStaleExpectedUnusedExports = (graph: ModuleGraph): UnusedExportFinding[] => + [...graph.nodes.values()].flatMap((node) => + [...node.exports.values()] + .filter( + (exportSymbol) => + exportSymbol.exportedName !== NAMESPACE_EXPORT_NAME && + isExpectedUnused(exportSymbol) && + hasUsageReference(exportSymbol), + ) + .map((exportSymbol) => ({ file: node.file, exportSymbol })), + ); + +const toUnusedFileIssue = (finding: UnusedFileFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEAD_CODE_CHECK_ID}/unused-file/${finding.file.relativePath}`, + title: "Unused file", + message: + "This source file is not reachable from any package, framework, test, or support entrypoint.", + location: { filePath: finding.file.relativePath }, + recommendation: "Remove the file or connect it to a real entrypoint.", + source: { checkId: DEAD_CODE_CHECK_ID, ruleId: "unused-file" }, + }); + +const toUnusedExportIssue = ( + finding: UnusedExportFinding, + ruleId: string, + title: string, + severity: ReactDoctorIssue["severity"] = "warning", +): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEAD_CODE_CHECK_ID}/${ruleId}/${finding.file.relativePath}/${finding.exportSymbol.exportedName}`, + title, + message: `The exported symbol "${finding.exportSymbol.exportedName}" is not referenced by reachable modules.`, + severity, + location: { + filePath: finding.file.relativePath, + line: finding.exportSymbol.position.line, + column: finding.exportSymbol.position.column, + }, + recommendation: "Remove the export or make it part of an entrypoint API.", + source: { checkId: DEAD_CODE_CHECK_ID, ruleId }, + }); + +const toDuplicateExportIssue = (finding: DuplicateExportFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEAD_CODE_CHECK_ID}/duplicate-export/${finding.exportName}`, + title: "Duplicate export", + message: `The exported symbol "${finding.exportName}" appears in ${finding.exports.length} files.`, + location: { filePath: finding.exports[0]?.file.relativePath ?? "" }, + recommendation: "Consolidate the public API or use more specific names.", + source: { checkId: DEAD_CODE_CHECK_ID, ruleId: "duplicate-export" }, + }); + +const toUnusedExportMemberIssue = (finding: UnusedExportMemberFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEAD_CODE_CHECK_ID}/unused-${finding.member.kind}-member/${finding.file.relativePath}/${finding.exportSymbol.exportedName}.${finding.member.name}`, + title: `Unused ${finding.member.kind} member`, + message: `The exported ${finding.member.kind} member "${finding.exportSymbol.exportedName}.${finding.member.name}" is not referenced by reachable modules.`, + location: { + filePath: finding.file.relativePath, + line: finding.member.position.line, + column: finding.member.position.column, + }, + recommendation: "Remove the member or reference it from reachable code.", + source: { checkId: DEAD_CODE_CHECK_ID, ruleId: `unused-${finding.member.kind}-member` }, + }); + +const toStaleExpectedUnusedIssue = (finding: UnusedExportFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEAD_CODE_CHECK_ID}/stale-expected-unused/${finding.file.relativePath}/${finding.exportSymbol.exportedName}`, + title: "Stale expected-unused marker", + message: `The exported symbol "${finding.exportSymbol.exportedName}" is marked @expected-unused but is now referenced.`, + location: { + filePath: finding.file.relativePath, + line: finding.exportSymbol.position.line, + column: finding.exportSymbol.position.column, + }, + recommendation: "Remove the @expected-unused marker or stop referencing the export.", + source: { checkId: DEAD_CODE_CHECK_ID, ruleId: "stale-expected-unused" }, + }); + +const inspectDeadCode = (graph: ModuleGraph): ReactDoctorIssue[] => { + const unusedExports = collectUnusedExports(graph); + return sortIssues([ + ...collectUnusedFiles(graph).map(toUnusedFileIssue), + ...unusedExports + .filter((finding) => !finding.exportSymbol.isTypeOnly) + .map((finding) => toUnusedExportIssue(finding, "unused-export", "Unused export")), + // Type-only exports rarely indicate runtime bugs (types disappear after + // compilation) and codebases routinely export types ahead of consumers, + // so demote them to `info` severity — visible in --verbose, exempt from + // scoring. + ...unusedExports + .filter((finding) => finding.exportSymbol.isTypeOnly) + .map((finding) => + toUnusedExportIssue(finding, "unused-type-export", "Unused type export", "info"), + ), + // `import * as ns` consumption is a stylistic concern, not a correctness + // issue, so demote to `info` as well. + ...collectNamespaceOnlyExports(graph).map((finding) => + toUnusedExportIssue(finding, "namespace-only-export", "Namespace-only export", "info"), + ), + ...collectUnusedExportMembers(graph).map(toUnusedExportMemberIssue), + ...collectStaleExpectedUnusedExports(graph).map(toStaleExpectedUnusedIssue), + ...collectDuplicateExports(graph).map(toDuplicateExportIssue), + ]); +}; + +export const deadCodeRule = defineRule({ + metadata: { + id: DEAD_CODE_RULE_ID, + name: "Codebase dead code", + description: + "Builds a project module graph and reports unused files, exports, types, and duplicate exports.", + category: "dead-code", + severity: "warning", + defaultEnabled: false, + tags: ["codebase", "dead-code", "oxc"], + }, + run: async ({ rootDirectory, includePaths, excludePatterns, signal, getCodebaseAnalysis }) => { + const analysis = + getCodebaseAnalysis?.() ?? + runCodebaseAnalysis({ rootDirectory, includePaths, excludePatterns, signal }); + return { + issues: inspectDeadCode((await analysis).graph), + }; + }, +}); diff --git a/packages/react-doctor/src/core/rules/codebase/dependencies.ts b/packages/react-doctor/src/core/rules/codebase/dependencies.ts new file mode 100644 index 0000000000..307b879322 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/dependencies.ts @@ -0,0 +1,489 @@ +import { + DEFINITELY_TYPED_SCOPE, + DEPENDENCIES_CHECK_ID, + IGNORED_DEFINITELY_TYPED_PACKAGE_NAMES, + PACKAGE_JSON_FILENAME, +} from "./analyzer/constants.js"; +import { runCodebaseAnalysis } from "./analyzer/index.js"; +import { isOptionalPeerDependency } from "./analyzer/manifest.js"; +import type { + DependencyBuckets, + ImportRecord, + ModuleGraph, + ProjectFile, + WorkspaceInfo, +} from "./analyzer/index.js"; +import { defineRule } from "../registry.js"; +import type { ReactDoctorIssue } from "../../types.js"; + +export const DEPENDENCIES_RULE_ID = DEPENDENCIES_CHECK_ID; + +interface DependencyFinding { + workspace: WorkspaceInfo; + packageName: string; + file?: ProjectFile; + importRecord?: ImportRecord; + dependencyBucket?: keyof DependencyBuckets; + dependencyBuckets?: Array; + sourceKind?: "config" | "import" | "manifest" | "script"; +} + +interface UnresolvedImportFinding { + file: ProjectFile; + importRecord: ImportRecord; + error: string; +} + +const dependencyBucketNames: Array = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; + +const createCodebaseIssue = ( + issue: Omit & { + severity?: ReactDoctorIssue["severity"]; + category?: string; + }, +): ReactDoctorIssue => ({ + severity: issue.severity ?? "warning", + category: issue.category ?? "Dependencies", + ...issue, +}); + +const sortIssues = (issues: ReactDoctorIssue[]): ReactDoctorIssue[] => + issues.sort((first, second) => { + const firstPath = first.location?.filePath ?? ""; + const secondPath = second.location?.filePath ?? ""; + return ( + firstPath.localeCompare(secondPath) || + (first.location?.line ?? 0) - (second.location?.line ?? 0) || + first.id.localeCompare(second.id) + ); + }); + +const findUsage = (graph: ModuleGraph, workspace: WorkspaceInfo, packageName: string) => + graph.packageUsages.find( + (usage) => usage.workspaceId === workspace.id && usage.packageName === packageName, + ); + +const isDefinitelyTypedPackage = (packageName: string): boolean => + packageName.startsWith(`${DEFINITELY_TYPED_SCOPE}/`); + +const toDefinitelyTypedPackageName = (packageName: string): string => { + if (isDefinitelyTypedPackage(packageName)) return packageName; + if (packageName.startsWith("@")) { + return `${DEFINITELY_TYPED_SCOPE}/${packageName.slice(1).replace("/", "__")}`; + } + return `${DEFINITELY_TYPED_SCOPE}/${packageName}`; +}; + +const toRuntimePackageName = (typesPackageName: string): string | null => { + if (!isDefinitelyTypedPackage(typesPackageName)) return null; + const unscopedName = typesPackageName.slice(DEFINITELY_TYPED_SCOPE.length + 1); + if (!unscopedName) return null; + if (unscopedName.includes("__")) { + const [scopeName, packageName] = unscopedName.split("__"); + return scopeName && packageName ? `@${scopeName}/${packageName}` : null; + } + return unscopedName; +}; + +const addDefinitelyTypedCompanionPackages = ( + workspace: WorkspaceInfo, + usedPackages: Set, +): void => { + for (const packageName of [...usedPackages]) { + if (isDefinitelyTypedPackage(packageName)) continue; + const typesPackageName = toDefinitelyTypedPackageName(packageName); + if (workspace.dependencyNames.has(typesPackageName)) usedPackages.add(typesPackageName); + } + for (const packageName of workspace.dependencyNames) { + const runtimePackageName = toRuntimePackageName(packageName); + if (!runtimePackageName || !IGNORED_DEFINITELY_TYPED_PACKAGE_NAMES.has(runtimePackageName)) { + continue; + } + usedPackages.add(packageName); + } +}; + +const getUsedPackages = (graph: ModuleGraph, workspace: WorkspaceInfo): Set => { + const usedPackages = new Set([ + ...graph.packageUsages + .filter((usage) => usage.workspaceId === workspace.id) + .map((usage) => usage.packageName), + ...workspace.manifestDependencyNames, + ...workspace.scriptDependencyNames, + ...workspace.typeScriptConfigDependencyNames, + ...workspace.cssImportDependencyNames, + ...(graph.pluginResults.get(workspace.id)?.toolingDependencies ?? []), + ]); + addDefinitelyTypedCompanionPackages(workspace, usedPackages); + return usedPackages; +}; + +const getNonImportUsedPackages = (graph: ModuleGraph, workspace: WorkspaceInfo): Set => + new Set([ + ...workspace.manifestDependencyNames, + ...workspace.scriptDependencyNames, + ...workspace.typeScriptConfigDependencyNames, + ...workspace.cssImportDependencyNames, + ...(graph.pluginResults.get(workspace.id)?.toolingDependencies ?? []), + ]); + +const hasDeclaredDependency = (workspace: WorkspaceInfo, packageName: string): boolean => + dependencyBucketNames.some((bucketName) => + workspace.dependencyBuckets[bucketName].has(packageName), + ); + +const hasDeclaredDependencyInGraph = (graph: ModuleGraph, packageName: string): boolean => + graph.workspaces.some((workspace) => hasDeclaredDependency(workspace, packageName)); + +const getDeclaredDependencyBuckets = ( + workspace: WorkspaceInfo, + packageName: string, +): Array => + dependencyBucketNames.filter((bucketName) => + workspace.dependencyBuckets[bucketName].has(packageName), + ); + +const createDependencyFinding = ( + graph: ModuleGraph, + workspace: WorkspaceInfo, + packageName: string, + dependencyBucket?: keyof DependencyBuckets, + sourceKind: DependencyFinding["sourceKind"] = "import", +): DependencyFinding => { + const usage = findUsage(graph, workspace, packageName); + const file = usage ? graph.files[usage.fromFileId] : undefined; + const importRecord = file + ? graph.nodes + .get(file.id) + ?.imports.find((resolvedImport) => resolvedImport.packageName === packageName)?.importRecord + : undefined; + return { workspace, packageName, file, importRecord, dependencyBucket, sourceKind }; +}; + +const createDuplicateDependencyFinding = ( + workspace: WorkspaceInfo, + packageName: string, +): DependencyFinding => ({ + workspace, + packageName, + dependencyBuckets: getDeclaredDependencyBuckets(workspace, packageName), +}); + +const collectDuplicateDependencyDeclarations = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.dependencyNames] + .filter((packageName) => getDeclaredDependencyBuckets(workspace, packageName).length > 1) + .map((packageName) => createDuplicateDependencyFinding(workspace, packageName)), + ); + +const collectUnresolvedImports = (graph: ModuleGraph): UnresolvedImportFinding[] => + graph.unresolvedImports.flatMap((resolvedImport) => { + const file = graph.files.find((projectFile) => + graph.nodes.get(projectFile.id)?.imports.includes(resolvedImport), + ); + if (!file) return []; + return [ + { + file, + importRecord: resolvedImport.importRecord, + error: resolvedImport.error ?? "Unable to resolve import.", + }, + ]; + }); + +const collectUnlistedImportDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.packageUsages + .filter((usage) => { + const workspace = graph.workspaces[usage.workspaceId]; + return ( + workspace && + !hasDeclaredDependency(workspace, usage.packageName) && + !hasDeclaredDependencyInGraph(graph, usage.packageName) + ); + }) + .map((usage) => { + const workspace = graph.workspaces[usage.workspaceId]; + return createDependencyFinding(graph, workspace, usage.packageName); + }); + +const collectUnlistedManifestDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.manifestDependencyNames] + .filter((packageName) => !hasDeclaredDependency(workspace, packageName)) + .map((packageName) => + createDependencyFinding(graph, workspace, packageName, undefined, "manifest"), + ), + ); + +const collectUnlistedScriptDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.scriptDependencyNames] + .filter((packageName) => !hasDeclaredDependency(workspace, packageName)) + .map((packageName) => + createDependencyFinding(graph, workspace, packageName, undefined, "script"), + ), + ); + +const collectUnlistedTypeScriptConfigDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.typeScriptConfigDependencyNames] + .filter((packageName) => !hasDeclaredDependency(workspace, packageName)) + .map((packageName) => + createDependencyFinding(graph, workspace, packageName, undefined, "config"), + ), + ); + +const collectUnlistedDependencies = (graph: ModuleGraph): DependencyFinding[] => [ + ...collectUnlistedImportDependencies(graph), + ...collectUnlistedManifestDependencies(graph), + ...collectUnlistedScriptDependencies(graph), + ...collectUnlistedTypeScriptConfigDependencies(graph), +]; + +const collectUnusedDependencies = ( + graph: ModuleGraph, + bucketName: keyof DependencyBuckets, +): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => { + const usedPackages = getUsedPackages(graph, workspace); + return [...workspace.dependencyBuckets[bucketName].keys()] + .filter((packageName) => !usedPackages.has(packageName)) + .map((packageName) => createDependencyFinding(graph, workspace, packageName, bucketName)); + }); + +const collectUnusedOptionalPeerDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.dependencyBuckets.peerDependencies.keys()] + .filter((packageName) => isOptionalPeerDependency(workspace, packageName)) + .filter((packageName) => !getUsedPackages(graph, workspace).has(packageName)) + .map((packageName) => + createDependencyFinding(graph, workspace, packageName, "peerDependencies"), + ), + ); + +const collectUnusedPeerDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => { + const usedPackages = getUsedPackages(graph, workspace); + return [...workspace.dependencyBuckets.peerDependencies.keys()] + .filter((packageName) => !isOptionalPeerDependency(workspace, packageName)) + .filter((packageName) => !usedPackages.has(packageName)) + .map((packageName) => + createDependencyFinding(graph, workspace, packageName, "peerDependencies"), + ); + }); + +const collectUnusedOptionalDependencies = (graph: ModuleGraph): DependencyFinding[] => + collectUnusedDependencies(graph, "optionalDependencies"); + +const collectRuntimeDevDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.dependencyBuckets.devDependencies.keys()] + .filter((packageName) => !workspace.dependencyBuckets.dependencies.has(packageName)) + .filter((packageName) => + graph.packageUsages.some( + (usage) => + usage.workspaceId === workspace.id && + usage.packageName === packageName && + usage.isRuntime && + !usage.isTypeOnly, + ), + ) + .map((packageName) => + createDependencyFinding(graph, workspace, packageName, "devDependencies"), + ), + ); + +const collectTypeOnlyDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.dependencyBuckets.dependencies.keys()] + .filter((packageName) => { + if (getNonImportUsedPackages(graph, workspace).has(packageName)) return false; + const usages = graph.packageUsages.filter( + (usage) => usage.workspaceId === workspace.id && usage.packageName === packageName, + ); + return usages.length > 0 && usages.every((usage) => usage.isTypeOnly); + }) + .map((packageName) => createDependencyFinding(graph, workspace, packageName, "dependencies")), + ); + +const collectTestOnlyDependencies = (graph: ModuleGraph): DependencyFinding[] => + graph.workspaces.flatMap((workspace) => + [...workspace.dependencyBuckets.dependencies.keys()] + .filter((packageName) => { + if (getNonImportUsedPackages(graph, workspace).has(packageName)) return false; + const usages = graph.packageUsages.filter( + (usage) => usage.workspaceId === workspace.id && usage.packageName === packageName, + ); + return usages.length > 0 && usages.every((usage) => usage.isTestOnly); + }) + .map((packageName) => createDependencyFinding(graph, workspace, packageName, "dependencies")), + ); + +const toUnresolvedImportIssue = (finding: UnresolvedImportFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEPENDENCIES_CHECK_ID}/unresolved/${finding.file.relativePath}/${finding.importRecord.source}`, + title: "Unresolved import", + message: `The import "${finding.importRecord.source}" could not be resolved.`, + severity: "error", + location: { + filePath: finding.file.relativePath, + line: finding.importRecord.position.line, + column: finding.importRecord.position.column, + }, + recommendation: + "Fix the specifier, dependency, tsconfig path, or generated module configuration.", + source: { checkId: DEPENDENCIES_CHECK_ID, ruleId: "unresolved-import" }, + }); + +const toDependencyIssue = ( + finding: DependencyFinding, + ruleId: string, + title: string, + message: string, +): ReactDoctorIssue => + createCodebaseIssue({ + id: `${DEPENDENCIES_CHECK_ID}/${ruleId}/${finding.workspace.name}/${finding.packageName}`, + title, + message, + location: finding.file + ? { + filePath: finding.file.relativePath, + line: finding.importRecord?.position.line, + column: finding.importRecord?.position.column, + } + : { + filePath: + finding.workspace.relativeDirectory === "." + ? PACKAGE_JSON_FILENAME + : `${finding.workspace.relativeDirectory}/${PACKAGE_JSON_FILENAME}`, + }, + recommendation: "Update the nearest package.json dependency bucket to match actual usage.", + source: { checkId: DEPENDENCIES_CHECK_ID, ruleId }, + }); + +const getUnlistedDependencyMessage = (finding: DependencyFinding): string => { + if (finding.sourceKind === "script") { + return `"${finding.packageName}" is used by package.json scripts but not listed in the workspace package.json.`; + } + if (finding.sourceKind === "manifest") { + return `"${finding.packageName}" is referenced by package.json configuration but not listed in the workspace package.json.`; + } + if (finding.sourceKind === "config") { + return `"${finding.packageName}" is referenced by tsconfig.json but not listed in the workspace package.json.`; + } + return `"${finding.packageName}" is imported but not listed in the workspace package.json.`; +}; + +const getDuplicateDependencyMessage = (finding: DependencyFinding): string => + `"${finding.packageName}" is declared in multiple dependency buckets: ${(finding.dependencyBuckets ?? []).join(", ")}.`; + +const inspectDependencies = (graph: ModuleGraph): ReactDoctorIssue[] => + sortIssues([ + ...collectUnresolvedImports(graph).map(toUnresolvedImportIssue), + ...collectDuplicateDependencyDeclarations(graph).map((finding) => + toDependencyIssue( + finding, + "duplicate-dependency-declaration", + "Duplicate dependency declaration", + getDuplicateDependencyMessage(finding), + ), + ), + ...collectUnlistedDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "unlisted-dependency", + "Unlisted dependency", + getUnlistedDependencyMessage(finding), + ), + ), + ...collectUnusedDependencies(graph, "dependencies").map((finding) => + toDependencyIssue( + finding, + "unused-dependency", + "Unused dependency", + `"${finding.packageName}" is listed in dependencies but not used.`, + ), + ), + ...collectUnusedDependencies(graph, "devDependencies").map((finding) => + toDependencyIssue( + finding, + "unused-dev-dependency", + "Unused dev dependency", + `"${finding.packageName}" is listed in devDependencies but not used.`, + ), + ), + ...collectUnusedOptionalPeerDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "unused-optional-peer-dependency", + "Unused optional peer dependency", + `"${finding.packageName}" is listed as an optional peer but not used.`, + ), + ), + ...collectUnusedPeerDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "unused-peer-dependency", + "Unused peer dependency", + `"${finding.packageName}" is listed in peerDependencies but not used.`, + ), + ), + ...collectUnusedOptionalDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "unused-optional-dependency", + "Unused optional dependency", + `"${finding.packageName}" is listed in optionalDependencies but not used.`, + ), + ), + ...collectRuntimeDevDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "runtime-dev-dependency", + "Runtime dependency listed in devDependencies", + `"${finding.packageName}" is imported by runtime code but listed in devDependencies.`, + ), + ), + ...collectTypeOnlyDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "type-only-dependency", + "Type-only production dependency", + `"${finding.packageName}" is only used in type positions.`, + ), + ), + ...collectTestOnlyDependencies(graph).map((finding) => + toDependencyIssue( + finding, + "test-only-dependency", + "Test-only production dependency", + `"${finding.packageName}" is only used from test entrypoints.`, + ), + ), + ]); + +export const dependenciesRule = defineRule({ + metadata: { + id: DEPENDENCIES_RULE_ID, + name: "Codebase dependencies", + description: + "Builds a workspace-aware module graph and reports unresolved, unlisted, unused, type-only, and test-only dependencies.", + category: "dependencies", + severity: "warning", + defaultEnabled: false, + tags: ["codebase", "dependencies", "oxc"], + }, + run: async ({ rootDirectory, includePaths, excludePatterns, signal, getCodebaseAnalysis }) => { + const analysis = + getCodebaseAnalysis?.() ?? + runCodebaseAnalysis({ rootDirectory, includePaths, excludePatterns, signal }); + return { + issues: inspectDependencies((await analysis).graph), + }; + }, +}); diff --git a/packages/react-doctor/src/core/rules/codebase/index.ts b/packages/react-doctor/src/core/rules/codebase/index.ts new file mode 100644 index 0000000000..c41bc1fdac --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/index.ts @@ -0,0 +1,3 @@ +export { DEAD_CODE_RULE_ID, deadCodeRule } from "./dead-code.js"; +export { DEPENDENCIES_RULE_ID, dependenciesRule } from "./dependencies.js"; +export { REACT_ARCHITECTURE_RULE_ID, reactArchitectureRule } from "./react-architecture.js"; diff --git a/packages/react-doctor/src/core/rules/codebase/react-architecture.ts b/packages/react-doctor/src/core/rules/codebase/react-architecture.ts new file mode 100644 index 0000000000..4286e2c150 --- /dev/null +++ b/packages/react-doctor/src/core/rules/codebase/react-architecture.ts @@ -0,0 +1,295 @@ +import { + BARREL_EXPORT_THRESHOLD_COUNT, + BARREL_IMPORTER_THRESHOLD_COUNT, + REACT_ARCHITECTURE_CHECK_ID, + REACT_CLIENT_DIRECTIVE, + REACT_SERVER_DIRECTIVE, + SERVER_ONLY_PACKAGE_NAME, +} from "./analyzer/constants.js"; +import { runCodebaseAnalysis } from "./analyzer/index.js"; +import type { + ImportRecord, + ModuleGraph, + ModuleGraphNode, + ProjectFile, + ResolvedImport, +} from "./analyzer/index.js"; +import { defineRule } from "../registry.js"; +import type { ReactDoctorIssue } from "../../types.js"; + +export const REACT_ARCHITECTURE_RULE_ID = REACT_ARCHITECTURE_CHECK_ID; + +interface BoundaryViolationFinding { + file: ProjectFile; + targetFile?: ProjectFile; + importRecord: ImportRecord; +} + +interface CircularDependencyFinding { + files: ProjectFile[]; +} + +interface BarrelHotspotFinding { + file: ProjectFile; + exportCount: number; + importerCount: number; +} + +interface StronglyConnectedComponentState { + index: number; + stack: number[]; + indexByFileId: Map; + lowLinkByFileId: Map; + fileIdsOnStack: Set; + components: number[][]; +} + +const createCodebaseIssue = ( + issue: Omit & { + severity?: ReactDoctorIssue["severity"]; + category?: string; + }, +): ReactDoctorIssue => ({ + severity: issue.severity ?? "warning", + category: issue.category ?? "Architecture", + ...issue, +}); + +const sortIssues = (issues: ReactDoctorIssue[]): ReactDoctorIssue[] => + issues.sort((first, second) => { + const firstPath = first.location?.filePath ?? ""; + const secondPath = second.location?.filePath ?? ""; + return ( + firstPath.localeCompare(secondPath) || + (first.location?.line ?? 0) - (second.location?.line ?? 0) || + first.id.localeCompare(second.id) + ); + }); + +const isClientNode = (node: ModuleGraphNode): boolean => + node.directives.has(REACT_CLIENT_DIRECTIVE); + +const isServerOnlyTarget = (graph: ModuleGraph, resolvedImport: ResolvedImport): boolean => { + if (resolvedImport.packageName === SERVER_ONLY_PACKAGE_NAME) return true; + return false; +}; + +const isServerActionBoundary = (graph: ModuleGraph, resolvedImport: ResolvedImport): boolean => { + if (resolvedImport.targetKind !== "internal" || !resolvedImport.targetFilePath) return false; + const targetFileId = graph.pathToFileId.get(resolvedImport.targetFilePath); + if (typeof targetFileId !== "number") return false; + const targetNode = graph.nodes.get(targetFileId); + return Boolean(targetNode?.directives.has(REACT_SERVER_DIRECTIVE)); +}; + +const collectClientBoundaryViolations = (graph: ModuleGraph): BoundaryViolationFinding[] => { + const findings: BoundaryViolationFinding[] = []; + for (const node of graph.nodes.values()) { + if (!isClientNode(node)) continue; + const pending: Array<{ currentNode: ModuleGraphNode; firstImport: ResolvedImport | null }> = [ + { currentNode: node, firstImport: null }, + ]; + const visited = new Set(); + while (pending.length > 0) { + const item = pending.pop(); + if (!item || visited.has(item.currentNode.file.id)) continue; + visited.add(item.currentNode.file.id); + for (const resolvedImport of item.currentNode.imports) { + if (resolvedImport.importRecord.isTypeOnly) continue; + const firstImport = item.firstImport ?? resolvedImport; + if (isServerOnlyTarget(graph, resolvedImport)) { + findings.push({ + file: node.file, + targetFile: resolvedImport.targetFilePath + ? graph.nodes.get(graph.pathToFileId.get(resolvedImport.targetFilePath) ?? -1)?.file + : undefined, + importRecord: firstImport.importRecord, + }); + continue; + } + if (isServerActionBoundary(graph, resolvedImport)) continue; + if (resolvedImport.targetKind === "internal" && resolvedImport.targetFilePath) { + const targetNode = graph.nodes.get( + graph.pathToFileId.get(resolvedImport.targetFilePath) ?? -1, + ); + if (targetNode) pending.push({ currentNode: targetNode, firstImport }); + } + } + } + } + return findings; +}; + +const collectBarrelHotspots = (graph: ModuleGraph): BarrelHotspotFinding[] => + [...graph.nodes.values()] + .filter( + (node) => + node.exports.size >= BARREL_EXPORT_THRESHOLD_COUNT && + node.importedBy.size >= BARREL_IMPORTER_THRESHOLD_COUNT, + ) + .map((node) => ({ + file: node.file, + exportCount: node.exports.size, + importerCount: node.importedBy.size, + })); + +const getInternalImportTargets = (graph: ModuleGraph, node: ModuleGraphNode): number[] => + [ + ...new Set( + node.imports + .filter( + (resolvedImport) => + resolvedImport.targetKind === "internal" && + resolvedImport.targetFilePath && + !resolvedImport.importRecord.isTypeOnly, + ) + .map((resolvedImport) => graph.pathToFileId.get(resolvedImport.targetFilePath ?? "")) + .filter((fileId): fileId is number => typeof fileId === "number"), + ), + ].sort((first, second) => { + const firstPath = graph.nodes.get(first)?.file.relativePath ?? ""; + const secondPath = graph.nodes.get(second)?.file.relativePath ?? ""; + return firstPath.localeCompare(secondPath); + }); + +const sortFileIdsByPath = (graph: ModuleGraph, fileIds: number[]): number[] => + [...fileIds].sort((first, second) => { + const firstPath = graph.nodes.get(first)?.file.relativePath ?? ""; + const secondPath = graph.nodes.get(second)?.file.relativePath ?? ""; + return firstPath.localeCompare(secondPath); + }); + +const visitStronglyConnectedComponent = ( + graph: ModuleGraph, + fileId: number, + state: StronglyConnectedComponentState, +): void => { + state.indexByFileId.set(fileId, state.index); + state.lowLinkByFileId.set(fileId, state.index); + state.index++; + state.stack.push(fileId); + state.fileIdsOnStack.add(fileId); + + const node = graph.nodes.get(fileId); + if (node) { + for (const targetFileId of getInternalImportTargets(graph, node)) { + if (!state.indexByFileId.has(targetFileId)) { + visitStronglyConnectedComponent(graph, targetFileId, state); + state.lowLinkByFileId.set( + fileId, + Math.min( + state.lowLinkByFileId.get(fileId) ?? 0, + state.lowLinkByFileId.get(targetFileId) ?? 0, + ), + ); + } else if (state.fileIdsOnStack.has(targetFileId)) { + state.lowLinkByFileId.set( + fileId, + Math.min( + state.lowLinkByFileId.get(fileId) ?? 0, + state.indexByFileId.get(targetFileId) ?? 0, + ), + ); + } + } + } + + if (state.lowLinkByFileId.get(fileId) !== state.indexByFileId.get(fileId)) return; + + const component: number[] = []; + while (state.stack.length > 0) { + const stackedFileId = state.stack.pop(); + if (typeof stackedFileId !== "number") break; + state.fileIdsOnStack.delete(stackedFileId); + component.push(stackedFileId); + if (stackedFileId === fileId) break; + } + if (component.length > 1) state.components.push(sortFileIdsByPath(graph, component)); +}; + +const collectCircularImports = (graph: ModuleGraph): CircularDependencyFinding[] => { + const state: StronglyConnectedComponentState = { + index: 0, + stack: [], + indexByFileId: new Map(), + lowLinkByFileId: new Map(), + fileIdsOnStack: new Set(), + components: [], + }; + + for (const fileId of sortFileIdsByPath(graph, [...graph.nodes.keys()])) { + if (!state.indexByFileId.has(fileId)) { + visitStronglyConnectedComponent(graph, fileId, state); + } + } + + return state.components.map((cycle) => ({ + files: cycle.flatMap((fileId) => { + const file = graph.nodes.get(fileId)?.file; + return file ? [file] : []; + }), + })); +}; + +const toCircularDependencyIssue = (finding: CircularDependencyFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${REACT_ARCHITECTURE_CHECK_ID}/circular/${finding.files.map((file) => file.relativePath).join(">")}`, + title: "Circular import", + message: `These files form a cycle: ${finding.files.map((file) => file.relativePath).join(" -> ")}.`, + location: { filePath: finding.files[0]?.relativePath ?? "" }, + recommendation: "Extract shared code or invert one dependency edge.", + source: { checkId: REACT_ARCHITECTURE_CHECK_ID, ruleId: "circular-import" }, + }); + +const toBoundaryIssue = (finding: BoundaryViolationFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${REACT_ARCHITECTURE_CHECK_ID}/client-server/${finding.file.relativePath}/${finding.importRecord.source}`, + title: "Client module reaches server-only code", + message: `The client graph reaches server-only import "${finding.importRecord.source}".`, + severity: "error", + location: { + filePath: finding.file.relativePath, + line: finding.importRecord.position.line, + column: finding.importRecord.position.column, + }, + recommendation: "Move the import behind a server component boundary or split shared code.", + source: { checkId: REACT_ARCHITECTURE_CHECK_ID, ruleId: "client-server-boundary" }, + }); + +const toBarrelIssue = (finding: BarrelHotspotFinding): ReactDoctorIssue => + createCodebaseIssue({ + id: `${REACT_ARCHITECTURE_CHECK_ID}/barrel/${finding.file.relativePath}`, + title: "Barrel import hotspot", + message: `This module exports ${finding.exportCount} symbols and is imported by ${finding.importerCount} modules.`, + location: { filePath: finding.file.relativePath }, + recommendation: "Prefer direct imports when the barrel inflates the dependency graph.", + source: { checkId: REACT_ARCHITECTURE_CHECK_ID, ruleId: "barrel-hotspot" }, + }); + +const inspectReactArchitecture = (graph: ModuleGraph): ReactDoctorIssue[] => + sortIssues([ + ...collectClientBoundaryViolations(graph).map(toBoundaryIssue), + ...collectCircularImports(graph).map(toCircularDependencyIssue), + ...collectBarrelHotspots(graph).map(toBarrelIssue), + ]); + +export const reactArchitectureRule = defineRule({ + metadata: { + id: REACT_ARCHITECTURE_RULE_ID, + name: "Codebase React architecture", + description: + "Builds a project module graph and reports React architecture boundary and dependency issues.", + category: "react-architecture", + severity: "warning", + defaultEnabled: false, + tags: ["codebase", "react-architecture", "oxc"], + }, + run: async ({ rootDirectory, includePaths, excludePatterns, signal, getCodebaseAnalysis }) => { + const analysis = + getCodebaseAnalysis?.() ?? + runCodebaseAnalysis({ rootDirectory, includePaths, excludePatterns, signal }); + return { + issues: inspectReactArchitecture((await analysis).graph), + }; + }, +}); diff --git a/packages/react-doctor/src/core/rules/index.ts b/packages/react-doctor/src/core/rules/index.ts new file mode 100644 index 0000000000..d895461d38 --- /dev/null +++ b/packages/react-doctor/src/core/rules/index.ts @@ -0,0 +1,46 @@ +import { reactProjectStructureRule } from "./react-project-structure.js"; +import { createRuleRegistry as createBaseRuleRegistry } from "./registry.js"; +import { + DEAD_CODE_RULE_ID, + DEPENDENCIES_RULE_ID, + REACT_ARCHITECTURE_RULE_ID, + deadCodeRule, + dependenciesRule, + reactArchitectureRule, +} from "./codebase/index.js"; +import type { RuleRegistryOptions } from "./registry.js"; +import type { ReactDoctorRule } from "./types.js"; + +export { defineRule } from "./registry.js"; +export type { + ReactDoctorRule, + ReactDoctorRuleContext, + ReactDoctorRuleExample, + ReactDoctorRuleMetadata, + ReactDoctorRuleResult, +} from "./types.js"; +export * from "./lint/index.js"; +export { + DEAD_CODE_RULE_ID, + DEPENDENCIES_RULE_ID, + REACT_ARCHITECTURE_RULE_ID, + deadCodeRule, + dependenciesRule, + reactArchitectureRule, + reactProjectStructureRule, +}; + +export const coreRules: ReactDoctorRule[] = [ + reactProjectStructureRule, + deadCodeRule, + dependenciesRule, + reactArchitectureRule, +]; + +export const createRuleRegistry = (options: RuleRegistryOptions = {}) => + createBaseRuleRegistry({ + ...options, + rules: options.rules ?? coreRules, + }); + +export const ruleRegistry = createRuleRegistry(); diff --git a/packages/react-doctor/src/core/rules/lint/config.ts b/packages/react-doctor/src/core/rules/lint/config.ts new file mode 100644 index 0000000000..d69a7e3723 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/config.ts @@ -0,0 +1,659 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { reactDoctorOxlintRules } from "./rules.js"; + +const esmRequire = createRequire(import.meta.url); + +export interface OxlintRuleSeverityMap { + [ruleKey: string]: "error" | "warn" | "off"; +} + +const REACT_DOCTOR_OXLINT_RULE_KEY_PREFIX = "react-doctor/"; +const REACT_HOOKS_JS_NAMESPACE = "react-hooks-js"; +const REACT_HOOKS_PLUGIN_SPECIFIER = "eslint-plugin-react-hooks"; +// Sentinel "no installed/declared React version" — chosen high enough to never +// gate a future React-major-specific rule by accident. +const UNCONSTRAINED_REACT_MAJOR = 99; +// HACK: oxlint-namespaces eslint-plugin-react-you-might-not-need-an-effect +// under `effect/` to keep rule keys short. Mirrors v1 (oxlint-config.ts). +// The plugin is opt-in: skipped when not installed. +const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect"; +const YOU_MIGHT_NOT_NEED_EFFECT_PLUGIN_SPECIFIER = + "eslint-plugin-react-you-might-not-need-an-effect"; +const DEFAULT_OXLINT_RULE_SEVERITY: OxlintRuleSeverityMap[string] = "warn"; +const NEXTJS_RULE_NAME_PREFIX = "nextjs-"; +const TANSTACK_AI_RULE_NAME_PREFIX = "tanstack-ai-"; +const TANSTACK_START_RULE_NAME_PREFIX = "tanstack-start-"; +const TANSTACK_QUERY_RULE_NAME_PREFIX = "query-"; +const REACT_NATIVE_RULE_NAME_PREFIXES: ReadonlyArray = ["expo-", "rn-"]; +const ECOSYSTEM_RULE_NAME_PREFIXES: ReadonlyArray = [ + "tailwind-", + "motion-", + "swr-", + "mobx-", + "i18n-", + "shadcn-", + "radix-", + "rhf-", + "testing-", + "storybook-", + "r3f-", +]; + +const REACT_DOCTOR_ERROR_RULE_NAMES: ReadonlySet = new Set([ + "nextjs-async-client-component", + "nextjs-no-head-import", + "nextjs-no-side-effect-in-get-handler", + "rn-no-raw-text", + "rn-no-deprecated-modules", + "rn-no-scroll-state", + "rn-animate-layout-property", + "tanstack-start-route-property-order", + "tanstack-start-server-fn-method-order", + "tanstack-start-no-dynamic-server-fn-import", + "tanstack-start-no-use-server-in-handler", + "tanstack-start-no-secrets-in-loader", + "query-no-unstable-query-key", + "tailwind-oklch-alpha-syntax", + "swr-no-unstable-key", + "radix-aschild-single-child", + "testing-await-user-event", + "storybook-await-play-interactions", + "r3f-no-new-in-frame", + "r3f-no-clone-in-frame", + "no-mutable-in-deps", + "no-effect-event-in-deps", + "rerender-dependencies", + "effect-needs-cleanup", + "no-random-key", + "no-nested-component-definition", + "no-legacy-class-lifecycles", + "no-legacy-context-api", + "no-layout-property-animation", + "no-global-css-variable-animation", + "no-eval", + "server-auth-actions", + "server-no-mutable-module-state", + "no-disabled-zoom", +]); + +export const YOU_MIGHT_NOT_NEED_EFFECT_OXLINT_RULES: OxlintRuleSeverityMap = { + "effect/no-derived-state": "warn", + "effect/no-chain-state-updates": "warn", + "effect/no-event-handler": "warn", + "effect/no-adjust-state-on-prop-change": "warn", + "effect/no-reset-all-state-on-prop-change": "warn", + "effect/no-pass-live-state-to-parent": "warn", + "effect/no-pass-data-to-parent": "warn", + "effect/no-initialize-state": "warn", +}; + +export const REACT_COMPILER_OXLINT_RULES: OxlintRuleSeverityMap = { + "react-hooks-js/set-state-in-render": "error", + "react-hooks-js/immutability": "error", + "react-hooks-js/refs": "error", + "react-hooks-js/purity": "error", + "react-hooks-js/hooks": "error", + "react-hooks-js/set-state-in-effect": "error", + "react-hooks-js/globals": "error", + "react-hooks-js/error-boundaries": "error", + "react-hooks-js/preserve-manual-memoization": "error", + "react-hooks-js/unsupported-syntax": "error", + "react-hooks-js/component-hook-factories": "error", + "react-hooks-js/static-components": "error", + "react-hooks-js/use-memo": "error", + "react-hooks-js/void-use-memo": "error", + "react-hooks-js/incompatible-library": "error", + "react-hooks-js/todo": "error", +}; + +export const BUILTIN_REACT_OXLINT_RULES: OxlintRuleSeverityMap = { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "react/no-direct-mutation-state": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-key": "error", + "react/no-children-prop": "warn", + "react/no-danger": "warn", + "react/jsx-no-script-url": "error", + "react/no-render-return-value": "warn", + "react/no-string-refs": "warn", + "react/no-is-mounted": "warn", + "react/require-render-return": "error", + "react/no-unknown-property": "warn", +}; + +export const BUILTIN_A11Y_OXLINT_RULES: OxlintRuleSeverityMap = { + "jsx-a11y/alt-text": "error", + "jsx-a11y/anchor-is-valid": "warn", + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/no-static-element-interactions": "warn", + "jsx-a11y/role-has-required-aria-props": "error", + "jsx-a11y/no-autofocus": "warn", + "jsx-a11y/heading-has-content": "warn", + "jsx-a11y/html-has-lang": "warn", + "jsx-a11y/no-redundant-roles": "warn", + "jsx-a11y/scope": "warn", + "jsx-a11y/tabindex-no-positive": "warn", + "jsx-a11y/label-has-associated-control": "warn", + "jsx-a11y/no-distracting-elements": "error", + "jsx-a11y/iframe-has-title": "warn", +}; + +export const BUILTIN_OXLINT_RULES: OxlintRuleSeverityMap = { + ...BUILTIN_REACT_OXLINT_RULES, + ...BUILTIN_A11Y_OXLINT_RULES, +}; + +const startsWithAny = (value: string, prefixes: ReadonlyArray): boolean => + prefixes.some((prefix) => value.startsWith(prefix)); + +const toReactDoctorOxlintRuleKey = (ruleName: string): string => + `${REACT_DOCTOR_OXLINT_RULE_KEY_PREFIX}${ruleName}`; + +const getReactDoctorRuleSeverity = (ruleName: string): OxlintRuleSeverityMap[string] => + REACT_DOCTOR_ERROR_RULE_NAMES.has(ruleName) ? "error" : DEFAULT_OXLINT_RULE_SEVERITY; + +const createReactDoctorRuleMap = ( + shouldIncludeRule: (ruleName: string) => boolean, +): OxlintRuleSeverityMap => { + const rules: OxlintRuleSeverityMap = {}; + for (const ruleName of Object.keys(reactDoctorOxlintRules)) { + if (shouldIncludeRule(ruleName)) { + rules[toReactDoctorOxlintRuleKey(ruleName)] = getReactDoctorRuleSeverity(ruleName); + } + } + return rules; +}; + +const isNextJsRuleName = (ruleName: string): boolean => + ruleName.startsWith(NEXTJS_RULE_NAME_PREFIX); + +const isReactNativeRuleName = (ruleName: string): boolean => + startsWithAny(ruleName, REACT_NATIVE_RULE_NAME_PREFIXES); + +const isTanStackAiRuleName = (ruleName: string): boolean => + ruleName.startsWith(TANSTACK_AI_RULE_NAME_PREFIX); + +const isTanStackStartRuleName = (ruleName: string): boolean => + ruleName.startsWith(TANSTACK_START_RULE_NAME_PREFIX); + +const isTanStackQueryRuleName = (ruleName: string): boolean => + ruleName.startsWith(TANSTACK_QUERY_RULE_NAME_PREFIX); + +const isEcosystemRuleName = (ruleName: string): boolean => + startsWithAny(ruleName, ECOSYSTEM_RULE_NAME_PREFIXES); + +const isFrameworkRuleName = (ruleName: string): boolean => + isNextJsRuleName(ruleName) || + isReactNativeRuleName(ruleName) || + isTanStackAiRuleName(ruleName) || + isTanStackStartRuleName(ruleName) || + isTanStackQueryRuleName(ruleName); + +export const NEXTJS_OXLINT_RULES: OxlintRuleSeverityMap = + createReactDoctorRuleMap(isNextJsRuleName); + +export const REACT_NATIVE_OXLINT_RULES: OxlintRuleSeverityMap = + createReactDoctorRuleMap(isReactNativeRuleName); + +export const TANSTACK_START_OXLINT_RULES: OxlintRuleSeverityMap = + createReactDoctorRuleMap(isTanStackStartRuleName); + +export const TANSTACK_AI_OXLINT_RULES: OxlintRuleSeverityMap = + createReactDoctorRuleMap(isTanStackAiRuleName); + +export const TANSTACK_QUERY_OXLINT_RULES: OxlintRuleSeverityMap = + createReactDoctorRuleMap(isTanStackQueryRuleName); + +export const ECOSYSTEM_OXLINT_RULES: OxlintRuleSeverityMap = + createReactDoctorRuleMap(isEcosystemRuleName); + +export const GLOBAL_REACT_DOCTOR_OXLINT_RULES: OxlintRuleSeverityMap = createReactDoctorRuleMap( + (ruleName) => !isFrameworkRuleName(ruleName) && !isEcosystemRuleName(ruleName), +); + +export const REACT_DOCTOR_CUSTOM_OXLINT_RULES: OxlintRuleSeverityMap = { + ...GLOBAL_REACT_DOCTOR_OXLINT_RULES, + ...NEXTJS_OXLINT_RULES, + ...REACT_NATIVE_OXLINT_RULES, + ...TANSTACK_AI_OXLINT_RULES, + ...TANSTACK_START_OXLINT_RULES, + ...TANSTACK_QUERY_OXLINT_RULES, + ...ECOSYSTEM_OXLINT_RULES, +}; + +export const CURATED_OXLINT_RULES: OxlintRuleSeverityMap = { + ...BUILTIN_REACT_OXLINT_RULES, + ...BUILTIN_A11Y_OXLINT_RULES, + ...REACT_DOCTOR_CUSTOM_OXLINT_RULES, +}; + +export const ALL_REACT_DOCTOR_OXLINT_RULE_KEYS: ReadonlySet = new Set( + Object.keys(REACT_DOCTOR_CUSTOM_OXLINT_RULES), +); + +export type ReactDoctorOxlintFramework = + | "expo" + | "nextjs" + | "react" + | "react-native" + | "tanstack-start" + | "unknown"; + +export interface ReactDoctorOxlintConfigOptions { + pluginPath: string; + projectRootDirectory?: string; + project?: ReactDoctorOxlintProjectInfo; + framework?: ReactDoctorOxlintFramework; + customRulesOnly?: boolean; + hasReactCompiler?: boolean; + hasTanStackAI?: boolean; + hasTanStackQuery?: boolean; + includeEcosystemRules?: boolean; + extendsPaths?: string[]; + ignoredTags?: ReadonlySet; +} + +export interface ReactDoctorOxlintProjectInfo { + framework?: ReactDoctorOxlintFramework; + hasReactCompiler?: boolean; + hasTanStackAI?: boolean; + hasTanStackQuery?: boolean; + hasTypeScript?: boolean; + reactMajorVersion?: number | null; + reactPeerDependencyRange?: string | null; + tailwindVersion?: string | null; +} + +export interface ReactDoctorOxlintJsPluginEntry { + name: string; + specifier: string; +} + +export interface ReactDoctorOxlintGeneratedConfig { + extends?: string[]; + categories: Record; + plugins: string[]; + jsPlugins: Array; + rules: OxlintRuleSeverityMap; +} + +const DISABLED_OXLINT_CATEGORIES: ReactDoctorOxlintGeneratedConfig["categories"] = { + correctness: "off", + nursery: "off", + pedantic: "off", + perf: "off", + restriction: "off", + style: "off", + suspicious: "off", +}; + +interface MaybePluginModule { + rules?: Record; + default?: { rules?: Record }; +} + +interface ResolvedPlugin { + entry: ReactDoctorOxlintJsPluginEntry; + availableRuleNames: ReadonlySet; +} + +interface RuleMetadataEntry { + requires?: ReadonlyArray; + tags: ReadonlySet; +} + +interface RuleGroupConfig { + rules: OxlintRuleSeverityMap; + requires?: ReadonlyArray; +} + +const EMPTY_TAGS: ReadonlySet = new Set(); +const DEFAULT_IGNORED_TAGS: ReadonlySet = new Set(["pedantic"]); +const TEST_NOISE_TAGS: ReadonlySet = new Set(["test-noise"]); +const PEDANTIC_TAGS: ReadonlySet = new Set(["pedantic"]); +const DESIGN_AND_TEST_NOISE_TAGS: ReadonlySet = new Set(["design", "test-noise"]); +const TAILWIND_VERSION_PATTERN = /(?:^|[^\d])(\d+)(?:\.(\d+))?/; +const PEER_COMPARATOR_SEPARATOR = /[\s,|]+/; +const PEER_WILDCARD_COMPARATOR = /^[*xX](?:\.[*xX])*$/; + +const withReactDoctorRuleKey = ( + ruleName: string, + metadata: RuleMetadataEntry, +): [string, RuleMetadataEntry] => [toReactDoctorOxlintRuleKey(ruleName), metadata]; + +const RULE_METADATA: ReadonlyMap = new Map([ + withReactDoctorRuleKey("no-react19-deprecated-apis", { + requires: ["react:19"], + tags: TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("no-default-props", { requires: ["react:19"], tags: TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-react-dom-deprecated-apis", { + requires: ["react:18"], + tags: TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("prefer-use-effect-event", { + requires: ["react:19"], + tags: TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("no-nested-component-definition", { tags: TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-eval", { tags: TEST_NOISE_TAGS }), + withReactDoctorRuleKey("design-no-bold-heading", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("tailwind-no-redundant-padding-axes", { + tags: DESIGN_AND_TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("tailwind-no-redundant-size-axes", { + requires: ["tailwind:3.4"], + tags: DESIGN_AND_TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("tailwind-no-space-on-flex-children", { + tags: DESIGN_AND_TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("design-no-three-period-ellipsis", { tags: PEDANTIC_TAGS }), + withReactDoctorRuleKey("i18n-no-literal-jsx-text", { tags: PEDANTIC_TAGS }), + withReactDoctorRuleKey("rendering-content-visibility", { tags: PEDANTIC_TAGS }), + withReactDoctorRuleKey("tailwind-no-default-palette", { + tags: DESIGN_AND_TEST_NOISE_TAGS, + }), + withReactDoctorRuleKey("design-no-vague-button-label", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-side-tab-border", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-pure-black-background", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-gradient-text", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-dark-mode-glow", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-justified-text", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-tiny-text", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-wide-letter-spacing", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-gray-on-colored-background", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-layout-transition-inline", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-outline-none", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-long-transition-duration", { tags: DESIGN_AND_TEST_NOISE_TAGS }), + withReactDoctorRuleKey("no-render-in-render", { tags: TEST_NOISE_TAGS }), + withReactDoctorRuleKey("rerender-split-combined-hooks", { tags: PEDANTIC_TAGS }), +]); + +const EMPTY_TAG_SET: ReadonlySet = new Set(); + +export const getReactDoctorRuleTags = (ruleKey: string): ReadonlySet => + RULE_METADATA.get(ruleKey)?.tags ?? EMPTY_TAG_SET; + +const REACT_DOCTOR_FRAMEWORK_RULE_GROUPS: ReadonlyArray = [ + { rules: NEXTJS_OXLINT_RULES, requires: ["nextjs"] }, + { rules: REACT_NATIVE_OXLINT_RULES, requires: ["react-native"] }, + { rules: TANSTACK_START_OXLINT_RULES, requires: ["tanstack-start"] }, + { rules: TANSTACK_AI_OXLINT_RULES, requires: ["tanstack-ai"] }, + { rules: TANSTACK_QUERY_OXLINT_RULES, requires: ["tanstack-query"] }, +]; + +const readPluginRuleNames = ( + pluginSpecifier: string, + pluginRequire: NodeJS.Require, +): ReadonlySet => { + try { + const pluginModule: MaybePluginModule = pluginRequire(pluginSpecifier); + const rules = pluginModule.rules ?? pluginModule.default?.rules; + return rules ? new Set(Object.keys(rules)) : new Set(); + } catch { + return new Set(); + } +}; + +const resolveOptionalJsPlugin = ( + namespace: string, + pluginSpecifier: string, + projectRootDirectory: string | undefined, +): ResolvedPlugin | null => { + try { + const pluginRequire = projectRootDirectory + ? createRequire(path.join(projectRootDirectory, "package.json")) + : esmRequire; + const resolvedSpecifier = pluginRequire.resolve(pluginSpecifier); + return { + entry: { name: namespace, specifier: resolvedSpecifier }, + availableRuleNames: readPluginRuleNames(resolvedSpecifier, pluginRequire), + }; + } catch { + return null; + } +}; + +const filterRulesToAvailable = ( + rules: OxlintRuleSeverityMap, + pluginNamespace: string, + availableRuleNames: ReadonlySet, +): OxlintRuleSeverityMap => { + if (availableRuleNames.size === 0) return rules; + const ruleKeyPrefix = `${pluginNamespace}/`; + const filteredRules: OxlintRuleSeverityMap = {}; + for (const [ruleKey, severity] of Object.entries(rules)) { + if (!ruleKey.startsWith(ruleKeyPrefix)) { + filteredRules[ruleKey] = severity; + continue; + } + const ruleName = ruleKey.slice(ruleKeyPrefix.length); + if (availableRuleNames.has(ruleName)) { + filteredRules[ruleKey] = severity; + } + } + return filteredRules; +}; + +const buildOptionalReactCompilerConfig = ( + customRulesOnly: boolean, + hasReactCompiler: boolean, + projectRootDirectory: string | undefined, +): { jsPlugin: ReactDoctorOxlintJsPluginEntry | null; rules: OxlintRuleSeverityMap } => { + if (customRulesOnly || !hasReactCompiler) return { jsPlugin: null, rules: {} }; + const plugin = resolveOptionalJsPlugin( + REACT_HOOKS_JS_NAMESPACE, + REACT_HOOKS_PLUGIN_SPECIFIER, + projectRootDirectory, + ); + if (!plugin) return { jsPlugin: null, rules: {} }; + return { + jsPlugin: plugin.entry, + rules: filterRulesToAvailable( + REACT_COMPILER_OXLINT_RULES, + REACT_HOOKS_JS_NAMESPACE, + plugin.availableRuleNames, + ), + }; +}; + +const buildOptionalYouMightNotNeedEffectConfig = ( + customRulesOnly: boolean, + projectRootDirectory: string | undefined, +): { jsPlugin: ReactDoctorOxlintJsPluginEntry | null; rules: OxlintRuleSeverityMap } => { + if (customRulesOnly) return { jsPlugin: null, rules: {} }; + const plugin = resolveOptionalJsPlugin( + YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, + YOU_MIGHT_NOT_NEED_EFFECT_PLUGIN_SPECIFIER, + projectRootDirectory, + ); + if (!plugin) return { jsPlugin: null, rules: {} }; + return { + jsPlugin: plugin.entry, + rules: filterRulesToAvailable( + YOU_MIGHT_NOT_NEED_EFFECT_OXLINT_RULES, + YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, + plugin.availableRuleNames, + ), + }; +}; + +const parseMajorMinor = ( + version: string | null | undefined, +): { major: number; minor: number } | null => { + if (!version) return null; + const match = version.match(TAILWIND_VERSION_PATTERN); + if (!match) return null; + return { + major: Number.parseInt(match[1], 10), + minor: match[2] ? Number.parseInt(match[2], 10) : 0, + }; +}; + +const isTailwindAtLeast = ( + version: { major: number; minor: number } | null, + minimum: { major: number; minor: number }, +): boolean => { + if (!version) return true; + if (version.major > minimum.major) return true; + if (version.major < minimum.major) return false; + return version.minor >= minimum.minor; +}; + +const comparatorMajor = (comparator: string): number | null => { + if (PEER_WILDCARD_COMPARATOR.test(comparator)) return null; + const firstIntegerMatch = comparator.match(/\d+/); + if (!firstIntegerMatch) return null; + const major = Number.parseInt(firstIntegerMatch[0], 10); + return major >= 1 ? major : null; +}; + +export const reactPeerRangeMinMajor = (range: string | null | undefined): number | null => { + if (typeof range !== "string") return null; + let lowestMajor: number | null = null; + for (const comparator of range.trim().split(PEER_COMPARATOR_SEPARATOR).filter(Boolean)) { + const major = comparatorMajor(comparator); + if (major !== null && (lowestMajor === null || major < lowestMajor)) { + lowestMajor = major; + } + } + return lowestMajor; +}; + +const effectiveReactMajor = (project: ReactDoctorOxlintProjectInfo): number => { + const installedMajor = project.reactMajorVersion ?? null; + const peerMajor = reactPeerRangeMinMajor(project.reactPeerDependencyRange); + if (installedMajor !== null && peerMajor !== null) return Math.min(installedMajor, peerMajor); + return installedMajor ?? peerMajor ?? UNCONSTRAINED_REACT_MAJOR; +}; + +export const buildReactDoctorOxlintCapabilities = ( + project: ReactDoctorOxlintProjectInfo, +): ReadonlySet => { + const capabilities = new Set(); + const framework = project.framework ?? "unknown"; + capabilities.add(framework); + if (framework === "expo" || framework === "react-native") capabilities.add("react-native"); + + const reactMajor = effectiveReactMajor(project); + for (let major = 17; major <= reactMajor; major++) { + capabilities.add(`react:${major}`); + } + + if (project.tailwindVersion !== null) { + capabilities.add("tailwind"); + if (isTailwindAtLeast(parseMajorMinor(project.tailwindVersion), { major: 3, minor: 4 })) { + capabilities.add("tailwind:3.4"); + } + } + + if (project.hasReactCompiler) capabilities.add("react-compiler"); + if (project.hasTanStackAI) capabilities.add("tanstack-ai"); + if (project.hasTanStackQuery) capabilities.add("tanstack-query"); + if (project.hasTypeScript) capabilities.add("typescript"); + return capabilities; +}; + +export const shouldEnableReactDoctorOxlintRule = ( + requires: ReadonlyArray | undefined, + tags: ReadonlySet, + capabilities: ReadonlySet, + ignoredTags: ReadonlySet, +): boolean => { + if (requires) { + for (const capability of requires) { + if (!capabilities.has(capability)) return false; + } + } + for (const tag of tags) { + if (ignoredTags.has(tag)) return false; + } + return true; +}; + +const addEnabledRules = ( + target: OxlintRuleSeverityMap, + rules: OxlintRuleSeverityMap, + capabilities: ReadonlySet, + ignoredTags: ReadonlySet, + defaultRequires?: ReadonlyArray, +): void => { + for (const [ruleKey, severity] of Object.entries(rules)) { + const metadata = RULE_METADATA.get(ruleKey); + const requires = metadata?.requires ?? defaultRequires; + const tags = metadata?.tags ?? EMPTY_TAGS; + if (shouldEnableReactDoctorOxlintRule(requires, tags, capabilities, ignoredTags)) { + target[ruleKey] = severity; + } + } +}; + +export const createReactDoctorOxlintConfig = ({ + pluginPath, + projectRootDirectory, + project, + framework = "unknown", + customRulesOnly = false, + hasReactCompiler = false, + hasTanStackAI = false, + hasTanStackQuery = false, + includeEcosystemRules = true, + extendsPaths = [], + ignoredTags = DEFAULT_IGNORED_TAGS, +}: ReactDoctorOxlintConfigOptions): ReactDoctorOxlintGeneratedConfig => { + const projectInfo: ReactDoctorOxlintProjectInfo = project ?? { + framework, + hasReactCompiler, + hasTanStackAI, + hasTanStackQuery, + }; + const capabilities = buildReactDoctorOxlintCapabilities(projectInfo); + const reactCompilerConfig = buildOptionalReactCompilerConfig( + customRulesOnly, + Boolean(projectInfo.hasReactCompiler), + projectRootDirectory, + ); + const youMightNotNeedEffectConfig = buildOptionalYouMightNotNeedEffectConfig( + customRulesOnly, + projectRootDirectory, + ); + const jsPlugins: Array = []; + if (reactCompilerConfig.jsPlugin) jsPlugins.push(reactCompilerConfig.jsPlugin); + if (youMightNotNeedEffectConfig.jsPlugin) jsPlugins.push(youMightNotNeedEffectConfig.jsPlugin); + jsPlugins.push(pluginPath); + const enabledReactDoctorRules: OxlintRuleSeverityMap = {}; + addEnabledRules( + enabledReactDoctorRules, + GLOBAL_REACT_DOCTOR_OXLINT_RULES, + capabilities, + ignoredTags, + ); + for (const ruleGroup of REACT_DOCTOR_FRAMEWORK_RULE_GROUPS) { + addEnabledRules( + enabledReactDoctorRules, + ruleGroup.rules, + capabilities, + ignoredTags, + ruleGroup.requires, + ); + } + if (includeEcosystemRules) { + addEnabledRules(enabledReactDoctorRules, ECOSYSTEM_OXLINT_RULES, capabilities, ignoredTags); + } + + return { + ...(extendsPaths.length > 0 ? { extends: extendsPaths } : {}), + categories: { ...DISABLED_OXLINT_CATEGORIES }, + plugins: customRulesOnly ? [] : ["react", "jsx-a11y"], + jsPlugins, + rules: { + ...(customRulesOnly ? {} : BUILTIN_OXLINT_RULES), + ...reactCompilerConfig.rules, + ...youMightNotNeedEffectConfig.rules, + ...enabledReactDoctorRules, + }, + }; +}; diff --git a/packages/react-doctor/src/plugin/constants.ts b/packages/react-doctor/src/core/rules/lint/constants.ts similarity index 91% rename from packages/react-doctor/src/plugin/constants.ts rename to packages/react-doctor/src/core/rules/lint/constants.ts index 16de7a065e..3608b7d1ad 100644 --- a/packages/react-doctor/src/plugin/constants.ts +++ b/packages/react-doctor/src/core/rules/lint/constants.ts @@ -4,6 +4,19 @@ export const RELATED_USE_STATE_THRESHOLD = 5; export const DEEP_NESTING_THRESHOLD = 3; export const DUPLICATE_STORAGE_READ_THRESHOLD = 2; export const SEQUENTIAL_AWAIT_THRESHOLD = 3; +export const SEQUENTIAL_DELAY_FUNCTION_NAMES = new Set([ + "sleep", + "delay", + "wait", + "waitForTimeout", + "waitForSelector", + "waitForNavigation", + "waitForLoadState", + "waitForEvent", + "waitForResponse", + "waitForRequest", + "waitFor", +]); export const PROPERTY_ACCESS_REPEAT_THRESHOLD = 3; export const BOOLEAN_PROP_THRESHOLD = 4; export const RENDER_PROP_PROLIFERATION_THRESHOLD = 3; @@ -98,6 +111,14 @@ export const AUTH_FUNCTION_NAMES = new Set([ "currentUser", "getAuth", "validateSession", + "checkAdminAccess", + "requireAdmin", + "ensureAuth", + "ensureAuthenticated", + "requireSession", + "assertAuth", + "protectRoute", + "guardAuth", ]); export const SECRET_PATTERNS = [ @@ -123,6 +144,9 @@ export const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([ "id", "key", "url", + "uri", + "endpoint", + "location", "path", "route", "page", @@ -269,7 +293,7 @@ export const TRIVIAL_INITIALIZER_NAMES = new Set([ // and should still get the "compute during render" message. // MemberExpression callees (e.g. `Math.floor`, `Date.now`) are // recognized via BUILTIN_GLOBAL_NAMESPACE_NAMES (the chain root), not -// here — putting "Math" or "Date" in this set wouldn't match because +// here - putting "Math" or "Date" in this set wouldn't match because // the expensive-derivation walker reads the *property* name. export const TRIVIAL_DERIVATION_CALLEE_NAMES = new Set([ "Boolean", @@ -287,7 +311,7 @@ export const TRIVIAL_DERIVATION_CALLEE_NAMES = new Set([ // Built-in JS globals whose method calls (`Math.floor(x)`, // `Date.now()`, `JSON.parse(x)`, …) are not reactive reads and don't -// count as "expensive derivations". The chain root is what matters — +// count as "expensive derivations". The chain root is what matters - // `Math.floor(raw)` should only treat `raw` as a reactive read, and // the call itself should be classified as trivial regardless of which // method is invoked. @@ -311,7 +335,7 @@ export const RENDER_FUNCTION_PATTERN = /^render[A-Z]/; export const UPPERCASE_PATTERN = /^[A-Z]/; export const PAGE_FILE_PATTERN = /\/page\.(tsx?|jsx?)$/; -// React's idiomatic event-handler prop convention — `onClick`, `onChange`, +// React's idiomatic event-handler prop convention - `onClick`, `onChange`, // `onSearch`, etc. Used by `prefer-use-effect-event` to decide whether a // destructured prop dep should be treated as function-typed. Without this // filter the rule false-positives on scalar props that happen to be @@ -323,7 +347,14 @@ export const INTERNAL_PAGE_PATH_PATTERN = /\/(?:(?:\((?:dashboard|admin|settings|account|internal|manage|console|portal|auth|onboarding|app|ee|protected)\))|(?:dashboard|admin|settings|account|internal|manage|console|portal))\//i; export const TEST_FILE_PATTERN = /\.(?:test|spec|stories)\.[tj]sx?$/; +export const TEST_OR_INFRA_FILE_PATTERN = + /(?:\.(?:test|spec|stories|e2e|integration)\.[tj]sx?$|\/(?:__tests__|tests?|__mocks__|__fixtures__|fixtures|e2e|playwright)\/)/; export const OG_ROUTE_PATTERN = /\/og\b/i; +export const OG_IMAGE_FILE_PATTERN = + /\/(?:opengraph-image|twitter-image|icon|apple-icon)\.[jt]sx?$|\.opengraph\.[jt]sx?$/; + +export const NON_SEO_PAGE_PATTERN = + /\/(?:install|callback|login|logout|signup|sign-up|sign-in|auth|verify|oauth)\//i; export const PAGES_DIRECTORY_PATTERN = /\/pages\//; @@ -348,6 +379,7 @@ export const EXECUTABLE_SCRIPT_TYPES = new Set([ export const APP_DIRECTORY_PATTERN = /\/app\//; export const ROUTE_HANDLER_FILE_PATTERN = /\/route\.(tsx?|jsx?)$/; +export const CRON_ROUTE_PATTERN = /\/cron[-_]?/i; export const MUTATION_METHOD_NAMES = new Set([ "create", @@ -418,7 +450,7 @@ export const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([ ]); // Timer registrations that ALWAYS need a corresponding cleanup call -// (a stricter subset of the scheduler list above — `requestAnimationFrame` +// (a stricter subset of the scheduler list above - `requestAnimationFrame` // and friends already invoke once and self-clean, but `setTimeout` / // `setInterval` keep firing until explicitly cleared). export const TIMER_CALLEE_NAMES_REQUIRING_CLEANUP = new Set(["setInterval", "setTimeout"]); @@ -427,9 +459,9 @@ export const TIMER_CLEANUP_CALLEE_NAMES = new Set(["clearInterval", "clearTimeou // Globals whose values mutate outside the React data flow. Listing // them as deps doesn't trigger a re-run when they change because -// React compares deps with `Object.is` during render — and the read +// React compares deps with `Object.is` during render - and the read // happens during render, before the mutation. From "Lifecycle of -// Reactive Effects" — Can global or mutable values be dependencies? +// Reactive Effects" - Can global or mutable values be dependencies? export const MUTABLE_GLOBAL_ROOTS = new Set([ "location", "window", @@ -464,6 +496,7 @@ export const UNSUBSCRIPTION_METHOD_NAMES = new Set([ "unsubscribe", "removeEventListener", "removeListener", + "remove", "off", "unwatch", "unlisten", @@ -472,7 +505,7 @@ export const UNSUBSCRIPTION_METHOD_NAMES = new Set([ // Identifier names recognized as "this is a release/teardown call" // when they appear as a direct call inside an effect's cleanup -// return — covers both library unsubscribe shorthands +// return - covers both library unsubscribe shorthands // (UNSUBSCRIPTION_METHOD_NAMES) and the generic teardown vocabulary // (`cleanup`, `dispose`, `destroy`, `teardown`). Matched // case-insensitively at the call site. @@ -493,7 +526,7 @@ export const CLEANUP_LIKE_RELEASE_CALLEE_NAMES = new Set([ // // Member-method names that, on their own, mark a call as external // sync regardless of receiver. These are unambiguous in real React -// codebases — they don't clash with built-in JS APIs. +// codebases - they don't clash with built-in JS APIs. // // Layered on top of `SUBSCRIPTION_METHOD_NAMES` so the subscribe-shape // detector and the external-sync detector can never disagree about @@ -505,7 +538,7 @@ export const EXTERNAL_SYNC_MEMBER_METHOD_NAMES = new Set([ "disconnect", "open", "close", - // Mutating HTTP verbs — `*.post(url, body)` is essentially always + // Mutating HTTP verbs - `*.post(url, body)` is essentially always // a network call. (`delete` is moved to the ambiguous set below // because Map / Set / URLSearchParams / Headers / FormData / // WeakMap all expose `.delete(...)` as a built-in method.) @@ -559,9 +592,9 @@ export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([ // Used by `no-event-trigger-state` to recognize when a useEffect body // is performing the §6 anti-pattern from "You Might Not Need an Effect" -// — running an event-shaped side effect (POST, navigation, notification, +// - running an event-shaped side effect (POST, navigation, notification, // analytics) that the user actually triggered with a button click. -// Tightly scoped on purpose — adding a callee name here can produce +// Tightly scoped on purpose - adding a callee name here can produce // false positives on pure helper functions, so the bar is "this name // almost always denotes a fire-and-forget user-action effect." // Layered on top of `FETCH_CALLEE_NAMES` so adding a new HTTP client @@ -574,7 +607,7 @@ export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([ // receiver-bound member-call shape (`analytics.track(...)`, // `api.del(...)`) in `EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS`. // -// `post` / `put` / `patch` are KEPT here — the canonical "You Might +// `post` / `put` / `patch` are KEPT here - the canonical "You Might // Not Need an Effect" §6 example is `post(jsonToSubmit)` as a bare // callee, so removing them would silently miss the textbook case. // The trade-off (FPs on user helpers named `post(...)`) is acceptable @@ -598,7 +631,7 @@ export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([ "captureEvent", ]); -// Recognized when the call shape is `.(...)` — covers +// Recognized when the call shape is `.(...)` - covers // `axios.post`, `api.post`, `analytics.track`, `posthog.capture`, // etc. without enumerating every possible object. Names here are // unambiguous: they don't clash with built-in JS prototype methods @@ -617,7 +650,7 @@ export const EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS = new Set([ // HACK: `push` and `replace` are router methods (`router.push("/foo")`, // `history.replace("/bar")`) but ALSO universal Array / String prototype // methods. `[1, 2].push(3)` and `"a".replace("b", "c")` are NOT event- -// shaped side effects — calling `setX` after them in a useEffect is +// shaped side effects - calling `setX` after them in a useEffect is // usually fine. We only treat them as event-triggered side effects when // the receiver looks router-shaped. Keeps the false-positive rate down // without losing the `router.push(...)` / `history.replace(...)` cases. @@ -636,7 +669,6 @@ export const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]); export const LARGE_BLUR_THRESHOLD_PX = 10; export const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/; export const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]); -export const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]); export const RAW_TEXT_PREVIEW_MAX_CHARS = 30; @@ -754,11 +786,11 @@ export const HEAVY_HEADING_TAILWIND_WEIGHTS = new Set([ export const TAILWIND_DEFAULT_PALETTE_NAMES = ["indigo", "gray", "slate"]; // HACK: the canonical Tailwind v3/v4 numeric color stops. Anchoring the -// `design-no-default-tailwind-palette` regex to this exact set (rather +// `tailwind-no-default-palette` regex to this exact set (rather // than `\d{2,3}`) avoids false-positiving on Radix Colors integrations // that map non-Tailwind stops onto Tailwind utilities (`text-gray-11`, // `text-gray-12`, `text-gray-10` are Radix scale numbers, not Tailwind -// defaults — flagging them as "the Tailwind template default" is wrong). +// defaults - flagging them as "the Tailwind template default" is wrong). export const TAILWIND_DEFAULT_PALETTE_STOPS = [ "50", "100", @@ -813,7 +845,7 @@ export const EM_DASH_CHARACTER = "\u2014"; // between Tailwind tokens isn't consumed. With a consuming `(?:$|\s|:)` // trailing group, `matchAll` over `"px-4 px-6"` would catch `px-4` plus // the trailing space, then fail to find a leading `\s` boundary for -// `px-6` because we just ate it — silently skipping the second token. +// `px-6` because we just ate it - silently skipping the second token. export const PADDING_HORIZONTAL_AXIS_PATTERN = /(?:^|\s)(-?)px-(\d+(?:\.\d+)?|\[[^\]]+\])(?=$|[\s:])/g; diff --git a/packages/react-doctor/src/core/rules/lint/design/design-no-bold-heading.ts b/packages/react-doctor/src/core/rules/lint/design/design-no-bold-heading.ts new file mode 100644 index 0000000000..35dea986c8 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/design-no-bold-heading.ts @@ -0,0 +1,65 @@ +import { defineRule } from "../../registry.js"; +import { + HEADING_TAG_NAMES, + HEAVY_HEADING_FONT_WEIGHT_MIN, + HEAVY_HEADING_TAILWIND_WEIGHTS, + findJsxAttribute, + getClassNameLiteral, + getInlineStyleObjectExpression, + getOpeningElementTagName, + getStylePropertyKeyName, + getStylePropertyNumericValue, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noBoldHeading = defineRule({ + recommendation: + "Use medium or semibold heading weights instead of font-bold so display text keeps readable letter shapes.", + examples: [ + { + before: `

`, + after: `

`, + }, + ], + create: (context: RuleContext) => ({ + JSXOpeningElement(openingNode: EsTreeNode) { + const tagName = getOpeningElementTagName(openingNode); + if (!tagName || !HEADING_TAG_NAMES.has(tagName)) return; + + const classAttribute = findJsxAttribute(openingNode.attributes ?? [], "className"); + if (classAttribute) { + const classNameLiteral = getClassNameLiteral(classAttribute); + if (classNameLiteral) { + for (const tailwindWeightToken of HEAVY_HEADING_TAILWIND_WEIGHTS) { + const tokenPattern = new RegExp(`(?:^|\\s)${tailwindWeightToken}(?:$|\\s|:)`); + if (tokenPattern.test(classNameLiteral)) { + context.report({ + node: classAttribute, + message: `${tailwindWeightToken} on <${tagName}> crushes counter shapes at display sizes - use font-semibold (600) or font-medium (500)`, + }); + return; + } + } + } + } + + const styleAttribute = findJsxAttribute(openingNode.attributes ?? [], "style"); + if (!styleAttribute) return; + const styleObject = getInlineStyleObjectExpression(styleAttribute); + if (!styleObject) return; + + for (const objectProperty of styleObject.properties ?? []) { + const stylePropertyName = getStylePropertyKeyName(objectProperty); + if (stylePropertyName !== "fontWeight") continue; + const numericWeight = getStylePropertyNumericValue(objectProperty); + if (numericWeight !== null && numericWeight >= HEAVY_HEADING_FONT_WEIGHT_MIN) { + context.report({ + node: objectProperty, + message: `fontWeight: ${numericWeight} on <${tagName}> crushes counter shapes at display sizes - use 500 or 600`, + }); + return; + } + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/design-no-default-tailwind-palette.ts b/packages/react-doctor/src/core/rules/lint/design/design-no-default-tailwind-palette.ts new file mode 100644 index 0000000000..932e8317de --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/design-no-default-tailwind-palette.ts @@ -0,0 +1,40 @@ +import { defineRule } from "../../registry.js"; +import { DEFAULT_PALETTE_REGEX, getClassNameLiteral, isNodeOfType } from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noDefaultTailwindPalette = defineRule({ + recommendation: + "Replace default gray/slate/zinc-heavy palettes with project tokens or a deliberate brand palette.", + examples: [ + { + before: ``, + after: ``, + }, + ], + create: (context: RuleContext) => ({ + JSXElement(jsxElementNode: EsTreeNode) { + const tagName = getOpeningElementTagName(jsxElementNode.openingElement); + if (!tagName || !isButtonLikeTagName(tagName)) return; + const labelText = collectJsxLabelText(jsxElementNode); + if (!labelText) return; + const normalizedLabel = labelText + .toLowerCase() + .replace(/[.!?…]+$/, "") + .trim(); + if (!VAGUE_BUTTON_LABELS.has(normalizedLabel)) return; + context.report({ + node: jsxElementNode.openingElement ?? jsxElementNode, + message: `Vague button label "${labelText}" - name the action ("Save changes", "Send invite", "Delete account") so screen readers and hesitant users know what happens`, + }); + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-dark-mode-glow.ts b/packages/react-doctor/src/core/rules/lint/design/no-dark-mode-glow.ts new file mode 100644 index 0000000000..d3ec866f72 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-dark-mode-glow.ts @@ -0,0 +1,57 @@ +import { defineRule } from "../../registry.js"; +import { + getInlineStyleExpression, + getStylePropertyKey, + getStylePropertyStringValue, + hasColoredGlowShadow, + isBackgroundDark, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noDarkModeGlow = defineRule({ + recommendation: + "Reduce or remove decorative glows in dark mode and rely on contrast, elevation, and spacing for hierarchy.", + examples: [ + { + before: `
`, + after: `
`, + }, + ], + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNode) { + const expression = getInlineStyleExpression(node); + if (!expression) return; + + let hasDarkBackground = false; + let shadowProperty: EsTreeNode | null = null; + let shadowValue: string | null = null; + + for (const property of expression.properties ?? []) { + const key = getStylePropertyKey(property); + if (!key) continue; + + if (key === "backgroundColor" || key === "background") { + const value = getStylePropertyStringValue(property); + if (value && isBackgroundDark(value)) { + hasDarkBackground = true; + } + } + + if (key === "boxShadow") { + shadowProperty = property; + shadowValue = getStylePropertyStringValue(property); + } + } + + if (!hasDarkBackground || !shadowValue || !shadowProperty) return; + + if (hasColoredGlowShadow(shadowValue)) { + context.report({ + node: shadowProperty, + message: + "Colored glow on dark background - the default AI-generated 'cool' look. Use subtle, purposeful lighting instead", + }); + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-disabled-zoom.ts b/packages/react-doctor/src/core/rules/lint/design/no-disabled-zoom.ts new file mode 100644 index 0000000000..acc9af9439 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-disabled-zoom.ts @@ -0,0 +1,54 @@ +import { defineRule } from "../../registry.js"; +import { findJsxAttribute, isNodeOfType } from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noDisabledZoom = defineRule({ + recommendation: + "Allow pinch zoom by removing user-scalable=no and restrictive maximum-scale values.", + examples: [ + { + before: ``, + after: ``, + }, + ], + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNode) { + if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "meta") return; + + const nameAttr = findJsxAttribute(node.attributes ?? [], "name"); + if (!nameAttr?.value) return; + const nameValue = isNodeOfType(nameAttr.value, "Literal") ? nameAttr.value.value : null; + if (nameValue !== "viewport") return; + + const contentAttr = findJsxAttribute(node.attributes ?? [], "content"); + if (!contentAttr?.value) return; + const contentValue = + isNodeOfType(contentAttr.value, "Literal") && typeof contentAttr.value.value === "string" + ? contentAttr.value.value + : null; + if (!contentValue) return; + + const hasUserScalableNo = /user-scalable\s*=\s*no/i.test(contentValue); + const maxScaleMatch = contentValue.match(/maximum-scale\s*=\s*([\d.]+)/i); + const hasRestrictiveMaxScale = maxScaleMatch !== null && parseFloat(maxScaleMatch[1]) < 2; + + if (hasUserScalableNo && hasRestrictiveMaxScale) { + context.report({ + node, + message: `user-scalable=no and maximum-scale=${maxScaleMatch[1]} disable pinch-to-zoom - this is an accessibility violation (WCAG 1.4.4). Remove both and fix layout if it breaks at 200% zoom`, + }); + } else if (hasUserScalableNo) { + context.report({ + node, + message: + "user-scalable=no disables pinch-to-zoom - this is an accessibility violation (WCAG 1.4.4). Remove it and fix layout if it breaks at 200% zoom", + }); + } else if (hasRestrictiveMaxScale) { + context.report({ + node, + message: `maximum-scale=${maxScaleMatch[1]} restricts zoom below 200% - this is an accessibility violation (WCAG 1.4.4). Use maximum-scale=5 or remove it`, + }); + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-gradient-text.ts b/packages/react-doctor/src/core/rules/lint/design/no-gradient-text.ts new file mode 100644 index 0000000000..0a59789e74 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-gradient-text.ts @@ -0,0 +1,61 @@ +import { defineRule } from "../../registry.js"; +import { + getInlineStyleExpression, + getStringFromClassNameAttr, + getStylePropertyKey, + getStylePropertyStringValue, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noGradientText = defineRule({ + recommendation: + "Use solid text color for important copy and reserve gradients for decorative accents with accessible fallbacks.", + examples: [ + { + before: `

`, + after: `

`, + }, + ], + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNode) { + const expression = getInlineStyleExpression(node); + if (!expression) return; + + let hasBackgroundClipText = false; + let hasGradientBackground = false; + + for (const property of expression.properties ?? []) { + const key = getStylePropertyKey(property); + const value = getStylePropertyStringValue(property); + if (!key || !value) continue; + + if ((key === "backgroundClip" || key === "WebkitBackgroundClip") && value === "text") { + hasBackgroundClipText = true; + } + if ((key === "backgroundImage" || key === "background") && value.includes("gradient")) { + hasGradientBackground = true; + } + } + + if (hasBackgroundClipText && hasGradientBackground) { + context.report({ + node, + message: + "Gradient text (background-clip: text) is decorative rather than meaningful - a common AI tell. Use solid colors for text", + }); + } + }, + JSXOpeningElement(node: EsTreeNode) { + const classStr = getStringFromClassNameAttr(node); + if (!classStr) return; + + if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) { + context.report({ + node, + message: + "Gradient text (bg-clip-text + bg-gradient) is decorative rather than meaningful - a common AI tell. Use solid colors for text", + }); + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-gray-on-colored-background.ts b/packages/react-doctor/src/core/rules/lint/design/no-gray-on-colored-background.ts new file mode 100644 index 0000000000..40aac8ec74 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-gray-on-colored-background.ts @@ -0,0 +1,32 @@ +import { defineRule } from "../../registry.js"; +import { getStringFromClassNameAttr } from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noGrayOnColoredBackground = defineRule({ + recommendation: + "Use foreground colors chosen for the colored surface instead of gray text that loses contrast on tinted backgrounds.", + examples: [ + { + before: `
`, + after: `
`, + }, + ], + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNode) { + const classStr = getStringFromClassNameAttr(node); + if (!classStr) return; + + const grayTextMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); + const coloredBgMatch = classStr.match( + /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/, + ); + + if (grayTextMatch && coloredBgMatch) { + context.report({ + node, + message: `Gray text (${grayTextMatch[0]}) on colored background (${coloredBgMatch[0]}) looks washed out - use a darker shade of the background color or white`, + }); + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-inline-bounce-easing.ts b/packages/react-doctor/src/core/rules/lint/design/no-inline-bounce-easing.ts new file mode 100644 index 0000000000..e457960b6a --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-inline-bounce-easing.ts @@ -0,0 +1,69 @@ +import { defineRule } from "../../registry.js"; +import { + getInlineStyleExpression, + getStringFromClassNameAttr, + getStylePropertyKey, + getStylePropertyStringValue, + hasBounceAnimationName, + isOvershootCubicBezier, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noInlineBounceEasing = defineRule({ + recommendation: + "Move easing curves into named tokens and use restrained spring or cubic-bezier values instead of inline bounce curves.", + examples: [ + { + before: `transitionTimingFunction: "cubic-bezier(.68,-.55,.27,1.55)"`, + after: `className="transition-transform ease-out"`, + }, + ], + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNode) { + const expression = getInlineStyleExpression(node); + if (!expression) return; + + for (const property of expression.properties ?? []) { + const key = getStylePropertyKey(property); + if (!key) continue; + + const value = getStylePropertyStringValue(property); + if (!value) continue; + + if ( + (key === "transition" || + key === "transitionTimingFunction" || + key === "animation" || + key === "animationTimingFunction") && + isOvershootCubicBezier(value) + ) { + context.report({ + node: property, + message: + "Bounce/elastic easing feels dated - real objects decelerate smoothly. Use ease-out or cubic-bezier(0.16, 1, 0.3, 1) instead", + }); + } + + if ((key === "animation" || key === "animationName") && hasBounceAnimationName(value)) { + context.report({ + node: property, + message: + "Bounce/elastic animation name detected - these feel tacky. Use exponential easing (ease-out-quart/expo) for natural deceleration", + }); + } + } + }, + JSXOpeningElement(node: EsTreeNode) { + const classStr = getStringFromClassNameAttr(node); + if (!classStr) return; + + if (/\banimate-bounce\b/.test(classStr)) { + context.report({ + node, + message: + "animate-bounce feels dated and tacky - use a subtle ease-out transform for natural deceleration", + }); + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-inline-exhaustive-style.ts b/packages/react-doctor/src/core/rules/lint/design/no-inline-exhaustive-style.ts new file mode 100644 index 0000000000..9c9eb60073 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-inline-exhaustive-style.ts @@ -0,0 +1,44 @@ +import { defineRule } from "../../registry.js"; +import { + INLINE_STYLE_PROPERTY_THRESHOLD, + OG_IMAGE_FILE_PATTERN, + OG_ROUTE_PATTERN, + getInlineStyleExpression, + isNodeOfType, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noInlineExhaustiveStyle = defineRule({ + recommendation: + "Move large inline style objects to classes, CSS variables, or focused style helpers so design tokens remain reusable.", + examples: [ + { + before: `
`, + after: `
`, + }, + ], + create: (context: RuleContext) => { + const filename = context.getFilename?.() ?? ""; + const isOgImageFile = OG_IMAGE_FILE_PATTERN.test(filename) || OG_ROUTE_PATTERN.test(filename); + + return { + JSXAttribute(node: EsTreeNode) { + if (isOgImageFile) return; + const expression = getInlineStyleExpression(node); + if (!expression) return; + + const propertyCount = + expression.properties?.filter((property: EsTreeNode) => + isNodeOfType(property, "Property"), + ).length ?? 0; + + if (propertyCount >= INLINE_STYLE_PROPERTY_THRESHOLD) { + context.report({ + node: expression, + message: `${propertyCount} inline style properties - extract to a CSS class, CSS module, or styled component for maintainability and reuse`, + }); + } + }, + }; + }, +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-justified-text.ts b/packages/react-doctor/src/core/rules/lint/design/no-justified-text.ts new file mode 100644 index 0000000000..e696002b9a --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-justified-text.ts @@ -0,0 +1,44 @@ +import { defineRule } from "../../registry.js"; +import { + getInlineStyleExpression, + getStylePropertyKey, + getStylePropertyStringValue, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noJustifiedText = defineRule({ + recommendation: + "Use left-aligned text for body copy instead of text-align: justify to avoid rivers and uneven word spacing.", + examples: [ + { + before: `

`, + after: `

`, + }, + ], + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNode) { + const expression = getInlineStyleExpression(node); + if (!expression) return; + + let isJustified = false; + let hasHyphens = false; + + for (const property of expression.properties ?? []) { + const key = getStylePropertyKey(property); + const value = getStylePropertyStringValue(property); + if (!key || !value) continue; + + if (key === "textAlign" && value === "justify") isJustified = true; + if ((key === "hyphens" || key === "WebkitHyphens") && value === "auto") hasHyphens = true; + } + + if (isJustified && !hasHyphens) { + context.report({ + node, + message: + 'Justified text without hyphens creates uneven word spacing ("rivers of white"). Use text-align: left, or add hyphens: auto', + }); + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-layout-transition-inline.ts b/packages/react-doctor/src/core/rules/lint/design/no-layout-transition-inline.ts new file mode 100644 index 0000000000..53e588fcc3 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-layout-transition-inline.ts @@ -0,0 +1,45 @@ +import { defineRule } from "../../registry.js"; +import { + getInlineStyleExpression, + getStylePropertyKey, + getStylePropertyStringValue, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noLayoutTransitionInline = defineRule({ + recommendation: + "Transition transform and opacity explicitly instead of inline layout-property transitions.", + examples: [ + { + before: `

`, + after: `
`, + }, + ], + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNode) { + const expression = getInlineStyleExpression(node); + if (!expression) return; + + for (const property of expression.properties ?? []) { + const key = getStylePropertyKey(property); + if (key !== "transition" && key !== "transitionProperty") continue; + + const value = getStylePropertyStringValue(property); + if (!value) continue; + + const lower = value.toLowerCase(); + if (/\ball\b/.test(lower)) continue; + + const layoutMatch = lower.match( + /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/, + ); + if (layoutMatch) { + context.report({ + node: property, + message: `Transitioning layout property "${layoutMatch[0]}" causes layout thrash every frame - use transform and opacity instead`, + }); + } + } + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/design/no-long-transition-duration.ts b/packages/react-doctor/src/core/rules/lint/design/no-long-transition-duration.ts new file mode 100644 index 0000000000..ec82c30fea --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/design/no-long-transition-duration.ts @@ -0,0 +1,77 @@ +import { defineRule } from "../../registry.js"; +import { + LONG_TRANSITION_DURATION_THRESHOLD_MS, + getInlineStyleExpression, + getStylePropertyKey, + getStylePropertyStringValue, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const noLongTransitionDuration = defineRule({ + recommendation: + "Keep interaction transitions short and purposeful, and reserve longer durations for large page-level motion.", + examples: [ + { + before: ``, + after: ``, + }, + ], + create: (context: RuleContext) => ({ + JSXText(node: EsTreeNode) { + const text = typeof node.value === "string" ? node.value.trim() : ""; + if (!text || !hasLetters(text)) return; + if (isInsideIgnoredTextElement(node)) return; + context.report({ + node, + message: `literal JSX text "${text}" is user-facing copy - read it from the translation layer`, + }); + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/has-letters.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/has-letters.ts new file mode 100644 index 0000000000..e6a1c88846 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/has-letters.ts @@ -0,0 +1 @@ +export const hasLetters = (value: string): boolean => /[A-Za-z]/.test(value); diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/index.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/index.ts new file mode 100644 index 0000000000..9aa5f29c69 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/index.ts @@ -0,0 +1,14 @@ +export { TRANSLATION_COMPONENT_NAMES } from "./translation-component-names.js"; +export { NON_USER_TEXT_ELEMENTS } from "./non-user-text-elements.js"; +export { TRANSLATION_HOOK_NAMES } from "./translation-hook-names.js"; +export { TRANSLATION_FUNCTION_NAMES } from "./translation-function-names.js"; +export { isInsideIgnoredTextElement } from "./is-inside-ignored-text-element.js"; +export { hasLetters } from "./has-letters.js"; +export type { EsTreeNode, Rule, RuleContext } from "../../utils/index.js"; +export { + getImportedName, + getImportSourceValue, + getJsxName, + getLocalName, + isNodeOfType, +} from "../../utils/index.js"; diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/is-inside-ignored-text-element.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/is-inside-ignored-text-element.ts new file mode 100644 index 0000000000..5efc6d8cba --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/is-inside-ignored-text-element.ts @@ -0,0 +1,17 @@ +import type { EsTreeNode } from "../../utils/index.js"; +import { NON_USER_TEXT_ELEMENTS } from "./non-user-text-elements.js"; +import { TRANSLATION_COMPONENT_NAMES } from "./translation-component-names.js"; +import { getJsxName, isNodeOfType } from "../../utils/index.js"; + +export const isInsideIgnoredTextElement = (node: EsTreeNode): boolean => { + let currentNode = node.parent; + while (currentNode) { + if (isNodeOfType(currentNode, "JSXElement")) { + const elementName = getJsxName(currentNode.openingElement?.name); + if (elementName && TRANSLATION_COMPONENT_NAMES.has(elementName)) return true; + if (elementName && NON_USER_TEXT_ELEMENTS.has(elementName)) return true; + } + currentNode = currentNode.parent; + } + return false; +}; diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/non-user-text-elements.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/non-user-text-elements.ts new file mode 100644 index 0000000000..8a606bed84 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/non-user-text-elements.ts @@ -0,0 +1,8 @@ +export const NON_USER_TEXT_ELEMENTS = new Set([ + "code", + "kbd", + "pre", + "script", + "style", + "textarea", +]); diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-component-names.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-component-names.ts new file mode 100644 index 0000000000..56276df02d --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-component-names.ts @@ -0,0 +1,6 @@ +export const TRANSLATION_COMPONENT_NAMES = new Set([ + "FormattedMessage", + "I18n", + "Trans", + "Translate", +]); diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-function-names.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-function-names.ts new file mode 100644 index 0000000000..df63e02443 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-function-names.ts @@ -0,0 +1 @@ +export const TRANSLATION_FUNCTION_NAMES = new Set(["t", "i18n.t"]); diff --git a/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-hook-names.ts b/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-hook-names.ts new file mode 100644 index 0000000000..dce8dd3323 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/i18n/utils/translation-hook-names.ts @@ -0,0 +1 @@ +export const TRANSLATION_HOOK_NAMES = new Set(["useTranslations", "useTranslation"]); diff --git a/packages/react-doctor/src/core/rules/lint/index.ts b/packages/react-doctor/src/core/rules/lint/index.ts new file mode 100644 index 0000000000..829da89f23 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/index.ts @@ -0,0 +1,42 @@ +export { + ALL_REACT_DOCTOR_OXLINT_RULE_KEYS, + BUILTIN_A11Y_OXLINT_RULES, + BUILTIN_OXLINT_RULES, + BUILTIN_REACT_OXLINT_RULES, + CURATED_OXLINT_RULES, + GLOBAL_REACT_DOCTOR_OXLINT_RULES, + NEXTJS_OXLINT_RULES, + REACT_COMPILER_OXLINT_RULES, + REACT_DOCTOR_CUSTOM_OXLINT_RULES, + REACT_NATIVE_OXLINT_RULES, + TANSTACK_QUERY_OXLINT_RULES, + TANSTACK_START_OXLINT_RULES, + buildReactDoctorOxlintCapabilities, + createReactDoctorOxlintConfig, + reactPeerRangeMinMajor, + shouldEnableReactDoctorOxlintRule, +} from "./config.js"; +export { reactDoctorOxlintPlugin } from "./rules.js"; +export type { + OxlintRuleSeverityMap, + ReactDoctorOxlintConfigOptions, + ReactDoctorOxlintFramework, + ReactDoctorOxlintGeneratedConfig, + ReactDoctorOxlintJsPluginEntry, + ReactDoctorOxlintProjectInfo, +} from "./config.js"; +export type { + EsTreeNode as OxlintEsTreeNode, + ParsedRgb as OxlintParsedRgb, + Rule as OxlintRule, + RuleContext as OxlintRuleContext, + RuleExample as OxlintRuleExample, + RulePlugin as OxlintRulePlugin, + RuleVisitors as OxlintRuleVisitors, +} from "./utils/index.js"; +export { + REACT_DOCTOR_OXLINT_PLUGIN_NAMESPACE, + REACT_DOCTOR_OXLINT_RULE_ID_PREFIX, + reactDoctorOxlintRuleMetadata, +} from "./metadata.js"; +export type { OxlintRuleMetadata } from "./metadata.js"; diff --git a/packages/react-doctor/src/core/rules/lint/metadata.ts b/packages/react-doctor/src/core/rules/lint/metadata.ts new file mode 100644 index 0000000000..ee7d1b69f5 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/metadata.ts @@ -0,0 +1,204 @@ +import { REACT_DOCTOR_CUSTOM_OXLINT_RULES } from "./config.js"; +import { reactDoctorOxlintRules } from "./rules.js"; +import type { OxlintRuleSeverityMap } from "./config.js"; +import type { ReactDoctorRuleMetadata } from "../types.js"; + +export interface OxlintRuleMetadata extends ReactDoctorRuleMetadata { + oxlintRuleName: string; + oxlintRuleKey: string; +} + +export const REACT_DOCTOR_OXLINT_PLUGIN_NAMESPACE = "react-doctor"; +export const REACT_DOCTOR_OXLINT_RULE_ID_PREFIX = "oxlint/react-doctor/"; + +const RULE_TITLE_WORD_UPPERCASE = /\b(css|html|url|svg|jsx|api|ua|rn)\b/gi; + +const toRuleDisplayName = (ruleName: string): string => { + const readable = ruleName + .replace(/^(no|prefer|require|use)-/, "") + .replace( + /^(nextjs|tanstack-start|tanstack-query|rn|js|server|client|query|effect|design|rendering|rerender|react-compiler|advanced)-/, + "", + ) + .replaceAll("-", " "); + const titled = readable.charAt(0).toUpperCase() + readable.slice(1); + return titled.replace(RULE_TITLE_WORD_UPPERCASE, (match) => match.toUpperCase()); +}; + +const REACT_DOCTOR_RULE_CATEGORY_MAP: Record = { + "no-derived-state-effect": "State & Effects", + "no-fetch-in-effect": "State & Effects", + "no-mirror-prop-effect": "State & Effects", + "no-mutable-in-deps": "State & Effects", + "no-cascading-set-state": "State & Effects", + "no-effect-chain": "State & Effects", + "no-effect-event-handler": "State & Effects", + "no-effect-event-in-deps": "State & Effects", + "no-event-trigger-state": "State & Effects", + "no-prop-callback-in-effect": "State & Effects", + "no-derived-useState": "State & Effects", + "no-direct-state-mutation": "State & Effects", + "no-set-state-in-render": "State & Effects", + "prefer-use-effect-event": "State & Effects", + "prefer-useReducer": "State & Effects", + "prefer-use-sync-external-store": "State & Effects", + "rerender-lazy-state-init": "Performance", + "rerender-functional-setstate": "Performance", + "rerender-dependencies": "State & Effects", + "rerender-state-only-in-handlers": "Performance", + "rerender-defer-reads-hook": "Performance", + "advanced-event-handler-refs": "Performance", + "effect-needs-cleanup": "State & Effects", + "no-generic-handler-names": "Architecture", + "no-giant-component": "Architecture", + "no-many-boolean-props": "Architecture", + "no-react19-deprecated-apis": "Architecture", + "no-render-prop-children": "Architecture", + "no-render-in-render": "Architecture", + "no-nested-component-definition": "Correctness", + "react-compiler-destructure-method": "Architecture", + "no-legacy-class-lifecycles": "Correctness", + "no-legacy-context-api": "Correctness", + "no-default-props": "Architecture", + "no-react-dom-deprecated-apis": "Architecture", + "no-usememo-simple-expression": "Performance", + "no-layout-property-animation": "Performance", + "rerender-memo-with-default-value": "Performance", + "rerender-memo-before-early-return": "Performance", + "rerender-transitions-scroll": "Performance", + "rerender-derived-state-from-hook": "Performance", + "async-defer-await": "Performance", + "async-await-in-loop": "Performance", + "rendering-animate-svg-wrapper": "Performance", + "rendering-hoist-jsx": "Performance", + "rendering-hydration-mismatch-time": "Correctness", + "rendering-usetransition-loading": "Performance", + "rendering-hydration-no-flicker": "Performance", + "rendering-script-defer-async": "Performance", + "no-inline-prop-on-memo-component": "Performance", + "no-transition-all": "Performance", + "no-global-css-variable-animation": "Performance", + "no-large-animated-blur": "Performance", + "no-scale-from-zero": "Performance", + "no-permanent-will-change": "Performance", + "no-secrets-in-client-code": "Security", + "no-barrel-import": "Bundle Size", + "no-dynamic-import-path": "Bundle Size", + "no-full-lodash-import": "Bundle Size", + "no-moment": "Bundle Size", + "prefer-dynamic-import": "Bundle Size", + "use-lazy-motion": "Bundle Size", + "no-undeferred-third-party": "Bundle Size", + "no-array-index-as-key": "Correctness", + "no-polymorphic-children": "Architecture", + "rendering-conditional-render": "Correctness", + "rendering-svg-precision": "Performance", + "no-prevent-default": "Correctness", + "no-uncontrolled-input": "Correctness", + "no-document-start-view-transition": "Correctness", + "no-flush-sync": "Performance", + "no-justified-text": "Accessibility", + "no-tiny-text": "Accessibility", + "no-gray-on-colored-background": "Accessibility", + "no-disabled-zoom": "Accessibility", + "no-outline-none": "Accessibility", + "design-no-vague-button-label": "Accessibility", + "no-inline-bounce-easing": "Performance", + "no-z-index-9999": "Architecture", + "no-inline-exhaustive-style": "Architecture", + "no-side-tab-border": "Architecture", + "no-pure-black-background": "Architecture", + "no-gradient-text": "Architecture", + "no-dark-mode-glow": "Architecture", + "no-wide-letter-spacing": "Architecture", + "no-layout-transition-inline": "Performance", + "no-long-transition-duration": "Performance", + "design-no-bold-heading": "Architecture", + "design-no-redundant-padding-axes": "Architecture", + "design-no-redundant-size-axes": "Architecture", + "design-no-space-on-flex-children": "Architecture", + "design-no-three-period-ellipsis": "Architecture", + "design-no-default-tailwind-palette": "Architecture", + "js-flatmap-filter": "Performance", + "js-combine-iterations": "Performance", + "js-tosorted-immutable": "Performance", + "js-hoist-regexp": "Performance", + "js-hoist-intl": "Performance", + "js-cache-property-access": "Performance", + "js-length-check-first": "Performance", + "js-min-max-loop": "Performance", + "js-set-map-lookups": "Performance", + "js-batch-dom-css": "Performance", + "js-index-maps": "Performance", + "js-cache-storage": "Performance", + "js-early-exit": "Performance", + "no-eval": "Security", + "async-parallel": "Performance", + "client-passive-event-listeners": "Performance", + "client-localstorage-no-version": "Correctness", + "query-stable-query-client": "TanStack Query", + "query-no-rest-destructuring": "TanStack Query", + "query-no-void-query-fn": "TanStack Query", + "query-no-query-in-effect": "TanStack Query", + "query-mutation-missing-invalidation": "TanStack Query", + "query-no-usequery-for-mutation": "TanStack Query", + "server-auth-actions": "Server", + "server-after-nonblocking": "Server", + "server-no-mutable-module-state": "Server", + "server-cache-with-object-literal": "Server", + "server-hoist-static-io": "Server", + "server-dedup-props": "Server", + "server-sequential-independent-await": "Server", + "server-fetch-without-revalidate": "Server", + "nextjs-no-side-effect-in-get-handler": "Security", + "tanstack-start-no-secrets-in-loader": "Security", + "tanstack-start-get-mutation": "Security", + "tanstack-start-loader-parallel-fetch": "Performance", +}; + +const resolveReactDoctorRuleCategory = (ruleName: string): string => { + const mapped = REACT_DOCTOR_RULE_CATEGORY_MAP[ruleName]; + if (mapped) return mapped; + if (ruleName.startsWith("nextjs-")) return "Next.js"; + if (ruleName.startsWith("rn-")) return "React Native"; + if (ruleName.startsWith("tanstack-start-")) return "TanStack Start"; + if (ruleName.startsWith("tanstack-query-") || ruleName.startsWith("query-")) { + return "TanStack Query"; + } + if (ruleName.startsWith("server-")) return "Server"; + if (ruleName.startsWith("js-")) return "Performance"; + if (ruleName.startsWith("design-")) return "Architecture"; + if (ruleName.startsWith("rendering-") || ruleName.startsWith("rerender-")) return "Performance"; + return "Other"; +}; + +const toReactDoctorSeverity = ( + severity: OxlintRuleSeverityMap[string], +): ReactDoctorRuleMetadata["severity"] => { + if (severity === "error") return "error"; + if (severity === "off") return "info"; + return "warning"; +}; + +export const reactDoctorOxlintRuleMetadata: OxlintRuleMetadata[] = Object.entries( + reactDoctorOxlintRules, +) + .sort(([ruleName], [nextRuleName]) => ruleName.localeCompare(nextRuleName)) + .map(([ruleName, rule]) => { + const oxlintRuleKey = `${REACT_DOCTOR_OXLINT_PLUGIN_NAMESPACE}/${ruleName}`; + const severity = REACT_DOCTOR_CUSTOM_OXLINT_RULES[oxlintRuleKey] ?? "warn"; + + return { + id: `${REACT_DOCTOR_OXLINT_RULE_ID_PREFIX}${ruleName}`, + name: toRuleDisplayName(ruleName), + description: `Runs the ${oxlintRuleKey} custom oxlint rule.`, + recommendation: rule.recommendation, + examples: rule.examples, + category: resolveReactDoctorRuleCategory(ruleName), + severity: toReactDoctorSeverity(severity), + defaultEnabled: false, + tags: ["oxlint", "custom", REACT_DOCTOR_OXLINT_PLUGIN_NAMESPACE], + oxlintRuleName: ruleName, + oxlintRuleKey, + }; + }); diff --git a/packages/react-doctor/src/core/rules/lint/mobx/mobx-observer-named-component.ts b/packages/react-doctor/src/core/rules/lint/mobx/mobx-observer-named-component.ts new file mode 100644 index 0000000000..105c4bdfc7 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/mobx/mobx-observer-named-component.ts @@ -0,0 +1,49 @@ +import { defineRule } from "../../registry.js"; +import { + MOBX_REACT_IMPORT_SOURCES, + getImportSourceValue, + getImportedName, + getLocalName, + isNodeOfType, +} from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +const isAnonymousComponent = (node: EsTreeNode | undefined): boolean => + (isNodeOfType(node, "ArrowFunctionExpression") || isNodeOfType(node, "FunctionExpression")) && + !node.id?.name; + +export const mobxObserverNamedComponent = defineRule({ + recommendation: + "Pass a named function to MobX observer so React DevTools, stack traces, and hooks linting retain a real component boundary.", + examples: [ + { + before: `export const UserCard = observer(() =>
{store.name}
);`, + after: `export const UserCard = observer(function UserCard() { return
{store.name}
; });`, + }, + ], + create: (context: RuleContext) => { + const observerNames = new Set(); + + return { + ImportDeclaration(node: EsTreeNode) { + if (!MOBX_REACT_IMPORT_SOURCES.has(getImportSourceValue(node) ?? "")) return; + for (const specifier of node.specifiers ?? []) { + if (getImportedName(specifier) !== "observer") continue; + const localName = getLocalName(specifier); + if (localName) observerNames.add(localName); + } + }, + CallExpression(node: EsTreeNode) { + if (!isNodeOfType(node.callee, "Identifier") || !observerNames.has(node.callee.name)) + return; + const componentArgument = node.arguments?.[0]; + if (!isAnonymousComponent(componentArgument)) return; + context.report({ + node: componentArgument, + message: + "observer() wraps an anonymous component - use a named function so MobX components remain debuggable", + }); + }, + }; + }, +}); diff --git a/packages/react-doctor/src/core/rules/lint/mobx/utils/index.ts b/packages/react-doctor/src/core/rules/lint/mobx/utils/index.ts new file mode 100644 index 0000000000..e39d243a69 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/mobx/utils/index.ts @@ -0,0 +1,8 @@ +export { MOBX_REACT_IMPORT_SOURCES } from "./mobx-react-import-sources.js"; +export type { EsTreeNode, Rule, RuleContext } from "../../utils/index.js"; +export { + getImportedName, + getImportSourceValue, + getLocalName, + isNodeOfType, +} from "../../utils/index.js"; diff --git a/packages/react-doctor/src/core/rules/lint/mobx/utils/mobx-react-import-sources.ts b/packages/react-doctor/src/core/rules/lint/mobx/utils/mobx-react-import-sources.ts new file mode 100644 index 0000000000..7fb1a50d79 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/mobx/utils/mobx-react-import-sources.ts @@ -0,0 +1 @@ +export const MOBX_REACT_IMPORT_SOURCES = new Set(["mobx-react", "mobx-react-lite"]); diff --git a/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-async-client-component.ts b/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-async-client-component.ts new file mode 100644 index 0000000000..c07055278b --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-async-client-component.ts @@ -0,0 +1,40 @@ +import { defineRule } from "../../registry.js"; +import { hasDirective, isComponentAssignment, isUppercaseName } from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const nextjsAsyncClientComponent = defineRule({ + recommendation: + "Keep Client Components synchronous and move async data loading to Server Components, loaders, or client data hooks.", + examples: [ + { + before: `"use client"; +export default async function Page() {}`, + after: `export default function Page() {}`, + }, + ], + create: (context: RuleContext) => { + let fileHasUseClient = false; + + return { + Program(programNode: EsTreeNode) { + fileHasUseClient = hasDirective(programNode, "use client"); + }, + FunctionDeclaration(node: EsTreeNode) { + if (!fileHasUseClient || !node.async) return; + if (!node.id?.name || !isUppercaseName(node.id.name)) return; + context.report({ + node, + message: `Async client component "${node.id.name}" - client components cannot be async`, + }); + }, + VariableDeclarator(node: EsTreeNode) { + if (!fileHasUseClient) return; + if (!isComponentAssignment(node) || !node.init?.async) return; + context.report({ + node, + message: `Async client component "${node.id.name}" - client components cannot be async`, + }); + }, + }; + }, +}); diff --git a/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-image-missing-sizes.ts b/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-image-missing-sizes.ts new file mode 100644 index 0000000000..b1d3ec745a --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-image-missing-sizes.ts @@ -0,0 +1,28 @@ +import { defineRule } from "../../registry.js"; +import { hasJsxAttribute, isNodeOfType } from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const nextjsImageMissingSizes = defineRule({ + recommendation: + "Provide width and height or fill plus sizes on Next.js images so layout and responsive image selection are stable.", + examples: [ + { + before: `Hero`, + after: `Hero`, + }, + ], + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNode) { + if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "Image") return; + const attributes = node.attributes ?? []; + if (!hasJsxAttribute(attributes, "fill")) return; + if (hasJsxAttribute(attributes, "sizes")) return; + + context.report({ + node, + message: + "next/image with fill but no sizes - the browser downloads the largest image. Add a sizes attribute for responsive behavior", + }); + }, + }), +}); diff --git a/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-inline-script-missing-id.ts b/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-inline-script-missing-id.ts new file mode 100644 index 0000000000..060a07f2d9 --- /dev/null +++ b/packages/react-doctor/src/core/rules/lint/nextjs/nextjs-inline-script-missing-id.ts @@ -0,0 +1,29 @@ +import { defineRule } from "../../registry.js"; +import { hasJsxAttribute, isNodeOfType } from "./utils/index.js"; +import type { EsTreeNode, Rule, RuleContext } from "./utils/index.js"; + +export const nextjsInlineScriptMissingId = defineRule({ + recommendation: + "Add a stable id to inline Next.js Script blocks so Next.js can track and dedupe them.", + examples: [ + { + before: ``, + after: ``, + }, + ], + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNode) { + if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "Script") return; + const attributes = node.attributes ?? []; + + if (hasJsxAttribute(attributes, "src")) return; + if (hasJsxAttribute(attributes, "id")) return; + + context.report({ + node, + message: + "Inline `, + after: ``, + }, + ], + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNode) { + if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "script") return; + + const attributes = node.attributes ?? []; + const hasSrc = attributes.some( + (attribute: EsTreeNode) => + isNodeOfType(attribute, "JSXAttribute") && + isNodeOfType(attribute.name, "JSXIdentifier") && + attribute.name.name === "src", + ); + + if (!hasSrc) return; + + const typeAttribute = attributes.find( + (attribute: EsTreeNode) => + isNodeOfType(attribute, "JSXAttribute") && + isNodeOfType(attribute.name, "JSXIdentifier") && + attribute.name.name === "type", + ); + const typeValue = isNodeOfType(typeAttribute?.value, "Literal") + ? typeAttribute.value.value + : null; + if (typeof typeValue === "string" && !EXECUTABLE_SCRIPT_TYPES.has(typeValue)) return; + if (typeValue === "module") return; + + const hasLoadingStrategy = attributes.some( + (attribute: EsTreeNode) => + isNodeOfType(attribute, "JSXAttribute") && + isNodeOfType(attribute.name, "JSXIdentifier") && + SCRIPT_LOADING_ATTRIBUTES.has(attribute.name.name), + ); + + if (!hasLoadingStrategy) { + context.report({ + node, + message: + " - - - diff --git a/packages/react-doctor/tests/fixtures/bun-catalog-workspace/apps/web/package.json b/packages/react-doctor/tests/fixtures/bun-catalog-workspace/apps/web/package.json deleted file mode 100644 index d5c6ca03e6..0000000000 --- a/packages/react-doctor/tests/fixtures/bun-catalog-workspace/apps/web/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "web", - "private": true, - "dependencies": { - "react": "catalog:", - "react-dom": "catalog:" - } -} diff --git a/packages/react-doctor/tests/fixtures/bun-catalog-workspace/package.json b/packages/react-doctor/tests/fixtures/bun-catalog-workspace/package.json deleted file mode 100644 index c32b7fbb8d..0000000000 --- a/packages/react-doctor/tests/fixtures/bun-catalog-workspace/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "bun-catalog-workspace", - "private": true, - "workspaces": { - "packages": [ - "apps/*" - ], - "catalog": { - "react": "^19.1.4", - "react-dom": "^19.1.4" - } - } -} diff --git a/packages/react-doctor/tests/fixtures/bun-grouped-catalog/apps/web/package.json b/packages/react-doctor/tests/fixtures/bun-grouped-catalog/apps/web/package.json deleted file mode 100644 index bdf8ceb06c..0000000000 --- a/packages/react-doctor/tests/fixtures/bun-grouped-catalog/apps/web/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "web", - "private": true, - "dependencies": { - "react": "catalog:react19", - "react-dom": "catalog:react19" - } -} diff --git a/packages/react-doctor/tests/fixtures/bun-grouped-catalog/package.json b/packages/react-doctor/tests/fixtures/bun-grouped-catalog/package.json deleted file mode 100644 index 1dbc488667..0000000000 --- a/packages/react-doctor/tests/fixtures/bun-grouped-catalog/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "bun-grouped-catalog", - "private": true, - "workspaces": { - "packages": [ - "apps/*" - ], - "catalogs": { - "react19": { - "react": "19.2.0", - "react-dom": "19.2.0" - } - } - } -} diff --git a/packages/react-doctor/tests/fixtures/bun-multiple-grouped-catalogs/apps/web/package.json b/packages/react-doctor/tests/fixtures/bun-multiple-grouped-catalogs/apps/web/package.json deleted file mode 100644 index bdf8ceb06c..0000000000 --- a/packages/react-doctor/tests/fixtures/bun-multiple-grouped-catalogs/apps/web/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "web", - "private": true, - "dependencies": { - "react": "catalog:react19", - "react-dom": "catalog:react19" - } -} diff --git a/packages/react-doctor/tests/fixtures/bun-multiple-grouped-catalogs/package.json b/packages/react-doctor/tests/fixtures/bun-multiple-grouped-catalogs/package.json deleted file mode 100644 index c2749a033f..0000000000 --- a/packages/react-doctor/tests/fixtures/bun-multiple-grouped-catalogs/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "bun-multiple-grouped-catalogs", - "private": true, - "workspaces": { - "packages": [ - "apps/*" - ], - "catalogs": { - "react18": { - "react": "18.3.1", - "react-dom": "18.3.1" - }, - "react19": { - "react": "19.2.0", - "react-dom": "19.2.0" - } - } - } -} diff --git a/packages/react-doctor/tests/fixtures/clean-react/package.json b/packages/react-doctor/tests/fixtures/clean-react/package.json deleted file mode 100644 index bfa96daab0..0000000000 --- a/packages/react-doctor/tests/fixtures/clean-react/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "test-clean-react", - "private": true, - "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/clean-react/src/app.tsx b/packages/react-doctor/tests/fixtures/clean-react/src/app.tsx deleted file mode 100644 index b1437d2a4e..0000000000 --- a/packages/react-doctor/tests/fixtures/clean-react/src/app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useState } from "react"; - -const App = () => { - const [count, setCount] = useState(0); - return ; -}; - -export { App }; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/package.json b/packages/react-doctor/tests/fixtures/codebase/full-port/package.json new file mode 100644 index 0000000000..f7c33e9b87 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/package.json @@ -0,0 +1,7 @@ +{ + "name": "full-port-fixture", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/package.json b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/package.json new file mode 100644 index 0000000000..6d888ebd58 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@fixture/app", + "private": true, + "dependencies": { + "@fixture/ui": "workspace:*", + "left-pad": "^1.3.0", + "test-only-pkg": "^1.0.0", + "type-only-pkg": "^1.0.0", + "unused-prod": "^1.0.0" + }, + "devDependencies": { + "unused-dev": "^1.0.0" + }, + "peerDependencies": { + "unused-peer": "^1.0.0" + }, + "peerDependenciesMeta": { + "unused-peer": { + "optional": true + } + } +} diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/barrel.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/barrel.ts new file mode 100644 index 0000000000..9fc60ceef2 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/barrel.ts @@ -0,0 +1,5 @@ +export const one = 1; +export const two = 2; +export const three = 3; +export const four = 4; +export const five = 5; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/bridge.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/bridge.ts new file mode 100644 index 0000000000..27f09420bb --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/bridge.ts @@ -0,0 +1 @@ +export { serverAction } from "./server"; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/client.tsx b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/client.tsx new file mode 100644 index 0000000000..b02dfed326 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/client.tsx @@ -0,0 +1,5 @@ +"use client"; + +import { serverAction } from "./bridge"; + +console.log(serverAction); diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-a.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-a.ts new file mode 100644 index 0000000000..a5478a778c --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-a.ts @@ -0,0 +1,3 @@ +import { one } from "./barrel"; + +console.log(one); diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-b.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-b.ts new file mode 100644 index 0000000000..6da5171753 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-b.ts @@ -0,0 +1,3 @@ +import { two } from "./barrel"; + +console.log(two); diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-c.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-c.ts new file mode 100644 index 0000000000..17b8402ce1 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/consumer-c.ts @@ -0,0 +1,3 @@ +import { three } from "./barrel"; + +console.log(three); diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/cycle-a.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/cycle-a.ts new file mode 100644 index 0000000000..46946c4d29 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/cycle-a.ts @@ -0,0 +1,3 @@ +import { cycleB } from "./cycle-b"; + +export const cycleA = cycleB; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/cycle-b.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/cycle-b.ts new file mode 100644 index 0000000000..fc312dbd96 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/cycle-b.ts @@ -0,0 +1,3 @@ +import { cycleA } from "./cycle-a"; + +export const cycleB = cycleA; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/dynamic.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/dynamic.ts new file mode 100644 index 0000000000..7d53653acf --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/dynamic.ts @@ -0,0 +1,5 @@ +const moduleName = require.resolve("./utils"); + +module.exports = { + moduleName, +}; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/example.testcase.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/example.testcase.ts new file mode 100644 index 0000000000..1e8c4d330a --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/example.testcase.ts @@ -0,0 +1,3 @@ +import testOnly from "test-only-pkg"; + +console.log(testOnly); diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/main.tsx b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/main.tsx new file mode 100644 index 0000000000..b25769e64d --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/main.tsx @@ -0,0 +1,14 @@ +import "./client"; +import "./cycle-a"; +import "./consumer-a"; +import "./consumer-b"; +import "./consumer-c"; +import { Button } from "@fixture/ui"; +import { helper } from "@app/utils"; +import type { LocalThing } from "./type-only"; +import leftPad from "left-pad"; +import("./dynamic"); + +type MainThing = LocalThing; + +console.log(Button, helper, leftPad); diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/server.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/server.ts new file mode 100644 index 0000000000..bf8e1abef6 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/server.ts @@ -0,0 +1,3 @@ +"use server"; + +export const serverAction = () => null; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/type-only.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/type-only.ts new file mode 100644 index 0000000000..3aadbacc84 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/type-only.ts @@ -0,0 +1,3 @@ +import type { Thing } from "type-only-pkg"; + +export type LocalThing = Thing; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/utils.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/utils.ts new file mode 100644 index 0000000000..040700dc28 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/src/utils.ts @@ -0,0 +1,2 @@ +export const helper = "helper"; +export const unusedHelper = "unused"; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/tsconfig.json b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/tsconfig.json new file mode 100644 index 0000000000..725eb3e6b9 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@app/*": ["src/*"] + } + } +} diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/package.json b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/package.json new file mode 100644 index 0000000000..1445913b1c --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixture/ui", + "private": true, + "exports": { + ".": "./dist/index.js" + } +} diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/src/button.tsx b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/src/button.tsx new file mode 100644 index 0000000000..0dc01ae4d3 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/src/button.tsx @@ -0,0 +1 @@ +export const Button = () => null; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/src/index.ts b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/src/index.ts new file mode 100644 index 0000000000..f589891942 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/packages/ui/src/index.ts @@ -0,0 +1,2 @@ +export { Button } from "./button"; +export const unusedUi = "unused"; diff --git a/packages/react-doctor/tests/fixtures/codebase/full-port/pnpm-workspace.yaml b/packages/react-doctor/tests/fixtures/codebase/full-port/pnpm-workspace.yaml new file mode 100644 index 0000000000..dee51e928d --- /dev/null +++ b/packages/react-doctor/tests/fixtures/codebase/full-port/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/packages/react-doctor/tests/fixtures/component-library/package.json b/packages/react-doctor/tests/fixtures/component-library/package.json deleted file mode 100644 index 86b1ed741b..0000000000 --- a/packages/react-doctor/tests/fixtures/component-library/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "test-component-library", - "private": true, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/monorepo-with-root-react/package.json b/packages/react-doctor/tests/fixtures/monorepo-with-root-react/package.json deleted file mode 100644 index c6baf91927..0000000000 --- a/packages/react-doctor/tests/fixtures/monorepo-with-root-react/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "monorepo-root", - "private": true, - "workspaces": [ - "packages/*" - ], - "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/monorepo-with-root-react/packages/ui/package.json b/packages/react-doctor/tests/fixtures/monorepo-with-root-react/packages/ui/package.json deleted file mode 100644 index fcc22360e5..0000000000 --- a/packages/react-doctor/tests/fixtures/monorepo-with-root-react/packages/ui/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "ui", - "private": true, - "dependencies": { - "react": "^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/nested-workspaces/apps/my-app/ClientApp/package.json b/packages/react-doctor/tests/fixtures/nested-workspaces/apps/my-app/ClientApp/package.json deleted file mode 100644 index 863a957cbc..0000000000 --- a/packages/react-doctor/tests/fixtures/nested-workspaces/apps/my-app/ClientApp/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "my-app-client", - "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/nested-workspaces/package.json b/packages/react-doctor/tests/fixtures/nested-workspaces/package.json deleted file mode 100644 index 3d06325ada..0000000000 --- a/packages/react-doctor/tests/fixtures/nested-workspaces/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "nested-workspaces-fixture", - "private": true, - "workspaces": [ - "apps/*/ClientApp", - "packages/*" - ] -} diff --git a/packages/react-doctor/tests/fixtures/nested-workspaces/packages/ui/package.json b/packages/react-doctor/tests/fixtures/nested-workspaces/packages/ui/package.json deleted file mode 100644 index 7afc61d2a0..0000000000 --- a/packages/react-doctor/tests/fixtures/nested-workspaces/packages/ui/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "ui", - "dependencies": { - "react": "^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/package.json b/packages/react-doctor/tests/fixtures/nextjs-app/package.json deleted file mode 100644 index 11b8ec277c..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-nextjs-app", - "private": true, - "dependencies": { - "next": "^15.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/actions.tsx b/packages/react-doctor/tests/fixtures/nextjs-app/src/app/actions.tsx deleted file mode 100644 index 308a9d0548..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/actions.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use server"; - -import { cache } from "react"; - -const analytics = { - track: (_event: string, _props?: Record) => {}, -}; - -let requestCount = 0; -const userCache = new Map(); -void userCache; - -const getUser = cache(async (params: { uid: number }) => { - return { uid: params.uid, name: "Anon" }; -}); - -export async function createUser(formData: FormData) { - requestCount += 1; - const name = formData.get("name"); - // Both of these MUST fire `server-after-nonblocking`: console.log - // because the rule treats it as a deferrable side effect (history), - // and analytics.track because it's a known SDK network round trip. - console.log("Creating user:", name); - analytics.track("user-created", { name }); - // server-cache-with-object-literal: fresh {} per call defeats cache(). - await getUser({ uid: 1 }); - await getUser({ uid: 1 }); - return { success: true, requestCount }; -} - -export async function deleteUser(userId: string) { - console.log("Deleting user:", userId); - return { success: true }; -} diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/dashboard/route.tsx b/packages/react-doctor/tests/fixtures/nextjs-app/src/app/dashboard/route.tsx deleted file mode 100644 index 63c1f5b717..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/dashboard/route.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server"; - -declare const db: { - user: { findUnique: (q: { where: { id: number } }) => Promise }; - posts: { findMany: () => Promise }; -}; - -// server-sequential-independent-await: two consecutive awaits with no -// data dependency on the first. -// server-fetch-without-revalidate: fetch without next.revalidate option. -export async function GET() { - const user = await db.user.findUnique({ where: { id: 1 } }); - const posts = await db.posts.findMany(); - const profile = await fetch("https://api.example.com/profile"); - return NextResponse.json({ user, posts, profile: await profile.json() }); -} diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/layout.tsx b/packages/react-doctor/tests/fixtures/nextjs-app/src/app/layout.tsx deleted file mode 100644 index 5f28bd448e..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -const Layout = ({ children }: { children: React.ReactNode }) => { - const [data, setData] = useState(null); - - useEffect(() => { - fetch("/api/layout-data") - .then((response) => response.json()) - .then((json) => setData(json)); - }, []); - - return ( - - {children} - - ); -}; - -export default Layout; diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/logout/route.tsx b/packages/react-doctor/tests/fixtures/nextjs-app/src/app/logout/route.tsx deleted file mode 100644 index 29c08e6d19..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/logout/route.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { NextResponse } from "next/server"; - -export async function GET() { - const cookieStore = await cookies(); - cookieStore.delete("session"); - redirect("/login"); - return NextResponse.json({ ok: true }); -} diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/og/route.tsx b/packages/react-doctor/tests/fixtures/nextjs-app/src/app/og/route.tsx deleted file mode 100644 index 1084e40bf4..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/og/route.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import fs from "node:fs"; -import { NextResponse } from "next/server"; - -// server-hoist-static-io: fs.readFileSync inside route handler. -export async function GET(request: Request) { - const fontData = fs.readFileSync("./fonts/Inter.ttf"); - // Also flag fetch(new URL(..., import.meta.url)). - const cssAsset = await fetch(new URL("./styles.css", import.meta.url)).then((r) => r.text()); - const url = new URL(request.url); - return NextResponse.json({ - bytes: fontData.byteLength, - css: cssAsset.length, - path: url.pathname, - }); -} diff --git a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/page.tsx b/packages/react-doctor/tests/fixtures/nextjs-app/src/app/page.tsx deleted file mode 100644 index 87254825e8..0000000000 --- a/packages/react-doctor/tests/fixtures/nextjs-app/src/app/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Head from "next/head"; - -const useSearchParams = () => new URLSearchParams(); - -const Page = () => { - const params = useSearchParams(); - - useEffect(() => { - fetch("/api/data"); - }, []); - - useEffect(() => { - router.push("/dashboard"); - }, []); - - return ( -
- photo - About - hero - -