react-doctor 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -22
- package/dist/cli.js +216 -53
- package/dist/eslint-plugin.js +1 -1
- package/dist/index.d.ts +47 -1
- package/dist/index.js +269 -24
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -41,17 +41,42 @@ Works with Claude Code, Cursor, Codex, OpenCode, and 50+ other agents.
|
|
|
41
41
|
|
|
42
42
|
## GitHub Actions
|
|
43
43
|
|
|
44
|
+
A composite action ships with this repository. Drop it into `.github/workflows/react-doctor.yml`:
|
|
45
|
+
|
|
44
46
|
```yaml
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
name: React Doctor
|
|
48
|
+
|
|
49
|
+
on:
|
|
50
|
+
pull_request:
|
|
51
|
+
push:
|
|
52
|
+
branches: [main]
|
|
53
|
+
|
|
54
|
+
permissions:
|
|
55
|
+
contents: read
|
|
56
|
+
pull-requests: write # required to post PR comments
|
|
57
|
+
|
|
58
|
+
jobs:
|
|
59
|
+
react-doctor:
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/checkout@v5
|
|
63
|
+
with:
|
|
64
|
+
fetch-depth: 0 # required for `diff`
|
|
65
|
+
- uses: millionco/react-doctor@main
|
|
66
|
+
with:
|
|
67
|
+
diff: main
|
|
68
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
52
69
|
```
|
|
53
70
|
|
|
54
|
-
When `github-token` is set on `pull_request` events, findings are posted as a PR comment. The action also
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
**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.
|
|
74
|
+
|
|
75
|
+
Prefer not to add a marketplace action? The bare `npx` form works too:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- run: npx -y react-doctor@latest --fail-on warning
|
|
79
|
+
```
|
|
55
80
|
|
|
56
81
|
## Configuration
|
|
57
82
|
|
|
@@ -64,20 +89,39 @@ Create a `react-doctor.config.json` in your project root:
|
|
|
64
89
|
"files": ["src/generated/**"],
|
|
65
90
|
"overrides": [
|
|
66
91
|
{
|
|
67
|
-
"files": ["components/diff/**"],
|
|
68
|
-
"rules": ["react-doctor/no-array-index-as-key"]
|
|
92
|
+
"files": ["components/modules/diff/**"],
|
|
93
|
+
"rules": ["react-doctor/no-array-index-as-key", "react-doctor/no-render-in-render"]
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"files": ["components/search/HighlightedSnippet.tsx"],
|
|
97
|
+
"rules": ["react/no-danger"]
|
|
69
98
|
}
|
|
70
99
|
]
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
102
|
```
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
Three nested keys, three layers of granularity — pick the narrowest one that fits:
|
|
105
|
+
|
|
106
|
+
- **`ignore.rules`** silences a rule across the whole codebase.
|
|
107
|
+
- **`ignore.files`** silences **every** rule on the matched files (use sparingly — it loses coverage for unrelated rules).
|
|
108
|
+
- **`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.
|
|
109
|
+
|
|
110
|
+
You can also use the `"reactDoctor"` key in `package.json`. CLI flags always override config values.
|
|
76
111
|
|
|
77
112
|
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.
|
|
78
113
|
|
|
79
114
|
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.
|
|
80
115
|
|
|
116
|
+
#### Optional companion plugins
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
| Plugin | Adds | Namespace |
|
|
121
|
+
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
|
122
|
+
| [`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/*` |
|
|
123
|
+
| [`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/*` |
|
|
124
|
+
|
|
81
125
|
### Inline suppressions
|
|
82
126
|
|
|
83
127
|
```tsx
|
|
@@ -88,7 +132,24 @@ useEffect(() => {
|
|
|
88
132
|
}, [value]);
|
|
89
133
|
```
|
|
90
134
|
|
|
91
|
-
When two rules fire on the same line,
|
|
135
|
+
When two rules fire on the same line, you have two equivalent options. Comma-separate the rule ids on a single comment:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers, react-doctor/no-derived-useState
|
|
139
|
+
const [localSearch, setLocalSearch] = useState(searchQuery);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
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:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers
|
|
146
|
+
// react-doctor-disable-next-line react-doctor/no-derived-useState
|
|
147
|
+
const [localSearch, setLocalSearch] = useState(searchQuery);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
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 <file:line>` — it reports whether a nearby suppression was found, what rules it covers, and why it didn't apply.
|
|
151
|
+
|
|
152
|
+
Block comments work inside JSX:
|
|
92
153
|
|
|
93
154
|
<!-- prettier-ignore -->
|
|
94
155
|
```tsx
|
|
@@ -151,10 +212,11 @@ Options:
|
|
|
151
212
|
--fail-on <level> exit with error on diagnostics: error, warning, none
|
|
152
213
|
--annotations output diagnostics as GitHub Actions annotations
|
|
153
214
|
--explain <file:line> diagnose why a rule fired or why a suppression didn't apply
|
|
215
|
+
--why <file:line> alias for --explain
|
|
154
216
|
-h, --help display help
|
|
155
217
|
```
|
|
156
218
|
|
|
157
|
-
When a suppression isn't working, `--explain <file:line>` reports what the scanner sees at that location, including why a nearby `react-doctor-disable-next-line` didn't apply. The same hint surfaces inline with `--verbose` and in `--json` output as `diagnostic.suppressionHint
|
|
219
|
+
When a suppression isn't working, `--explain <file:line>` (or its alias `--why <file:line>`) 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.
|
|
158
220
|
|
|
159
221
|
`--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.
|
|
160
222
|
|
|
@@ -211,15 +273,15 @@ Top React codebases scanned by React Doctor, ranked by score. Updated automatica
|
|
|
211
273
|
| # | Repo | Score |
|
|
212
274
|
| -- | ---- | ----: |
|
|
213
275
|
| 1 | [executor](https://github.com/RhysSullivan/executor) | 96 |
|
|
214
|
-
| 2 | [nodejs.org](https://github.com/nodejs/nodejs.org) |
|
|
215
|
-
| 3 | [tldraw](https://github.com/tldraw/tldraw) |
|
|
216
|
-
| 4 | [t3code](https://github.com/pingdotgg/t3code) |
|
|
217
|
-
| 5 | [
|
|
218
|
-
| 6 | [excalidraw](https://github.com/excalidraw/excalidraw) |
|
|
219
|
-
| 7 | [
|
|
220
|
-
| 8 | [
|
|
221
|
-
| 9 | [
|
|
222
|
-
| 10 | [
|
|
276
|
+
| 2 | [nodejs.org](https://github.com/nodejs/nodejs.org) | 86 |
|
|
277
|
+
| 3 | [tldraw](https://github.com/tldraw/tldraw) | 70 |
|
|
278
|
+
| 4 | [t3code](https://github.com/pingdotgg/t3code) | 68 |
|
|
279
|
+
| 5 | [better-auth](https://github.com/better-auth/better-auth) | 64 |
|
|
280
|
+
| 6 | [excalidraw](https://github.com/excalidraw/excalidraw) | 63 |
|
|
281
|
+
| 7 | [mastra](https://github.com/mastra-ai/mastra) | 63 |
|
|
282
|
+
| 8 | [payload](https://github.com/payloadcms/payload) | 60 |
|
|
283
|
+
| 9 | [typebot](https://github.com/baptisteArno/typebot.io) | 57 |
|
|
284
|
+
| 10 | [plane](https://github.com/makeplane/plane) | 56 |
|
|
223
285
|
|
|
224
286
|
<!-- LEADERBOARD:END -->
|
|
225
287
|
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs, { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readdirS
|
|
|
3
3
|
import os, { tmpdir } from "node:os";
|
|
4
4
|
import path, { join } from "node:path";
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
6
|
-
import { Command
|
|
6
|
+
import { Command } from "commander";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
|
|
9
9
|
import pc from "picocolors";
|
|
@@ -39,10 +39,18 @@ const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
|
|
|
39
39
|
const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
|
|
40
40
|
const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
41
41
|
const IGNORED_DIRECTORIES = new Set([
|
|
42
|
-
"
|
|
43
|
-
"
|
|
42
|
+
".git",
|
|
43
|
+
".next",
|
|
44
|
+
".nuxt",
|
|
45
|
+
".output",
|
|
46
|
+
".svelte-kit",
|
|
47
|
+
".turbo",
|
|
44
48
|
"build",
|
|
45
|
-
"coverage"
|
|
49
|
+
"coverage",
|
|
50
|
+
"dist",
|
|
51
|
+
"node_modules",
|
|
52
|
+
"out",
|
|
53
|
+
"storybook-static"
|
|
46
54
|
]);
|
|
47
55
|
const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
|
|
48
56
|
const SKILL_NAME = "react-doctor";
|
|
@@ -304,6 +312,47 @@ const runInstallSkill = async (options = {}) => {
|
|
|
304
312
|
}
|
|
305
313
|
};
|
|
306
314
|
//#endregion
|
|
315
|
+
//#region src/errors.ts
|
|
316
|
+
var ReactDoctorError = class extends Error {
|
|
317
|
+
name = "ReactDoctorError";
|
|
318
|
+
constructor(message, options) {
|
|
319
|
+
super(message, options);
|
|
320
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
var NoReactDependencyError = class extends ReactDoctorError {
|
|
324
|
+
name = "NoReactDependencyError";
|
|
325
|
+
directory;
|
|
326
|
+
constructor(directory, options) {
|
|
327
|
+
super(buildNoReactDependencyError(directory), options);
|
|
328
|
+
this.directory = directory;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
var PackageJsonNotFoundError = class extends ReactDoctorError {
|
|
332
|
+
name = "PackageJsonNotFoundError";
|
|
333
|
+
directory;
|
|
334
|
+
constructor(directory, options) {
|
|
335
|
+
super(`No package.json found in ${directory}`, options);
|
|
336
|
+
this.directory = directory;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/utils/resolve-config-root-dir.ts
|
|
341
|
+
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
342
|
+
if (!config || !configSourceDirectory) return null;
|
|
343
|
+
const rawRootDir = config.rootDir;
|
|
344
|
+
if (typeof rawRootDir !== "string") return null;
|
|
345
|
+
const trimmedRootDir = rawRootDir.trim();
|
|
346
|
+
if (trimmedRootDir.length === 0) return null;
|
|
347
|
+
const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
|
|
348
|
+
if (resolvedRootDir === configSourceDirectory) return null;
|
|
349
|
+
if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
|
|
350
|
+
logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return resolvedRootDir;
|
|
354
|
+
};
|
|
355
|
+
//#endregion
|
|
307
356
|
//#region src/utils/build-hidden-diagnostics-summary.ts
|
|
308
357
|
const buildHiddenDiagnosticsSummary = (hiddenDiagnostics) => {
|
|
309
358
|
const errorCount = hiddenDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
@@ -802,7 +851,7 @@ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
|
802
851
|
};
|
|
803
852
|
//#endregion
|
|
804
853
|
//#region src/utils/find-stacked-disable-comments.ts
|
|
805
|
-
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\
|
|
854
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
806
855
|
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
807
856
|
const collected = [];
|
|
808
857
|
let isStillInChain = true;
|
|
@@ -824,13 +873,21 @@ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
|
824
873
|
};
|
|
825
874
|
//#endregion
|
|
826
875
|
//#region src/utils/is-rule-listed-in-comment.ts
|
|
876
|
+
const stripDescriptionTail = (ruleList) => {
|
|
877
|
+
const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
|
|
878
|
+
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
879
|
+
return ruleList.slice(0, descriptionMatch.index);
|
|
880
|
+
};
|
|
827
881
|
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
828
|
-
|
|
829
|
-
|
|
882
|
+
const trimmed = ruleList?.trim();
|
|
883
|
+
if (!trimmed) return true;
|
|
884
|
+
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
885
|
+
if (!ruleSection) return true;
|
|
886
|
+
return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
830
887
|
};
|
|
831
888
|
//#endregion
|
|
832
889
|
//#region src/utils/evaluate-suppression.ts
|
|
833
|
-
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\
|
|
890
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
834
891
|
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
835
892
|
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
836
893
|
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
@@ -1251,9 +1308,9 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
|
|
|
1251
1308
|
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
|
|
1252
1309
|
return null;
|
|
1253
1310
|
};
|
|
1254
|
-
const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
1311
|
+
const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
|
|
1255
1312
|
const rawVersion = collectAllDependencies(packageJson)[packageName];
|
|
1256
|
-
const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
|
|
1313
|
+
const catalogName = explicitCatalogReference ?? (rawVersion ? extractCatalogName(rawVersion) : null);
|
|
1257
1314
|
if (isPlainObject(packageJson.catalog)) {
|
|
1258
1315
|
const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
|
|
1259
1316
|
if (version) return version;
|
|
@@ -1270,9 +1327,22 @@ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
|
1270
1327
|
}
|
|
1271
1328
|
}
|
|
1272
1329
|
const workspaces = packageJson.workspaces;
|
|
1273
|
-
if (workspaces && !Array.isArray(workspaces)
|
|
1274
|
-
|
|
1275
|
-
|
|
1330
|
+
if (workspaces && !Array.isArray(workspaces)) {
|
|
1331
|
+
if (isPlainObject(workspaces.catalog)) {
|
|
1332
|
+
const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
|
|
1333
|
+
if (version) return version;
|
|
1334
|
+
}
|
|
1335
|
+
if (isPlainObject(workspaces.catalogs)) {
|
|
1336
|
+
const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
|
|
1337
|
+
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
1338
|
+
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
1339
|
+
if (version) return version;
|
|
1340
|
+
}
|
|
1341
|
+
for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
1342
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
1343
|
+
if (version) return version;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1276
1346
|
}
|
|
1277
1347
|
if (rootDirectory) {
|
|
1278
1348
|
const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
|
|
@@ -1359,7 +1429,8 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
1359
1429
|
};
|
|
1360
1430
|
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
1361
1431
|
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
1362
|
-
const
|
|
1432
|
+
const leafPackageJsonPath = path.join(directory, "package.json");
|
|
1433
|
+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, isFile(leafPackageJsonPath) ? extractCatalogName(collectAllDependencies(readPackageJson(leafPackageJsonPath)).react ?? "") ?? null : null);
|
|
1363
1434
|
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
1364
1435
|
return {
|
|
1365
1436
|
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
@@ -1392,36 +1463,58 @@ const hasReactDependency = (packageJson) => {
|
|
|
1392
1463
|
const allDependencies = collectAllDependencies(packageJson);
|
|
1393
1464
|
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
1394
1465
|
};
|
|
1395
|
-
const
|
|
1396
|
-
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
1466
|
+
const toReactWorkspacePackages = (directories) => {
|
|
1397
1467
|
const packages = [];
|
|
1398
|
-
const
|
|
1399
|
-
|
|
1400
|
-
const rootPackageJson = readPackageJson(rootPackageJsonPath);
|
|
1401
|
-
if (hasReactDependency(rootPackageJson)) {
|
|
1402
|
-
const name = rootPackageJson.name ?? path.basename(rootDirectory);
|
|
1403
|
-
packages.push({
|
|
1404
|
-
name,
|
|
1405
|
-
directory: rootDirectory
|
|
1406
|
-
});
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
1410
|
-
for (const entry of entries) {
|
|
1411
|
-
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1412
|
-
const subdirectory = path.join(rootDirectory, entry.name);
|
|
1413
|
-
const packageJsonPath = path.join(subdirectory, "package.json");
|
|
1468
|
+
for (const directory of directories) {
|
|
1469
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
1414
1470
|
if (!isFile(packageJsonPath)) continue;
|
|
1415
1471
|
const packageJson = readPackageJson(packageJsonPath);
|
|
1416
1472
|
if (!hasReactDependency(packageJson)) continue;
|
|
1417
|
-
const name = packageJson.name ??
|
|
1473
|
+
const name = packageJson.name ?? path.basename(directory);
|
|
1418
1474
|
packages.push({
|
|
1419
1475
|
name,
|
|
1420
|
-
directory
|
|
1476
|
+
directory
|
|
1421
1477
|
});
|
|
1422
1478
|
}
|
|
1423
1479
|
return packages;
|
|
1424
1480
|
};
|
|
1481
|
+
const listManifestWorkspacePackages = (rootDirectory) => {
|
|
1482
|
+
if (isFile(path.join(rootDirectory, "package.json"))) return listWorkspacePackages(rootDirectory);
|
|
1483
|
+
const patterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
1484
|
+
const nxPatterns = patterns.length > 0 ? [] : getNxWorkspaceDirectories(rootDirectory);
|
|
1485
|
+
return toReactWorkspacePackages((patterns.length > 0 ? patterns : nxPatterns).flatMap((pattern) => resolveWorkspaceDirectories(rootDirectory, pattern)));
|
|
1486
|
+
};
|
|
1487
|
+
const discoverReactSubprojectsByFilesystem = (rootDirectory) => {
|
|
1488
|
+
const packages = [];
|
|
1489
|
+
const pendingDirectories = [rootDirectory];
|
|
1490
|
+
while (pendingDirectories.length > 0) {
|
|
1491
|
+
const currentDirectory = pendingDirectories.shift();
|
|
1492
|
+
if (!currentDirectory) continue;
|
|
1493
|
+
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
1494
|
+
if (isFile(packageJsonPath)) {
|
|
1495
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
1496
|
+
if (hasReactDependency(packageJson)) {
|
|
1497
|
+
const name = packageJson.name ?? path.basename(currentDirectory);
|
|
1498
|
+
packages.push({
|
|
1499
|
+
name,
|
|
1500
|
+
directory: currentDirectory
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).toSorted((firstEntry, secondEntry) => firstEntry.name.localeCompare(secondEntry.name));
|
|
1505
|
+
for (const entry of entries) {
|
|
1506
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
1507
|
+
pendingDirectories.push(path.join(currentDirectory, entry.name));
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return packages;
|
|
1511
|
+
};
|
|
1512
|
+
const discoverReactSubprojects = (rootDirectory) => {
|
|
1513
|
+
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
1514
|
+
const manifestPackages = listManifestWorkspacePackages(rootDirectory);
|
|
1515
|
+
if (manifestPackages.length > 0) return manifestPackages;
|
|
1516
|
+
return discoverReactSubprojectsByFilesystem(rootDirectory);
|
|
1517
|
+
};
|
|
1425
1518
|
const listWorkspacePackages = (rootDirectory) => {
|
|
1426
1519
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
1427
1520
|
if (!isFile(packageJsonPath)) return [];
|
|
@@ -1487,15 +1580,16 @@ const discoverProject = (directory) => {
|
|
|
1487
1580
|
const cached = cachedProjectInfos.get(directory);
|
|
1488
1581
|
if (cached !== void 0) return cached;
|
|
1489
1582
|
const packageJsonPath = path.join(directory, "package.json");
|
|
1490
|
-
if (!isFile(packageJsonPath)) throw new
|
|
1583
|
+
if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
|
|
1491
1584
|
const packageJson = readPackageJson(packageJsonPath);
|
|
1492
1585
|
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
1493
|
-
|
|
1586
|
+
const leafCatalogReference = extractCatalogName(collectAllDependencies(packageJson).react ?? "") ?? null;
|
|
1587
|
+
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory, leafCatalogReference);
|
|
1494
1588
|
if (!reactVersion) {
|
|
1495
1589
|
const monorepoRoot = findMonorepoRoot(directory);
|
|
1496
1590
|
if (monorepoRoot) {
|
|
1497
1591
|
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
1498
|
-
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
|
|
1592
|
+
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot, leafCatalogReference);
|
|
1499
1593
|
}
|
|
1500
1594
|
}
|
|
1501
1595
|
if (!reactVersion || framework === "unknown") {
|
|
@@ -1569,6 +1663,7 @@ const BOOLEAN_FIELD_NAMES = [
|
|
|
1569
1663
|
"respectInlineDisables",
|
|
1570
1664
|
"adoptExistingLintConfig"
|
|
1571
1665
|
];
|
|
1666
|
+
const STRING_FIELD_NAMES = ["rootDir"];
|
|
1572
1667
|
const warnConfigField = (message) => {
|
|
1573
1668
|
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1574
1669
|
};
|
|
@@ -1584,6 +1679,10 @@ const coerceMaybeBooleanString = (fieldName, value) => {
|
|
|
1584
1679
|
}
|
|
1585
1680
|
warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1586
1681
|
};
|
|
1682
|
+
const validateString = (fieldName, value) => {
|
|
1683
|
+
if (typeof value === "string") return value;
|
|
1684
|
+
warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1685
|
+
};
|
|
1587
1686
|
const validateConfigTypes = (config) => {
|
|
1588
1687
|
const validated = { ...config };
|
|
1589
1688
|
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
@@ -1593,6 +1692,13 @@ const validateConfigTypes = (config) => {
|
|
|
1593
1692
|
if (coerced === void 0) delete validated[fieldName];
|
|
1594
1693
|
else validated[fieldName] = coerced;
|
|
1595
1694
|
}
|
|
1695
|
+
for (const fieldName of STRING_FIELD_NAMES) {
|
|
1696
|
+
const original = config[fieldName];
|
|
1697
|
+
if (original === void 0) continue;
|
|
1698
|
+
const validatedString = validateString(fieldName, original);
|
|
1699
|
+
if (validatedString === void 0) delete validated[fieldName];
|
|
1700
|
+
else validated[fieldName] = validatedString;
|
|
1701
|
+
}
|
|
1596
1702
|
return validated;
|
|
1597
1703
|
};
|
|
1598
1704
|
//#endregion
|
|
@@ -1604,7 +1710,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1604
1710
|
if (isFile(configFilePath)) try {
|
|
1605
1711
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
1606
1712
|
const parsed = JSON.parse(fileContent);
|
|
1607
|
-
if (isPlainObject(parsed)) return
|
|
1713
|
+
if (isPlainObject(parsed)) return {
|
|
1714
|
+
config: validateConfigTypes(parsed),
|
|
1715
|
+
sourceDirectory: directory
|
|
1716
|
+
};
|
|
1608
1717
|
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
1609
1718
|
} catch (error) {
|
|
1610
1719
|
logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1615,7 +1724,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1615
1724
|
const packageJson = JSON.parse(fileContent);
|
|
1616
1725
|
if (isPlainObject(packageJson)) {
|
|
1617
1726
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
1618
|
-
if (isPlainObject(embeddedConfig)) return
|
|
1727
|
+
if (isPlainObject(embeddedConfig)) return {
|
|
1728
|
+
config: validateConfigTypes(embeddedConfig),
|
|
1729
|
+
sourceDirectory: directory
|
|
1730
|
+
};
|
|
1619
1731
|
}
|
|
1620
1732
|
} catch {
|
|
1621
1733
|
return null;
|
|
@@ -1624,7 +1736,7 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1624
1736
|
};
|
|
1625
1737
|
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1626
1738
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1627
|
-
const
|
|
1739
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
1628
1740
|
const cached = cachedConfigs.get(rootDirectory);
|
|
1629
1741
|
if (cached !== void 0) return cached;
|
|
1630
1742
|
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
@@ -2260,6 +2372,16 @@ const TANSTACK_START_RULES = {
|
|
|
2260
2372
|
"react-doctor/tanstack-start-redirect-in-try-catch": "warn",
|
|
2261
2373
|
"react-doctor/tanstack-start-loader-parallel-fetch": "warn"
|
|
2262
2374
|
};
|
|
2375
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
|
|
2376
|
+
"effect/no-derived-state": "warn",
|
|
2377
|
+
"effect/no-chain-state-updates": "warn",
|
|
2378
|
+
"effect/no-event-handler": "warn",
|
|
2379
|
+
"effect/no-adjust-state-on-prop-change": "warn",
|
|
2380
|
+
"effect/no-reset-all-state-on-prop-change": "warn",
|
|
2381
|
+
"effect/no-pass-live-state-to-parent": "warn",
|
|
2382
|
+
"effect/no-pass-data-to-parent": "warn",
|
|
2383
|
+
"effect/no-initialize-state": "warn"
|
|
2384
|
+
};
|
|
2263
2385
|
const REACT_COMPILER_RULES = {
|
|
2264
2386
|
"react-hooks-js/set-state-in-render": "error",
|
|
2265
2387
|
"react-hooks-js/immutability": "error",
|
|
@@ -2304,6 +2426,23 @@ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
|
2304
2426
|
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2305
2427
|
};
|
|
2306
2428
|
};
|
|
2429
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
|
|
2430
|
+
const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
|
|
2431
|
+
if (customRulesOnly) return null;
|
|
2432
|
+
let pluginSpecifier;
|
|
2433
|
+
try {
|
|
2434
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
|
|
2435
|
+
} catch {
|
|
2436
|
+
return null;
|
|
2437
|
+
}
|
|
2438
|
+
return {
|
|
2439
|
+
entry: {
|
|
2440
|
+
name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
|
|
2441
|
+
specifier: pluginSpecifier
|
|
2442
|
+
},
|
|
2443
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2444
|
+
};
|
|
2445
|
+
};
|
|
2307
2446
|
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
2308
2447
|
if (availableRuleNames.size === 0) return rules;
|
|
2309
2448
|
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
@@ -2514,6 +2653,11 @@ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
|
2514
2653
|
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
2515
2654
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
2516
2655
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2656
|
+
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
2657
|
+
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
|
|
2658
|
+
const jsPlugins = [];
|
|
2659
|
+
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
|
|
2660
|
+
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
|
|
2517
2661
|
return {
|
|
2518
2662
|
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
2519
2663
|
categories: {
|
|
@@ -2526,11 +2670,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
2526
2670
|
nursery: "off"
|
|
2527
2671
|
},
|
|
2528
2672
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2529
|
-
jsPlugins:
|
|
2673
|
+
jsPlugins: [...jsPlugins, pluginPath],
|
|
2530
2674
|
rules: {
|
|
2531
2675
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2532
2676
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
2533
2677
|
...reactCompilerRules,
|
|
2678
|
+
...youMightNotNeedEffectRules,
|
|
2534
2679
|
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
2535
2680
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
2536
2681
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
@@ -2644,6 +2789,7 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2644
2789
|
"react-doctor": "Other",
|
|
2645
2790
|
"jsx-a11y": "Accessibility",
|
|
2646
2791
|
knip: "Dead Code",
|
|
2792
|
+
effect: "State & Effects",
|
|
2647
2793
|
eslint: "Correctness",
|
|
2648
2794
|
oxc: "Correctness",
|
|
2649
2795
|
typescript: "Correctness",
|
|
@@ -3591,7 +3737,15 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
|
|
|
3591
3737
|
};
|
|
3592
3738
|
const scan = async (directory, inputOptions = {}) => {
|
|
3593
3739
|
const startTime = performance.now();
|
|
3594
|
-
|
|
3740
|
+
let scanDirectory = directory;
|
|
3741
|
+
let userConfig;
|
|
3742
|
+
if (inputOptions.configOverride !== void 0) userConfig = inputOptions.configOverride;
|
|
3743
|
+
else {
|
|
3744
|
+
const loadedConfig = loadConfigWithSource(directory);
|
|
3745
|
+
const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
|
|
3746
|
+
if (redirectedDirectory) scanDirectory = redirectedDirectory;
|
|
3747
|
+
userConfig = loadedConfig?.config ?? null;
|
|
3748
|
+
}
|
|
3595
3749
|
const options = mergeScanOptions(inputOptions, userConfig);
|
|
3596
3750
|
const wasLoggerSilent = isLoggerSilent();
|
|
3597
3751
|
const wasSpinnerSilent = isSpinnerSilent();
|
|
@@ -3600,7 +3754,7 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
3600
3754
|
setSpinnerSilent(true);
|
|
3601
3755
|
}
|
|
3602
3756
|
try {
|
|
3603
|
-
return await runScan(
|
|
3757
|
+
return await runScan(scanDirectory, options, userConfig, startTime);
|
|
3604
3758
|
} finally {
|
|
3605
3759
|
if (options.silent) {
|
|
3606
3760
|
setLoggerSilent(wasLoggerSilent);
|
|
@@ -3612,7 +3766,7 @@ const runScan = async (directory, options, userConfig, startTime) => {
|
|
|
3612
3766
|
const projectInfo = discoverProject(directory);
|
|
3613
3767
|
const { includePaths } = options;
|
|
3614
3768
|
const isDiffMode = includePaths.length > 0;
|
|
3615
|
-
if (!projectInfo.reactVersion) throw new
|
|
3769
|
+
if (!projectInfo.reactVersion) throw new NoReactDependencyError(directory);
|
|
3616
3770
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
|
|
3617
3771
|
const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
|
|
3618
3772
|
if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
|
|
@@ -4128,7 +4282,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
|
4128
4282
|
};
|
|
4129
4283
|
//#endregion
|
|
4130
4284
|
//#region src/cli.ts
|
|
4131
|
-
const VERSION = "0.1.
|
|
4285
|
+
const VERSION = "0.1.6";
|
|
4132
4286
|
const VALID_FAIL_ON_LEVELS = new Set([
|
|
4133
4287
|
"error",
|
|
4134
4288
|
"warning",
|
|
@@ -4307,20 +4461,28 @@ const validateModeFlags = (flags) => {
|
|
|
4307
4461
|
if (flags.explain !== void 0 && flags.why !== void 0) throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
|
|
4308
4462
|
if ((flags.explain ?? flags.why) !== void 0 && (flags.json || flags.score || flags.annotations || flags.staged)) throw new Error("--explain cannot be combined with --json, --score, --annotations, or --staged.");
|
|
4309
4463
|
};
|
|
4310
|
-
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 <name>", "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 <level>", "exit with error code on diagnostics: error, warning, none", "error").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").
|
|
4464
|
+
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 <name>", "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 <level>", "exit with error code on diagnostics: error, warning, none", "error").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "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, flags) => {
|
|
4311
4465
|
const isScoreOnly = flags.score;
|
|
4312
4466
|
const isJsonMode = flags.json;
|
|
4313
4467
|
const isQuiet = isScoreOnly || isJsonMode;
|
|
4314
|
-
const
|
|
4468
|
+
const requestedDirectory = path.resolve(directory);
|
|
4315
4469
|
const jsonStartTime = performance.now();
|
|
4316
4470
|
isJsonModeActive = isJsonMode;
|
|
4317
4471
|
isCompactJsonOutput = Boolean(flags.jsonCompact);
|
|
4318
|
-
resolvedDirectoryForCancel =
|
|
4472
|
+
resolvedDirectoryForCancel = requestedDirectory;
|
|
4319
4473
|
cancelStartTime = jsonStartTime;
|
|
4320
4474
|
if (isJsonMode) setLoggerSilent(true);
|
|
4321
4475
|
try {
|
|
4322
4476
|
validateModeFlags(flags);
|
|
4323
|
-
const
|
|
4477
|
+
const loadedConfig = loadConfigWithSource(requestedDirectory);
|
|
4478
|
+
const userConfig = loadedConfig?.config ?? null;
|
|
4479
|
+
const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
|
|
4480
|
+
const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
|
|
4481
|
+
resolvedDirectoryForCancel = resolvedDirectory;
|
|
4482
|
+
if (redirectedDirectory && !isQuiet) {
|
|
4483
|
+
logger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
|
|
4484
|
+
logger.break();
|
|
4485
|
+
}
|
|
4324
4486
|
const explainArgument = flags.explain ?? flags.why;
|
|
4325
4487
|
if (explainArgument !== void 0) {
|
|
4326
4488
|
await runExplain(explainArgument, {
|
|
@@ -4394,7 +4556,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
4394
4556
|
totalElapsedMilliseconds: performance.now() - jsonStartTime
|
|
4395
4557
|
}));
|
|
4396
4558
|
if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
|
|
4397
|
-
if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4559
|
+
if (!isScoreOnly && shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4398
4560
|
} finally {
|
|
4399
4561
|
cleanupSnapshot?.();
|
|
4400
4562
|
}
|
|
@@ -4438,7 +4600,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
4438
4600
|
}
|
|
4439
4601
|
const scanResult = await scan(projectDirectory, {
|
|
4440
4602
|
...scanOptions,
|
|
4441
|
-
includePaths
|
|
4603
|
+
includePaths,
|
|
4604
|
+
configOverride: userConfig
|
|
4442
4605
|
});
|
|
4443
4606
|
allDiagnostics.push(...scanResult.diagnostics);
|
|
4444
4607
|
completedScans.push({
|
|
@@ -4457,13 +4620,13 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
4457
4620
|
totalElapsedMilliseconds: performance.now() - jsonStartTime
|
|
4458
4621
|
}));
|
|
4459
4622
|
if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
|
|
4460
|
-
if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4623
|
+
if (!isScoreOnly && shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4461
4624
|
} catch (error) {
|
|
4462
4625
|
try {
|
|
4463
4626
|
if (isJsonMode) {
|
|
4464
4627
|
writeJsonReport(buildJsonReportError({
|
|
4465
4628
|
version: VERSION,
|
|
4466
|
-
directory:
|
|
4629
|
+
directory: resolvedDirectoryForCancel ?? requestedDirectory,
|
|
4467
4630
|
error,
|
|
4468
4631
|
elapsedMilliseconds: performance.now() - jsonStartTime,
|
|
4469
4632
|
mode: currentReportMode
|
package/dist/eslint-plugin.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -76,6 +76,24 @@ interface ReactDoctorConfig {
|
|
|
76
76
|
failOn?: FailOnLevel;
|
|
77
77
|
customRulesOnly?: boolean;
|
|
78
78
|
share?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Redirect react-doctor at a different project directory than the one
|
|
81
|
+
* it was invoked against. Resolved relative to the location of the
|
|
82
|
+
* config file that declared this field (NOT relative to the CWD), so
|
|
83
|
+
* the redirect is stable no matter where the CLI / `diagnose()` is
|
|
84
|
+
* run from. Absolute paths are used as-is.
|
|
85
|
+
*
|
|
86
|
+
* Typical use: a monorepo root holds the only `react-doctor.config.json`
|
|
87
|
+
* (so editor tooling and child commands all find it), but the React
|
|
88
|
+
* app lives in `apps/web`. Setting `"rootDir": "apps/web"` makes
|
|
89
|
+
* every invocation that loads this config scan that subproject
|
|
90
|
+
* without anyone needing to `cd` first or pass an explicit path.
|
|
91
|
+
*
|
|
92
|
+
* Ignored if the resolved path does not exist or is not a directory
|
|
93
|
+
* (a warning is emitted and react-doctor falls back to the originally
|
|
94
|
+
* requested directory).
|
|
95
|
+
*/
|
|
96
|
+
rootDir?: string;
|
|
79
97
|
textComponents?: string[];
|
|
80
98
|
/**
|
|
81
99
|
* Names of components that safely route string-only children through a
|
|
@@ -212,6 +230,34 @@ declare const filterSourceFiles: (filePaths: string[]) => string[];
|
|
|
212
230
|
//#region src/utils/summarize-diagnostics.d.ts
|
|
213
231
|
declare const summarizeDiagnostics: (diagnostics: Diagnostic[], worstScore?: number | null, worstScoreLabel?: string | null) => JsonReportSummary;
|
|
214
232
|
//#endregion
|
|
233
|
+
//#region src/errors.d.ts
|
|
234
|
+
declare class ReactDoctorError extends Error {
|
|
235
|
+
readonly name: string;
|
|
236
|
+
constructor(message: string, options?: ErrorOptions);
|
|
237
|
+
}
|
|
238
|
+
declare class ProjectNotFoundError extends ReactDoctorError {
|
|
239
|
+
readonly name = "ProjectNotFoundError";
|
|
240
|
+
readonly directory: string;
|
|
241
|
+
constructor(directory: string, options?: ErrorOptions);
|
|
242
|
+
}
|
|
243
|
+
declare class NoReactDependencyError extends ReactDoctorError {
|
|
244
|
+
readonly name = "NoReactDependencyError";
|
|
245
|
+
readonly directory: string;
|
|
246
|
+
constructor(directory: string, options?: ErrorOptions);
|
|
247
|
+
}
|
|
248
|
+
declare class PackageJsonNotFoundError extends ReactDoctorError {
|
|
249
|
+
readonly name = "PackageJsonNotFoundError";
|
|
250
|
+
readonly directory: string;
|
|
251
|
+
constructor(directory: string, options?: ErrorOptions);
|
|
252
|
+
}
|
|
253
|
+
declare class AmbiguousProjectError extends ReactDoctorError {
|
|
254
|
+
readonly name = "AmbiguousProjectError";
|
|
255
|
+
readonly directory: string;
|
|
256
|
+
readonly candidates: readonly string[];
|
|
257
|
+
constructor(directory: string, candidates: readonly string[], options?: ErrorOptions);
|
|
258
|
+
}
|
|
259
|
+
declare const isReactDoctorError: (value: unknown) => value is ReactDoctorError;
|
|
260
|
+
//#endregion
|
|
215
261
|
//#region src/index.d.ts
|
|
216
262
|
declare const clearCaches: () => void;
|
|
217
263
|
interface ToJsonReportOptions {
|
|
@@ -222,5 +268,5 @@ interface ToJsonReportOptions {
|
|
|
222
268
|
declare const toJsonReport: (result: DiagnoseResult, options: ToJsonReportOptions) => JsonReport;
|
|
223
269
|
declare const diagnose: (directory: string, options?: DiagnoseOptions) => Promise<DiagnoseResult>;
|
|
224
270
|
//#endregion
|
|
225
|
-
export { type DiagnoseOptions, type DiagnoseResult, type Diagnostic, type DiffInfo, type JsonReport, type JsonReportDiffInfo, type JsonReportError, type JsonReportMode, type JsonReportProjectEntry, type JsonReportSummary, type ProjectInfo, type ReactDoctorConfig, type ScoreResult, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };
|
|
271
|
+
export { AmbiguousProjectError, type DiagnoseOptions, type DiagnoseResult, type Diagnostic, type DiffInfo, type JsonReport, type JsonReportDiffInfo, type JsonReportError, type JsonReportMode, type JsonReportProjectEntry, type JsonReportSummary, NoReactDependencyError, PackageJsonNotFoundError, type ProjectInfo, ProjectNotFoundError, type ReactDoctorConfig, ReactDoctorError, type ScoreResult, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
226
272
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -28,14 +28,66 @@ const KNIP_CONFIG_LOCATIONS = [
|
|
|
28
28
|
];
|
|
29
29
|
const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
|
|
30
30
|
const IGNORED_DIRECTORIES = new Set([
|
|
31
|
-
"
|
|
32
|
-
"
|
|
31
|
+
".git",
|
|
32
|
+
".next",
|
|
33
|
+
".nuxt",
|
|
34
|
+
".output",
|
|
35
|
+
".svelte-kit",
|
|
36
|
+
".turbo",
|
|
33
37
|
"build",
|
|
34
|
-
"coverage"
|
|
38
|
+
"coverage",
|
|
39
|
+
"dist",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"out",
|
|
42
|
+
"storybook-static"
|
|
35
43
|
]);
|
|
36
44
|
const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
37
45
|
const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
|
|
38
46
|
//#endregion
|
|
47
|
+
//#region src/errors.ts
|
|
48
|
+
var ReactDoctorError = class extends Error {
|
|
49
|
+
name = "ReactDoctorError";
|
|
50
|
+
constructor(message, options) {
|
|
51
|
+
super(message, options);
|
|
52
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var ProjectNotFoundError = class extends ReactDoctorError {
|
|
56
|
+
name = "ProjectNotFoundError";
|
|
57
|
+
directory;
|
|
58
|
+
constructor(directory, options) {
|
|
59
|
+
super(`No React project found in ${directory}. Expected a package.json at the directory root or a nested package.json with a React dependency.`, options);
|
|
60
|
+
this.directory = directory;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var NoReactDependencyError = class extends ReactDoctorError {
|
|
64
|
+
name = "NoReactDependencyError";
|
|
65
|
+
directory;
|
|
66
|
+
constructor(directory, options) {
|
|
67
|
+
super(buildNoReactDependencyError(directory), options);
|
|
68
|
+
this.directory = directory;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
var PackageJsonNotFoundError = class extends ReactDoctorError {
|
|
72
|
+
name = "PackageJsonNotFoundError";
|
|
73
|
+
directory;
|
|
74
|
+
constructor(directory, options) {
|
|
75
|
+
super(`No package.json found in ${directory}`, options);
|
|
76
|
+
this.directory = directory;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var AmbiguousProjectError = class extends ReactDoctorError {
|
|
80
|
+
name = "AmbiguousProjectError";
|
|
81
|
+
directory;
|
|
82
|
+
candidates;
|
|
83
|
+
constructor(directory, candidates, options) {
|
|
84
|
+
super(`Multiple React projects found under ${directory} (${candidates.length} candidates): ${candidates.join(", ")}. Re-run diagnose() with one of those subdirectories, or iterate them yourself.`, options);
|
|
85
|
+
this.directory = directory;
|
|
86
|
+
this.candidates = candidates;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const isReactDoctorError = (value) => value instanceof ReactDoctorError;
|
|
90
|
+
//#endregion
|
|
39
91
|
//#region src/utils/summarize-diagnostics.ts
|
|
40
92
|
const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
|
|
41
93
|
let errorCount = 0;
|
|
@@ -770,9 +822,9 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
|
|
|
770
822
|
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
|
|
771
823
|
return null;
|
|
772
824
|
};
|
|
773
|
-
const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
825
|
+
const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
|
|
774
826
|
const rawVersion = collectAllDependencies(packageJson)[packageName];
|
|
775
|
-
const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
|
|
827
|
+
const catalogName = explicitCatalogReference ?? (rawVersion ? extractCatalogName(rawVersion) : null);
|
|
776
828
|
if (isPlainObject(packageJson.catalog)) {
|
|
777
829
|
const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
|
|
778
830
|
if (version) return version;
|
|
@@ -789,9 +841,22 @@ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
|
789
841
|
}
|
|
790
842
|
}
|
|
791
843
|
const workspaces = packageJson.workspaces;
|
|
792
|
-
if (workspaces && !Array.isArray(workspaces)
|
|
793
|
-
|
|
794
|
-
|
|
844
|
+
if (workspaces && !Array.isArray(workspaces)) {
|
|
845
|
+
if (isPlainObject(workspaces.catalog)) {
|
|
846
|
+
const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
|
|
847
|
+
if (version) return version;
|
|
848
|
+
}
|
|
849
|
+
if (isPlainObject(workspaces.catalogs)) {
|
|
850
|
+
const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
|
|
851
|
+
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
852
|
+
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
853
|
+
if (version) return version;
|
|
854
|
+
}
|
|
855
|
+
for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
856
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
857
|
+
if (version) return version;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
795
860
|
}
|
|
796
861
|
if (rootDirectory) {
|
|
797
862
|
const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
|
|
@@ -878,7 +943,8 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
878
943
|
};
|
|
879
944
|
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
880
945
|
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
881
|
-
const
|
|
946
|
+
const leafPackageJsonPath = path.join(directory, "package.json");
|
|
947
|
+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, isFile(leafPackageJsonPath) ? extractCatalogName(collectAllDependencies(readPackageJson(leafPackageJsonPath)).react ?? "") ?? null : null);
|
|
882
948
|
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
883
949
|
return {
|
|
884
950
|
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
@@ -902,6 +968,95 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
|
902
968
|
}
|
|
903
969
|
return result;
|
|
904
970
|
};
|
|
971
|
+
const REACT_DEPENDENCY_NAMES = new Set([
|
|
972
|
+
"react",
|
|
973
|
+
"react-native",
|
|
974
|
+
"next"
|
|
975
|
+
]);
|
|
976
|
+
const hasReactDependency = (packageJson) => {
|
|
977
|
+
const allDependencies = collectAllDependencies(packageJson);
|
|
978
|
+
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
979
|
+
};
|
|
980
|
+
const toReactWorkspacePackages = (directories) => {
|
|
981
|
+
const packages = [];
|
|
982
|
+
for (const directory of directories) {
|
|
983
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
984
|
+
if (!isFile(packageJsonPath)) continue;
|
|
985
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
986
|
+
if (!hasReactDependency(packageJson)) continue;
|
|
987
|
+
const name = packageJson.name ?? path.basename(directory);
|
|
988
|
+
packages.push({
|
|
989
|
+
name,
|
|
990
|
+
directory
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
return packages;
|
|
994
|
+
};
|
|
995
|
+
const listManifestWorkspacePackages = (rootDirectory) => {
|
|
996
|
+
if (isFile(path.join(rootDirectory, "package.json"))) return listWorkspacePackages(rootDirectory);
|
|
997
|
+
const patterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
998
|
+
const nxPatterns = patterns.length > 0 ? [] : getNxWorkspaceDirectories(rootDirectory);
|
|
999
|
+
return toReactWorkspacePackages((patterns.length > 0 ? patterns : nxPatterns).flatMap((pattern) => resolveWorkspaceDirectories(rootDirectory, pattern)));
|
|
1000
|
+
};
|
|
1001
|
+
const discoverReactSubprojectsByFilesystem = (rootDirectory) => {
|
|
1002
|
+
const packages = [];
|
|
1003
|
+
const pendingDirectories = [rootDirectory];
|
|
1004
|
+
while (pendingDirectories.length > 0) {
|
|
1005
|
+
const currentDirectory = pendingDirectories.shift();
|
|
1006
|
+
if (!currentDirectory) continue;
|
|
1007
|
+
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
1008
|
+
if (isFile(packageJsonPath)) {
|
|
1009
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
1010
|
+
if (hasReactDependency(packageJson)) {
|
|
1011
|
+
const name = packageJson.name ?? path.basename(currentDirectory);
|
|
1012
|
+
packages.push({
|
|
1013
|
+
name,
|
|
1014
|
+
directory: currentDirectory
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).toSorted((firstEntry, secondEntry) => firstEntry.name.localeCompare(secondEntry.name));
|
|
1019
|
+
for (const entry of entries) {
|
|
1020
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
1021
|
+
pendingDirectories.push(path.join(currentDirectory, entry.name));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return packages;
|
|
1025
|
+
};
|
|
1026
|
+
const discoverReactSubprojects = (rootDirectory) => {
|
|
1027
|
+
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
1028
|
+
const manifestPackages = listManifestWorkspacePackages(rootDirectory);
|
|
1029
|
+
if (manifestPackages.length > 0) return manifestPackages;
|
|
1030
|
+
return discoverReactSubprojectsByFilesystem(rootDirectory);
|
|
1031
|
+
};
|
|
1032
|
+
const listWorkspacePackages = (rootDirectory) => {
|
|
1033
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
1034
|
+
if (!isFile(packageJsonPath)) return [];
|
|
1035
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
1036
|
+
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
1037
|
+
if (patterns.length === 0) return [];
|
|
1038
|
+
const packages = [];
|
|
1039
|
+
if (hasReactDependency(packageJson)) {
|
|
1040
|
+
const rootName = packageJson.name ?? path.basename(rootDirectory);
|
|
1041
|
+
packages.push({
|
|
1042
|
+
name: rootName,
|
|
1043
|
+
directory: rootDirectory
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
for (const pattern of patterns) {
|
|
1047
|
+
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
1048
|
+
for (const workspaceDirectory of directories) {
|
|
1049
|
+
const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json"));
|
|
1050
|
+
if (!hasReactDependency(workspacePackageJson)) continue;
|
|
1051
|
+
const name = workspacePackageJson.name ?? path.basename(workspaceDirectory);
|
|
1052
|
+
packages.push({
|
|
1053
|
+
name,
|
|
1054
|
+
directory: workspaceDirectory
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return packages;
|
|
1059
|
+
};
|
|
905
1060
|
const hasCompilerPackage = (packageJson) => {
|
|
906
1061
|
const allDependencies = collectAllDependencies(packageJson);
|
|
907
1062
|
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
@@ -942,15 +1097,16 @@ const discoverProject = (directory) => {
|
|
|
942
1097
|
const cached = cachedProjectInfos.get(directory);
|
|
943
1098
|
if (cached !== void 0) return cached;
|
|
944
1099
|
const packageJsonPath = path.join(directory, "package.json");
|
|
945
|
-
if (!isFile(packageJsonPath)) throw new
|
|
1100
|
+
if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
|
|
946
1101
|
const packageJson = readPackageJson(packageJsonPath);
|
|
947
1102
|
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
948
|
-
|
|
1103
|
+
const leafCatalogReference = extractCatalogName(collectAllDependencies(packageJson).react ?? "") ?? null;
|
|
1104
|
+
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory, leafCatalogReference);
|
|
949
1105
|
if (!reactVersion) {
|
|
950
1106
|
const monorepoRoot = findMonorepoRoot(directory);
|
|
951
1107
|
if (monorepoRoot) {
|
|
952
1108
|
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
953
|
-
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
|
|
1109
|
+
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot, leafCatalogReference);
|
|
954
1110
|
}
|
|
955
1111
|
}
|
|
956
1112
|
if (!reactVersion || framework === "unknown") {
|
|
@@ -996,6 +1152,7 @@ const BOOLEAN_FIELD_NAMES = [
|
|
|
996
1152
|
"respectInlineDisables",
|
|
997
1153
|
"adoptExistingLintConfig"
|
|
998
1154
|
];
|
|
1155
|
+
const STRING_FIELD_NAMES = ["rootDir"];
|
|
999
1156
|
const warnConfigField$1 = (message) => {
|
|
1000
1157
|
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1001
1158
|
};
|
|
@@ -1011,6 +1168,10 @@ const coerceMaybeBooleanString = (fieldName, value) => {
|
|
|
1011
1168
|
}
|
|
1012
1169
|
warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1013
1170
|
};
|
|
1171
|
+
const validateString = (fieldName, value) => {
|
|
1172
|
+
if (typeof value === "string") return value;
|
|
1173
|
+
warnConfigField$1(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1174
|
+
};
|
|
1014
1175
|
const validateConfigTypes = (config) => {
|
|
1015
1176
|
const validated = { ...config };
|
|
1016
1177
|
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
@@ -1020,6 +1181,13 @@ const validateConfigTypes = (config) => {
|
|
|
1020
1181
|
if (coerced === void 0) delete validated[fieldName];
|
|
1021
1182
|
else validated[fieldName] = coerced;
|
|
1022
1183
|
}
|
|
1184
|
+
for (const fieldName of STRING_FIELD_NAMES) {
|
|
1185
|
+
const original = config[fieldName];
|
|
1186
|
+
if (original === void 0) continue;
|
|
1187
|
+
const validatedString = validateString(fieldName, original);
|
|
1188
|
+
if (validatedString === void 0) delete validated[fieldName];
|
|
1189
|
+
else validated[fieldName] = validatedString;
|
|
1190
|
+
}
|
|
1023
1191
|
return validated;
|
|
1024
1192
|
};
|
|
1025
1193
|
//#endregion
|
|
@@ -1031,7 +1199,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1031
1199
|
if (isFile(configFilePath)) try {
|
|
1032
1200
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
1033
1201
|
const parsed = JSON.parse(fileContent);
|
|
1034
|
-
if (isPlainObject(parsed)) return
|
|
1202
|
+
if (isPlainObject(parsed)) return {
|
|
1203
|
+
config: validateConfigTypes(parsed),
|
|
1204
|
+
sourceDirectory: directory
|
|
1205
|
+
};
|
|
1035
1206
|
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
1036
1207
|
} catch (error) {
|
|
1037
1208
|
logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1042,7 +1213,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1042
1213
|
const packageJson = JSON.parse(fileContent);
|
|
1043
1214
|
if (isPlainObject(packageJson)) {
|
|
1044
1215
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
1045
|
-
if (isPlainObject(embeddedConfig)) return
|
|
1216
|
+
if (isPlainObject(embeddedConfig)) return {
|
|
1217
|
+
config: validateConfigTypes(embeddedConfig),
|
|
1218
|
+
sourceDirectory: directory
|
|
1219
|
+
};
|
|
1046
1220
|
}
|
|
1047
1221
|
} catch {
|
|
1048
1222
|
return null;
|
|
@@ -1054,7 +1228,7 @@ const cachedConfigs = /* @__PURE__ */ new Map();
|
|
|
1054
1228
|
const clearConfigCache = () => {
|
|
1055
1229
|
cachedConfigs.clear();
|
|
1056
1230
|
};
|
|
1057
|
-
const
|
|
1231
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
1058
1232
|
const cached = cachedConfigs.get(rootDirectory);
|
|
1059
1233
|
if (cached !== void 0) return cached;
|
|
1060
1234
|
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
@@ -1261,7 +1435,7 @@ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
|
1261
1435
|
};
|
|
1262
1436
|
//#endregion
|
|
1263
1437
|
//#region src/utils/find-stacked-disable-comments.ts
|
|
1264
|
-
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\
|
|
1438
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1265
1439
|
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
1266
1440
|
const collected = [];
|
|
1267
1441
|
let isStillInChain = true;
|
|
@@ -1283,13 +1457,21 @@ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
|
1283
1457
|
};
|
|
1284
1458
|
//#endregion
|
|
1285
1459
|
//#region src/utils/is-rule-listed-in-comment.ts
|
|
1460
|
+
const stripDescriptionTail = (ruleList) => {
|
|
1461
|
+
const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
|
|
1462
|
+
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
1463
|
+
return ruleList.slice(0, descriptionMatch.index);
|
|
1464
|
+
};
|
|
1286
1465
|
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
1287
|
-
|
|
1288
|
-
|
|
1466
|
+
const trimmed = ruleList?.trim();
|
|
1467
|
+
if (!trimmed) return true;
|
|
1468
|
+
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
1469
|
+
if (!ruleSection) return true;
|
|
1470
|
+
return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
1289
1471
|
};
|
|
1290
1472
|
//#endregion
|
|
1291
1473
|
//#region src/utils/evaluate-suppression.ts
|
|
1292
|
-
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\
|
|
1474
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1293
1475
|
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
1294
1476
|
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1295
1477
|
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
@@ -1514,6 +1696,31 @@ const createNodeReadFileLinesSync = (rootDirectory) => {
|
|
|
1514
1696
|
};
|
|
1515
1697
|
};
|
|
1516
1698
|
//#endregion
|
|
1699
|
+
//#region src/utils/resolve-config-root-dir.ts
|
|
1700
|
+
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
1701
|
+
if (!config || !configSourceDirectory) return null;
|
|
1702
|
+
const rawRootDir = config.rootDir;
|
|
1703
|
+
if (typeof rawRootDir !== "string") return null;
|
|
1704
|
+
const trimmedRootDir = rawRootDir.trim();
|
|
1705
|
+
if (trimmedRootDir.length === 0) return null;
|
|
1706
|
+
const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
|
|
1707
|
+
if (resolvedRootDir === configSourceDirectory) return null;
|
|
1708
|
+
if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
|
|
1709
|
+
logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
return resolvedRootDir;
|
|
1713
|
+
};
|
|
1714
|
+
//#endregion
|
|
1715
|
+
//#region src/utils/resolve-diagnose-target.ts
|
|
1716
|
+
const resolveDiagnoseTarget = (directory) => {
|
|
1717
|
+
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
1718
|
+
const reactSubprojects = discoverReactSubprojects(directory);
|
|
1719
|
+
if (reactSubprojects.length === 0) return null;
|
|
1720
|
+
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
1721
|
+
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
1722
|
+
};
|
|
1723
|
+
//#endregion
|
|
1517
1724
|
//#region src/utils/resolve-lint-include-paths.ts
|
|
1518
1725
|
const listSourceFilesViaGit = (rootDirectory) => {
|
|
1519
1726
|
const result = spawnSync("git", [
|
|
@@ -1923,6 +2130,16 @@ const TANSTACK_START_RULES = {
|
|
|
1923
2130
|
"react-doctor/tanstack-start-redirect-in-try-catch": "warn",
|
|
1924
2131
|
"react-doctor/tanstack-start-loader-parallel-fetch": "warn"
|
|
1925
2132
|
};
|
|
2133
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
|
|
2134
|
+
"effect/no-derived-state": "warn",
|
|
2135
|
+
"effect/no-chain-state-updates": "warn",
|
|
2136
|
+
"effect/no-event-handler": "warn",
|
|
2137
|
+
"effect/no-adjust-state-on-prop-change": "warn",
|
|
2138
|
+
"effect/no-reset-all-state-on-prop-change": "warn",
|
|
2139
|
+
"effect/no-pass-live-state-to-parent": "warn",
|
|
2140
|
+
"effect/no-pass-data-to-parent": "warn",
|
|
2141
|
+
"effect/no-initialize-state": "warn"
|
|
2142
|
+
};
|
|
1926
2143
|
const REACT_COMPILER_RULES = {
|
|
1927
2144
|
"react-hooks-js/set-state-in-render": "error",
|
|
1928
2145
|
"react-hooks-js/immutability": "error",
|
|
@@ -1967,6 +2184,23 @@ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
|
1967
2184
|
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
1968
2185
|
};
|
|
1969
2186
|
};
|
|
2187
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
|
|
2188
|
+
const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
|
|
2189
|
+
if (customRulesOnly) return null;
|
|
2190
|
+
let pluginSpecifier;
|
|
2191
|
+
try {
|
|
2192
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
|
|
2193
|
+
} catch {
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
return {
|
|
2197
|
+
entry: {
|
|
2198
|
+
name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
|
|
2199
|
+
specifier: pluginSpecifier
|
|
2200
|
+
},
|
|
2201
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2202
|
+
};
|
|
2203
|
+
};
|
|
1970
2204
|
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
1971
2205
|
if (availableRuleNames.size === 0) return rules;
|
|
1972
2206
|
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
@@ -2177,6 +2411,11 @@ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
|
2177
2411
|
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
2178
2412
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
2179
2413
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2414
|
+
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
2415
|
+
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
|
|
2416
|
+
const jsPlugins = [];
|
|
2417
|
+
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
|
|
2418
|
+
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
|
|
2180
2419
|
return {
|
|
2181
2420
|
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
2182
2421
|
categories: {
|
|
@@ -2189,11 +2428,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
2189
2428
|
nursery: "off"
|
|
2190
2429
|
},
|
|
2191
2430
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2192
|
-
jsPlugins:
|
|
2431
|
+
jsPlugins: [...jsPlugins, pluginPath],
|
|
2193
2432
|
rules: {
|
|
2194
2433
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2195
2434
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
2196
2435
|
...reactCompilerRules,
|
|
2436
|
+
...youMightNotNeedEffectRules,
|
|
2197
2437
|
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
2198
2438
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
2199
2439
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
@@ -2307,6 +2547,7 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2307
2547
|
"react-doctor": "Other",
|
|
2308
2548
|
"jsx-a11y": "Accessibility",
|
|
2309
2549
|
knip: "Dead Code",
|
|
2550
|
+
effect: "State & Effects",
|
|
2310
2551
|
eslint: "Correctness",
|
|
2311
2552
|
oxc: "Correctness",
|
|
2312
2553
|
typescript: "Correctness",
|
|
@@ -3078,12 +3319,16 @@ const settledOrEmpty = (settled, label) => {
|
|
|
3078
3319
|
};
|
|
3079
3320
|
const diagnose = async (directory, options = {}) => {
|
|
3080
3321
|
const startTime = globalThis.performance.now();
|
|
3081
|
-
const
|
|
3082
|
-
const
|
|
3322
|
+
const requestedDirectory = path.resolve(directory);
|
|
3323
|
+
const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
|
|
3324
|
+
const directoryAfterRedirect = resolveConfigRootDir(initialLoadedConfig?.config ?? null, initialLoadedConfig?.sourceDirectory ?? null) ?? requestedDirectory;
|
|
3325
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
|
|
3326
|
+
if (!resolvedDirectory) throw new ProjectNotFoundError(directoryAfterRedirect);
|
|
3327
|
+
const userConfig = initialLoadedConfig?.config ?? loadConfigWithSource(resolvedDirectory)?.config ?? null;
|
|
3083
3328
|
const includePaths = options.includePaths ?? [];
|
|
3084
3329
|
const isDiffMode = includePaths.length > 0;
|
|
3085
3330
|
const projectInfo = discoverProject(resolvedDirectory);
|
|
3086
|
-
if (!projectInfo.reactVersion) throw new
|
|
3331
|
+
if (!projectInfo.reactVersion) throw new NoReactDependencyError(resolvedDirectory);
|
|
3087
3332
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
3088
3333
|
const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
|
|
3089
3334
|
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
@@ -3126,6 +3371,6 @@ const diagnose = async (directory, options = {}) => {
|
|
|
3126
3371
|
};
|
|
3127
3372
|
};
|
|
3128
3373
|
//#endregion
|
|
3129
|
-
export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };
|
|
3374
|
+
export { AmbiguousProjectError, NoReactDependencyError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
3130
3375
|
|
|
3131
3376
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-doctor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
@@ -69,14 +69,19 @@
|
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@types/prompts": "^2.4.9",
|
|
72
|
-
"eslint-plugin-react-hooks": "^7.1.1"
|
|
72
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
73
|
+
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1"
|
|
73
74
|
},
|
|
74
75
|
"peerDependencies": {
|
|
75
|
-
"eslint-plugin-react-hooks": "^6 || ^7"
|
|
76
|
+
"eslint-plugin-react-hooks": "^6 || ^7",
|
|
77
|
+
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10"
|
|
76
78
|
},
|
|
77
79
|
"peerDependenciesMeta": {
|
|
78
80
|
"eslint-plugin-react-hooks": {
|
|
79
81
|
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"eslint-plugin-react-you-might-not-need-an-effect": {
|
|
84
|
+
"optional": true
|
|
80
85
|
}
|
|
81
86
|
},
|
|
82
87
|
"engines": {
|