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/cli.js
CHANGED
|
@@ -33,7 +33,6 @@ const FETCH_TIMEOUT_MS = 1e4;
|
|
|
33
33
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
34
34
|
const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
|
|
35
35
|
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
36
|
-
const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
|
|
37
36
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
38
37
|
const ERROR_RULE_PENALTY = 1.5;
|
|
39
38
|
const WARNING_RULE_PENALTY = .75;
|
|
@@ -129,6 +128,13 @@ const estimateScoreLocally = (diagnostics) => {
|
|
|
129
128
|
estimatedLabel: getScoreLabel(estimatedScore)
|
|
130
129
|
};
|
|
131
130
|
};
|
|
131
|
+
const calculateScoreLocally = (diagnostics) => {
|
|
132
|
+
const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
|
|
133
|
+
return {
|
|
134
|
+
score: currentScore,
|
|
135
|
+
label: currentLabel
|
|
136
|
+
};
|
|
137
|
+
};
|
|
132
138
|
const calculateScore = async (diagnostics) => {
|
|
133
139
|
try {
|
|
134
140
|
const response = await proxyFetch(SCORE_API_URL, {
|
|
@@ -136,10 +142,10 @@ const calculateScore = async (diagnostics) => {
|
|
|
136
142
|
headers: { "Content-Type": "application/json" },
|
|
137
143
|
body: JSON.stringify({ diagnostics })
|
|
138
144
|
});
|
|
139
|
-
if (!response.ok) return
|
|
145
|
+
if (!response.ok) return calculateScoreLocally(diagnostics);
|
|
140
146
|
return await response.json();
|
|
141
147
|
} catch {
|
|
142
|
-
return
|
|
148
|
+
return calculateScoreLocally(diagnostics);
|
|
143
149
|
}
|
|
144
150
|
};
|
|
145
151
|
const fetchEstimatedScore = async (diagnostics) => {
|
|
@@ -178,13 +184,33 @@ const colorizeByScore = (text, score) => {
|
|
|
178
184
|
//#region src/plugin/constants.ts
|
|
179
185
|
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
180
186
|
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/utils/is-file.ts
|
|
189
|
+
const isFile = (filePath) => {
|
|
190
|
+
try {
|
|
191
|
+
return fs.statSync(filePath).isFile();
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
181
197
|
//#endregion
|
|
182
198
|
//#region src/utils/read-package-json.ts
|
|
183
|
-
const readPackageJson = (packageJsonPath) =>
|
|
199
|
+
const readPackageJson = (packageJsonPath) => {
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof Error && "code" in error) {
|
|
204
|
+
const { code } = error;
|
|
205
|
+
if (code === "EISDIR" || code === "EACCES") return {};
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
184
210
|
|
|
185
211
|
//#endregion
|
|
186
212
|
//#region src/utils/check-reduced-motion.ts
|
|
187
|
-
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
|
|
213
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
188
214
|
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
189
215
|
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
190
216
|
filePath: "package.json",
|
|
@@ -200,7 +226,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
|
200
226
|
};
|
|
201
227
|
const checkReducedMotion = (rootDirectory) => {
|
|
202
228
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
203
|
-
if (!
|
|
229
|
+
if (!isFile(packageJsonPath)) return [];
|
|
204
230
|
let hasMotionLibrary = false;
|
|
205
231
|
try {
|
|
206
232
|
const packageJson = readPackageJson(packageJsonPath);
|
|
@@ -228,7 +254,7 @@ const checkReducedMotion = (rootDirectory) => {
|
|
|
228
254
|
//#region src/utils/match-glob-pattern.ts
|
|
229
255
|
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
230
256
|
const compileGlobPattern = (pattern) => {
|
|
231
|
-
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
257
|
+
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
232
258
|
let regexSource = "^";
|
|
233
259
|
let characterIndex = 0;
|
|
234
260
|
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
@@ -266,25 +292,67 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
|
|
|
266
292
|
return true;
|
|
267
293
|
});
|
|
268
294
|
};
|
|
295
|
+
const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
|
|
296
|
+
const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
|
|
297
|
+
const isRuleSuppressed = (commentRules, ruleId) => {
|
|
298
|
+
if (!commentRules?.trim()) return true;
|
|
299
|
+
return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
|
|
300
|
+
};
|
|
301
|
+
const filterInlineSuppressions = (diagnostics, rootDirectory) => {
|
|
302
|
+
const fileLineCache = /* @__PURE__ */ new Map();
|
|
303
|
+
const getFileLines = (filePath) => {
|
|
304
|
+
const cached = fileLineCache.get(filePath);
|
|
305
|
+
if (cached !== void 0) return cached;
|
|
306
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
|
|
307
|
+
try {
|
|
308
|
+
const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
309
|
+
fileLineCache.set(filePath, lines);
|
|
310
|
+
return lines;
|
|
311
|
+
} catch {
|
|
312
|
+
fileLineCache.set(filePath, null);
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
return diagnostics.filter((diagnostic) => {
|
|
317
|
+
if (diagnostic.line <= 0) return true;
|
|
318
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
319
|
+
if (!lines) return true;
|
|
320
|
+
const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
321
|
+
const currentLine = lines[diagnostic.line - 1];
|
|
322
|
+
if (currentLine) {
|
|
323
|
+
const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
|
|
324
|
+
if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
|
|
325
|
+
}
|
|
326
|
+
if (diagnostic.line >= 2) {
|
|
327
|
+
const prevLine = lines[diagnostic.line - 2];
|
|
328
|
+
if (prevLine) {
|
|
329
|
+
const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
330
|
+
if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
});
|
|
335
|
+
};
|
|
269
336
|
|
|
270
337
|
//#endregion
|
|
271
338
|
//#region src/utils/combine-diagnostics.ts
|
|
272
339
|
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
273
340
|
const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
|
|
274
|
-
const
|
|
341
|
+
const merged = [
|
|
275
342
|
...lintDiagnostics,
|
|
276
343
|
...deadCodeDiagnostics,
|
|
277
344
|
...isDiffMode ? [] : checkReducedMotion(directory)
|
|
278
345
|
];
|
|
279
|
-
return userConfig ? filterIgnoredDiagnostics(
|
|
346
|
+
return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig) : merged, directory);
|
|
280
347
|
};
|
|
281
348
|
|
|
282
349
|
//#endregion
|
|
283
350
|
//#region src/utils/find-monorepo-root.ts
|
|
284
351
|
const isMonorepoRoot = (directory) => {
|
|
285
|
-
if (
|
|
352
|
+
if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
353
|
+
if (isFile(path.join(directory, "nx.json"))) return true;
|
|
286
354
|
const packageJsonPath = path.join(directory, "package.json");
|
|
287
|
-
if (!
|
|
355
|
+
if (!isFile(packageJsonPath)) return false;
|
|
288
356
|
const packageJson = readPackageJson(packageJsonPath);
|
|
289
357
|
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
|
|
290
358
|
};
|
|
@@ -297,6 +365,10 @@ const findMonorepoRoot = (startDirectory) => {
|
|
|
297
365
|
return null;
|
|
298
366
|
};
|
|
299
367
|
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/utils/is-plain-object.ts
|
|
370
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
371
|
+
|
|
300
372
|
//#endregion
|
|
301
373
|
//#region src/utils/discover-project.ts
|
|
302
374
|
const REACT_COMPILER_PACKAGES = new Set([
|
|
@@ -335,7 +407,9 @@ const FRAMEWORK_PACKAGES = {
|
|
|
335
407
|
vite: "vite",
|
|
336
408
|
"react-scripts": "cra",
|
|
337
409
|
"@remix-run/react": "remix",
|
|
338
|
-
gatsby: "gatsby"
|
|
410
|
+
gatsby: "gatsby",
|
|
411
|
+
expo: "expo",
|
|
412
|
+
"react-native": "react-native"
|
|
339
413
|
};
|
|
340
414
|
const FRAMEWORK_DISPLAY_NAMES = {
|
|
341
415
|
nextjs: "Next.js",
|
|
@@ -343,10 +417,34 @@ const FRAMEWORK_DISPLAY_NAMES = {
|
|
|
343
417
|
cra: "Create React App",
|
|
344
418
|
remix: "Remix",
|
|
345
419
|
gatsby: "Gatsby",
|
|
420
|
+
expo: "Expo",
|
|
421
|
+
"react-native": "React Native",
|
|
346
422
|
unknown: "React"
|
|
347
423
|
};
|
|
348
424
|
const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
|
|
349
|
-
const
|
|
425
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
426
|
+
"node_modules",
|
|
427
|
+
"dist",
|
|
428
|
+
"build",
|
|
429
|
+
"coverage"
|
|
430
|
+
]);
|
|
431
|
+
const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
432
|
+
let count = 0;
|
|
433
|
+
const stack = [rootDirectory];
|
|
434
|
+
while (stack.length > 0) {
|
|
435
|
+
const currentDirectory = stack.pop();
|
|
436
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
437
|
+
for (const entry of entries) {
|
|
438
|
+
if (entry.isDirectory()) {
|
|
439
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return count;
|
|
446
|
+
};
|
|
447
|
+
const countSourceFilesViaGit = (rootDirectory) => {
|
|
350
448
|
const result = spawnSync("git", [
|
|
351
449
|
"ls-files",
|
|
352
450
|
"--cached",
|
|
@@ -357,9 +455,10 @@ const countSourceFiles = (rootDirectory) => {
|
|
|
357
455
|
encoding: "utf-8",
|
|
358
456
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
359
457
|
});
|
|
360
|
-
if (result.error || result.status !== 0) return
|
|
458
|
+
if (result.error || result.status !== 0) return null;
|
|
361
459
|
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
362
460
|
};
|
|
461
|
+
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
363
462
|
const collectAllDependencies = (packageJson) => ({
|
|
364
463
|
...packageJson.peerDependencies,
|
|
365
464
|
...packageJson.dependencies,
|
|
@@ -369,16 +468,37 @@ const detectFramework = (dependencies) => {
|
|
|
369
468
|
for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
|
|
370
469
|
return "unknown";
|
|
371
470
|
};
|
|
471
|
+
const isCatalogReference = (version) => version.startsWith("catalog:");
|
|
472
|
+
const resolveVersionFromCatalog = (catalog, packageName) => {
|
|
473
|
+
const version = catalog[packageName];
|
|
474
|
+
if (typeof version === "string" && !isCatalogReference(version)) return version;
|
|
475
|
+
return null;
|
|
476
|
+
};
|
|
477
|
+
const resolveCatalogVersion = (packageJson, packageName) => {
|
|
478
|
+
const raw = packageJson;
|
|
479
|
+
if (isPlainObject(raw.catalog)) {
|
|
480
|
+
const version = resolveVersionFromCatalog(raw.catalog, packageName);
|
|
481
|
+
if (version) return version;
|
|
482
|
+
}
|
|
483
|
+
if (isPlainObject(raw.catalogs)) {
|
|
484
|
+
for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
485
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
486
|
+
if (version) return version;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
};
|
|
372
491
|
const extractDependencyInfo = (packageJson) => {
|
|
373
492
|
const allDependencies = collectAllDependencies(packageJson);
|
|
493
|
+
const rawVersion = allDependencies.react ?? null;
|
|
374
494
|
return {
|
|
375
|
-
reactVersion:
|
|
495
|
+
reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
|
|
376
496
|
framework: detectFramework(allDependencies)
|
|
377
497
|
};
|
|
378
498
|
};
|
|
379
499
|
const parsePnpmWorkspacePatterns = (rootDirectory) => {
|
|
380
500
|
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
381
|
-
if (!
|
|
501
|
+
if (!isFile(workspacePath)) return [];
|
|
382
502
|
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
383
503
|
const patterns = [];
|
|
384
504
|
let isInsidePackagesBlock = false;
|
|
@@ -404,14 +524,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
|
404
524
|
const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
|
|
405
525
|
if (!cleanPattern.includes("*")) {
|
|
406
526
|
const directoryPath = path.join(rootDirectory, cleanPattern);
|
|
407
|
-
if (fs.existsSync(directoryPath) &&
|
|
527
|
+
if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
|
|
408
528
|
return [];
|
|
409
529
|
}
|
|
410
530
|
const wildcardIndex = cleanPattern.indexOf("*");
|
|
411
531
|
const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
|
|
412
532
|
const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
|
|
413
533
|
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
414
|
-
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() &&
|
|
534
|
+
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")));
|
|
415
535
|
};
|
|
416
536
|
const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
417
537
|
const monorepoRoot = findMonorepoRoot(directory);
|
|
@@ -419,11 +539,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
419
539
|
reactVersion: null,
|
|
420
540
|
framework: "unknown"
|
|
421
541
|
};
|
|
422
|
-
const
|
|
542
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
543
|
+
if (!isFile(monorepoPackageJsonPath)) return {
|
|
544
|
+
reactVersion: null,
|
|
545
|
+
framework: "unknown"
|
|
546
|
+
};
|
|
547
|
+
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
423
548
|
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
549
|
+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
|
|
424
550
|
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
425
551
|
return {
|
|
426
|
-
reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
|
|
552
|
+
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
427
553
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
428
554
|
};
|
|
429
555
|
};
|
|
@@ -444,19 +570,35 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
|
444
570
|
}
|
|
445
571
|
return result;
|
|
446
572
|
};
|
|
573
|
+
const REACT_DEPENDENCY_NAMES = new Set([
|
|
574
|
+
"react",
|
|
575
|
+
"react-native",
|
|
576
|
+
"next"
|
|
577
|
+
]);
|
|
447
578
|
const hasReactDependency = (packageJson) => {
|
|
448
579
|
const allDependencies = collectAllDependencies(packageJson);
|
|
449
|
-
return Object.keys(allDependencies).some((packageName) =>
|
|
580
|
+
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
450
581
|
};
|
|
451
582
|
const discoverReactSubprojects = (rootDirectory) => {
|
|
452
583
|
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
453
|
-
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
454
584
|
const packages = [];
|
|
585
|
+
const rootPackageJsonPath = path.join(rootDirectory, "package.json");
|
|
586
|
+
if (isFile(rootPackageJsonPath)) {
|
|
587
|
+
const rootPackageJson = readPackageJson(rootPackageJsonPath);
|
|
588
|
+
if (hasReactDependency(rootPackageJson)) {
|
|
589
|
+
const name = rootPackageJson.name ?? path.basename(rootDirectory);
|
|
590
|
+
packages.push({
|
|
591
|
+
name,
|
|
592
|
+
directory: rootDirectory
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
455
597
|
for (const entry of entries) {
|
|
456
598
|
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
457
599
|
const subdirectory = path.join(rootDirectory, entry.name);
|
|
458
600
|
const packageJsonPath = path.join(subdirectory, "package.json");
|
|
459
|
-
if (!
|
|
601
|
+
if (!isFile(packageJsonPath)) continue;
|
|
460
602
|
const packageJson = readPackageJson(packageJsonPath);
|
|
461
603
|
if (!hasReactDependency(packageJson)) continue;
|
|
462
604
|
const name = packageJson.name ?? entry.name;
|
|
@@ -469,7 +611,7 @@ const discoverReactSubprojects = (rootDirectory) => {
|
|
|
469
611
|
};
|
|
470
612
|
const listWorkspacePackages = (rootDirectory) => {
|
|
471
613
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
472
|
-
if (!
|
|
614
|
+
if (!isFile(packageJsonPath)) return [];
|
|
473
615
|
const patterns = getWorkspacePatterns(rootDirectory, readPackageJson(packageJsonPath));
|
|
474
616
|
if (patterns.length === 0) return [];
|
|
475
617
|
const packages = [];
|
|
@@ -492,7 +634,7 @@ const hasCompilerPackage = (packageJson) => {
|
|
|
492
634
|
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
493
635
|
};
|
|
494
636
|
const fileContainsPattern = (filePath, pattern) => {
|
|
495
|
-
if (!
|
|
637
|
+
if (!isFile(filePath)) return false;
|
|
496
638
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
497
639
|
return pattern.test(content);
|
|
498
640
|
};
|
|
@@ -506,7 +648,7 @@ const detectReactCompiler = (directory, packageJson) => {
|
|
|
506
648
|
let ancestorDirectory = path.dirname(directory);
|
|
507
649
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
508
650
|
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
509
|
-
if (
|
|
651
|
+
if (isFile(ancestorPackagePath)) {
|
|
510
652
|
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
511
653
|
}
|
|
512
654
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
@@ -515,9 +657,10 @@ const detectReactCompiler = (directory, packageJson) => {
|
|
|
515
657
|
};
|
|
516
658
|
const discoverProject = (directory) => {
|
|
517
659
|
const packageJsonPath = path.join(directory, "package.json");
|
|
518
|
-
if (!
|
|
660
|
+
if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
|
|
519
661
|
const packageJson = readPackageJson(packageJsonPath);
|
|
520
662
|
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
663
|
+
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react");
|
|
521
664
|
if (!reactVersion || framework === "unknown") {
|
|
522
665
|
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
|
|
523
666
|
if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
|
|
@@ -617,23 +760,18 @@ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText
|
|
|
617
760
|
//#region src/utils/load-config.ts
|
|
618
761
|
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
619
762
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
620
|
-
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
621
763
|
const loadConfig = (rootDirectory) => {
|
|
622
764
|
const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
|
|
623
|
-
if (
|
|
765
|
+
if (isFile(configFilePath)) try {
|
|
624
766
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
625
767
|
const parsed = JSON.parse(fileContent);
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
return null;
|
|
629
|
-
}
|
|
630
|
-
return parsed;
|
|
768
|
+
if (isPlainObject(parsed)) return parsed;
|
|
769
|
+
console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
631
770
|
} catch (error) {
|
|
632
771
|
console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
633
|
-
return null;
|
|
634
772
|
}
|
|
635
773
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
636
|
-
if (
|
|
774
|
+
if (isFile(packageJsonPath)) try {
|
|
637
775
|
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
638
776
|
const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
|
|
639
777
|
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
@@ -862,11 +1000,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
|
|
|
862
1000
|
const extractFailedPluginName = (error) => {
|
|
863
1001
|
return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
|
|
864
1002
|
};
|
|
1003
|
+
const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
|
|
1004
|
+
const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
|
|
865
1005
|
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
1006
|
+
const tsConfigFile = resolveTsConfigFile(knipCwd);
|
|
866
1007
|
const options = await silenced(() => createOptions({
|
|
867
1008
|
cwd: knipCwd,
|
|
868
1009
|
isShowProgress: false,
|
|
869
|
-
...workspaceName ? { workspace: workspaceName } : {}
|
|
1010
|
+
...workspaceName ? { workspace: workspaceName } : {},
|
|
1011
|
+
...tsConfigFile ? { tsConfigFile } : {}
|
|
870
1012
|
}));
|
|
871
1013
|
const parsedConfig = options.parsedConfig;
|
|
872
1014
|
for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
|
|
@@ -888,7 +1030,7 @@ const runKnip = async (rootDirectory) => {
|
|
|
888
1030
|
let knipResult;
|
|
889
1031
|
if (monorepoRoot) {
|
|
890
1032
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
891
|
-
const workspaceName = (
|
|
1033
|
+
const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
|
|
892
1034
|
try {
|
|
893
1035
|
knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
|
|
894
1036
|
} catch {
|
|
@@ -938,6 +1080,16 @@ const NEXTJS_RULES = {
|
|
|
938
1080
|
"react-doctor/nextjs-no-head-import": "error",
|
|
939
1081
|
"react-doctor/nextjs-no-side-effect-in-get-handler": "error"
|
|
940
1082
|
};
|
|
1083
|
+
const REACT_NATIVE_RULES = {
|
|
1084
|
+
"react-doctor/rn-no-raw-text": "error",
|
|
1085
|
+
"react-doctor/rn-no-deprecated-modules": "error",
|
|
1086
|
+
"react-doctor/rn-no-legacy-expo-packages": "warn",
|
|
1087
|
+
"react-doctor/rn-no-dimensions-get": "warn",
|
|
1088
|
+
"react-doctor/rn-no-inline-flatlist-renderitem": "warn",
|
|
1089
|
+
"react-doctor/rn-no-legacy-shadow-styles": "warn",
|
|
1090
|
+
"react-doctor/rn-prefer-reanimated": "warn",
|
|
1091
|
+
"react-doctor/rn-no-single-element-style-array": "warn"
|
|
1092
|
+
};
|
|
941
1093
|
const REACT_COMPILER_RULES = {
|
|
942
1094
|
"react-hooks-js/set-state-in-render": "error",
|
|
943
1095
|
"react-hooks-js/immutability": "error",
|
|
@@ -1041,7 +1193,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
1041
1193
|
"react-doctor/server-after-nonblocking": "warn",
|
|
1042
1194
|
"react-doctor/client-passive-event-listeners": "warn",
|
|
1043
1195
|
"react-doctor/async-parallel": "warn",
|
|
1044
|
-
...framework === "nextjs" ? NEXTJS_RULES : {}
|
|
1196
|
+
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
1197
|
+
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
|
|
1045
1198
|
}
|
|
1046
1199
|
});
|
|
1047
1200
|
|
|
@@ -1149,10 +1302,18 @@ const RULE_CATEGORY_MAP = {
|
|
|
1149
1302
|
"react-doctor/server-auth-actions": "Server",
|
|
1150
1303
|
"react-doctor/server-after-nonblocking": "Server",
|
|
1151
1304
|
"react-doctor/client-passive-event-listeners": "Performance",
|
|
1152
|
-
"react-doctor/async-parallel": "Performance"
|
|
1305
|
+
"react-doctor/async-parallel": "Performance",
|
|
1306
|
+
"react-doctor/rn-no-raw-text": "React Native",
|
|
1307
|
+
"react-doctor/rn-no-deprecated-modules": "React Native",
|
|
1308
|
+
"react-doctor/rn-no-legacy-expo-packages": "React Native",
|
|
1309
|
+
"react-doctor/rn-no-dimensions-get": "React Native",
|
|
1310
|
+
"react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
|
|
1311
|
+
"react-doctor/rn-no-legacy-shadow-styles": "React Native",
|
|
1312
|
+
"react-doctor/rn-prefer-reanimated": "React Native",
|
|
1313
|
+
"react-doctor/rn-no-single-element-style-array": "React Native"
|
|
1153
1314
|
};
|
|
1154
1315
|
const RULE_HELP_MAP = {
|
|
1155
|
-
"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}
|
|
1316
|
+
"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",
|
|
1156
1317
|
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
1157
1318
|
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
1158
1319
|
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
@@ -1192,7 +1353,7 @@ const RULE_HELP_MAP = {
|
|
|
1192
1353
|
"nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
|
|
1193
1354
|
"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",
|
|
1194
1355
|
"nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
|
|
1195
|
-
"nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in
|
|
1356
|
+
"nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
|
|
1196
1357
|
"nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
|
|
1197
1358
|
"nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
|
|
1198
1359
|
"nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
|
|
@@ -1205,7 +1366,15 @@ const RULE_HELP_MAP = {
|
|
|
1205
1366
|
"server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
|
|
1206
1367
|
"server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
|
|
1207
1368
|
"client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
|
|
1208
|
-
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
|
|
1369
|
+
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
|
|
1370
|
+
"rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
|
|
1371
|
+
"rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
|
|
1372
|
+
"rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
|
|
1373
|
+
"rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
|
|
1374
|
+
"rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
|
|
1375
|
+
"rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
|
|
1376
|
+
"rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
|
|
1377
|
+
"rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
|
|
1209
1378
|
};
|
|
1210
1379
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
1211
1380
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
@@ -1272,7 +1441,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
|
|
|
1272
1441
|
child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
|
|
1273
1442
|
child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
|
|
1274
1443
|
child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
|
|
1275
|
-
child.on("close", () => {
|
|
1444
|
+
child.on("close", (code, signal) => {
|
|
1445
|
+
if (signal) {
|
|
1446
|
+
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
1447
|
+
const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
|
|
1448
|
+
const detail = stderrOutput ? `: ${stderrOutput}` : "";
|
|
1449
|
+
reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1276
1452
|
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
|
|
1277
1453
|
if (!output) {
|
|
1278
1454
|
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
@@ -1543,7 +1719,7 @@ const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMillis
|
|
|
1543
1719
|
renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
|
|
1544
1720
|
return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
|
|
1545
1721
|
};
|
|
1546
|
-
const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
|
|
1722
|
+
const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
|
|
1547
1723
|
printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
|
|
1548
1724
|
try {
|
|
1549
1725
|
const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
|
|
@@ -1552,9 +1728,11 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
|
|
|
1552
1728
|
} catch {
|
|
1553
1729
|
logger.break();
|
|
1554
1730
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1731
|
+
if (!isOffline) {
|
|
1732
|
+
const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
|
|
1733
|
+
logger.break();
|
|
1734
|
+
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1735
|
+
}
|
|
1558
1736
|
};
|
|
1559
1737
|
const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
|
|
1560
1738
|
if (!isLintEnabled) return null;
|
|
@@ -1639,13 +1817,15 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1639
1817
|
return lintDiagnostics;
|
|
1640
1818
|
} catch (error) {
|
|
1641
1819
|
didLintFail = true;
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1820
|
+
if (!options.scoreOnly) {
|
|
1821
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1822
|
+
if (errorMessage.includes("native binding")) {
|
|
1823
|
+
lintSpinner?.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
|
|
1824
|
+
logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
|
|
1825
|
+
} else {
|
|
1826
|
+
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
|
|
1827
|
+
logger.error(errorMessage);
|
|
1828
|
+
}
|
|
1649
1829
|
}
|
|
1650
1830
|
return [];
|
|
1651
1831
|
}
|
|
@@ -1658,8 +1838,10 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1658
1838
|
return knipDiagnostics;
|
|
1659
1839
|
} catch (error) {
|
|
1660
1840
|
didDeadCodeFail = true;
|
|
1661
|
-
|
|
1662
|
-
|
|
1841
|
+
if (!options.scoreOnly) {
|
|
1842
|
+
deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
|
|
1843
|
+
logger.error(String(error));
|
|
1844
|
+
}
|
|
1663
1845
|
return [];
|
|
1664
1846
|
}
|
|
1665
1847
|
})() : Promise.resolve([]);
|
|
@@ -1670,8 +1852,8 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1670
1852
|
if (didLintFail) skippedChecks.push("lint");
|
|
1671
1853
|
if (didDeadCodeFail) skippedChecks.push("dead code");
|
|
1672
1854
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
1673
|
-
const scoreResult = options.offline ?
|
|
1674
|
-
const noScoreMessage =
|
|
1855
|
+
const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
|
|
1856
|
+
const noScoreMessage = OFFLINE_MESSAGE;
|
|
1675
1857
|
if (options.scoreOnly) {
|
|
1676
1858
|
if (scoreResult) logger.log(`${scoreResult.score}`);
|
|
1677
1859
|
else logger.dim(noScoreMessage);
|
|
@@ -1702,7 +1884,7 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1702
1884
|
}
|
|
1703
1885
|
printDiagnostics(diagnostics, options.verbose);
|
|
1704
1886
|
const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
|
|
1705
|
-
printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
|
|
1887
|
+
printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, options.offline);
|
|
1706
1888
|
if (hasSkippedChecks) {
|
|
1707
1889
|
const skippedLabel = skippedChecks.join(" and ");
|
|
1708
1890
|
logger.break();
|
|
@@ -2029,7 +2211,18 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
2029
2211
|
|
|
2030
2212
|
//#endregion
|
|
2031
2213
|
//#region src/cli.ts
|
|
2032
|
-
const VERSION = "0.0.
|
|
2214
|
+
const VERSION = "0.0.30";
|
|
2215
|
+
const VALID_FAIL_ON_LEVELS = new Set([
|
|
2216
|
+
"error",
|
|
2217
|
+
"warning",
|
|
2218
|
+
"none"
|
|
2219
|
+
]);
|
|
2220
|
+
const isValidFailOnLevel = (level) => VALID_FAIL_ON_LEVELS.has(level);
|
|
2221
|
+
const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
|
|
2222
|
+
if (failOnLevel === "none") return false;
|
|
2223
|
+
if (failOnLevel === "warning") return diagnostics.length > 0;
|
|
2224
|
+
return diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
2225
|
+
};
|
|
2033
2226
|
const exitWithFixHint = () => {
|
|
2034
2227
|
logger.break();
|
|
2035
2228
|
logger.log("Cancelled.");
|
|
@@ -2052,8 +2245,8 @@ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVa
|
|
|
2052
2245
|
const resolveCliScanOptions = (flags, userConfig, programInstance) => {
|
|
2053
2246
|
const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
|
|
2054
2247
|
return {
|
|
2055
|
-
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ??
|
|
2056
|
-
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ??
|
|
2248
|
+
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
|
|
2249
|
+
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
|
|
2057
2250
|
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
|
|
2058
2251
|
scoreOnly: flags.score,
|
|
2059
2252
|
offline: flags.offline
|
|
@@ -2081,7 +2274,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
|
|
|
2081
2274
|
});
|
|
2082
2275
|
return Boolean(shouldScanChangedOnly);
|
|
2083
2276
|
};
|
|
2084
|
-
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("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--
|
|
2277
|
+
const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--ami", "enable Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
|
|
2085
2278
|
const isScoreOnly = flags.score;
|
|
2086
2279
|
try {
|
|
2087
2280
|
const resolvedDirectory = path.resolve(directory);
|
|
@@ -2131,6 +2324,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
2131
2324
|
allDiagnostics.push(...scanResult.diagnostics);
|
|
2132
2325
|
if (!isScoreOnly) logger.break();
|
|
2133
2326
|
}
|
|
2327
|
+
const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
|
|
2328
|
+
if (shouldFailForDiagnostics(allDiagnostics, isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none")) process.exitCode = 1;
|
|
2134
2329
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|
|
2135
2330
|
if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
|
|
2136
2331
|
await maybePromptSkillInstall(shouldSkipAmiPrompts);
|
|
@@ -2193,7 +2388,11 @@ const openAmiToFix = (directory) => {
|
|
|
2193
2388
|
if (!isInstalled) {
|
|
2194
2389
|
if (process.platform === "darwin") {
|
|
2195
2390
|
installAmi();
|
|
2196
|
-
logger.success("Ami installed successfully.");
|
|
2391
|
+
if (isAmiInstalled()) logger.success("Ami installed successfully.");
|
|
2392
|
+
else {
|
|
2393
|
+
logger.error("Installation could not be verified.");
|
|
2394
|
+
logger.dim(`Install manually at ${highlighter.info(AMI_WEBSITE_URL)}`);
|
|
2395
|
+
}
|
|
2197
2396
|
} else {
|
|
2198
2397
|
logger.error("Ami is not installed.");
|
|
2199
2398
|
logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);
|