react-doctor 0.0.28 → 0.0.30
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 +24 -1
- package/dist/cli.js +264 -65
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +238 -39
- package/dist/index.js.map +1 -1
- package/dist/react-doctor-plugin.js +11 -3
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//#region src/types.d.ts
|
|
2
|
-
type
|
|
2
|
+
type FailOnLevel = "error" | "warning" | "none";
|
|
3
|
+
type Framework = "nextjs" | "vite" | "cra" | "remix" | "gatsby" | "expo" | "react-native" | "unknown";
|
|
3
4
|
interface ProjectInfo {
|
|
4
5
|
rootDirectory: string;
|
|
5
6
|
projectName: string;
|
|
@@ -41,6 +42,7 @@ interface ReactDoctorConfig {
|
|
|
41
42
|
deadCode?: boolean;
|
|
42
43
|
verbose?: boolean;
|
|
43
44
|
diff?: boolean | string;
|
|
45
|
+
failOn?: FailOnLevel;
|
|
44
46
|
}
|
|
45
47
|
//#endregion
|
|
46
48
|
//#region src/utils/get-diff-files.d.ts
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,SAAA;AAAA,
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,WAAA;AAAA,KAEA,SAAA;AAAA,UAUK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAyBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,MAAA,GAAS,WAAA;AAAA;;;cChGE,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;UCnFjB,eAAA;EACf,IAAA;EACA,QAAA;EACA,YAAA;AAAA;AAAA,UAGe,cAAA;EACf,WAAA,EAAa,UAAA;EACb,KAAA,EAAO,WAAA;EACP,OAAA,EAAS,WAAA;EACT,mBAAA;AAAA;AAAA,cAGW,QAAA,GACX,SAAA,UACA,OAAA,GAAS,eAAA,KACR,OAAA,CAAQ,cAAA"}
|
package/dist/index.js
CHANGED
|
@@ -12,11 +12,18 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
13
13
|
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
14
14
|
const ERROR_PREVIEW_LENGTH_CHARS = 200;
|
|
15
|
+
const PERFECT_SCORE = 100;
|
|
16
|
+
const SCORE_GOOD_THRESHOLD = 75;
|
|
17
|
+
const SCORE_OK_THRESHOLD = 50;
|
|
15
18
|
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
16
19
|
const FETCH_TIMEOUT_MS = 1e4;
|
|
17
20
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
18
21
|
const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
|
|
19
22
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
23
|
+
const ERROR_RULE_PENALTY = 1.5;
|
|
24
|
+
const WARNING_RULE_PENALTY = .75;
|
|
25
|
+
const ERROR_ESTIMATED_FIX_RATE = .85;
|
|
26
|
+
const WARNING_ESTIMATED_FIX_RATE = .8;
|
|
20
27
|
const MAX_KNIP_RETRIES = 5;
|
|
21
28
|
const AMI_WEBSITE_URL = "https://ami.dev";
|
|
22
29
|
const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
|
|
@@ -71,6 +78,46 @@ const proxyFetch = async (url, init) => {
|
|
|
71
78
|
|
|
72
79
|
//#endregion
|
|
73
80
|
//#region src/utils/calculate-score.ts
|
|
81
|
+
const getScoreLabel = (score) => {
|
|
82
|
+
if (score >= SCORE_GOOD_THRESHOLD) return "Great";
|
|
83
|
+
if (score >= SCORE_OK_THRESHOLD) return "Needs work";
|
|
84
|
+
return "Critical";
|
|
85
|
+
};
|
|
86
|
+
const countUniqueRules = (diagnostics) => {
|
|
87
|
+
const errorRules = /* @__PURE__ */ new Set();
|
|
88
|
+
const warningRules = /* @__PURE__ */ new Set();
|
|
89
|
+
for (const diagnostic of diagnostics) {
|
|
90
|
+
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
91
|
+
if (diagnostic.severity === "error") errorRules.add(ruleKey);
|
|
92
|
+
else warningRules.add(ruleKey);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
errorRuleCount: errorRules.size,
|
|
96
|
+
warningRuleCount: warningRules.size
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
|
|
100
|
+
const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
|
|
101
|
+
return Math.max(0, Math.round(PERFECT_SCORE - penalty));
|
|
102
|
+
};
|
|
103
|
+
const estimateScoreLocally = (diagnostics) => {
|
|
104
|
+
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
105
|
+
const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
106
|
+
const estimatedScore = scoreFromRuleCounts(Math.round(errorRuleCount * (1 - ERROR_ESTIMATED_FIX_RATE)), Math.round(warningRuleCount * (1 - WARNING_ESTIMATED_FIX_RATE)));
|
|
107
|
+
return {
|
|
108
|
+
currentScore,
|
|
109
|
+
currentLabel: getScoreLabel(currentScore),
|
|
110
|
+
estimatedScore,
|
|
111
|
+
estimatedLabel: getScoreLabel(estimatedScore)
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
const calculateScoreLocally = (diagnostics) => {
|
|
115
|
+
const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
|
|
116
|
+
return {
|
|
117
|
+
score: currentScore,
|
|
118
|
+
label: currentLabel
|
|
119
|
+
};
|
|
120
|
+
};
|
|
74
121
|
const calculateScore = async (diagnostics) => {
|
|
75
122
|
try {
|
|
76
123
|
const response = await proxyFetch(SCORE_API_URL, {
|
|
@@ -78,10 +125,10 @@ const calculateScore = async (diagnostics) => {
|
|
|
78
125
|
headers: { "Content-Type": "application/json" },
|
|
79
126
|
body: JSON.stringify({ diagnostics })
|
|
80
127
|
});
|
|
81
|
-
if (!response.ok) return
|
|
128
|
+
if (!response.ok) return calculateScoreLocally(diagnostics);
|
|
82
129
|
return await response.json();
|
|
83
130
|
} catch {
|
|
84
|
-
return
|
|
131
|
+
return calculateScoreLocally(diagnostics);
|
|
85
132
|
}
|
|
86
133
|
};
|
|
87
134
|
|
|
@@ -89,13 +136,33 @@ const calculateScore = async (diagnostics) => {
|
|
|
89
136
|
//#region src/plugin/constants.ts
|
|
90
137
|
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
91
138
|
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/utils/is-file.ts
|
|
141
|
+
const isFile = (filePath) => {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(filePath).isFile();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
92
149
|
//#endregion
|
|
93
150
|
//#region src/utils/read-package-json.ts
|
|
94
|
-
const readPackageJson = (packageJsonPath) =>
|
|
151
|
+
const readPackageJson = (packageJsonPath) => {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error instanceof Error && "code" in error) {
|
|
156
|
+
const { code } = error;
|
|
157
|
+
if (code === "EISDIR" || code === "EACCES") return {};
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
95
162
|
|
|
96
163
|
//#endregion
|
|
97
164
|
//#region src/utils/check-reduced-motion.ts
|
|
98
|
-
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
|
|
165
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
99
166
|
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
100
167
|
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
101
168
|
filePath: "package.json",
|
|
@@ -111,7 +178,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
|
111
178
|
};
|
|
112
179
|
const checkReducedMotion = (rootDirectory) => {
|
|
113
180
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
114
|
-
if (!
|
|
181
|
+
if (!isFile(packageJsonPath)) return [];
|
|
115
182
|
let hasMotionLibrary = false;
|
|
116
183
|
try {
|
|
117
184
|
const packageJson = readPackageJson(packageJsonPath);
|
|
@@ -139,7 +206,7 @@ const checkReducedMotion = (rootDirectory) => {
|
|
|
139
206
|
//#region src/utils/match-glob-pattern.ts
|
|
140
207
|
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
141
208
|
const compileGlobPattern = (pattern) => {
|
|
142
|
-
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
209
|
+
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
143
210
|
let regexSource = "^";
|
|
144
211
|
let characterIndex = 0;
|
|
145
212
|
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
@@ -177,25 +244,67 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
|
|
|
177
244
|
return true;
|
|
178
245
|
});
|
|
179
246
|
};
|
|
247
|
+
const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
|
|
248
|
+
const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
|
|
249
|
+
const isRuleSuppressed = (commentRules, ruleId) => {
|
|
250
|
+
if (!commentRules?.trim()) return true;
|
|
251
|
+
return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
|
|
252
|
+
};
|
|
253
|
+
const filterInlineSuppressions = (diagnostics, rootDirectory) => {
|
|
254
|
+
const fileLineCache = /* @__PURE__ */ new Map();
|
|
255
|
+
const getFileLines = (filePath) => {
|
|
256
|
+
const cached = fileLineCache.get(filePath);
|
|
257
|
+
if (cached !== void 0) return cached;
|
|
258
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
|
|
259
|
+
try {
|
|
260
|
+
const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
261
|
+
fileLineCache.set(filePath, lines);
|
|
262
|
+
return lines;
|
|
263
|
+
} catch {
|
|
264
|
+
fileLineCache.set(filePath, null);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
return diagnostics.filter((diagnostic) => {
|
|
269
|
+
if (diagnostic.line <= 0) return true;
|
|
270
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
271
|
+
if (!lines) return true;
|
|
272
|
+
const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
273
|
+
const currentLine = lines[diagnostic.line - 1];
|
|
274
|
+
if (currentLine) {
|
|
275
|
+
const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
|
|
276
|
+
if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
|
|
277
|
+
}
|
|
278
|
+
if (diagnostic.line >= 2) {
|
|
279
|
+
const prevLine = lines[diagnostic.line - 2];
|
|
280
|
+
if (prevLine) {
|
|
281
|
+
const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
282
|
+
if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
};
|
|
180
288
|
|
|
181
289
|
//#endregion
|
|
182
290
|
//#region src/utils/combine-diagnostics.ts
|
|
183
291
|
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
184
292
|
const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
|
|
185
|
-
const
|
|
293
|
+
const merged = [
|
|
186
294
|
...lintDiagnostics,
|
|
187
295
|
...deadCodeDiagnostics,
|
|
188
296
|
...isDiffMode ? [] : checkReducedMotion(directory)
|
|
189
297
|
];
|
|
190
|
-
return userConfig ? filterIgnoredDiagnostics(
|
|
298
|
+
return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig) : merged, directory);
|
|
191
299
|
};
|
|
192
300
|
|
|
193
301
|
//#endregion
|
|
194
302
|
//#region src/utils/find-monorepo-root.ts
|
|
195
303
|
const isMonorepoRoot = (directory) => {
|
|
196
|
-
if (
|
|
304
|
+
if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
305
|
+
if (isFile(path.join(directory, "nx.json"))) return true;
|
|
197
306
|
const packageJsonPath = path.join(directory, "package.json");
|
|
198
|
-
if (!
|
|
307
|
+
if (!isFile(packageJsonPath)) return false;
|
|
199
308
|
const packageJson = readPackageJson(packageJsonPath);
|
|
200
309
|
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
|
|
201
310
|
};
|
|
@@ -208,6 +317,10 @@ const findMonorepoRoot = (startDirectory) => {
|
|
|
208
317
|
return null;
|
|
209
318
|
};
|
|
210
319
|
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/utils/is-plain-object.ts
|
|
322
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
323
|
+
|
|
211
324
|
//#endregion
|
|
212
325
|
//#region src/utils/discover-project.ts
|
|
213
326
|
const REACT_COMPILER_PACKAGES = new Set([
|
|
@@ -246,9 +359,33 @@ const FRAMEWORK_PACKAGES = {
|
|
|
246
359
|
vite: "vite",
|
|
247
360
|
"react-scripts": "cra",
|
|
248
361
|
"@remix-run/react": "remix",
|
|
249
|
-
gatsby: "gatsby"
|
|
362
|
+
gatsby: "gatsby",
|
|
363
|
+
expo: "expo",
|
|
364
|
+
"react-native": "react-native"
|
|
365
|
+
};
|
|
366
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
367
|
+
"node_modules",
|
|
368
|
+
"dist",
|
|
369
|
+
"build",
|
|
370
|
+
"coverage"
|
|
371
|
+
]);
|
|
372
|
+
const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
373
|
+
let count = 0;
|
|
374
|
+
const stack = [rootDirectory];
|
|
375
|
+
while (stack.length > 0) {
|
|
376
|
+
const currentDirectory = stack.pop();
|
|
377
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
378
|
+
for (const entry of entries) {
|
|
379
|
+
if (entry.isDirectory()) {
|
|
380
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return count;
|
|
250
387
|
};
|
|
251
|
-
const
|
|
388
|
+
const countSourceFilesViaGit = (rootDirectory) => {
|
|
252
389
|
const result = spawnSync("git", [
|
|
253
390
|
"ls-files",
|
|
254
391
|
"--cached",
|
|
@@ -259,9 +396,10 @@ const countSourceFiles = (rootDirectory) => {
|
|
|
259
396
|
encoding: "utf-8",
|
|
260
397
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
261
398
|
});
|
|
262
|
-
if (result.error || result.status !== 0) return
|
|
399
|
+
if (result.error || result.status !== 0) return null;
|
|
263
400
|
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
264
401
|
};
|
|
402
|
+
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
265
403
|
const collectAllDependencies = (packageJson) => ({
|
|
266
404
|
...packageJson.peerDependencies,
|
|
267
405
|
...packageJson.dependencies,
|
|
@@ -271,16 +409,37 @@ const detectFramework = (dependencies) => {
|
|
|
271
409
|
for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
|
|
272
410
|
return "unknown";
|
|
273
411
|
};
|
|
412
|
+
const isCatalogReference = (version) => version.startsWith("catalog:");
|
|
413
|
+
const resolveVersionFromCatalog = (catalog, packageName) => {
|
|
414
|
+
const version = catalog[packageName];
|
|
415
|
+
if (typeof version === "string" && !isCatalogReference(version)) return version;
|
|
416
|
+
return null;
|
|
417
|
+
};
|
|
418
|
+
const resolveCatalogVersion = (packageJson, packageName) => {
|
|
419
|
+
const raw = packageJson;
|
|
420
|
+
if (isPlainObject(raw.catalog)) {
|
|
421
|
+
const version = resolveVersionFromCatalog(raw.catalog, packageName);
|
|
422
|
+
if (version) return version;
|
|
423
|
+
}
|
|
424
|
+
if (isPlainObject(raw.catalogs)) {
|
|
425
|
+
for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
426
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
427
|
+
if (version) return version;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
};
|
|
274
432
|
const extractDependencyInfo = (packageJson) => {
|
|
275
433
|
const allDependencies = collectAllDependencies(packageJson);
|
|
434
|
+
const rawVersion = allDependencies.react ?? null;
|
|
276
435
|
return {
|
|
277
|
-
reactVersion:
|
|
436
|
+
reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
|
|
278
437
|
framework: detectFramework(allDependencies)
|
|
279
438
|
};
|
|
280
439
|
};
|
|
281
440
|
const parsePnpmWorkspacePatterns = (rootDirectory) => {
|
|
282
441
|
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
283
|
-
if (!
|
|
442
|
+
if (!isFile(workspacePath)) return [];
|
|
284
443
|
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
285
444
|
const patterns = [];
|
|
286
445
|
let isInsidePackagesBlock = false;
|
|
@@ -306,14 +465,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
|
306
465
|
const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
|
|
307
466
|
if (!cleanPattern.includes("*")) {
|
|
308
467
|
const directoryPath = path.join(rootDirectory, cleanPattern);
|
|
309
|
-
if (fs.existsSync(directoryPath) &&
|
|
468
|
+
if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
|
|
310
469
|
return [];
|
|
311
470
|
}
|
|
312
471
|
const wildcardIndex = cleanPattern.indexOf("*");
|
|
313
472
|
const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
|
|
314
473
|
const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
|
|
315
474
|
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
316
|
-
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() &&
|
|
475
|
+
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && isFile(path.join(entryPath, "package.json")));
|
|
317
476
|
};
|
|
318
477
|
const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
319
478
|
const monorepoRoot = findMonorepoRoot(directory);
|
|
@@ -321,11 +480,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
321
480
|
reactVersion: null,
|
|
322
481
|
framework: "unknown"
|
|
323
482
|
};
|
|
324
|
-
const
|
|
483
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
484
|
+
if (!isFile(monorepoPackageJsonPath)) return {
|
|
485
|
+
reactVersion: null,
|
|
486
|
+
framework: "unknown"
|
|
487
|
+
};
|
|
488
|
+
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
325
489
|
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
490
|
+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
|
|
326
491
|
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
327
492
|
return {
|
|
328
|
-
reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
|
|
493
|
+
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
329
494
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
330
495
|
};
|
|
331
496
|
};
|
|
@@ -351,7 +516,7 @@ const hasCompilerPackage = (packageJson) => {
|
|
|
351
516
|
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
352
517
|
};
|
|
353
518
|
const fileContainsPattern = (filePath, pattern) => {
|
|
354
|
-
if (!
|
|
519
|
+
if (!isFile(filePath)) return false;
|
|
355
520
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
356
521
|
return pattern.test(content);
|
|
357
522
|
};
|
|
@@ -365,7 +530,7 @@ const detectReactCompiler = (directory, packageJson) => {
|
|
|
365
530
|
let ancestorDirectory = path.dirname(directory);
|
|
366
531
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
367
532
|
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
368
|
-
if (
|
|
533
|
+
if (isFile(ancestorPackagePath)) {
|
|
369
534
|
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
370
535
|
}
|
|
371
536
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
@@ -374,9 +539,10 @@ const detectReactCompiler = (directory, packageJson) => {
|
|
|
374
539
|
};
|
|
375
540
|
const discoverProject = (directory) => {
|
|
376
541
|
const packageJsonPath = path.join(directory, "package.json");
|
|
377
|
-
if (!
|
|
542
|
+
if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
|
|
378
543
|
const packageJson = readPackageJson(packageJsonPath);
|
|
379
544
|
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
545
|
+
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react");
|
|
380
546
|
if (!reactVersion || framework === "unknown") {
|
|
381
547
|
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
|
|
382
548
|
if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
|
|
@@ -406,23 +572,18 @@ const discoverProject = (directory) => {
|
|
|
406
572
|
//#region src/utils/load-config.ts
|
|
407
573
|
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
408
574
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
409
|
-
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
410
575
|
const loadConfig = (rootDirectory) => {
|
|
411
576
|
const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
|
|
412
|
-
if (
|
|
577
|
+
if (isFile(configFilePath)) try {
|
|
413
578
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
414
579
|
const parsed = JSON.parse(fileContent);
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
return null;
|
|
418
|
-
}
|
|
419
|
-
return parsed;
|
|
580
|
+
if (isPlainObject(parsed)) return parsed;
|
|
581
|
+
console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
420
582
|
} catch (error) {
|
|
421
583
|
console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
422
|
-
return null;
|
|
423
584
|
}
|
|
424
585
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
425
|
-
if (
|
|
586
|
+
if (isFile(packageJsonPath)) try {
|
|
426
587
|
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
427
588
|
const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
|
|
428
589
|
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
@@ -490,11 +651,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
|
|
|
490
651
|
const extractFailedPluginName = (error) => {
|
|
491
652
|
return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
|
|
492
653
|
};
|
|
654
|
+
const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
|
|
655
|
+
const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
|
|
493
656
|
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
657
|
+
const tsConfigFile = resolveTsConfigFile(knipCwd);
|
|
494
658
|
const options = await silenced(() => createOptions({
|
|
495
659
|
cwd: knipCwd,
|
|
496
660
|
isShowProgress: false,
|
|
497
|
-
...workspaceName ? { workspace: workspaceName } : {}
|
|
661
|
+
...workspaceName ? { workspace: workspaceName } : {},
|
|
662
|
+
...tsConfigFile ? { tsConfigFile } : {}
|
|
498
663
|
}));
|
|
499
664
|
const parsedConfig = options.parsedConfig;
|
|
500
665
|
for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
|
|
@@ -516,7 +681,7 @@ const runKnip = async (rootDirectory) => {
|
|
|
516
681
|
let knipResult;
|
|
517
682
|
if (monorepoRoot) {
|
|
518
683
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
519
|
-
const workspaceName = (
|
|
684
|
+
const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
|
|
520
685
|
try {
|
|
521
686
|
knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
|
|
522
687
|
} catch {
|
|
@@ -566,6 +731,16 @@ const NEXTJS_RULES = {
|
|
|
566
731
|
"react-doctor/nextjs-no-head-import": "error",
|
|
567
732
|
"react-doctor/nextjs-no-side-effect-in-get-handler": "error"
|
|
568
733
|
};
|
|
734
|
+
const REACT_NATIVE_RULES = {
|
|
735
|
+
"react-doctor/rn-no-raw-text": "error",
|
|
736
|
+
"react-doctor/rn-no-deprecated-modules": "error",
|
|
737
|
+
"react-doctor/rn-no-legacy-expo-packages": "warn",
|
|
738
|
+
"react-doctor/rn-no-dimensions-get": "warn",
|
|
739
|
+
"react-doctor/rn-no-inline-flatlist-renderitem": "warn",
|
|
740
|
+
"react-doctor/rn-no-legacy-shadow-styles": "warn",
|
|
741
|
+
"react-doctor/rn-prefer-reanimated": "warn",
|
|
742
|
+
"react-doctor/rn-no-single-element-style-array": "warn"
|
|
743
|
+
};
|
|
569
744
|
const REACT_COMPILER_RULES = {
|
|
570
745
|
"react-hooks-js/set-state-in-render": "error",
|
|
571
746
|
"react-hooks-js/immutability": "error",
|
|
@@ -669,7 +844,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
669
844
|
"react-doctor/server-after-nonblocking": "warn",
|
|
670
845
|
"react-doctor/client-passive-event-listeners": "warn",
|
|
671
846
|
"react-doctor/async-parallel": "warn",
|
|
672
|
-
...framework === "nextjs" ? NEXTJS_RULES : {}
|
|
847
|
+
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
848
|
+
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
|
|
673
849
|
}
|
|
674
850
|
});
|
|
675
851
|
|
|
@@ -777,10 +953,18 @@ const RULE_CATEGORY_MAP = {
|
|
|
777
953
|
"react-doctor/server-auth-actions": "Server",
|
|
778
954
|
"react-doctor/server-after-nonblocking": "Server",
|
|
779
955
|
"react-doctor/client-passive-event-listeners": "Performance",
|
|
780
|
-
"react-doctor/async-parallel": "Performance"
|
|
956
|
+
"react-doctor/async-parallel": "Performance",
|
|
957
|
+
"react-doctor/rn-no-raw-text": "React Native",
|
|
958
|
+
"react-doctor/rn-no-deprecated-modules": "React Native",
|
|
959
|
+
"react-doctor/rn-no-legacy-expo-packages": "React Native",
|
|
960
|
+
"react-doctor/rn-no-dimensions-get": "React Native",
|
|
961
|
+
"react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
|
|
962
|
+
"react-doctor/rn-no-legacy-shadow-styles": "React Native",
|
|
963
|
+
"react-doctor/rn-prefer-reanimated": "React Native",
|
|
964
|
+
"react-doctor/rn-no-single-element-style-array": "React Native"
|
|
781
965
|
};
|
|
782
966
|
const RULE_HELP_MAP = {
|
|
783
|
-
"no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop}
|
|
967
|
+
"no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`. See https://react.dev/learn/you-might-not-need-an-effect",
|
|
784
968
|
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
785
969
|
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
786
970
|
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
@@ -820,7 +1004,7 @@ const RULE_HELP_MAP = {
|
|
|
820
1004
|
"nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
|
|
821
1005
|
"nextjs-no-client-fetch-for-server-data": "Remove 'use client' and fetch directly in the Server Component — no API round-trip, secrets stay on server",
|
|
822
1006
|
"nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
|
|
823
|
-
"nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in
|
|
1007
|
+
"nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
|
|
824
1008
|
"nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
|
|
825
1009
|
"nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
|
|
826
1010
|
"nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
|
|
@@ -833,7 +1017,15 @@ const RULE_HELP_MAP = {
|
|
|
833
1017
|
"server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
|
|
834
1018
|
"server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
|
|
835
1019
|
"client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
|
|
836
|
-
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
|
|
1020
|
+
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
|
|
1021
|
+
"rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
|
|
1022
|
+
"rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
|
|
1023
|
+
"rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
|
|
1024
|
+
"rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
|
|
1025
|
+
"rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
|
|
1026
|
+
"rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
|
|
1027
|
+
"rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
|
|
1028
|
+
"rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
|
|
837
1029
|
};
|
|
838
1030
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
839
1031
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
@@ -900,7 +1092,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
|
|
|
900
1092
|
child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
|
|
901
1093
|
child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
|
|
902
1094
|
child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
|
|
903
|
-
child.on("close", () => {
|
|
1095
|
+
child.on("close", (code, signal) => {
|
|
1096
|
+
if (signal) {
|
|
1097
|
+
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
1098
|
+
const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
|
|
1099
|
+
const detail = stderrOutput ? `: ${stderrOutput}` : "";
|
|
1100
|
+
reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
904
1103
|
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
|
|
905
1104
|
if (!output) {
|
|
906
1105
|
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|