react-doctor 0.2.0-beta.4 → 0.2.0-beta.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 +46 -0
- package/dist/cli.js +241 -23
- package/dist/index.d.ts +94 -0
- package/dist/index.js +172 -13
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -113,6 +113,25 @@ React Doctor respects `.gitignore`, `.eslintignore`, `.oxlintignore`, `.prettier
|
|
|
113
113
|
|
|
114
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.
|
|
115
115
|
|
|
116
|
+
#### Surface controls (CLI, PR comments, score, CI failure)
|
|
117
|
+
|
|
118
|
+
Diagnostics flow through four independent surfaces — `cli`, `prComment`, `score`, and `ciFailure` — and each one can be tuned per tag, category, or rule id. By default the `design` tag (Tailwind shorthand cleanup like `w-5 h-5 → size-5`, pure-black backgrounds, gradient text, …) stays visible on the local CLI but is excluded from the PR comment, the score, and the `--fail-on` gate so style cleanup can't dilute meaningful React findings:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"surfaces": {
|
|
123
|
+
"prComment": {
|
|
124
|
+
"includeTags": ["design"],
|
|
125
|
+
"excludeCategories": ["Performance"]
|
|
126
|
+
},
|
|
127
|
+
"score": { "includeRules": ["react-doctor/design-no-redundant-size-axes"] },
|
|
128
|
+
"ciFailure": { "excludeTags": ["test-noise"] }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Each surface accepts `includeTags`, `excludeTags`, `includeCategories`, `excludeCategories`, `includeRules`, and `excludeRules`. Include wins over exclude when both match. Run the CLI with `--pr-comment` (the GitHub Action passes it automatically when `github-token` is set) to apply the `prComment` surface to the printed output destined for sticky PR comments.
|
|
134
|
+
|
|
116
135
|
#### Optional companion plugins
|
|
117
136
|
|
|
118
137
|
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.
|
|
@@ -210,6 +229,8 @@ Options:
|
|
|
210
229
|
--offline skip the score API and share URL (no score shown)
|
|
211
230
|
--fail-on <level> exit with error on diagnostics: error, warning, none
|
|
212
231
|
--annotations output diagnostics as GitHub Actions annotations
|
|
232
|
+
--pr-comment tune CLI output for sticky PR comments (drops design
|
|
233
|
+
cleanup from the printed list and fail-on gate)
|
|
213
234
|
--explain <file:line> diagnose why a rule fired or why a suppression didn't apply
|
|
214
235
|
--why <file:line> alias for --explain
|
|
215
236
|
-h, --help display help
|
|
@@ -235,6 +256,7 @@ When a suppression isn't working, `--explain <file:line>` (or its alias `--why <
|
|
|
235
256
|
| `offline` | `boolean` | `false` |
|
|
236
257
|
| `textComponents` | `string[]` | `[]` |
|
|
237
258
|
| `rawTextWrapperComponents` | `string[]` | `[]` |
|
|
259
|
+
| `serverAuthFunctionNames` | `string[]` | `[]` |
|
|
238
260
|
| `respectInlineDisables` | `boolean` | `true` |
|
|
239
261
|
| `adoptExistingLintConfig` | `boolean` | `true` |
|
|
240
262
|
| `ignore.tags` | `string[]` | `[]` |
|
|
@@ -243,10 +265,34 @@ When a suppression isn't working, `--explain <file:line>` (or its alias `--why <
|
|
|
243
265
|
|
|
244
266
|
`rawTextWrapperComponents` is the narrower option for components that are not text elements but safely route string-only children through an internal `<Text>` (e.g. `heroui-native`'s `Button`, which stringifies its children and renders them through a `ButtonLabel`). Listed wrappers suppress `rn-no-raw-text` only when their children are entirely stringifiable. A wrapper with mixed children — e.g. `<Button>Save<Icon /></Button>` — still reports because the wrapper can't safely route raw text alongside a sibling JSX element.
|
|
245
267
|
|
|
268
|
+
`serverAuthFunctionNames` teaches `server-auth-actions` about custom auth guards your codebase wraps around its auth library (e.g. `requireWorkspaceMember`, `ensureSignedIn`). Listed names are accepted as a valid top-of-action auth check whether called bare (`requireWorkspaceMember()`) or as a member (`guards.requireWorkspaceMember()`), and — unlike the built-in default list — are treated as distinctive so the receiver is not re-validated.
|
|
269
|
+
|
|
246
270
|
`ignore.tags` suppresses entire categories of rules by tag. For example, `"tags": ["design"]` disables all opinionated design rules (gradient text, pure black backgrounds, side tab borders, default Tailwind palettes). Available tags: `"design"`.
|
|
247
271
|
|
|
248
272
|
`offline` skips the score API entirely — no score is shown and no share URL is generated. Automatically enabled in CI environments (GitHub Actions, GitLab CI, CircleCI) so CI runs don't depend on the network.
|
|
249
273
|
|
|
274
|
+
### React Native rules in mixed monorepos
|
|
275
|
+
|
|
276
|
+
`rn-*` rules respect per-package boundaries automatically. In a mixed React Native + web monorepo (`apps/mobile` alongside `apps/web` / `apps/vite-app` / `packages/storybook` / `apps/docs`), every `rn-*` rule walks up to the file's nearest `package.json` before running:
|
|
277
|
+
|
|
278
|
+
- Packages that declare `react-native`, `react-native-tvos`, `expo`, `expo-router`, `@expo/*`, `react-native-windows`, `react-native-macos`, anything under the `@react-native/` or `@react-native-` namespaces (`@react-native-firebase/app`, `@react-native-async-storage/async-storage`, …), or Metro's top-level `"react-native"` resolution field → rules ON.
|
|
279
|
+
- Packages that declare a web-only framework (`next`, `vite`, `react-scripts`, `gatsby`, `@remix-run/*`, `@docusaurus/*`, `@storybook/*`, or plain `react-dom` without an RN sibling) → rules OFF.
|
|
280
|
+
- Packages with no clear local signal → fall back to the project-level framework detection.
|
|
281
|
+
|
|
282
|
+
File extensions override the package classification when they're unambiguous: `*.web.tsx` / `*.web.jsx` are always skipped (Metro resolves these only against `react-native-web`); `*.ios.tsx` / `*.android.tsx` / `*.native.tsx` are always scanned (mobile-only).
|
|
283
|
+
|
|
284
|
+
The detection is bidirectional: a web-rooted monorepo (root `package.json` declares `next` or `vite`) still loads the `rn-*` rules when any workspace targets React Native — the file-level boundary then keeps them silent on the web workspaces and active on the mobile ones.
|
|
285
|
+
|
|
286
|
+
`rn-no-raw-text` additionally short-circuits raw text inside platform-fork branches:
|
|
287
|
+
|
|
288
|
+
- `if (Platform.OS === "web") { … }` consequent — and the `else` branch of `if (Platform.OS !== "web")`.
|
|
289
|
+
- `Platform.OS === "web" ? <X /> : …` ternaries, `Platform.OS === "web" && <X />` short-circuits, and the reversed-operand form `"web" === Platform.OS`.
|
|
290
|
+
- `switch (Platform.OS) { case "web": … }` case bodies (other cases still report).
|
|
291
|
+
- `Platform.select({ web: <X />, default: <Y /> })` — only the `web` arm is exempt.
|
|
292
|
+
- `Platform?.OS === "web"` (optional chain) and `Platform.OS! === "web"` (TS non-null assertion) parse the same way as the bare form.
|
|
293
|
+
|
|
294
|
+
The walker stops at function and `Program` boundaries — JSX defined inside a callback hoisted out of a `Platform.OS` branch does not inherit the parent guard. Negative platform checks like `Platform.OS === "ios"` are deliberately NOT treated as web exemptions; only the explicit web branch is.
|
|
295
|
+
|
|
250
296
|
## Scoring
|
|
251
297
|
|
|
252
298
|
The health score formula: `100 - (unique_error_rules x 1.5) - (unique_warning_rules x 0.75)`.
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,25 @@ import { randomUUID } from "node:crypto";
|
|
|
12
12
|
import basePrompts from "prompts";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
|
|
15
|
+
//#region ../types/dist/index.js
|
|
16
|
+
const REACT_NATIVE_DEPENDENCY_NAMES = new Set([
|
|
17
|
+
"react-native",
|
|
18
|
+
"react-native-tvos",
|
|
19
|
+
"expo",
|
|
20
|
+
"expo-router",
|
|
21
|
+
"@expo/cli",
|
|
22
|
+
"@expo/metro-config",
|
|
23
|
+
"@expo/metro-runtime",
|
|
24
|
+
"react-native-windows",
|
|
25
|
+
"react-native-macos"
|
|
26
|
+
]);
|
|
27
|
+
const REACT_NATIVE_DEPENDENCY_PREFIXES = ["@react-native/", "@react-native-"];
|
|
28
|
+
const isReactNativeDependencyName = (dependencyName) => {
|
|
29
|
+
if (REACT_NATIVE_DEPENDENCY_NAMES.has(dependencyName)) return true;
|
|
30
|
+
for (const prefix of REACT_NATIVE_DEPENDENCY_PREFIXES) if (dependencyName.startsWith(prefix)) return true;
|
|
31
|
+
return false;
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
15
34
|
//#region ../project-info/dist/index.js
|
|
16
35
|
var ReactDoctorError = class extends Error {
|
|
17
36
|
name = "ReactDoctorError";
|
|
@@ -648,6 +667,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
648
667
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
649
668
|
};
|
|
650
669
|
};
|
|
670
|
+
const containsAnyReactNativeDependency = (section) => {
|
|
671
|
+
if (!section) return false;
|
|
672
|
+
for (const dependencyName of Object.keys(section)) if (isReactNativeDependencyName(dependencyName)) return true;
|
|
673
|
+
return false;
|
|
674
|
+
};
|
|
675
|
+
const isPackageJsonReactNativeAware = (packageJson) => {
|
|
676
|
+
if (typeof packageJson["react-native"] === "string") return true;
|
|
677
|
+
if (containsAnyReactNativeDependency(packageJson.dependencies)) return true;
|
|
678
|
+
if (containsAnyReactNativeDependency(packageJson.devDependencies)) return true;
|
|
679
|
+
if (containsAnyReactNativeDependency(packageJson.peerDependencies)) return true;
|
|
680
|
+
if (containsAnyReactNativeDependency(packageJson.optionalDependencies)) return true;
|
|
681
|
+
return false;
|
|
682
|
+
};
|
|
683
|
+
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => {
|
|
684
|
+
if (isPackageJsonReactNativeAware(rootPackageJson)) return true;
|
|
685
|
+
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
686
|
+
if (patterns.length === 0) return false;
|
|
687
|
+
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
688
|
+
for (const pattern of patterns) {
|
|
689
|
+
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
690
|
+
for (const workspaceDirectory of directories) {
|
|
691
|
+
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
692
|
+
visitedDirectories.add(workspaceDirectory);
|
|
693
|
+
if (isPackageJsonReactNativeAware(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return false;
|
|
697
|
+
};
|
|
651
698
|
const TANSTACK_QUERY_PACKAGES = new Set([
|
|
652
699
|
"@tanstack/react-query",
|
|
653
700
|
"@tanstack/query-core",
|
|
@@ -820,6 +867,7 @@ const discoverProject = (directory) => {
|
|
|
820
867
|
const projectName = packageJson.name ?? path.basename(directory);
|
|
821
868
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
822
869
|
const sourceFileCount = countSourceFiles(directory);
|
|
870
|
+
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
823
871
|
const projectInfo = {
|
|
824
872
|
rootDirectory: directory,
|
|
825
873
|
projectName,
|
|
@@ -830,6 +878,7 @@ const discoverProject = (directory) => {
|
|
|
830
878
|
hasTypeScript,
|
|
831
879
|
hasReactCompiler: detectReactCompiler(directory, packageJson),
|
|
832
880
|
hasTanStackQuery: hasTanStackQuery(packageJson),
|
|
881
|
+
hasReactNativeWorkspace,
|
|
833
882
|
sourceFileCount
|
|
834
883
|
};
|
|
835
884
|
cachedProjectInfos.set(directory, projectInfo);
|
|
@@ -1850,6 +1899,71 @@ const detectUserLintConfigPaths = (rootDirectory) => {
|
|
|
1850
1899
|
}
|
|
1851
1900
|
return [];
|
|
1852
1901
|
};
|
|
1902
|
+
const DIAGNOSTIC_SURFACES = [
|
|
1903
|
+
"cli",
|
|
1904
|
+
"prComment",
|
|
1905
|
+
"score",
|
|
1906
|
+
"ciFailure"
|
|
1907
|
+
];
|
|
1908
|
+
const isDiagnosticSurface = (value) => typeof value === "string" && DIAGNOSTIC_SURFACES.includes(value);
|
|
1909
|
+
/**
|
|
1910
|
+
* Built-in surface exclusions applied before any user config.
|
|
1911
|
+
*
|
|
1912
|
+
* `design`-tagged rules are weak-signal style cleanup — they still ship
|
|
1913
|
+
* to the local CLI so developers see them while editing, but they're
|
|
1914
|
+
* removed from the PR comment surface, the score, and the CI gate so
|
|
1915
|
+
* they can't bury real React findings or fail a build over a Tailwind
|
|
1916
|
+
* shorthand. Override per-surface via `config.surfaces.<surface>` to
|
|
1917
|
+
* promote individual rules back in by tag, category, or rule id.
|
|
1918
|
+
*/
|
|
1919
|
+
const DEFAULT_SURFACE_EXCLUDED_TAGS = {
|
|
1920
|
+
cli: [],
|
|
1921
|
+
prComment: ["design"],
|
|
1922
|
+
score: ["design"],
|
|
1923
|
+
ciFailure: ["design"]
|
|
1924
|
+
};
|
|
1925
|
+
const toStringSet = (values) => {
|
|
1926
|
+
if (!values || values.length === 0) return /* @__PURE__ */ new Set();
|
|
1927
|
+
const collected = /* @__PURE__ */ new Set();
|
|
1928
|
+
for (const value of values) if (typeof value === "string" && value.length > 0) collected.add(value);
|
|
1929
|
+
return collected;
|
|
1930
|
+
};
|
|
1931
|
+
const buildResolvedControls = (surface, userControls) => {
|
|
1932
|
+
const baseExcludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
|
|
1933
|
+
const userIncludeTags = toStringSet(userControls?.includeTags);
|
|
1934
|
+
for (const includedTag of userIncludeTags) baseExcludeTags.delete(includedTag);
|
|
1935
|
+
const userExcludeTags = toStringSet(userControls?.excludeTags);
|
|
1936
|
+
for (const excludedTag of userExcludeTags) baseExcludeTags.add(excludedTag);
|
|
1937
|
+
return {
|
|
1938
|
+
includeTags: userIncludeTags,
|
|
1939
|
+
excludeTags: baseExcludeTags,
|
|
1940
|
+
includeCategories: toStringSet(userControls?.includeCategories),
|
|
1941
|
+
excludeCategories: toStringSet(userControls?.excludeCategories),
|
|
1942
|
+
includeRuleKeys: toStringSet(userControls?.includeRules),
|
|
1943
|
+
excludeRuleKeys: toStringSet(userControls?.excludeRules)
|
|
1944
|
+
};
|
|
1945
|
+
};
|
|
1946
|
+
const getRuleTags = (diagnostic) => {
|
|
1947
|
+
if (diagnostic.plugin !== "react-doctor") return [];
|
|
1948
|
+
return reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [];
|
|
1949
|
+
};
|
|
1950
|
+
const intersectsAny = (values, candidateSet) => {
|
|
1951
|
+
for (const value of values) if (candidateSet.has(value)) return true;
|
|
1952
|
+
return false;
|
|
1953
|
+
};
|
|
1954
|
+
const isDiagnosticOnSurface = (diagnostic, surface, config) => {
|
|
1955
|
+
const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
|
|
1956
|
+
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1957
|
+
const tags = getRuleTags(diagnostic);
|
|
1958
|
+
if (resolved.includeRuleKeys.has(ruleKey)) return true;
|
|
1959
|
+
if (resolved.includeCategories.has(diagnostic.category)) return true;
|
|
1960
|
+
if (intersectsAny(tags, resolved.includeTags)) return true;
|
|
1961
|
+
if (resolved.excludeRuleKeys.has(ruleKey)) return false;
|
|
1962
|
+
if (resolved.excludeCategories.has(diagnostic.category)) return false;
|
|
1963
|
+
if (intersectsAny(tags, resolved.excludeTags)) return false;
|
|
1964
|
+
return true;
|
|
1965
|
+
};
|
|
1966
|
+
const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
|
|
1853
1967
|
const runGit = (cwd, args) => {
|
|
1854
1968
|
const result = spawnSync("git", args, {
|
|
1855
1969
|
cwd,
|
|
@@ -1996,6 +2110,61 @@ const validateString = (fieldName, value) => {
|
|
|
1996
2110
|
if (typeof value === "string") return value;
|
|
1997
2111
|
warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1998
2112
|
};
|
|
2113
|
+
const SURFACE_CONTROL_FIELD_NAMES = [
|
|
2114
|
+
"includeTags",
|
|
2115
|
+
"excludeTags",
|
|
2116
|
+
"includeCategories",
|
|
2117
|
+
"excludeCategories",
|
|
2118
|
+
"includeRules",
|
|
2119
|
+
"excludeRules"
|
|
2120
|
+
];
|
|
2121
|
+
const validateStringArrayField = (fieldName, value) => {
|
|
2122
|
+
if (value === void 0) return void 0;
|
|
2123
|
+
if (!Array.isArray(value)) {
|
|
2124
|
+
warnConfigField(`config field "${fieldName}" must be an array of strings (got ${typeof value}); ignoring this field.`);
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const collected = [];
|
|
2128
|
+
for (const entry of value) {
|
|
2129
|
+
if (typeof entry !== "string") {
|
|
2130
|
+
warnConfigField(`config field "${fieldName}" contains a non-string entry (${typeof entry}); ignoring the entry.`);
|
|
2131
|
+
continue;
|
|
2132
|
+
}
|
|
2133
|
+
collected.push(entry);
|
|
2134
|
+
}
|
|
2135
|
+
return collected;
|
|
2136
|
+
};
|
|
2137
|
+
const validateSurfaceControls = (surface, rawControls) => {
|
|
2138
|
+
if (rawControls === void 0) return void 0;
|
|
2139
|
+
if (typeof rawControls !== "object" || rawControls === null || Array.isArray(rawControls)) {
|
|
2140
|
+
warnConfigField(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const validated = {};
|
|
2144
|
+
for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
|
|
2145
|
+
const value = rawControls[fieldName];
|
|
2146
|
+
const validatedValue = validateStringArrayField(`surfaces.${surface}.${fieldName}`, value);
|
|
2147
|
+
if (validatedValue !== void 0) validated[fieldName] = validatedValue;
|
|
2148
|
+
}
|
|
2149
|
+
return validated;
|
|
2150
|
+
};
|
|
2151
|
+
const validateSurfacesField = (rawSurfaces) => {
|
|
2152
|
+
if (rawSurfaces === void 0) return void 0;
|
|
2153
|
+
if (typeof rawSurfaces !== "object" || rawSurfaces === null || Array.isArray(rawSurfaces)) {
|
|
2154
|
+
warnConfigField(`config field "surfaces" must be an object (got ${typeof rawSurfaces}); ignoring this field.`);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const validated = {};
|
|
2158
|
+
for (const [key, value] of Object.entries(rawSurfaces)) {
|
|
2159
|
+
if (!isDiagnosticSurface(key)) {
|
|
2160
|
+
warnConfigField(`config field "surfaces.${key}" is not a known surface (expected one of: ${DIAGNOSTIC_SURFACES.join(", ")}); ignoring.`);
|
|
2161
|
+
continue;
|
|
2162
|
+
}
|
|
2163
|
+
const controls = validateSurfaceControls(key, value);
|
|
2164
|
+
if (controls !== void 0) validated[key] = controls;
|
|
2165
|
+
}
|
|
2166
|
+
return validated;
|
|
2167
|
+
};
|
|
1999
2168
|
const validateConfigTypes = (config) => {
|
|
2000
2169
|
const validated = { ...config };
|
|
2001
2170
|
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
@@ -2012,6 +2181,11 @@ const validateConfigTypes = (config) => {
|
|
|
2012
2181
|
if (validatedString === void 0) delete validated[fieldName];
|
|
2013
2182
|
else validated[fieldName] = validatedString;
|
|
2014
2183
|
}
|
|
2184
|
+
if (config.surfaces !== void 0) {
|
|
2185
|
+
const validatedSurfaces = validateSurfacesField(config.surfaces);
|
|
2186
|
+
if (validatedSurfaces === void 0) delete validated.surfaces;
|
|
2187
|
+
else validated.surfaces = validatedSurfaces;
|
|
2188
|
+
}
|
|
2015
2189
|
return validated;
|
|
2016
2190
|
};
|
|
2017
2191
|
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
@@ -2313,7 +2487,7 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
2313
2487
|
const buildCapabilities = (project) => {
|
|
2314
2488
|
const capabilities = /* @__PURE__ */ new Set();
|
|
2315
2489
|
capabilities.add(project.framework);
|
|
2316
|
-
if (project.framework === "expo" || project.framework === "react-native") capabilities.add("react-native");
|
|
2490
|
+
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
2317
2491
|
const reactMajor = project.reactMajorVersion;
|
|
2318
2492
|
if (reactMajor !== null) for (let major = 17; major <= reactMajor; major++) capabilities.add(`react:${major}`);
|
|
2319
2493
|
if (project.tailwindVersion !== null) {
|
|
@@ -2453,7 +2627,11 @@ const BUILTIN_A11Y_RULES = {
|
|
|
2453
2627
|
"jsx-a11y/no-distracting-elements": "error",
|
|
2454
2628
|
"jsx-a11y/iframe-has-title": "warn"
|
|
2455
2629
|
};
|
|
2456
|
-
const
|
|
2630
|
+
const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
2631
|
+
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
2632
|
+
return fs.realpathSync(rootDirectory);
|
|
2633
|
+
};
|
|
2634
|
+
const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames }) => {
|
|
2457
2635
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
|
|
2458
2636
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2459
2637
|
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
@@ -2482,6 +2660,11 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
2482
2660
|
},
|
|
2483
2661
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2484
2662
|
jsPlugins: [...jsPlugins, pluginPath],
|
|
2663
|
+
settings: { "react-doctor": {
|
|
2664
|
+
framework: project.framework,
|
|
2665
|
+
rootDirectory: resolveSettingsRootDirectory(project.rootDirectory),
|
|
2666
|
+
...serverAuthFunctionNames && serverAuthFunctionNames.length > 0 ? { serverAuthFunctionNames: [...serverAuthFunctionNames] } : {}
|
|
2667
|
+
} },
|
|
2485
2668
|
rules: {
|
|
2486
2669
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2487
2670
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
@@ -2784,7 +2967,25 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
|
|
|
2784
2967
|
const bindingResolution = resolveUseCallBinding(sourceText, absolutePath, primaryLabel.span.offset);
|
|
2785
2968
|
return bindingResolution !== null && !bindingResolution.isReactUseBinding;
|
|
2786
2969
|
};
|
|
2787
|
-
const
|
|
2970
|
+
const getPublicEnvPrefix = (framework) => {
|
|
2971
|
+
switch (framework) {
|
|
2972
|
+
case "nextjs": return "NEXT_PUBLIC_*";
|
|
2973
|
+
case "vite":
|
|
2974
|
+
case "tanstack-start": return "VITE_*";
|
|
2975
|
+
case "cra": return "REACT_APP_*";
|
|
2976
|
+
case "gatsby": return "GATSBY_*";
|
|
2977
|
+
default: return null;
|
|
2978
|
+
}
|
|
2979
|
+
};
|
|
2980
|
+
const buildNoSecretsRecommendation = (project, fallbackRecommendation) => {
|
|
2981
|
+
const publicEnvPrefix = getPublicEnvPrefix(project.framework);
|
|
2982
|
+
if (!publicEnvPrefix) return fallbackRecommendation;
|
|
2983
|
+
return `Move secrets to server-only code. In ${formatFrameworkName(project.framework)}, only \`${publicEnvPrefix}\` env vars are exposed to the browser, and they must not contain secrets`;
|
|
2984
|
+
};
|
|
2985
|
+
const getRuleRecommendation = (ruleName, project) => {
|
|
2986
|
+
if (ruleName === "no-secrets-in-client-code") return buildNoSecretsRecommendation(project, reactDoctorPlugin.rules["no-secrets-in-client-code"]?.recommendation ?? "Move secrets to server-only code");
|
|
2987
|
+
return reactDoctorPlugin.rules[ruleName]?.recommendation;
|
|
2988
|
+
};
|
|
2788
2989
|
const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
|
|
2789
2990
|
const esmRequire = createRequire(import.meta.url);
|
|
2790
2991
|
const PLUGIN_CATEGORY_MAP = {
|
|
@@ -2809,14 +3010,14 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2809
3010
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2810
3011
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
2811
3012
|
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
2812
|
-
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
3013
|
+
const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
|
|
2813
3014
|
if (plugin === "react-hooks-js") return {
|
|
2814
3015
|
message: REACT_COMPILER_MESSAGE,
|
|
2815
3016
|
help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
|
|
2816
3017
|
};
|
|
2817
3018
|
return {
|
|
2818
3019
|
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2819
|
-
help: help || getRuleRecommendation(rule) || ""
|
|
3020
|
+
help: help || getRuleRecommendation(rule, project) || ""
|
|
2820
3021
|
};
|
|
2821
3022
|
};
|
|
2822
3023
|
const parseRuleCode = (code) => {
|
|
@@ -2919,7 +3120,7 @@ const isOxlintOutput = (value) => {
|
|
|
2919
3120
|
const candidate = value;
|
|
2920
3121
|
return Array.isArray(candidate.diagnostics);
|
|
2921
3122
|
};
|
|
2922
|
-
const parseOxlintOutput = (stdout, rootDirectory) => {
|
|
3123
|
+
const parseOxlintOutput = (stdout, project, rootDirectory) => {
|
|
2923
3124
|
if (!stdout) return [];
|
|
2924
3125
|
const jsonStart = stdout.indexOf("{");
|
|
2925
3126
|
const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
|
|
@@ -2933,7 +3134,7 @@ const parseOxlintOutput = (stdout, rootDirectory) => {
|
|
|
2933
3134
|
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
|
|
2934
3135
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2935
3136
|
const primaryLabel = diagnostic.labels[0];
|
|
2936
|
-
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
3137
|
+
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
|
|
2937
3138
|
return {
|
|
2938
3139
|
filePath: diagnostic.filename,
|
|
2939
3140
|
plugin,
|
|
@@ -2963,7 +3164,7 @@ const validateRuleRegistration = () => {
|
|
|
2963
3164
|
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2964
3165
|
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2965
3166
|
if (!getRuleCategory(ruleName)) missingCategory.push(fullKey);
|
|
2966
|
-
if (!
|
|
3167
|
+
if (!reactDoctorPlugin.rules[ruleName]?.recommendation) missingHelp.push(fullKey);
|
|
2967
3168
|
if (FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && !reactDoctorPlugin.rules[ruleName]?.requires) missingMetadata.push(fullKey);
|
|
2968
3169
|
}
|
|
2969
3170
|
if (missingCategory.length > 0 || missingHelp.length > 0 || missingMetadata.length > 0) {
|
|
@@ -2976,7 +3177,8 @@ const validateRuleRegistration = () => {
|
|
|
2976
3177
|
}
|
|
2977
3178
|
};
|
|
2978
3179
|
const runOxlint = async (options) => {
|
|
2979
|
-
const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), onPartialFailure } = options;
|
|
3180
|
+
const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, onPartialFailure } = options;
|
|
3181
|
+
const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
|
|
2980
3182
|
validateRuleRegistration();
|
|
2981
3183
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2982
3184
|
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
@@ -2988,7 +3190,8 @@ const runOxlint = async (options) => {
|
|
|
2988
3190
|
project,
|
|
2989
3191
|
customRulesOnly,
|
|
2990
3192
|
extendsPaths,
|
|
2991
|
-
ignoredTags
|
|
3193
|
+
ignoredTags,
|
|
3194
|
+
serverAuthFunctionNames
|
|
2992
3195
|
});
|
|
2993
3196
|
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2994
3197
|
try {
|
|
@@ -3025,7 +3228,7 @@ const runOxlint = async (options) => {
|
|
|
3025
3228
|
const spawnLintBatch = async (batch) => {
|
|
3026
3229
|
const batchArgs = [...baseArgs, ...batch];
|
|
3027
3230
|
try {
|
|
3028
|
-
return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), rootDirectory);
|
|
3231
|
+
return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), project, rootDirectory);
|
|
3029
3232
|
} catch (error) {
|
|
3030
3233
|
if (!isSplittableOxlintBatchError(error)) throw error;
|
|
3031
3234
|
if (batch.length <= 1) {
|
|
@@ -3055,7 +3258,8 @@ const runOxlint = async (options) => {
|
|
|
3055
3258
|
project,
|
|
3056
3259
|
customRulesOnly,
|
|
3057
3260
|
extendsPaths: [],
|
|
3058
|
-
ignoredTags
|
|
3261
|
+
ignoredTags,
|
|
3262
|
+
serverAuthFunctionNames
|
|
3059
3263
|
}));
|
|
3060
3264
|
return await spawnLintBatches();
|
|
3061
3265
|
}
|
|
@@ -3585,7 +3789,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
3585
3789
|
share: userConfig?.share ?? true,
|
|
3586
3790
|
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
3587
3791
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
3588
|
-
ignoredTags: buildIgnoredTags(userConfig)
|
|
3792
|
+
ignoredTags: buildIgnoredTags(userConfig),
|
|
3793
|
+
outputSurface: inputOptions.outputSurface ?? "cli"
|
|
3589
3794
|
});
|
|
3590
3795
|
const inspect = async (directory, inputOptions = {}) => {
|
|
3591
3796
|
const startTime = performance.now();
|
|
@@ -3643,6 +3848,7 @@ const runInspect = async (directory, options, userConfig, startTime) => {
|
|
|
3643
3848
|
respectInlineDisables: options.respectInlineDisables,
|
|
3644
3849
|
adoptExistingLintConfig: options.adoptExistingLintConfig,
|
|
3645
3850
|
ignoredTags: options.ignoredTags,
|
|
3851
|
+
userConfig,
|
|
3646
3852
|
onPartialFailure: (reason) => lintPartialFailures.push(reason)
|
|
3647
3853
|
});
|
|
3648
3854
|
lintSpinner?.succeed("Running lint checks.");
|
|
@@ -3670,7 +3876,8 @@ const runInspect = async (directory, options, userConfig, startTime) => {
|
|
|
3670
3876
|
const skippedChecks = [];
|
|
3671
3877
|
if (didLintFail) skippedChecks.push("lint");
|
|
3672
3878
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
3673
|
-
const
|
|
3879
|
+
const scoreDiagnostics = filterDiagnosticsForSurface(diagnostics, "score", userConfig);
|
|
3880
|
+
const scoreResult = options.offline ? null : await calculateScore(scoreDiagnostics);
|
|
3674
3881
|
const noScoreMessage = options.offline ? "Score unavailable in offline mode." : "Score unavailable (could not reach the score API).";
|
|
3675
3882
|
const skippedCheckReasons = {};
|
|
3676
3883
|
if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
|
|
@@ -3688,11 +3895,14 @@ const runInspect = async (directory, options, userConfig, startTime) => {
|
|
|
3688
3895
|
else logger.dim(noScoreMessage);
|
|
3689
3896
|
return buildResult();
|
|
3690
3897
|
}
|
|
3691
|
-
|
|
3898
|
+
const surfaceDiagnostics = filterDiagnosticsForSurface(diagnostics, options.outputSurface, userConfig);
|
|
3899
|
+
const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length;
|
|
3900
|
+
if (surfaceDiagnostics.length === 0) {
|
|
3692
3901
|
if (hasSkippedChecks) {
|
|
3693
3902
|
const skippedLabel = skippedChecks.join(" and ");
|
|
3694
3903
|
logger.warn(`No issues detected, but ${skippedLabel} checks failed — results are incomplete.`);
|
|
3695
|
-
} else logger.success(
|
|
3904
|
+
} else if (demotedDiagnosticCount > 0) logger.success(`No issues found! (${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface — see config.surfaces.)`);
|
|
3905
|
+
else logger.success("No issues found!");
|
|
3696
3906
|
logger.break();
|
|
3697
3907
|
if (hasSkippedChecks) {
|
|
3698
3908
|
printBrandingOnlyHeader();
|
|
@@ -3702,10 +3912,14 @@ const runInspect = async (directory, options, userConfig, startTime) => {
|
|
|
3702
3912
|
return buildResult();
|
|
3703
3913
|
}
|
|
3704
3914
|
logger.break();
|
|
3705
|
-
printDiagnostics(
|
|
3915
|
+
printDiagnostics(surfaceDiagnostics, options.verbose, directory);
|
|
3916
|
+
if (demotedDiagnosticCount > 0) {
|
|
3917
|
+
logger.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
|
|
3918
|
+
logger.break();
|
|
3919
|
+
}
|
|
3706
3920
|
const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
|
|
3707
3921
|
const shouldShowShareLink = !options.offline && options.share;
|
|
3708
|
-
printSummary(
|
|
3922
|
+
printSummary(surfaceDiagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, !shouldShowShareLink);
|
|
3709
3923
|
if (hasSkippedChecks) {
|
|
3710
3924
|
const skippedLabel = skippedChecks.join(" and ");
|
|
3711
3925
|
logger.break();
|
|
@@ -3805,7 +4019,7 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
3805
4019
|
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
|
|
3806
4020
|
//#endregion
|
|
3807
4021
|
//#region src/cli/utils/version.ts
|
|
3808
|
-
const VERSION = "0.2.0-beta.
|
|
4022
|
+
const VERSION = "0.2.0-beta.5";
|
|
3809
4023
|
//#endregion
|
|
3810
4024
|
//#region src/cli/utils/json-mode.ts
|
|
3811
4025
|
let context = null;
|
|
@@ -3871,7 +4085,8 @@ const resolveCliInspectOptions = (flags, userConfig) => ({
|
|
|
3871
4085
|
scoreOnly: Boolean(flags.score),
|
|
3872
4086
|
offline: Boolean(flags.offline) || (userConfig?.offline ?? false) || isCiEnvironment(),
|
|
3873
4087
|
silent: Boolean(flags.json),
|
|
3874
|
-
respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
|
|
4088
|
+
respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
4089
|
+
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
3875
4090
|
});
|
|
3876
4091
|
//#endregion
|
|
3877
4092
|
//#region src/cli/utils/resolve-diff-mode.ts
|
|
@@ -4096,6 +4311,7 @@ const validateModeFlags = (flags) => {
|
|
|
4096
4311
|
if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
|
|
4097
4312
|
if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
|
|
4098
4313
|
if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
|
|
4314
|
+
if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
|
|
4099
4315
|
if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
|
|
4100
4316
|
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.");
|
|
4101
4317
|
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.");
|
|
@@ -4193,7 +4409,8 @@ const inspectAction = async (directory, flags) => {
|
|
|
4193
4409
|
totalElapsedMilliseconds: performance.now() - startTime
|
|
4194
4410
|
}));
|
|
4195
4411
|
if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
|
|
4196
|
-
|
|
4412
|
+
const ciFailureDiagnostics = filterDiagnosticsForSurface(remappedDiagnostics, "ciFailure", userConfig);
|
|
4413
|
+
if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
|
|
4197
4414
|
} finally {
|
|
4198
4415
|
snapshot.cleanup();
|
|
4199
4416
|
}
|
|
@@ -4256,7 +4473,8 @@ const inspectAction = async (directory, flags) => {
|
|
|
4256
4473
|
totalElapsedMilliseconds: performance.now() - startTime
|
|
4257
4474
|
}));
|
|
4258
4475
|
if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
|
|
4259
|
-
|
|
4476
|
+
const ciFailureDiagnostics = filterDiagnosticsForSurface(allDiagnostics, "ciFailure", userConfig);
|
|
4477
|
+
if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
|
|
4260
4478
|
} catch (error) {
|
|
4261
4479
|
if (isJsonMode) {
|
|
4262
4480
|
writeJsonErrorReport(error);
|
|
@@ -4387,7 +4605,7 @@ const exitGracefully = () => {
|
|
|
4387
4605
|
//#region src/cli/index.ts
|
|
4388
4606
|
process.on("SIGINT", exitGracefully);
|
|
4389
4607
|
process.on("SIGTERM", exitGracefully);
|
|
4390
|
-
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("--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 the score API and the share URL (no score is shown)").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 (default: 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").addHelpText("after", `
|
|
4608
|
+
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("--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 the score API and the share URL (no score is shown)").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 (default: error)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").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").addHelpText("after", `
|
|
4391
4609
|
${highlighter.dim("Configuration:")}
|
|
4392
4610
|
Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
|
|
4393
4611
|
CLI flags always override config values. See the README for the full schema.
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,52 @@ interface ReactDoctorIgnoreConfig {
|
|
|
11
11
|
overrides?: ReactDoctorIgnoreOverride[];
|
|
12
12
|
tags?: string[];
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Discrete output channels a diagnostic can flow through after a scan.
|
|
16
|
+
* Each surface is filtered independently so a rule can be visible
|
|
17
|
+
* locally but excluded from PR comments, the score, or the CI gate:
|
|
18
|
+
*
|
|
19
|
+
* - `cli` — local terminal output from `react-doctor` (`printDiagnostics`).
|
|
20
|
+
* - `prComment` — output captured by the GitHub Action for the sticky
|
|
21
|
+
* PR comment. Enabled when the CLI is run with `--pr-comment` (the
|
|
22
|
+
* action sets this automatically when `github-token` is provided).
|
|
23
|
+
* - `score` — diagnostics shipped to the React Doctor score API
|
|
24
|
+
* (or counted toward local score calculations).
|
|
25
|
+
* - `ciFailure` — diagnostics that count toward the `--fail-on` exit
|
|
26
|
+
* code gate. A diagnostic excluded from this surface never fails the
|
|
27
|
+
* build, regardless of severity.
|
|
28
|
+
*
|
|
29
|
+
* Defaults: design rules (tag `"design"`) are excluded from `prComment`,
|
|
30
|
+
* `score`, and `ciFailure` so style cleanup doesn't dilute meaningful
|
|
31
|
+
* React findings. They remain in `cli` so locally-running developers
|
|
32
|
+
* still see the suggestion when they touch the file.
|
|
33
|
+
*/
|
|
34
|
+
type DiagnosticSurface = "cli" | "prComment" | "score" | "ciFailure";
|
|
35
|
+
interface SurfaceControls {
|
|
36
|
+
/**
|
|
37
|
+
* Tag names whose diagnostics should be force-included on the surface,
|
|
38
|
+
* even if a default or category-level exclusion would otherwise drop
|
|
39
|
+
* them. Include wins over exclude when both apply to the same rule.
|
|
40
|
+
*/
|
|
41
|
+
includeTags?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Tag names whose diagnostics should be excluded from the surface.
|
|
44
|
+
* Use this to silence whole rule families (e.g. `["design"]`,
|
|
45
|
+
* `["test-noise"]`) for a single channel without touching others.
|
|
46
|
+
*/
|
|
47
|
+
excludeTags?: string[];
|
|
48
|
+
/** Category names (e.g. `"Architecture"`) to force-include. */
|
|
49
|
+
includeCategories?: string[];
|
|
50
|
+
/** Category names (e.g. `"Architecture"`) to exclude. */
|
|
51
|
+
excludeCategories?: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Fully-qualified rule keys (`"<plugin>/<rule>"`, e.g.
|
|
54
|
+
* `"react-doctor/design-no-redundant-size-axes"`) to force-include.
|
|
55
|
+
*/
|
|
56
|
+
includeRules?: string[];
|
|
57
|
+
/** Fully-qualified rule keys to exclude from this surface. */
|
|
58
|
+
excludeRules?: string[];
|
|
59
|
+
}
|
|
14
60
|
interface ReactDoctorConfig {
|
|
15
61
|
ignore?: ReactDoctorIgnoreConfig;
|
|
16
62
|
lint?: boolean;
|
|
@@ -56,6 +102,20 @@ interface ReactDoctorConfig {
|
|
|
56
102
|
* suppresses regardless of sibling content.
|
|
57
103
|
*/
|
|
58
104
|
rawTextWrapperComponents?: string[];
|
|
105
|
+
/**
|
|
106
|
+
* Project-level allowlist of function names that the
|
|
107
|
+
* `server-auth-actions` rule treats as an auth check at the top of
|
|
108
|
+
* a server action. Names are accepted whether called as a bare
|
|
109
|
+
* identifier (`myAuthGuard()`) or as the final property of a
|
|
110
|
+
* member call (`ctx.myAuthGuard()`); unlike the built-in default
|
|
111
|
+
* list, user-provided names are treated as distinctive and never
|
|
112
|
+
* subject to receiver-object disambiguation.
|
|
113
|
+
*
|
|
114
|
+
* Use this to teach react-doctor about custom auth guards in
|
|
115
|
+
* codebases that wrap their auth library — e.g. a project-local
|
|
116
|
+
* `requireWorkspaceMember` or `ensureSignedIn`.
|
|
117
|
+
*/
|
|
118
|
+
serverAuthFunctionNames?: string[];
|
|
59
119
|
/**
|
|
60
120
|
* Whether to respect inline `// eslint-disable*`, `// oxlint-disable*`,
|
|
61
121
|
* and `// react-doctor-disable*` comments in source files. Default: `true`.
|
|
@@ -96,6 +156,26 @@ interface ReactDoctorConfig {
|
|
|
96
156
|
* Set to `false` to scan only react-doctor's curated rule set.
|
|
97
157
|
*/
|
|
98
158
|
adoptExistingLintConfig?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Per-surface include/exclude controls. Each `DiagnosticSurface` is
|
|
161
|
+
* resolved independently against rule tags, category, and id so a
|
|
162
|
+
* single rule can be visible locally yet hidden from PR comments,
|
|
163
|
+
* neutralized from the score, and excluded from `--fail-on` — all
|
|
164
|
+
* without touching the rule's severity or activation.
|
|
165
|
+
*
|
|
166
|
+
* Defaults (applied before user overrides):
|
|
167
|
+
*
|
|
168
|
+
* - `prComment` excludes tag `"design"`
|
|
169
|
+
* - `score` excludes tag `"design"`
|
|
170
|
+
* - `ciFailure` excludes tag `"design"`
|
|
171
|
+
*
|
|
172
|
+
* Pass any controls block (even an empty `{}`) to keep the default
|
|
173
|
+
* exclusions; the user's include/exclude entries layer on top.
|
|
174
|
+
* Include entries always win over exclude entries — handy for
|
|
175
|
+
* promoting a single high-signal `design-*` rule back into the
|
|
176
|
+
* score or PR-comment surface.
|
|
177
|
+
*/
|
|
178
|
+
surfaces?: Partial<Record<DiagnosticSurface, SurfaceControls>>;
|
|
99
179
|
} //#endregion
|
|
100
180
|
//#region src/diagnostic.d.ts
|
|
101
181
|
interface Diagnostic {
|
|
@@ -124,6 +204,20 @@ interface ProjectInfo {
|
|
|
124
204
|
hasTypeScript: boolean;
|
|
125
205
|
hasReactCompiler: boolean;
|
|
126
206
|
hasTanStackQuery: boolean;
|
|
207
|
+
/**
|
|
208
|
+
* `true` when the project (or any of its workspace packages) declares
|
|
209
|
+
* React Native or Expo as a dependency. Enables the `react-native`
|
|
210
|
+
* capability — and therefore every `rn-*` rule — even on web-rooted
|
|
211
|
+
* monorepos where the entry-point `package.json` is Next / Vite /
|
|
212
|
+
* Remix but a sibling workspace (`apps/mobile`, `packages/native-ui`)
|
|
213
|
+
* targets React Native. The file-level package-boundary check in
|
|
214
|
+
* `oxlint-plugin-react-doctor` still keeps the rules silent on the
|
|
215
|
+
* web workspaces.
|
|
216
|
+
*
|
|
217
|
+
* `false` collapses the gate to the legacy "framework is RN" behavior
|
|
218
|
+
* — no `rn-*` rules load for the project at all.
|
|
219
|
+
*/
|
|
220
|
+
hasReactNativeWorkspace: boolean;
|
|
127
221
|
sourceFileCount: number;
|
|
128
222
|
}
|
|
129
223
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,25 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
5
5
|
import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import * as ts from "typescript";
|
|
8
|
+
//#region ../types/dist/index.js
|
|
9
|
+
const REACT_NATIVE_DEPENDENCY_NAMES = new Set([
|
|
10
|
+
"react-native",
|
|
11
|
+
"react-native-tvos",
|
|
12
|
+
"expo",
|
|
13
|
+
"expo-router",
|
|
14
|
+
"@expo/cli",
|
|
15
|
+
"@expo/metro-config",
|
|
16
|
+
"@expo/metro-runtime",
|
|
17
|
+
"react-native-windows",
|
|
18
|
+
"react-native-macos"
|
|
19
|
+
]);
|
|
20
|
+
const REACT_NATIVE_DEPENDENCY_PREFIXES = ["@react-native/", "@react-native-"];
|
|
21
|
+
const isReactNativeDependencyName = (dependencyName) => {
|
|
22
|
+
if (REACT_NATIVE_DEPENDENCY_NAMES.has(dependencyName)) return true;
|
|
23
|
+
for (const prefix of REACT_NATIVE_DEPENDENCY_PREFIXES) if (dependencyName.startsWith(prefix)) return true;
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
8
27
|
//#region ../project-info/dist/index.js
|
|
9
28
|
var ReactDoctorError = class extends Error {
|
|
10
29
|
name = "ReactDoctorError";
|
|
@@ -225,6 +244,18 @@ const FRAMEWORK_PACKAGES = {
|
|
|
225
244
|
expo: "expo",
|
|
226
245
|
"react-native": "react-native"
|
|
227
246
|
};
|
|
247
|
+
const FRAMEWORK_DISPLAY_NAMES = {
|
|
248
|
+
nextjs: "Next.js",
|
|
249
|
+
"tanstack-start": "TanStack Start",
|
|
250
|
+
vite: "Vite",
|
|
251
|
+
cra: "Create React App",
|
|
252
|
+
remix: "Remix",
|
|
253
|
+
gatsby: "Gatsby",
|
|
254
|
+
expo: "Expo",
|
|
255
|
+
"react-native": "React Native",
|
|
256
|
+
unknown: "React"
|
|
257
|
+
};
|
|
258
|
+
const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
|
|
228
259
|
const detectFramework = (dependencies) => {
|
|
229
260
|
for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
|
|
230
261
|
return "unknown";
|
|
@@ -651,6 +682,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
651
682
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
652
683
|
};
|
|
653
684
|
};
|
|
685
|
+
const containsAnyReactNativeDependency = (section) => {
|
|
686
|
+
if (!section) return false;
|
|
687
|
+
for (const dependencyName of Object.keys(section)) if (isReactNativeDependencyName(dependencyName)) return true;
|
|
688
|
+
return false;
|
|
689
|
+
};
|
|
690
|
+
const isPackageJsonReactNativeAware = (packageJson) => {
|
|
691
|
+
if (typeof packageJson["react-native"] === "string") return true;
|
|
692
|
+
if (containsAnyReactNativeDependency(packageJson.dependencies)) return true;
|
|
693
|
+
if (containsAnyReactNativeDependency(packageJson.devDependencies)) return true;
|
|
694
|
+
if (containsAnyReactNativeDependency(packageJson.peerDependencies)) return true;
|
|
695
|
+
if (containsAnyReactNativeDependency(packageJson.optionalDependencies)) return true;
|
|
696
|
+
return false;
|
|
697
|
+
};
|
|
698
|
+
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => {
|
|
699
|
+
if (isPackageJsonReactNativeAware(rootPackageJson)) return true;
|
|
700
|
+
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
701
|
+
if (patterns.length === 0) return false;
|
|
702
|
+
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
703
|
+
for (const pattern of patterns) {
|
|
704
|
+
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
705
|
+
for (const workspaceDirectory of directories) {
|
|
706
|
+
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
707
|
+
visitedDirectories.add(workspaceDirectory);
|
|
708
|
+
if (isPackageJsonReactNativeAware(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return false;
|
|
712
|
+
};
|
|
654
713
|
const TANSTACK_QUERY_PACKAGES = new Set([
|
|
655
714
|
"@tanstack/react-query",
|
|
656
715
|
"@tanstack/query-core",
|
|
@@ -826,6 +885,7 @@ const discoverProject = (directory) => {
|
|
|
826
885
|
const projectName = packageJson.name ?? path.basename(directory);
|
|
827
886
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
828
887
|
const sourceFileCount = countSourceFiles(directory);
|
|
888
|
+
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
829
889
|
const projectInfo = {
|
|
830
890
|
rootDirectory: directory,
|
|
831
891
|
projectName,
|
|
@@ -836,6 +896,7 @@ const discoverProject = (directory) => {
|
|
|
836
896
|
hasTypeScript,
|
|
837
897
|
hasReactCompiler: detectReactCompiler(directory, packageJson),
|
|
838
898
|
hasTanStackQuery: hasTanStackQuery(packageJson),
|
|
899
|
+
hasReactNativeWorkspace,
|
|
839
900
|
sourceFileCount
|
|
840
901
|
};
|
|
841
902
|
cachedProjectInfos.set(directory, projectInfo);
|
|
@@ -1843,6 +1904,13 @@ const detectUserLintConfigPaths = (rootDirectory) => {
|
|
|
1843
1904
|
}
|
|
1844
1905
|
return [];
|
|
1845
1906
|
};
|
|
1907
|
+
const DIAGNOSTIC_SURFACES = [
|
|
1908
|
+
"cli",
|
|
1909
|
+
"prComment",
|
|
1910
|
+
"score",
|
|
1911
|
+
"ciFailure"
|
|
1912
|
+
];
|
|
1913
|
+
const isDiagnosticSurface = (value) => typeof value === "string" && DIAGNOSTIC_SURFACES.includes(value);
|
|
1846
1914
|
const runGit = (cwd, args) => {
|
|
1847
1915
|
const result = spawnSync("git", args, {
|
|
1848
1916
|
cwd,
|
|
@@ -1989,6 +2057,61 @@ const validateString = (fieldName, value) => {
|
|
|
1989
2057
|
if (typeof value === "string") return value;
|
|
1990
2058
|
warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1991
2059
|
};
|
|
2060
|
+
const SURFACE_CONTROL_FIELD_NAMES = [
|
|
2061
|
+
"includeTags",
|
|
2062
|
+
"excludeTags",
|
|
2063
|
+
"includeCategories",
|
|
2064
|
+
"excludeCategories",
|
|
2065
|
+
"includeRules",
|
|
2066
|
+
"excludeRules"
|
|
2067
|
+
];
|
|
2068
|
+
const validateStringArrayField = (fieldName, value) => {
|
|
2069
|
+
if (value === void 0) return void 0;
|
|
2070
|
+
if (!Array.isArray(value)) {
|
|
2071
|
+
warnConfigField(`config field "${fieldName}" must be an array of strings (got ${typeof value}); ignoring this field.`);
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
const collected = [];
|
|
2075
|
+
for (const entry of value) {
|
|
2076
|
+
if (typeof entry !== "string") {
|
|
2077
|
+
warnConfigField(`config field "${fieldName}" contains a non-string entry (${typeof entry}); ignoring the entry.`);
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
collected.push(entry);
|
|
2081
|
+
}
|
|
2082
|
+
return collected;
|
|
2083
|
+
};
|
|
2084
|
+
const validateSurfaceControls = (surface, rawControls) => {
|
|
2085
|
+
if (rawControls === void 0) return void 0;
|
|
2086
|
+
if (typeof rawControls !== "object" || rawControls === null || Array.isArray(rawControls)) {
|
|
2087
|
+
warnConfigField(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
const validated = {};
|
|
2091
|
+
for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
|
|
2092
|
+
const value = rawControls[fieldName];
|
|
2093
|
+
const validatedValue = validateStringArrayField(`surfaces.${surface}.${fieldName}`, value);
|
|
2094
|
+
if (validatedValue !== void 0) validated[fieldName] = validatedValue;
|
|
2095
|
+
}
|
|
2096
|
+
return validated;
|
|
2097
|
+
};
|
|
2098
|
+
const validateSurfacesField = (rawSurfaces) => {
|
|
2099
|
+
if (rawSurfaces === void 0) return void 0;
|
|
2100
|
+
if (typeof rawSurfaces !== "object" || rawSurfaces === null || Array.isArray(rawSurfaces)) {
|
|
2101
|
+
warnConfigField(`config field "surfaces" must be an object (got ${typeof rawSurfaces}); ignoring this field.`);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
const validated = {};
|
|
2105
|
+
for (const [key, value] of Object.entries(rawSurfaces)) {
|
|
2106
|
+
if (!isDiagnosticSurface(key)) {
|
|
2107
|
+
warnConfigField(`config field "surfaces.${key}" is not a known surface (expected one of: ${DIAGNOSTIC_SURFACES.join(", ")}); ignoring.`);
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
const controls = validateSurfaceControls(key, value);
|
|
2111
|
+
if (controls !== void 0) validated[key] = controls;
|
|
2112
|
+
}
|
|
2113
|
+
return validated;
|
|
2114
|
+
};
|
|
1992
2115
|
const validateConfigTypes = (config) => {
|
|
1993
2116
|
const validated = { ...config };
|
|
1994
2117
|
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
@@ -2005,6 +2128,11 @@ const validateConfigTypes = (config) => {
|
|
|
2005
2128
|
if (validatedString === void 0) delete validated[fieldName];
|
|
2006
2129
|
else validated[fieldName] = validatedString;
|
|
2007
2130
|
}
|
|
2131
|
+
if (config.surfaces !== void 0) {
|
|
2132
|
+
const validatedSurfaces = validateSurfacesField(config.surfaces);
|
|
2133
|
+
if (validatedSurfaces === void 0) delete validated.surfaces;
|
|
2134
|
+
else validated.surfaces = validatedSurfaces;
|
|
2135
|
+
}
|
|
2008
2136
|
return validated;
|
|
2009
2137
|
};
|
|
2010
2138
|
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
@@ -2242,7 +2370,7 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
2242
2370
|
const buildCapabilities = (project) => {
|
|
2243
2371
|
const capabilities = /* @__PURE__ */ new Set();
|
|
2244
2372
|
capabilities.add(project.framework);
|
|
2245
|
-
if (project.framework === "expo" || project.framework === "react-native") capabilities.add("react-native");
|
|
2373
|
+
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
2246
2374
|
const reactMajor = project.reactMajorVersion;
|
|
2247
2375
|
if (reactMajor !== null) for (let major = 17; major <= reactMajor; major++) capabilities.add(`react:${major}`);
|
|
2248
2376
|
if (project.tailwindVersion !== null) {
|
|
@@ -2382,7 +2510,11 @@ const BUILTIN_A11Y_RULES = {
|
|
|
2382
2510
|
"jsx-a11y/no-distracting-elements": "error",
|
|
2383
2511
|
"jsx-a11y/iframe-has-title": "warn"
|
|
2384
2512
|
};
|
|
2385
|
-
const
|
|
2513
|
+
const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
2514
|
+
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
2515
|
+
return fs.realpathSync(rootDirectory);
|
|
2516
|
+
};
|
|
2517
|
+
const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames }) => {
|
|
2386
2518
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
|
|
2387
2519
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2388
2520
|
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
@@ -2411,6 +2543,11 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
2411
2543
|
},
|
|
2412
2544
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2413
2545
|
jsPlugins: [...jsPlugins, pluginPath],
|
|
2546
|
+
settings: { "react-doctor": {
|
|
2547
|
+
framework: project.framework,
|
|
2548
|
+
rootDirectory: resolveSettingsRootDirectory(project.rootDirectory),
|
|
2549
|
+
...serverAuthFunctionNames && serverAuthFunctionNames.length > 0 ? { serverAuthFunctionNames: [...serverAuthFunctionNames] } : {}
|
|
2550
|
+
} },
|
|
2414
2551
|
rules: {
|
|
2415
2552
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2416
2553
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
@@ -2713,7 +2850,25 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
|
|
|
2713
2850
|
const bindingResolution = resolveUseCallBinding(sourceText, absolutePath, primaryLabel.span.offset);
|
|
2714
2851
|
return bindingResolution !== null && !bindingResolution.isReactUseBinding;
|
|
2715
2852
|
};
|
|
2716
|
-
const
|
|
2853
|
+
const getPublicEnvPrefix = (framework) => {
|
|
2854
|
+
switch (framework) {
|
|
2855
|
+
case "nextjs": return "NEXT_PUBLIC_*";
|
|
2856
|
+
case "vite":
|
|
2857
|
+
case "tanstack-start": return "VITE_*";
|
|
2858
|
+
case "cra": return "REACT_APP_*";
|
|
2859
|
+
case "gatsby": return "GATSBY_*";
|
|
2860
|
+
default: return null;
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
const buildNoSecretsRecommendation = (project, fallbackRecommendation) => {
|
|
2864
|
+
const publicEnvPrefix = getPublicEnvPrefix(project.framework);
|
|
2865
|
+
if (!publicEnvPrefix) return fallbackRecommendation;
|
|
2866
|
+
return `Move secrets to server-only code. In ${formatFrameworkName(project.framework)}, only \`${publicEnvPrefix}\` env vars are exposed to the browser, and they must not contain secrets`;
|
|
2867
|
+
};
|
|
2868
|
+
const getRuleRecommendation = (ruleName, project) => {
|
|
2869
|
+
if (ruleName === "no-secrets-in-client-code") return buildNoSecretsRecommendation(project, reactDoctorPlugin.rules["no-secrets-in-client-code"]?.recommendation ?? "Move secrets to server-only code");
|
|
2870
|
+
return reactDoctorPlugin.rules[ruleName]?.recommendation;
|
|
2871
|
+
};
|
|
2717
2872
|
const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
|
|
2718
2873
|
const esmRequire = createRequire(import.meta.url);
|
|
2719
2874
|
const PLUGIN_CATEGORY_MAP = {
|
|
@@ -2738,14 +2893,14 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2738
2893
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2739
2894
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
2740
2895
|
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
2741
|
-
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2896
|
+
const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
|
|
2742
2897
|
if (plugin === "react-hooks-js") return {
|
|
2743
2898
|
message: REACT_COMPILER_MESSAGE,
|
|
2744
2899
|
help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
|
|
2745
2900
|
};
|
|
2746
2901
|
return {
|
|
2747
2902
|
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2748
|
-
help: help || getRuleRecommendation(rule) || ""
|
|
2903
|
+
help: help || getRuleRecommendation(rule, project) || ""
|
|
2749
2904
|
};
|
|
2750
2905
|
};
|
|
2751
2906
|
const parseRuleCode = (code) => {
|
|
@@ -2848,7 +3003,7 @@ const isOxlintOutput = (value) => {
|
|
|
2848
3003
|
const candidate = value;
|
|
2849
3004
|
return Array.isArray(candidate.diagnostics);
|
|
2850
3005
|
};
|
|
2851
|
-
const parseOxlintOutput = (stdout, rootDirectory) => {
|
|
3006
|
+
const parseOxlintOutput = (stdout, project, rootDirectory) => {
|
|
2852
3007
|
if (!stdout) return [];
|
|
2853
3008
|
const jsonStart = stdout.indexOf("{");
|
|
2854
3009
|
const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
|
|
@@ -2862,7 +3017,7 @@ const parseOxlintOutput = (stdout, rootDirectory) => {
|
|
|
2862
3017
|
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
|
|
2863
3018
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2864
3019
|
const primaryLabel = diagnostic.labels[0];
|
|
2865
|
-
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
3020
|
+
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
|
|
2866
3021
|
return {
|
|
2867
3022
|
filePath: diagnostic.filename,
|
|
2868
3023
|
plugin,
|
|
@@ -2892,7 +3047,7 @@ const validateRuleRegistration = () => {
|
|
|
2892
3047
|
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2893
3048
|
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2894
3049
|
if (!getRuleCategory(ruleName)) missingCategory.push(fullKey);
|
|
2895
|
-
if (!
|
|
3050
|
+
if (!reactDoctorPlugin.rules[ruleName]?.recommendation) missingHelp.push(fullKey);
|
|
2896
3051
|
if (FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && !reactDoctorPlugin.rules[ruleName]?.requires) missingMetadata.push(fullKey);
|
|
2897
3052
|
}
|
|
2898
3053
|
if (missingCategory.length > 0 || missingHelp.length > 0 || missingMetadata.length > 0) {
|
|
@@ -2905,7 +3060,8 @@ const validateRuleRegistration = () => {
|
|
|
2905
3060
|
}
|
|
2906
3061
|
};
|
|
2907
3062
|
const runOxlint = async (options) => {
|
|
2908
|
-
const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), onPartialFailure } = options;
|
|
3063
|
+
const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, onPartialFailure } = options;
|
|
3064
|
+
const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
|
|
2909
3065
|
validateRuleRegistration();
|
|
2910
3066
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2911
3067
|
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
@@ -2917,7 +3073,8 @@ const runOxlint = async (options) => {
|
|
|
2917
3073
|
project,
|
|
2918
3074
|
customRulesOnly,
|
|
2919
3075
|
extendsPaths,
|
|
2920
|
-
ignoredTags
|
|
3076
|
+
ignoredTags,
|
|
3077
|
+
serverAuthFunctionNames
|
|
2921
3078
|
});
|
|
2922
3079
|
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2923
3080
|
try {
|
|
@@ -2954,7 +3111,7 @@ const runOxlint = async (options) => {
|
|
|
2954
3111
|
const spawnLintBatch = async (batch) => {
|
|
2955
3112
|
const batchArgs = [...baseArgs, ...batch];
|
|
2956
3113
|
try {
|
|
2957
|
-
return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), rootDirectory);
|
|
3114
|
+
return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), project, rootDirectory);
|
|
2958
3115
|
} catch (error) {
|
|
2959
3116
|
if (!isSplittableOxlintBatchError(error)) throw error;
|
|
2960
3117
|
if (batch.length <= 1) {
|
|
@@ -2984,7 +3141,8 @@ const runOxlint = async (options) => {
|
|
|
2984
3141
|
project,
|
|
2985
3142
|
customRulesOnly,
|
|
2986
3143
|
extendsPaths: [],
|
|
2987
|
-
ignoredTags
|
|
3144
|
+
ignoredTags,
|
|
3145
|
+
serverAuthFunctionNames
|
|
2988
3146
|
}));
|
|
2989
3147
|
return await spawnLintBatches();
|
|
2990
3148
|
}
|
|
@@ -3048,7 +3206,8 @@ const diagnose = async (directory, options = {}) => {
|
|
|
3048
3206
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
3049
3207
|
respectInlineDisables: effectiveRespectInlineDisables,
|
|
3050
3208
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
3051
|
-
ignoredTags
|
|
3209
|
+
ignoredTags,
|
|
3210
|
+
userConfig
|
|
3052
3211
|
}).catch((error) => {
|
|
3053
3212
|
console.error("Lint failed:", error);
|
|
3054
3213
|
return EMPTY_DIAGNOSTICS;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-doctor",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.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",
|
|
@@ -56,15 +56,15 @@
|
|
|
56
56
|
"picocolors": "^1.1.1",
|
|
57
57
|
"prompts": "^2.4.2",
|
|
58
58
|
"typescript": ">=5.0.4 <7",
|
|
59
|
-
"oxlint-plugin-react-doctor": "0.2.0-beta.
|
|
59
|
+
"oxlint-plugin-react-doctor": "0.2.0-beta.5"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/prompts": "^2.4.9",
|
|
63
63
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
64
64
|
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
|
|
65
|
-
"@react-doctor/
|
|
66
|
-
"@react-doctor/project-info": "0.2.0-beta.
|
|
67
|
-
"@react-doctor/
|
|
65
|
+
"@react-doctor/types": "0.2.0-beta.3",
|
|
66
|
+
"@react-doctor/project-info": "0.2.0-beta.3",
|
|
67
|
+
"@react-doctor/core": "0.2.0-beta.5"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"eslint-plugin-react-hooks": "^6 || ^7",
|