react-doctor 0.1.4 → 0.1.5
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 +163 -30
- package/dist/eslint-plugin.js +1 -1
- package/dist/index.d.ts +47 -1
- package/dist/index.js +208 -21
- 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";
|
|
@@ -304,6 +304,47 @@ const runInstallSkill = async (options = {}) => {
|
|
|
304
304
|
}
|
|
305
305
|
};
|
|
306
306
|
//#endregion
|
|
307
|
+
//#region src/errors.ts
|
|
308
|
+
var ReactDoctorError = class extends Error {
|
|
309
|
+
name = "ReactDoctorError";
|
|
310
|
+
constructor(message, options) {
|
|
311
|
+
super(message, options);
|
|
312
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
var NoReactDependencyError = class extends ReactDoctorError {
|
|
316
|
+
name = "NoReactDependencyError";
|
|
317
|
+
directory;
|
|
318
|
+
constructor(directory, options) {
|
|
319
|
+
super(buildNoReactDependencyError(directory), options);
|
|
320
|
+
this.directory = directory;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
var PackageJsonNotFoundError = class extends ReactDoctorError {
|
|
324
|
+
name = "PackageJsonNotFoundError";
|
|
325
|
+
directory;
|
|
326
|
+
constructor(directory, options) {
|
|
327
|
+
super(`No package.json found in ${directory}`, options);
|
|
328
|
+
this.directory = directory;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/utils/resolve-config-root-dir.ts
|
|
333
|
+
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
334
|
+
if (!config || !configSourceDirectory) return null;
|
|
335
|
+
const rawRootDir = config.rootDir;
|
|
336
|
+
if (typeof rawRootDir !== "string") return null;
|
|
337
|
+
const trimmedRootDir = rawRootDir.trim();
|
|
338
|
+
if (trimmedRootDir.length === 0) return null;
|
|
339
|
+
const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
|
|
340
|
+
if (resolvedRootDir === configSourceDirectory) return null;
|
|
341
|
+
if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
|
|
342
|
+
logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
return resolvedRootDir;
|
|
346
|
+
};
|
|
347
|
+
//#endregion
|
|
307
348
|
//#region src/utils/build-hidden-diagnostics-summary.ts
|
|
308
349
|
const buildHiddenDiagnosticsSummary = (hiddenDiagnostics) => {
|
|
309
350
|
const errorCount = hiddenDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
@@ -802,7 +843,7 @@ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
|
802
843
|
};
|
|
803
844
|
//#endregion
|
|
804
845
|
//#region src/utils/find-stacked-disable-comments.ts
|
|
805
|
-
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\
|
|
846
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
806
847
|
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
807
848
|
const collected = [];
|
|
808
849
|
let isStillInChain = true;
|
|
@@ -824,13 +865,21 @@ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
|
824
865
|
};
|
|
825
866
|
//#endregion
|
|
826
867
|
//#region src/utils/is-rule-listed-in-comment.ts
|
|
868
|
+
const stripDescriptionTail = (ruleList) => {
|
|
869
|
+
const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
|
|
870
|
+
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
871
|
+
return ruleList.slice(0, descriptionMatch.index);
|
|
872
|
+
};
|
|
827
873
|
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
828
|
-
|
|
829
|
-
|
|
874
|
+
const trimmed = ruleList?.trim();
|
|
875
|
+
if (!trimmed) return true;
|
|
876
|
+
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
877
|
+
if (!ruleSection) return true;
|
|
878
|
+
return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
830
879
|
};
|
|
831
880
|
//#endregion
|
|
832
881
|
//#region src/utils/evaluate-suppression.ts
|
|
833
|
-
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\
|
|
882
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
834
883
|
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
835
884
|
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
836
885
|
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
@@ -1251,9 +1300,9 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
|
|
|
1251
1300
|
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
|
|
1252
1301
|
return null;
|
|
1253
1302
|
};
|
|
1254
|
-
const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
1303
|
+
const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
|
|
1255
1304
|
const rawVersion = collectAllDependencies(packageJson)[packageName];
|
|
1256
|
-
const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
|
|
1305
|
+
const catalogName = explicitCatalogReference ?? (rawVersion ? extractCatalogName(rawVersion) : null);
|
|
1257
1306
|
if (isPlainObject(packageJson.catalog)) {
|
|
1258
1307
|
const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
|
|
1259
1308
|
if (version) return version;
|
|
@@ -1270,9 +1319,22 @@ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
|
1270
1319
|
}
|
|
1271
1320
|
}
|
|
1272
1321
|
const workspaces = packageJson.workspaces;
|
|
1273
|
-
if (workspaces && !Array.isArray(workspaces)
|
|
1274
|
-
|
|
1275
|
-
|
|
1322
|
+
if (workspaces && !Array.isArray(workspaces)) {
|
|
1323
|
+
if (isPlainObject(workspaces.catalog)) {
|
|
1324
|
+
const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
|
|
1325
|
+
if (version) return version;
|
|
1326
|
+
}
|
|
1327
|
+
if (isPlainObject(workspaces.catalogs)) {
|
|
1328
|
+
const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
|
|
1329
|
+
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
1330
|
+
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
1331
|
+
if (version) return version;
|
|
1332
|
+
}
|
|
1333
|
+
for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
1334
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
1335
|
+
if (version) return version;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1276
1338
|
}
|
|
1277
1339
|
if (rootDirectory) {
|
|
1278
1340
|
const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
|
|
@@ -1359,7 +1421,8 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
1359
1421
|
};
|
|
1360
1422
|
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
1361
1423
|
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
1362
|
-
const
|
|
1424
|
+
const leafPackageJsonPath = path.join(directory, "package.json");
|
|
1425
|
+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, isFile(leafPackageJsonPath) ? extractCatalogName(collectAllDependencies(readPackageJson(leafPackageJsonPath)).react ?? "") ?? null : null);
|
|
1363
1426
|
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
1364
1427
|
return {
|
|
1365
1428
|
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
@@ -1487,15 +1550,16 @@ const discoverProject = (directory) => {
|
|
|
1487
1550
|
const cached = cachedProjectInfos.get(directory);
|
|
1488
1551
|
if (cached !== void 0) return cached;
|
|
1489
1552
|
const packageJsonPath = path.join(directory, "package.json");
|
|
1490
|
-
if (!isFile(packageJsonPath)) throw new
|
|
1553
|
+
if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
|
|
1491
1554
|
const packageJson = readPackageJson(packageJsonPath);
|
|
1492
1555
|
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
1493
|
-
|
|
1556
|
+
const leafCatalogReference = extractCatalogName(collectAllDependencies(packageJson).react ?? "") ?? null;
|
|
1557
|
+
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory, leafCatalogReference);
|
|
1494
1558
|
if (!reactVersion) {
|
|
1495
1559
|
const monorepoRoot = findMonorepoRoot(directory);
|
|
1496
1560
|
if (monorepoRoot) {
|
|
1497
1561
|
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
1498
|
-
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
|
|
1562
|
+
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot, leafCatalogReference);
|
|
1499
1563
|
}
|
|
1500
1564
|
}
|
|
1501
1565
|
if (!reactVersion || framework === "unknown") {
|
|
@@ -1569,6 +1633,7 @@ const BOOLEAN_FIELD_NAMES = [
|
|
|
1569
1633
|
"respectInlineDisables",
|
|
1570
1634
|
"adoptExistingLintConfig"
|
|
1571
1635
|
];
|
|
1636
|
+
const STRING_FIELD_NAMES = ["rootDir"];
|
|
1572
1637
|
const warnConfigField = (message) => {
|
|
1573
1638
|
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1574
1639
|
};
|
|
@@ -1584,6 +1649,10 @@ const coerceMaybeBooleanString = (fieldName, value) => {
|
|
|
1584
1649
|
}
|
|
1585
1650
|
warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1586
1651
|
};
|
|
1652
|
+
const validateString = (fieldName, value) => {
|
|
1653
|
+
if (typeof value === "string") return value;
|
|
1654
|
+
warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1655
|
+
};
|
|
1587
1656
|
const validateConfigTypes = (config) => {
|
|
1588
1657
|
const validated = { ...config };
|
|
1589
1658
|
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
@@ -1593,6 +1662,13 @@ const validateConfigTypes = (config) => {
|
|
|
1593
1662
|
if (coerced === void 0) delete validated[fieldName];
|
|
1594
1663
|
else validated[fieldName] = coerced;
|
|
1595
1664
|
}
|
|
1665
|
+
for (const fieldName of STRING_FIELD_NAMES) {
|
|
1666
|
+
const original = config[fieldName];
|
|
1667
|
+
if (original === void 0) continue;
|
|
1668
|
+
const validatedString = validateString(fieldName, original);
|
|
1669
|
+
if (validatedString === void 0) delete validated[fieldName];
|
|
1670
|
+
else validated[fieldName] = validatedString;
|
|
1671
|
+
}
|
|
1596
1672
|
return validated;
|
|
1597
1673
|
};
|
|
1598
1674
|
//#endregion
|
|
@@ -1604,7 +1680,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1604
1680
|
if (isFile(configFilePath)) try {
|
|
1605
1681
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
1606
1682
|
const parsed = JSON.parse(fileContent);
|
|
1607
|
-
if (isPlainObject(parsed)) return
|
|
1683
|
+
if (isPlainObject(parsed)) return {
|
|
1684
|
+
config: validateConfigTypes(parsed),
|
|
1685
|
+
sourceDirectory: directory
|
|
1686
|
+
};
|
|
1608
1687
|
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
1609
1688
|
} catch (error) {
|
|
1610
1689
|
logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1615,7 +1694,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1615
1694
|
const packageJson = JSON.parse(fileContent);
|
|
1616
1695
|
if (isPlainObject(packageJson)) {
|
|
1617
1696
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
1618
|
-
if (isPlainObject(embeddedConfig)) return
|
|
1697
|
+
if (isPlainObject(embeddedConfig)) return {
|
|
1698
|
+
config: validateConfigTypes(embeddedConfig),
|
|
1699
|
+
sourceDirectory: directory
|
|
1700
|
+
};
|
|
1619
1701
|
}
|
|
1620
1702
|
} catch {
|
|
1621
1703
|
return null;
|
|
@@ -1624,7 +1706,7 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1624
1706
|
};
|
|
1625
1707
|
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1626
1708
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1627
|
-
const
|
|
1709
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
1628
1710
|
const cached = cachedConfigs.get(rootDirectory);
|
|
1629
1711
|
if (cached !== void 0) return cached;
|
|
1630
1712
|
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
@@ -2260,6 +2342,16 @@ const TANSTACK_START_RULES = {
|
|
|
2260
2342
|
"react-doctor/tanstack-start-redirect-in-try-catch": "warn",
|
|
2261
2343
|
"react-doctor/tanstack-start-loader-parallel-fetch": "warn"
|
|
2262
2344
|
};
|
|
2345
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
|
|
2346
|
+
"effect/no-derived-state": "warn",
|
|
2347
|
+
"effect/no-chain-state-updates": "warn",
|
|
2348
|
+
"effect/no-event-handler": "warn",
|
|
2349
|
+
"effect/no-adjust-state-on-prop-change": "warn",
|
|
2350
|
+
"effect/no-reset-all-state-on-prop-change": "warn",
|
|
2351
|
+
"effect/no-pass-live-state-to-parent": "warn",
|
|
2352
|
+
"effect/no-pass-data-to-parent": "warn",
|
|
2353
|
+
"effect/no-initialize-state": "warn"
|
|
2354
|
+
};
|
|
2263
2355
|
const REACT_COMPILER_RULES = {
|
|
2264
2356
|
"react-hooks-js/set-state-in-render": "error",
|
|
2265
2357
|
"react-hooks-js/immutability": "error",
|
|
@@ -2304,6 +2396,23 @@ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
|
2304
2396
|
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2305
2397
|
};
|
|
2306
2398
|
};
|
|
2399
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
|
|
2400
|
+
const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
|
|
2401
|
+
if (customRulesOnly) return null;
|
|
2402
|
+
let pluginSpecifier;
|
|
2403
|
+
try {
|
|
2404
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
|
|
2405
|
+
} catch {
|
|
2406
|
+
return null;
|
|
2407
|
+
}
|
|
2408
|
+
return {
|
|
2409
|
+
entry: {
|
|
2410
|
+
name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
|
|
2411
|
+
specifier: pluginSpecifier
|
|
2412
|
+
},
|
|
2413
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2414
|
+
};
|
|
2415
|
+
};
|
|
2307
2416
|
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
2308
2417
|
if (availableRuleNames.size === 0) return rules;
|
|
2309
2418
|
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
@@ -2514,6 +2623,11 @@ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
|
2514
2623
|
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
2515
2624
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
2516
2625
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2626
|
+
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
2627
|
+
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
|
|
2628
|
+
const jsPlugins = [];
|
|
2629
|
+
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
|
|
2630
|
+
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
|
|
2517
2631
|
return {
|
|
2518
2632
|
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
2519
2633
|
categories: {
|
|
@@ -2526,11 +2640,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
2526
2640
|
nursery: "off"
|
|
2527
2641
|
},
|
|
2528
2642
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2529
|
-
jsPlugins:
|
|
2643
|
+
jsPlugins: [...jsPlugins, pluginPath],
|
|
2530
2644
|
rules: {
|
|
2531
2645
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2532
2646
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
2533
2647
|
...reactCompilerRules,
|
|
2648
|
+
...youMightNotNeedEffectRules,
|
|
2534
2649
|
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
2535
2650
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
2536
2651
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
@@ -2644,6 +2759,7 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2644
2759
|
"react-doctor": "Other",
|
|
2645
2760
|
"jsx-a11y": "Accessibility",
|
|
2646
2761
|
knip: "Dead Code",
|
|
2762
|
+
effect: "State & Effects",
|
|
2647
2763
|
eslint: "Correctness",
|
|
2648
2764
|
oxc: "Correctness",
|
|
2649
2765
|
typescript: "Correctness",
|
|
@@ -3591,7 +3707,15 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
|
|
|
3591
3707
|
};
|
|
3592
3708
|
const scan = async (directory, inputOptions = {}) => {
|
|
3593
3709
|
const startTime = performance.now();
|
|
3594
|
-
|
|
3710
|
+
let scanDirectory = directory;
|
|
3711
|
+
let userConfig;
|
|
3712
|
+
if (inputOptions.configOverride !== void 0) userConfig = inputOptions.configOverride;
|
|
3713
|
+
else {
|
|
3714
|
+
const loadedConfig = loadConfigWithSource(directory);
|
|
3715
|
+
const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
|
|
3716
|
+
if (redirectedDirectory) scanDirectory = redirectedDirectory;
|
|
3717
|
+
userConfig = loadedConfig?.config ?? null;
|
|
3718
|
+
}
|
|
3595
3719
|
const options = mergeScanOptions(inputOptions, userConfig);
|
|
3596
3720
|
const wasLoggerSilent = isLoggerSilent();
|
|
3597
3721
|
const wasSpinnerSilent = isSpinnerSilent();
|
|
@@ -3600,7 +3724,7 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
3600
3724
|
setSpinnerSilent(true);
|
|
3601
3725
|
}
|
|
3602
3726
|
try {
|
|
3603
|
-
return await runScan(
|
|
3727
|
+
return await runScan(scanDirectory, options, userConfig, startTime);
|
|
3604
3728
|
} finally {
|
|
3605
3729
|
if (options.silent) {
|
|
3606
3730
|
setLoggerSilent(wasLoggerSilent);
|
|
@@ -3612,7 +3736,7 @@ const runScan = async (directory, options, userConfig, startTime) => {
|
|
|
3612
3736
|
const projectInfo = discoverProject(directory);
|
|
3613
3737
|
const { includePaths } = options;
|
|
3614
3738
|
const isDiffMode = includePaths.length > 0;
|
|
3615
|
-
if (!projectInfo.reactVersion) throw new
|
|
3739
|
+
if (!projectInfo.reactVersion) throw new NoReactDependencyError(directory);
|
|
3616
3740
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
|
|
3617
3741
|
const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
|
|
3618
3742
|
if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
|
|
@@ -4128,7 +4252,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
|
4128
4252
|
};
|
|
4129
4253
|
//#endregion
|
|
4130
4254
|
//#region src/cli.ts
|
|
4131
|
-
const VERSION = "0.1.
|
|
4255
|
+
const VERSION = "0.1.5";
|
|
4132
4256
|
const VALID_FAIL_ON_LEVELS = new Set([
|
|
4133
4257
|
"error",
|
|
4134
4258
|
"warning",
|
|
@@ -4307,20 +4431,28 @@ const validateModeFlags = (flags) => {
|
|
|
4307
4431
|
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
4432
|
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
4433
|
};
|
|
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").
|
|
4434
|
+
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
4435
|
const isScoreOnly = flags.score;
|
|
4312
4436
|
const isJsonMode = flags.json;
|
|
4313
4437
|
const isQuiet = isScoreOnly || isJsonMode;
|
|
4314
|
-
const
|
|
4438
|
+
const requestedDirectory = path.resolve(directory);
|
|
4315
4439
|
const jsonStartTime = performance.now();
|
|
4316
4440
|
isJsonModeActive = isJsonMode;
|
|
4317
4441
|
isCompactJsonOutput = Boolean(flags.jsonCompact);
|
|
4318
|
-
resolvedDirectoryForCancel =
|
|
4442
|
+
resolvedDirectoryForCancel = requestedDirectory;
|
|
4319
4443
|
cancelStartTime = jsonStartTime;
|
|
4320
4444
|
if (isJsonMode) setLoggerSilent(true);
|
|
4321
4445
|
try {
|
|
4322
4446
|
validateModeFlags(flags);
|
|
4323
|
-
const
|
|
4447
|
+
const loadedConfig = loadConfigWithSource(requestedDirectory);
|
|
4448
|
+
const userConfig = loadedConfig?.config ?? null;
|
|
4449
|
+
const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
|
|
4450
|
+
const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
|
|
4451
|
+
resolvedDirectoryForCancel = resolvedDirectory;
|
|
4452
|
+
if (redirectedDirectory && !isQuiet) {
|
|
4453
|
+
logger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
|
|
4454
|
+
logger.break();
|
|
4455
|
+
}
|
|
4324
4456
|
const explainArgument = flags.explain ?? flags.why;
|
|
4325
4457
|
if (explainArgument !== void 0) {
|
|
4326
4458
|
await runExplain(explainArgument, {
|
|
@@ -4394,7 +4526,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
4394
4526
|
totalElapsedMilliseconds: performance.now() - jsonStartTime
|
|
4395
4527
|
}));
|
|
4396
4528
|
if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
|
|
4397
|
-
if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4529
|
+
if (!isScoreOnly && shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4398
4530
|
} finally {
|
|
4399
4531
|
cleanupSnapshot?.();
|
|
4400
4532
|
}
|
|
@@ -4438,7 +4570,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
4438
4570
|
}
|
|
4439
4571
|
const scanResult = await scan(projectDirectory, {
|
|
4440
4572
|
...scanOptions,
|
|
4441
|
-
includePaths
|
|
4573
|
+
includePaths,
|
|
4574
|
+
configOverride: userConfig
|
|
4442
4575
|
});
|
|
4443
4576
|
allDiagnostics.push(...scanResult.diagnostics);
|
|
4444
4577
|
completedScans.push({
|
|
@@ -4457,13 +4590,13 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
4457
4590
|
totalElapsedMilliseconds: performance.now() - jsonStartTime
|
|
4458
4591
|
}));
|
|
4459
4592
|
if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
|
|
4460
|
-
if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4593
|
+
if (!isScoreOnly && shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
|
|
4461
4594
|
} catch (error) {
|
|
4462
4595
|
try {
|
|
4463
4596
|
if (isJsonMode) {
|
|
4464
4597
|
writeJsonReport(buildJsonReportError({
|
|
4465
4598
|
version: VERSION,
|
|
4466
|
-
directory:
|
|
4599
|
+
directory: resolvedDirectoryForCancel ?? requestedDirectory,
|
|
4467
4600
|
error,
|
|
4468
4601
|
elapsedMilliseconds: performance.now() - jsonStartTime,
|
|
4469
4602
|
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
|
@@ -36,6 +36,50 @@ const IGNORED_DIRECTORIES = new Set([
|
|
|
36
36
|
const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
37
37
|
const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
|
|
38
38
|
//#endregion
|
|
39
|
+
//#region src/errors.ts
|
|
40
|
+
var ReactDoctorError = class extends Error {
|
|
41
|
+
name = "ReactDoctorError";
|
|
42
|
+
constructor(message, options) {
|
|
43
|
+
super(message, options);
|
|
44
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var ProjectNotFoundError = class extends ReactDoctorError {
|
|
48
|
+
name = "ProjectNotFoundError";
|
|
49
|
+
directory;
|
|
50
|
+
constructor(directory, options) {
|
|
51
|
+
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);
|
|
52
|
+
this.directory = directory;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var NoReactDependencyError = class extends ReactDoctorError {
|
|
56
|
+
name = "NoReactDependencyError";
|
|
57
|
+
directory;
|
|
58
|
+
constructor(directory, options) {
|
|
59
|
+
super(buildNoReactDependencyError(directory), options);
|
|
60
|
+
this.directory = directory;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var PackageJsonNotFoundError = class extends ReactDoctorError {
|
|
64
|
+
name = "PackageJsonNotFoundError";
|
|
65
|
+
directory;
|
|
66
|
+
constructor(directory, options) {
|
|
67
|
+
super(`No package.json found in ${directory}`, options);
|
|
68
|
+
this.directory = directory;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
var AmbiguousProjectError = class extends ReactDoctorError {
|
|
72
|
+
name = "AmbiguousProjectError";
|
|
73
|
+
directory;
|
|
74
|
+
candidates;
|
|
75
|
+
constructor(directory, candidates, options) {
|
|
76
|
+
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);
|
|
77
|
+
this.directory = directory;
|
|
78
|
+
this.candidates = candidates;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const isReactDoctorError = (value) => value instanceof ReactDoctorError;
|
|
82
|
+
//#endregion
|
|
39
83
|
//#region src/utils/summarize-diagnostics.ts
|
|
40
84
|
const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
|
|
41
85
|
let errorCount = 0;
|
|
@@ -770,9 +814,9 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
|
|
|
770
814
|
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
|
|
771
815
|
return null;
|
|
772
816
|
};
|
|
773
|
-
const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
817
|
+
const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
|
|
774
818
|
const rawVersion = collectAllDependencies(packageJson)[packageName];
|
|
775
|
-
const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
|
|
819
|
+
const catalogName = explicitCatalogReference ?? (rawVersion ? extractCatalogName(rawVersion) : null);
|
|
776
820
|
if (isPlainObject(packageJson.catalog)) {
|
|
777
821
|
const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
|
|
778
822
|
if (version) return version;
|
|
@@ -789,9 +833,22 @@ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
|
|
|
789
833
|
}
|
|
790
834
|
}
|
|
791
835
|
const workspaces = packageJson.workspaces;
|
|
792
|
-
if (workspaces && !Array.isArray(workspaces)
|
|
793
|
-
|
|
794
|
-
|
|
836
|
+
if (workspaces && !Array.isArray(workspaces)) {
|
|
837
|
+
if (isPlainObject(workspaces.catalog)) {
|
|
838
|
+
const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
|
|
839
|
+
if (version) return version;
|
|
840
|
+
}
|
|
841
|
+
if (isPlainObject(workspaces.catalogs)) {
|
|
842
|
+
const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
|
|
843
|
+
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
844
|
+
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
845
|
+
if (version) return version;
|
|
846
|
+
}
|
|
847
|
+
for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
848
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
849
|
+
if (version) return version;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
795
852
|
}
|
|
796
853
|
if (rootDirectory) {
|
|
797
854
|
const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
|
|
@@ -878,7 +935,8 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
878
935
|
};
|
|
879
936
|
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
880
937
|
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
881
|
-
const
|
|
938
|
+
const leafPackageJsonPath = path.join(directory, "package.json");
|
|
939
|
+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, isFile(leafPackageJsonPath) ? extractCatalogName(collectAllDependencies(readPackageJson(leafPackageJsonPath)).react ?? "") ?? null : null);
|
|
882
940
|
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
883
941
|
return {
|
|
884
942
|
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
@@ -902,6 +960,45 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
|
902
960
|
}
|
|
903
961
|
return result;
|
|
904
962
|
};
|
|
963
|
+
const REACT_DEPENDENCY_NAMES = new Set([
|
|
964
|
+
"react",
|
|
965
|
+
"react-native",
|
|
966
|
+
"next"
|
|
967
|
+
]);
|
|
968
|
+
const hasReactDependency = (packageJson) => {
|
|
969
|
+
const allDependencies = collectAllDependencies(packageJson);
|
|
970
|
+
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
971
|
+
};
|
|
972
|
+
const discoverReactSubprojects = (rootDirectory) => {
|
|
973
|
+
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
974
|
+
const packages = [];
|
|
975
|
+
const rootPackageJsonPath = path.join(rootDirectory, "package.json");
|
|
976
|
+
if (isFile(rootPackageJsonPath)) {
|
|
977
|
+
const rootPackageJson = readPackageJson(rootPackageJsonPath);
|
|
978
|
+
if (hasReactDependency(rootPackageJson)) {
|
|
979
|
+
const name = rootPackageJson.name ?? path.basename(rootDirectory);
|
|
980
|
+
packages.push({
|
|
981
|
+
name,
|
|
982
|
+
directory: rootDirectory
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
987
|
+
for (const entry of entries) {
|
|
988
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
989
|
+
const subdirectory = path.join(rootDirectory, entry.name);
|
|
990
|
+
const packageJsonPath = path.join(subdirectory, "package.json");
|
|
991
|
+
if (!isFile(packageJsonPath)) continue;
|
|
992
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
993
|
+
if (!hasReactDependency(packageJson)) continue;
|
|
994
|
+
const name = packageJson.name ?? entry.name;
|
|
995
|
+
packages.push({
|
|
996
|
+
name,
|
|
997
|
+
directory: subdirectory
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return packages;
|
|
1001
|
+
};
|
|
905
1002
|
const hasCompilerPackage = (packageJson) => {
|
|
906
1003
|
const allDependencies = collectAllDependencies(packageJson);
|
|
907
1004
|
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
@@ -942,15 +1039,16 @@ const discoverProject = (directory) => {
|
|
|
942
1039
|
const cached = cachedProjectInfos.get(directory);
|
|
943
1040
|
if (cached !== void 0) return cached;
|
|
944
1041
|
const packageJsonPath = path.join(directory, "package.json");
|
|
945
|
-
if (!isFile(packageJsonPath)) throw new
|
|
1042
|
+
if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
|
|
946
1043
|
const packageJson = readPackageJson(packageJsonPath);
|
|
947
1044
|
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
948
|
-
|
|
1045
|
+
const leafCatalogReference = extractCatalogName(collectAllDependencies(packageJson).react ?? "") ?? null;
|
|
1046
|
+
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory, leafCatalogReference);
|
|
949
1047
|
if (!reactVersion) {
|
|
950
1048
|
const monorepoRoot = findMonorepoRoot(directory);
|
|
951
1049
|
if (monorepoRoot) {
|
|
952
1050
|
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
953
|
-
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
|
|
1051
|
+
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot, leafCatalogReference);
|
|
954
1052
|
}
|
|
955
1053
|
}
|
|
956
1054
|
if (!reactVersion || framework === "unknown") {
|
|
@@ -996,6 +1094,7 @@ const BOOLEAN_FIELD_NAMES = [
|
|
|
996
1094
|
"respectInlineDisables",
|
|
997
1095
|
"adoptExistingLintConfig"
|
|
998
1096
|
];
|
|
1097
|
+
const STRING_FIELD_NAMES = ["rootDir"];
|
|
999
1098
|
const warnConfigField$1 = (message) => {
|
|
1000
1099
|
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1001
1100
|
};
|
|
@@ -1011,6 +1110,10 @@ const coerceMaybeBooleanString = (fieldName, value) => {
|
|
|
1011
1110
|
}
|
|
1012
1111
|
warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1013
1112
|
};
|
|
1113
|
+
const validateString = (fieldName, value) => {
|
|
1114
|
+
if (typeof value === "string") return value;
|
|
1115
|
+
warnConfigField$1(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1116
|
+
};
|
|
1014
1117
|
const validateConfigTypes = (config) => {
|
|
1015
1118
|
const validated = { ...config };
|
|
1016
1119
|
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
@@ -1020,6 +1123,13 @@ const validateConfigTypes = (config) => {
|
|
|
1020
1123
|
if (coerced === void 0) delete validated[fieldName];
|
|
1021
1124
|
else validated[fieldName] = coerced;
|
|
1022
1125
|
}
|
|
1126
|
+
for (const fieldName of STRING_FIELD_NAMES) {
|
|
1127
|
+
const original = config[fieldName];
|
|
1128
|
+
if (original === void 0) continue;
|
|
1129
|
+
const validatedString = validateString(fieldName, original);
|
|
1130
|
+
if (validatedString === void 0) delete validated[fieldName];
|
|
1131
|
+
else validated[fieldName] = validatedString;
|
|
1132
|
+
}
|
|
1023
1133
|
return validated;
|
|
1024
1134
|
};
|
|
1025
1135
|
//#endregion
|
|
@@ -1031,7 +1141,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1031
1141
|
if (isFile(configFilePath)) try {
|
|
1032
1142
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
1033
1143
|
const parsed = JSON.parse(fileContent);
|
|
1034
|
-
if (isPlainObject(parsed)) return
|
|
1144
|
+
if (isPlainObject(parsed)) return {
|
|
1145
|
+
config: validateConfigTypes(parsed),
|
|
1146
|
+
sourceDirectory: directory
|
|
1147
|
+
};
|
|
1035
1148
|
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
1036
1149
|
} catch (error) {
|
|
1037
1150
|
logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1042,7 +1155,10 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1042
1155
|
const packageJson = JSON.parse(fileContent);
|
|
1043
1156
|
if (isPlainObject(packageJson)) {
|
|
1044
1157
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
1045
|
-
if (isPlainObject(embeddedConfig)) return
|
|
1158
|
+
if (isPlainObject(embeddedConfig)) return {
|
|
1159
|
+
config: validateConfigTypes(embeddedConfig),
|
|
1160
|
+
sourceDirectory: directory
|
|
1161
|
+
};
|
|
1046
1162
|
}
|
|
1047
1163
|
} catch {
|
|
1048
1164
|
return null;
|
|
@@ -1054,7 +1170,7 @@ const cachedConfigs = /* @__PURE__ */ new Map();
|
|
|
1054
1170
|
const clearConfigCache = () => {
|
|
1055
1171
|
cachedConfigs.clear();
|
|
1056
1172
|
};
|
|
1057
|
-
const
|
|
1173
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
1058
1174
|
const cached = cachedConfigs.get(rootDirectory);
|
|
1059
1175
|
if (cached !== void 0) return cached;
|
|
1060
1176
|
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
@@ -1261,7 +1377,7 @@ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
|
1261
1377
|
};
|
|
1262
1378
|
//#endregion
|
|
1263
1379
|
//#region src/utils/find-stacked-disable-comments.ts
|
|
1264
|
-
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\
|
|
1380
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1265
1381
|
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
1266
1382
|
const collected = [];
|
|
1267
1383
|
let isStillInChain = true;
|
|
@@ -1283,13 +1399,21 @@ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
|
1283
1399
|
};
|
|
1284
1400
|
//#endregion
|
|
1285
1401
|
//#region src/utils/is-rule-listed-in-comment.ts
|
|
1402
|
+
const stripDescriptionTail = (ruleList) => {
|
|
1403
|
+
const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
|
|
1404
|
+
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
1405
|
+
return ruleList.slice(0, descriptionMatch.index);
|
|
1406
|
+
};
|
|
1286
1407
|
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
1287
|
-
|
|
1288
|
-
|
|
1408
|
+
const trimmed = ruleList?.trim();
|
|
1409
|
+
if (!trimmed) return true;
|
|
1410
|
+
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
1411
|
+
if (!ruleSection) return true;
|
|
1412
|
+
return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
1289
1413
|
};
|
|
1290
1414
|
//#endregion
|
|
1291
1415
|
//#region src/utils/evaluate-suppression.ts
|
|
1292
|
-
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\
|
|
1416
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1293
1417
|
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
1294
1418
|
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1295
1419
|
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
@@ -1514,6 +1638,31 @@ const createNodeReadFileLinesSync = (rootDirectory) => {
|
|
|
1514
1638
|
};
|
|
1515
1639
|
};
|
|
1516
1640
|
//#endregion
|
|
1641
|
+
//#region src/utils/resolve-config-root-dir.ts
|
|
1642
|
+
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
1643
|
+
if (!config || !configSourceDirectory) return null;
|
|
1644
|
+
const rawRootDir = config.rootDir;
|
|
1645
|
+
if (typeof rawRootDir !== "string") return null;
|
|
1646
|
+
const trimmedRootDir = rawRootDir.trim();
|
|
1647
|
+
if (trimmedRootDir.length === 0) return null;
|
|
1648
|
+
const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
|
|
1649
|
+
if (resolvedRootDir === configSourceDirectory) return null;
|
|
1650
|
+
if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
|
|
1651
|
+
logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
return resolvedRootDir;
|
|
1655
|
+
};
|
|
1656
|
+
//#endregion
|
|
1657
|
+
//#region src/utils/resolve-diagnose-target.ts
|
|
1658
|
+
const resolveDiagnoseTarget = (directory) => {
|
|
1659
|
+
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
1660
|
+
const reactSubprojects = discoverReactSubprojects(directory);
|
|
1661
|
+
if (reactSubprojects.length === 0) return null;
|
|
1662
|
+
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
1663
|
+
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)));
|
|
1664
|
+
};
|
|
1665
|
+
//#endregion
|
|
1517
1666
|
//#region src/utils/resolve-lint-include-paths.ts
|
|
1518
1667
|
const listSourceFilesViaGit = (rootDirectory) => {
|
|
1519
1668
|
const result = spawnSync("git", [
|
|
@@ -1923,6 +2072,16 @@ const TANSTACK_START_RULES = {
|
|
|
1923
2072
|
"react-doctor/tanstack-start-redirect-in-try-catch": "warn",
|
|
1924
2073
|
"react-doctor/tanstack-start-loader-parallel-fetch": "warn"
|
|
1925
2074
|
};
|
|
2075
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
|
|
2076
|
+
"effect/no-derived-state": "warn",
|
|
2077
|
+
"effect/no-chain-state-updates": "warn",
|
|
2078
|
+
"effect/no-event-handler": "warn",
|
|
2079
|
+
"effect/no-adjust-state-on-prop-change": "warn",
|
|
2080
|
+
"effect/no-reset-all-state-on-prop-change": "warn",
|
|
2081
|
+
"effect/no-pass-live-state-to-parent": "warn",
|
|
2082
|
+
"effect/no-pass-data-to-parent": "warn",
|
|
2083
|
+
"effect/no-initialize-state": "warn"
|
|
2084
|
+
};
|
|
1926
2085
|
const REACT_COMPILER_RULES = {
|
|
1927
2086
|
"react-hooks-js/set-state-in-render": "error",
|
|
1928
2087
|
"react-hooks-js/immutability": "error",
|
|
@@ -1967,6 +2126,23 @@ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
|
1967
2126
|
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
1968
2127
|
};
|
|
1969
2128
|
};
|
|
2129
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
|
|
2130
|
+
const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
|
|
2131
|
+
if (customRulesOnly) return null;
|
|
2132
|
+
let pluginSpecifier;
|
|
2133
|
+
try {
|
|
2134
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
|
|
2135
|
+
} catch {
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
return {
|
|
2139
|
+
entry: {
|
|
2140
|
+
name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
|
|
2141
|
+
specifier: pluginSpecifier
|
|
2142
|
+
},
|
|
2143
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2144
|
+
};
|
|
2145
|
+
};
|
|
1970
2146
|
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
1971
2147
|
if (availableRuleNames.size === 0) return rules;
|
|
1972
2148
|
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
@@ -2177,6 +2353,11 @@ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
|
2177
2353
|
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
2178
2354
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
2179
2355
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2356
|
+
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
2357
|
+
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
|
|
2358
|
+
const jsPlugins = [];
|
|
2359
|
+
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
|
|
2360
|
+
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
|
|
2180
2361
|
return {
|
|
2181
2362
|
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
2182
2363
|
categories: {
|
|
@@ -2189,11 +2370,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
2189
2370
|
nursery: "off"
|
|
2190
2371
|
},
|
|
2191
2372
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2192
|
-
jsPlugins:
|
|
2373
|
+
jsPlugins: [...jsPlugins, pluginPath],
|
|
2193
2374
|
rules: {
|
|
2194
2375
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2195
2376
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
2196
2377
|
...reactCompilerRules,
|
|
2378
|
+
...youMightNotNeedEffectRules,
|
|
2197
2379
|
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
2198
2380
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
2199
2381
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
@@ -2307,6 +2489,7 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2307
2489
|
"react-doctor": "Other",
|
|
2308
2490
|
"jsx-a11y": "Accessibility",
|
|
2309
2491
|
knip: "Dead Code",
|
|
2492
|
+
effect: "State & Effects",
|
|
2310
2493
|
eslint: "Correctness",
|
|
2311
2494
|
oxc: "Correctness",
|
|
2312
2495
|
typescript: "Correctness",
|
|
@@ -3078,12 +3261,16 @@ const settledOrEmpty = (settled, label) => {
|
|
|
3078
3261
|
};
|
|
3079
3262
|
const diagnose = async (directory, options = {}) => {
|
|
3080
3263
|
const startTime = globalThis.performance.now();
|
|
3081
|
-
const
|
|
3082
|
-
const
|
|
3264
|
+
const requestedDirectory = path.resolve(directory);
|
|
3265
|
+
const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
|
|
3266
|
+
const directoryAfterRedirect = resolveConfigRootDir(initialLoadedConfig?.config ?? null, initialLoadedConfig?.sourceDirectory ?? null) ?? requestedDirectory;
|
|
3267
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
|
|
3268
|
+
if (!resolvedDirectory) throw new ProjectNotFoundError(directoryAfterRedirect);
|
|
3269
|
+
const userConfig = initialLoadedConfig?.config ?? loadConfigWithSource(resolvedDirectory)?.config ?? null;
|
|
3083
3270
|
const includePaths = options.includePaths ?? [];
|
|
3084
3271
|
const isDiffMode = includePaths.length > 0;
|
|
3085
3272
|
const projectInfo = discoverProject(resolvedDirectory);
|
|
3086
|
-
if (!projectInfo.reactVersion) throw new
|
|
3273
|
+
if (!projectInfo.reactVersion) throw new NoReactDependencyError(resolvedDirectory);
|
|
3087
3274
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
3088
3275
|
const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
|
|
3089
3276
|
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
@@ -3126,6 +3313,6 @@ const diagnose = async (directory, options = {}) => {
|
|
|
3126
3313
|
};
|
|
3127
3314
|
};
|
|
3128
3315
|
//#endregion
|
|
3129
|
-
export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };
|
|
3316
|
+
export { AmbiguousProjectError, NoReactDependencyError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
3130
3317
|
|
|
3131
3318
|
//# 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.5",
|
|
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": {
|