react-doctor 0.0.17 → 0.0.19
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 +41 -0
- package/dist/cli.js +470 -222
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +196 -27
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -5,9 +5,9 @@ import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
5
5
|
import os, { homedir, tmpdir } from "node:os";
|
|
6
6
|
import path, { join } from "node:path";
|
|
7
7
|
import { Command } from "commander";
|
|
8
|
-
import pc from "picocolors";
|
|
9
8
|
import { randomUUID } from "node:crypto";
|
|
10
9
|
import { performance } from "node:perf_hooks";
|
|
10
|
+
import pc from "picocolors";
|
|
11
11
|
import { main } from "knip";
|
|
12
12
|
import { createOptions } from "knip/session";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -30,84 +30,7 @@ const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
|
30
30
|
const SHARE_BASE_URL = "https://www.react.doctor/share";
|
|
31
31
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
32
32
|
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
33
|
-
|
|
34
|
-
//#endregion
|
|
35
|
-
//#region src/utils/highlighter.ts
|
|
36
|
-
const highlighter = {
|
|
37
|
-
error: pc.red,
|
|
38
|
-
warn: pc.yellow,
|
|
39
|
-
info: pc.cyan,
|
|
40
|
-
success: pc.green,
|
|
41
|
-
dim: pc.dim
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
//#endregion
|
|
45
|
-
//#region src/utils/strip-ansi.ts
|
|
46
|
-
const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
|
|
47
|
-
const ANSI_ESCAPE_PATTERN = new RegExp(ANSI_ESCAPE_SEQUENCE, "g");
|
|
48
|
-
const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
49
|
-
|
|
50
|
-
//#endregion
|
|
51
|
-
//#region src/utils/logger.ts
|
|
52
|
-
const loggerCaptureState = {
|
|
53
|
-
isEnabled: false,
|
|
54
|
-
lines: []
|
|
55
|
-
};
|
|
56
|
-
const captureLogLine = (text) => {
|
|
57
|
-
if (!loggerCaptureState.isEnabled) return;
|
|
58
|
-
loggerCaptureState.lines.push(stripAnsi(text));
|
|
59
|
-
};
|
|
60
|
-
const writeLogLine = (text) => {
|
|
61
|
-
console.log(text);
|
|
62
|
-
captureLogLine(text);
|
|
63
|
-
};
|
|
64
|
-
const startLoggerCapture = () => {
|
|
65
|
-
loggerCaptureState.isEnabled = true;
|
|
66
|
-
loggerCaptureState.lines = [];
|
|
67
|
-
};
|
|
68
|
-
const stopLoggerCapture = () => {
|
|
69
|
-
const capturedOutput = loggerCaptureState.lines.join("\n");
|
|
70
|
-
loggerCaptureState.isEnabled = false;
|
|
71
|
-
loggerCaptureState.lines = [];
|
|
72
|
-
return capturedOutput;
|
|
73
|
-
};
|
|
74
|
-
const logger = {
|
|
75
|
-
error(...args) {
|
|
76
|
-
writeLogLine(highlighter.error(args.join(" ")));
|
|
77
|
-
},
|
|
78
|
-
warn(...args) {
|
|
79
|
-
writeLogLine(highlighter.warn(args.join(" ")));
|
|
80
|
-
},
|
|
81
|
-
info(...args) {
|
|
82
|
-
writeLogLine(highlighter.info(args.join(" ")));
|
|
83
|
-
},
|
|
84
|
-
success(...args) {
|
|
85
|
-
writeLogLine(highlighter.success(args.join(" ")));
|
|
86
|
-
},
|
|
87
|
-
dim(...args) {
|
|
88
|
-
writeLogLine(highlighter.dim(args.join(" ")));
|
|
89
|
-
},
|
|
90
|
-
log(...args) {
|
|
91
|
-
writeLogLine(args.join(" "));
|
|
92
|
-
},
|
|
93
|
-
break() {
|
|
94
|
-
writeLogLine("");
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
//#endregion
|
|
99
|
-
//#region src/utils/handle-error.ts
|
|
100
|
-
const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
|
|
101
|
-
const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
102
|
-
logger.break();
|
|
103
|
-
logger.error("Something went wrong. Please check the error below for more details.");
|
|
104
|
-
logger.error("If the problem persists, please open an issue on GitHub.");
|
|
105
|
-
logger.error("");
|
|
106
|
-
if (error instanceof Error) logger.error(error.message);
|
|
107
|
-
logger.break();
|
|
108
|
-
if (options.shouldExit) process.exit(1);
|
|
109
|
-
process.exitCode = 1;
|
|
110
|
-
};
|
|
33
|
+
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
111
34
|
|
|
112
35
|
//#endregion
|
|
113
36
|
//#region src/utils/calculate-score.ts
|
|
@@ -130,10 +53,56 @@ const calculateScore = async (diagnostics) => {
|
|
|
130
53
|
}
|
|
131
54
|
};
|
|
132
55
|
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/plugin/constants.ts
|
|
58
|
+
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
59
|
+
|
|
133
60
|
//#endregion
|
|
134
61
|
//#region src/utils/read-package-json.ts
|
|
135
62
|
const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
136
63
|
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/utils/check-reduced-motion.ts
|
|
66
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
|
|
67
|
+
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
68
|
+
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
69
|
+
filePath: "package.json",
|
|
70
|
+
plugin: "react-doctor",
|
|
71
|
+
rule: "require-reduced-motion",
|
|
72
|
+
severity: "error",
|
|
73
|
+
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
74
|
+
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
75
|
+
line: 0,
|
|
76
|
+
column: 0,
|
|
77
|
+
category: "Accessibility",
|
|
78
|
+
weight: 2
|
|
79
|
+
};
|
|
80
|
+
const checkReducedMotion = (rootDirectory) => {
|
|
81
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
82
|
+
if (!fs.existsSync(packageJsonPath)) return [];
|
|
83
|
+
let hasMotionLibrary = false;
|
|
84
|
+
try {
|
|
85
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
86
|
+
const allDependencies = {
|
|
87
|
+
...packageJson.dependencies,
|
|
88
|
+
...packageJson.devDependencies
|
|
89
|
+
};
|
|
90
|
+
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
if (!hasMotionLibrary) return [];
|
|
95
|
+
try {
|
|
96
|
+
execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
|
|
97
|
+
cwd: rootDirectory,
|
|
98
|
+
stdio: "pipe"
|
|
99
|
+
});
|
|
100
|
+
return [];
|
|
101
|
+
} catch {
|
|
102
|
+
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
137
106
|
//#endregion
|
|
138
107
|
//#region src/utils/discover-project.ts
|
|
139
108
|
const REACT_COMPILER_PACKAGES = new Set([
|
|
@@ -193,6 +162,7 @@ const countSourceFiles = (rootDirectory) => {
|
|
|
193
162
|
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
194
163
|
};
|
|
195
164
|
const collectAllDependencies = (packageJson) => ({
|
|
165
|
+
...packageJson.peerDependencies,
|
|
196
166
|
...packageJson.dependencies,
|
|
197
167
|
...packageJson.devDependencies
|
|
198
168
|
});
|
|
@@ -242,23 +212,34 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
|
242
212
|
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
243
213
|
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
|
|
244
214
|
};
|
|
245
|
-
const
|
|
215
|
+
const isMonorepoRoot = (directory) => {
|
|
216
|
+
if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
217
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
218
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
219
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
220
|
+
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
|
|
221
|
+
};
|
|
222
|
+
const findMonorepoRoot$1 = (startDirectory) => {
|
|
246
223
|
let currentDirectory = path.dirname(startDirectory);
|
|
247
|
-
const result = {
|
|
248
|
-
reactVersion: null,
|
|
249
|
-
framework: "unknown"
|
|
250
|
-
};
|
|
251
224
|
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
252
|
-
|
|
253
|
-
if (fs.existsSync(packageJsonPath)) {
|
|
254
|
-
const info = extractDependencyInfo(readPackageJson(packageJsonPath));
|
|
255
|
-
if (!result.reactVersion && info.reactVersion) result.reactVersion = info.reactVersion;
|
|
256
|
-
if (result.framework === "unknown" && info.framework !== "unknown") result.framework = info.framework;
|
|
257
|
-
if (result.reactVersion && result.framework !== "unknown") return result;
|
|
258
|
-
}
|
|
225
|
+
if (isMonorepoRoot(currentDirectory)) return currentDirectory;
|
|
259
226
|
currentDirectory = path.dirname(currentDirectory);
|
|
260
227
|
}
|
|
261
|
-
return
|
|
228
|
+
return null;
|
|
229
|
+
};
|
|
230
|
+
const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
231
|
+
const monorepoRoot = findMonorepoRoot$1(directory);
|
|
232
|
+
if (!monorepoRoot) return {
|
|
233
|
+
reactVersion: null,
|
|
234
|
+
framework: "unknown"
|
|
235
|
+
};
|
|
236
|
+
const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
|
|
237
|
+
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
238
|
+
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
239
|
+
return {
|
|
240
|
+
reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
|
|
241
|
+
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
242
|
+
};
|
|
262
243
|
};
|
|
263
244
|
const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
264
245
|
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
@@ -355,10 +336,10 @@ const discoverProject = (directory) => {
|
|
|
355
336
|
if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
|
|
356
337
|
if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
|
|
357
338
|
}
|
|
358
|
-
if (!reactVersion || framework === "unknown") {
|
|
359
|
-
const
|
|
360
|
-
if (!reactVersion) reactVersion =
|
|
361
|
-
if (framework === "unknown") framework =
|
|
339
|
+
if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(directory)) {
|
|
340
|
+
const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory);
|
|
341
|
+
if (!reactVersion) reactVersion = monorepoInfo.reactVersion;
|
|
342
|
+
if (framework === "unknown") framework = monorepoInfo.framework;
|
|
362
343
|
}
|
|
363
344
|
const projectName = packageJson.name ?? path.basename(directory);
|
|
364
345
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
@@ -375,6 +356,49 @@ const discoverProject = (directory) => {
|
|
|
375
356
|
};
|
|
376
357
|
};
|
|
377
358
|
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/utils/match-glob-pattern.ts
|
|
361
|
+
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
362
|
+
const compileGlobPattern = (pattern) => {
|
|
363
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
364
|
+
let regexSource = "^";
|
|
365
|
+
let characterIndex = 0;
|
|
366
|
+
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
367
|
+
regexSource += "(?:.+/)?";
|
|
368
|
+
characterIndex += 3;
|
|
369
|
+
} else {
|
|
370
|
+
regexSource += ".*";
|
|
371
|
+
characterIndex += 2;
|
|
372
|
+
}
|
|
373
|
+
else if (normalizedPattern[characterIndex] === "*") {
|
|
374
|
+
regexSource += "[^/]*";
|
|
375
|
+
characterIndex++;
|
|
376
|
+
} else if (normalizedPattern[characterIndex] === "?") {
|
|
377
|
+
regexSource += "[^/]";
|
|
378
|
+
characterIndex++;
|
|
379
|
+
} else {
|
|
380
|
+
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
381
|
+
characterIndex++;
|
|
382
|
+
}
|
|
383
|
+
regexSource += "$";
|
|
384
|
+
return new RegExp(regexSource);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/utils/filter-diagnostics.ts
|
|
389
|
+
const filterIgnoredDiagnostics = (diagnostics, config) => {
|
|
390
|
+
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
|
|
391
|
+
const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
|
|
392
|
+
if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
|
|
393
|
+
return diagnostics.filter((diagnostic) => {
|
|
394
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
395
|
+
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
396
|
+
const normalizedPath = diagnostic.filePath.replace(/\\/g, "/");
|
|
397
|
+
if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
378
402
|
//#endregion
|
|
379
403
|
//#region src/utils/group-by.ts
|
|
380
404
|
const groupBy = (items, keyFn) => {
|
|
@@ -389,48 +413,100 @@ const groupBy = (items, keyFn) => {
|
|
|
389
413
|
};
|
|
390
414
|
|
|
391
415
|
//#endregion
|
|
392
|
-
//#region src/
|
|
393
|
-
const
|
|
416
|
+
//#region src/utils/highlighter.ts
|
|
417
|
+
const highlighter = {
|
|
418
|
+
error: pc.red,
|
|
419
|
+
warn: pc.yellow,
|
|
420
|
+
info: pc.cyan,
|
|
421
|
+
success: pc.green,
|
|
422
|
+
dim: pc.dim
|
|
423
|
+
};
|
|
394
424
|
|
|
395
425
|
//#endregion
|
|
396
|
-
//#region src/utils/
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
};
|
|
411
|
-
|
|
426
|
+
//#region src/utils/indent-multiline-text.ts
|
|
427
|
+
const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
|
|
428
|
+
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/utils/load-config.ts
|
|
431
|
+
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
432
|
+
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
433
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
434
|
+
const loadConfig = (rootDirectory) => {
|
|
435
|
+
const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
|
|
436
|
+
if (fs.existsSync(configFilePath)) try {
|
|
437
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
438
|
+
const parsed = JSON.parse(fileContent);
|
|
439
|
+
if (!isPlainObject(parsed)) {
|
|
440
|
+
console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
return parsed;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
412
448
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const allDependencies = {
|
|
418
|
-
...packageJson.dependencies,
|
|
419
|
-
...packageJson.devDependencies
|
|
420
|
-
};
|
|
421
|
-
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
449
|
+
if (fs.existsSync(packageJsonPath)) try {
|
|
450
|
+
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
451
|
+
const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
|
|
452
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
422
453
|
} catch {
|
|
423
|
-
return
|
|
454
|
+
return null;
|
|
424
455
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
456
|
+
return null;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/utils/strip-ansi.ts
|
|
461
|
+
const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
|
|
462
|
+
const ANSI_ESCAPE_PATTERN = new RegExp(ANSI_ESCAPE_SEQUENCE, "g");
|
|
463
|
+
const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
464
|
+
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/utils/logger.ts
|
|
467
|
+
const loggerCaptureState = {
|
|
468
|
+
isEnabled: false,
|
|
469
|
+
lines: []
|
|
470
|
+
};
|
|
471
|
+
const captureLogLine = (text) => {
|
|
472
|
+
if (!loggerCaptureState.isEnabled) return;
|
|
473
|
+
loggerCaptureState.lines.push(stripAnsi(text));
|
|
474
|
+
};
|
|
475
|
+
const writeLogLine = (text) => {
|
|
476
|
+
console.log(text);
|
|
477
|
+
captureLogLine(text);
|
|
478
|
+
};
|
|
479
|
+
const startLoggerCapture = () => {
|
|
480
|
+
loggerCaptureState.isEnabled = true;
|
|
481
|
+
loggerCaptureState.lines = [];
|
|
482
|
+
};
|
|
483
|
+
const stopLoggerCapture = () => {
|
|
484
|
+
const capturedOutput = loggerCaptureState.lines.join("\n");
|
|
485
|
+
loggerCaptureState.isEnabled = false;
|
|
486
|
+
loggerCaptureState.lines = [];
|
|
487
|
+
return capturedOutput;
|
|
488
|
+
};
|
|
489
|
+
const logger = {
|
|
490
|
+
error(...args) {
|
|
491
|
+
writeLogLine(highlighter.error(args.join(" ")));
|
|
492
|
+
},
|
|
493
|
+
warn(...args) {
|
|
494
|
+
writeLogLine(highlighter.warn(args.join(" ")));
|
|
495
|
+
},
|
|
496
|
+
info(...args) {
|
|
497
|
+
writeLogLine(highlighter.info(args.join(" ")));
|
|
498
|
+
},
|
|
499
|
+
success(...args) {
|
|
500
|
+
writeLogLine(highlighter.success(args.join(" ")));
|
|
501
|
+
},
|
|
502
|
+
dim(...args) {
|
|
503
|
+
writeLogLine(highlighter.dim(args.join(" ")));
|
|
504
|
+
},
|
|
505
|
+
log(...args) {
|
|
506
|
+
writeLogLine(args.join(" "));
|
|
507
|
+
},
|
|
508
|
+
break() {
|
|
509
|
+
writeLogLine("");
|
|
434
510
|
}
|
|
435
511
|
};
|
|
436
512
|
|
|
@@ -474,15 +550,18 @@ const silenced = async (fn) => {
|
|
|
474
550
|
const originalLog = console.log;
|
|
475
551
|
const originalInfo = console.info;
|
|
476
552
|
const originalWarn = console.warn;
|
|
553
|
+
const originalError = console.error;
|
|
477
554
|
console.log = () => {};
|
|
478
555
|
console.info = () => {};
|
|
479
556
|
console.warn = () => {};
|
|
557
|
+
console.error = () => {};
|
|
480
558
|
try {
|
|
481
559
|
return await fn();
|
|
482
560
|
} finally {
|
|
483
561
|
console.log = originalLog;
|
|
484
562
|
console.info = originalInfo;
|
|
485
563
|
console.warn = originalWarn;
|
|
564
|
+
console.error = originalError;
|
|
486
565
|
}
|
|
487
566
|
};
|
|
488
567
|
const findMonorepoRoot = (directory) => {
|
|
@@ -498,13 +577,26 @@ const findMonorepoRoot = (directory) => {
|
|
|
498
577
|
}
|
|
499
578
|
return null;
|
|
500
579
|
};
|
|
580
|
+
const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
|
|
581
|
+
const extractFailedPluginName = (error) => {
|
|
582
|
+
return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
|
|
583
|
+
};
|
|
584
|
+
const MAX_KNIP_RETRIES = 5;
|
|
501
585
|
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
502
586
|
const options = await silenced(() => createOptions({
|
|
503
587
|
cwd: knipCwd,
|
|
504
588
|
isShowProgress: false,
|
|
505
589
|
...workspaceName ? { workspace: workspaceName } : {}
|
|
506
590
|
}));
|
|
507
|
-
|
|
591
|
+
const parsedConfig = options.parsedConfig;
|
|
592
|
+
for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
|
|
593
|
+
return await silenced(() => main(options));
|
|
594
|
+
} catch (error) {
|
|
595
|
+
const failedPlugin = extractFailedPluginName(error);
|
|
596
|
+
if (!failedPlugin || attempt === MAX_KNIP_RETRIES) throw error;
|
|
597
|
+
parsedConfig[failedPlugin] = false;
|
|
598
|
+
}
|
|
599
|
+
throw new Error("Unreachable");
|
|
508
600
|
};
|
|
509
601
|
const hasNodeModules = (directory) => {
|
|
510
602
|
const nodeModulesPath = path.join(directory, "node_modules");
|
|
@@ -834,7 +926,8 @@ const resolvePluginPath = () => {
|
|
|
834
926
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
835
927
|
return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
|
|
836
928
|
};
|
|
837
|
-
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
|
|
929
|
+
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
|
|
930
|
+
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
838
931
|
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
|
|
839
932
|
const config = createOxlintConfig({
|
|
840
933
|
pluginPath: resolvePluginPath(),
|
|
@@ -851,7 +944,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
851
944
|
"json"
|
|
852
945
|
];
|
|
853
946
|
if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
|
|
854
|
-
args.push(
|
|
947
|
+
if (includePaths !== void 0) args.push(...includePaths);
|
|
948
|
+
else args.push(".");
|
|
855
949
|
const stdout = await new Promise((resolve, reject) => {
|
|
856
950
|
const child = spawn(process.execPath, args, { cwd: rootDirectory });
|
|
857
951
|
const stdoutBuffers = [];
|
|
@@ -878,7 +972,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
878
972
|
} catch {
|
|
879
973
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
|
|
880
974
|
}
|
|
881
|
-
return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
975
|
+
return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
882
976
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
883
977
|
const primaryLabel = diagnostic.labels[0];
|
|
884
978
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -930,10 +1024,6 @@ const spinner = (text) => ({ start() {
|
|
|
930
1024
|
};
|
|
931
1025
|
} });
|
|
932
1026
|
|
|
933
|
-
//#endregion
|
|
934
|
-
//#region src/utils/indent-multiline-text.ts
|
|
935
|
-
const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
|
|
936
|
-
|
|
937
1027
|
//#endregion
|
|
938
1028
|
//#region src/scan.ts
|
|
939
1029
|
const SEVERITY_ORDER = {
|
|
@@ -1138,9 +1228,19 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
|
|
|
1138
1228
|
logger.break();
|
|
1139
1229
|
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1140
1230
|
};
|
|
1141
|
-
const scan = async (directory,
|
|
1231
|
+
const scan = async (directory, inputOptions = {}) => {
|
|
1142
1232
|
const startTime = performance.now();
|
|
1143
1233
|
const projectInfo = discoverProject(directory);
|
|
1234
|
+
const userConfig = loadConfig(directory);
|
|
1235
|
+
const options = {
|
|
1236
|
+
lint: inputOptions.lint ?? userConfig?.lint ?? true,
|
|
1237
|
+
deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
|
|
1238
|
+
verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
|
|
1239
|
+
scoreOnly: inputOptions.scoreOnly ?? false,
|
|
1240
|
+
includePaths: inputOptions.includePaths
|
|
1241
|
+
};
|
|
1242
|
+
const includePaths = options.includePaths ?? [];
|
|
1243
|
+
const isDiffMode = includePaths.length > 0;
|
|
1144
1244
|
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
1145
1245
|
if (!options.scoreOnly) {
|
|
1146
1246
|
const frameworkLabel = formatFrameworkName(projectInfo.framework);
|
|
@@ -1152,13 +1252,16 @@ const scan = async (directory, options) => {
|
|
|
1152
1252
|
completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
|
|
1153
1253
|
completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
|
|
1154
1254
|
completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
|
|
1155
|
-
completeStep(`
|
|
1255
|
+
if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
|
|
1256
|
+
else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
|
|
1257
|
+
if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
|
|
1156
1258
|
logger.break();
|
|
1157
1259
|
}
|
|
1260
|
+
const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
1158
1261
|
const lintPromise = options.lint ? (async () => {
|
|
1159
1262
|
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1160
1263
|
try {
|
|
1161
|
-
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
|
|
1264
|
+
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
|
|
1162
1265
|
lintSpinner?.succeed("Running lint checks.");
|
|
1163
1266
|
return lintDiagnostics;
|
|
1164
1267
|
} catch (error) {
|
|
@@ -1167,7 +1270,7 @@ const scan = async (directory, options) => {
|
|
|
1167
1270
|
return [];
|
|
1168
1271
|
}
|
|
1169
1272
|
})() : Promise.resolve([]);
|
|
1170
|
-
const deadCodePromise = options.deadCode ? (async () => {
|
|
1273
|
+
const deadCodePromise = options.deadCode && !isDiffMode ? (async () => {
|
|
1171
1274
|
const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
|
|
1172
1275
|
try {
|
|
1173
1276
|
const knipDiagnostics = await runKnip(directory);
|
|
@@ -1180,11 +1283,12 @@ const scan = async (directory, options) => {
|
|
|
1180
1283
|
}
|
|
1181
1284
|
})() : Promise.resolve([]);
|
|
1182
1285
|
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
1183
|
-
const
|
|
1286
|
+
const allDiagnostics = [
|
|
1184
1287
|
...lintDiagnostics,
|
|
1185
1288
|
...deadCodeDiagnostics,
|
|
1186
|
-
...checkReducedMotion(directory)
|
|
1289
|
+
...isDiffMode ? [] : checkReducedMotion(directory)
|
|
1187
1290
|
];
|
|
1291
|
+
const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
|
|
1188
1292
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
1189
1293
|
const scoreResult = await calculateScore(diagnostics);
|
|
1190
1294
|
if (options.scoreOnly) {
|
|
@@ -1202,7 +1306,157 @@ const scan = async (directory, options) => {
|
|
|
1202
1306
|
return;
|
|
1203
1307
|
}
|
|
1204
1308
|
printDiagnostics(diagnostics, options.verbose);
|
|
1205
|
-
|
|
1309
|
+
const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
|
|
1310
|
+
printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount);
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
//#endregion
|
|
1314
|
+
//#region src/utils/copy-to-clipboard.ts
|
|
1315
|
+
const getClipboardCommands = () => {
|
|
1316
|
+
if (process.platform === "darwin") return [{
|
|
1317
|
+
command: "pbcopy",
|
|
1318
|
+
args: []
|
|
1319
|
+
}];
|
|
1320
|
+
if (process.platform === "win32") return [{
|
|
1321
|
+
command: "clip",
|
|
1322
|
+
args: []
|
|
1323
|
+
}];
|
|
1324
|
+
return [
|
|
1325
|
+
{
|
|
1326
|
+
command: "wl-copy",
|
|
1327
|
+
args: []
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
command: "xclip",
|
|
1331
|
+
args: ["-selection", "clipboard"]
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
command: "xsel",
|
|
1335
|
+
args: ["--clipboard", "--input"]
|
|
1336
|
+
}
|
|
1337
|
+
];
|
|
1338
|
+
};
|
|
1339
|
+
const copyToClipboard = (text) => {
|
|
1340
|
+
const clipboardCommands = getClipboardCommands();
|
|
1341
|
+
for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
|
|
1342
|
+
input: text,
|
|
1343
|
+
stdio: [
|
|
1344
|
+
"pipe",
|
|
1345
|
+
"ignore",
|
|
1346
|
+
"ignore"
|
|
1347
|
+
],
|
|
1348
|
+
encoding: "utf8"
|
|
1349
|
+
}).status === 0) return true;
|
|
1350
|
+
return false;
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/utils/get-diff-files.ts
|
|
1355
|
+
const getCurrentBranch = (directory) => {
|
|
1356
|
+
try {
|
|
1357
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1358
|
+
cwd: directory,
|
|
1359
|
+
stdio: "pipe"
|
|
1360
|
+
}).toString().trim();
|
|
1361
|
+
return branch === "HEAD" ? null : branch;
|
|
1362
|
+
} catch {
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
const detectDefaultBranch = (directory) => {
|
|
1367
|
+
try {
|
|
1368
|
+
return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1369
|
+
cwd: directory,
|
|
1370
|
+
stdio: "pipe"
|
|
1371
|
+
}).toString().trim().replace("refs/remotes/origin/", "");
|
|
1372
|
+
} catch {
|
|
1373
|
+
for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
|
|
1374
|
+
execSync(`git rev-parse --verify ${candidate}`, {
|
|
1375
|
+
cwd: directory,
|
|
1376
|
+
stdio: "pipe"
|
|
1377
|
+
});
|
|
1378
|
+
return candidate;
|
|
1379
|
+
} catch {}
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
const getChangedFilesSinceBranch = (directory, baseBranch) => {
|
|
1384
|
+
try {
|
|
1385
|
+
const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
|
|
1386
|
+
cwd: directory,
|
|
1387
|
+
stdio: "pipe"
|
|
1388
|
+
}).toString().trim()}`, {
|
|
1389
|
+
cwd: directory,
|
|
1390
|
+
stdio: "pipe"
|
|
1391
|
+
}).toString().trim();
|
|
1392
|
+
if (!output) return [];
|
|
1393
|
+
return output.split("\n").filter(Boolean);
|
|
1394
|
+
} catch {
|
|
1395
|
+
return [];
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
const getDiffInfo = (directory, explicitBaseBranch) => {
|
|
1399
|
+
const currentBranch = getCurrentBranch(directory);
|
|
1400
|
+
if (!currentBranch) return null;
|
|
1401
|
+
const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
|
|
1402
|
+
if (!baseBranch) return null;
|
|
1403
|
+
if (currentBranch === baseBranch) return null;
|
|
1404
|
+
return {
|
|
1405
|
+
currentBranch,
|
|
1406
|
+
baseBranch,
|
|
1407
|
+
changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
|
|
1408
|
+
};
|
|
1409
|
+
};
|
|
1410
|
+
const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
|
|
1411
|
+
|
|
1412
|
+
//#endregion
|
|
1413
|
+
//#region src/utils/global-install.ts
|
|
1414
|
+
const isGloballyInstalled = () => {
|
|
1415
|
+
try {
|
|
1416
|
+
return !execSync("which react-doctor", {
|
|
1417
|
+
stdio: "pipe",
|
|
1418
|
+
encoding: "utf-8"
|
|
1419
|
+
}).trim().includes("/_npx/");
|
|
1420
|
+
} catch {
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
const maybeInstallGlobally = () => {
|
|
1425
|
+
try {
|
|
1426
|
+
if (isGloballyInstalled()) return;
|
|
1427
|
+
const child = spawn("npm", [
|
|
1428
|
+
"install",
|
|
1429
|
+
"-g",
|
|
1430
|
+
"react-doctor@latest"
|
|
1431
|
+
], {
|
|
1432
|
+
detached: true,
|
|
1433
|
+
stdio: "ignore"
|
|
1434
|
+
});
|
|
1435
|
+
child.on("error", () => {});
|
|
1436
|
+
child.unref();
|
|
1437
|
+
} catch {}
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/utils/handle-error.ts
|
|
1442
|
+
const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
|
|
1443
|
+
const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
1444
|
+
logger.break();
|
|
1445
|
+
logger.error("Something went wrong. Please check the error below for more details.");
|
|
1446
|
+
logger.error("If the problem persists, please open an issue on GitHub.");
|
|
1447
|
+
logger.error("");
|
|
1448
|
+
if (error instanceof Error) logger.error(error.message);
|
|
1449
|
+
logger.break();
|
|
1450
|
+
if (options.shouldExit) process.exit(1);
|
|
1451
|
+
process.exitCode = 1;
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
//#endregion
|
|
1455
|
+
//#region src/utils/should-auto-select-current-choice.ts
|
|
1456
|
+
const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
|
|
1457
|
+
if (choiceStates.some((choiceState) => choiceState.selected)) return false;
|
|
1458
|
+
const currentChoice = choiceStates[cursor];
|
|
1459
|
+
return Boolean(currentChoice) && !currentChoice.disabled;
|
|
1206
1460
|
};
|
|
1207
1461
|
|
|
1208
1462
|
//#endregion
|
|
@@ -1216,6 +1470,7 @@ const shouldSelectAllChoices = (choiceStates) => {
|
|
|
1216
1470
|
const require = createRequire(import.meta.url);
|
|
1217
1471
|
const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
|
|
1218
1472
|
let didPatchMultiselectToggleAll = false;
|
|
1473
|
+
let didPatchMultiselectSubmit = false;
|
|
1219
1474
|
const onCancel = () => {
|
|
1220
1475
|
logger.break();
|
|
1221
1476
|
logger.log("Cancelled.");
|
|
@@ -1240,8 +1495,19 @@ const patchMultiselectToggleAll = () => {
|
|
|
1240
1495
|
this.render();
|
|
1241
1496
|
};
|
|
1242
1497
|
};
|
|
1498
|
+
const patchMultiselectSubmit = () => {
|
|
1499
|
+
if (didPatchMultiselectSubmit) return;
|
|
1500
|
+
didPatchMultiselectSubmit = true;
|
|
1501
|
+
const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
|
|
1502
|
+
const originalSubmit = multiselectPromptConstructor.prototype.submit;
|
|
1503
|
+
multiselectPromptConstructor.prototype.submit = function() {
|
|
1504
|
+
if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
|
|
1505
|
+
originalSubmit.call(this);
|
|
1506
|
+
};
|
|
1507
|
+
};
|
|
1243
1508
|
const prompts = (questions) => {
|
|
1244
1509
|
patchMultiselectToggleAll();
|
|
1510
|
+
patchMultiselectSubmit();
|
|
1245
1511
|
return basePrompts(questions, { onCancel });
|
|
1246
1512
|
};
|
|
1247
1513
|
|
|
@@ -1346,93 +1612,49 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
1346
1612
|
}
|
|
1347
1613
|
};
|
|
1348
1614
|
|
|
1349
|
-
//#endregion
|
|
1350
|
-
//#region src/utils/global-install.ts
|
|
1351
|
-
const isGloballyInstalled = () => {
|
|
1352
|
-
try {
|
|
1353
|
-
return !execSync("which react-doctor", {
|
|
1354
|
-
stdio: "pipe",
|
|
1355
|
-
encoding: "utf-8"
|
|
1356
|
-
}).trim().includes("/_npx/");
|
|
1357
|
-
} catch {
|
|
1358
|
-
return false;
|
|
1359
|
-
}
|
|
1360
|
-
};
|
|
1361
|
-
const maybeInstallGlobally = () => {
|
|
1362
|
-
try {
|
|
1363
|
-
if (isGloballyInstalled()) return;
|
|
1364
|
-
const child = spawn("npm", [
|
|
1365
|
-
"install",
|
|
1366
|
-
"-g",
|
|
1367
|
-
"react-doctor@latest"
|
|
1368
|
-
], {
|
|
1369
|
-
detached: true,
|
|
1370
|
-
stdio: "ignore"
|
|
1371
|
-
});
|
|
1372
|
-
child.on("error", () => {});
|
|
1373
|
-
child.unref();
|
|
1374
|
-
} catch {}
|
|
1375
|
-
};
|
|
1376
|
-
|
|
1377
|
-
//#endregion
|
|
1378
|
-
//#region src/utils/copy-to-clipboard.ts
|
|
1379
|
-
const getClipboardCommands = () => {
|
|
1380
|
-
if (process.platform === "darwin") return [{
|
|
1381
|
-
command: "pbcopy",
|
|
1382
|
-
args: []
|
|
1383
|
-
}];
|
|
1384
|
-
if (process.platform === "win32") return [{
|
|
1385
|
-
command: "clip",
|
|
1386
|
-
args: []
|
|
1387
|
-
}];
|
|
1388
|
-
return [
|
|
1389
|
-
{
|
|
1390
|
-
command: "wl-copy",
|
|
1391
|
-
args: []
|
|
1392
|
-
},
|
|
1393
|
-
{
|
|
1394
|
-
command: "xclip",
|
|
1395
|
-
args: ["-selection", "clipboard"]
|
|
1396
|
-
},
|
|
1397
|
-
{
|
|
1398
|
-
command: "xsel",
|
|
1399
|
-
args: ["--clipboard", "--input"]
|
|
1400
|
-
}
|
|
1401
|
-
];
|
|
1402
|
-
};
|
|
1403
|
-
const copyToClipboard = (text) => {
|
|
1404
|
-
const clipboardCommands = getClipboardCommands();
|
|
1405
|
-
for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
|
|
1406
|
-
input: text,
|
|
1407
|
-
stdio: [
|
|
1408
|
-
"pipe",
|
|
1409
|
-
"ignore",
|
|
1410
|
-
"ignore"
|
|
1411
|
-
],
|
|
1412
|
-
encoding: "utf8"
|
|
1413
|
-
}).status === 0) return true;
|
|
1414
|
-
return false;
|
|
1415
|
-
};
|
|
1416
|
-
|
|
1417
1615
|
//#endregion
|
|
1418
1616
|
//#region src/cli.ts
|
|
1419
|
-
const VERSION = "0.0.
|
|
1617
|
+
const VERSION = "0.0.19";
|
|
1420
1618
|
process.on("SIGINT", () => process.exit(0));
|
|
1421
1619
|
process.on("SIGTERM", () => process.exit(0));
|
|
1422
|
-
const
|
|
1620
|
+
const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
|
|
1621
|
+
if (effectiveDiff !== void 0 && effectiveDiff !== false) {
|
|
1622
|
+
if (diffInfo) return true;
|
|
1623
|
+
if (!isScoreOnly) {
|
|
1624
|
+
logger.warn("Not on a feature branch or could not determine base branch. Running full scan.");
|
|
1625
|
+
logger.break();
|
|
1626
|
+
}
|
|
1627
|
+
return false;
|
|
1628
|
+
}
|
|
1629
|
+
if (effectiveDiff === false || !diffInfo) return false;
|
|
1630
|
+
const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
|
|
1631
|
+
if (changedSourceFiles.length === 0) return false;
|
|
1632
|
+
if (shouldSkipPrompts) return true;
|
|
1633
|
+
if (isScoreOnly) return false;
|
|
1634
|
+
const { shouldScanBranchOnly } = await prompts({
|
|
1635
|
+
type: "confirm",
|
|
1636
|
+
name: "shouldScanBranchOnly",
|
|
1637
|
+
message: `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`,
|
|
1638
|
+
initial: true
|
|
1639
|
+
});
|
|
1640
|
+
return Boolean(shouldScanBranchOnly);
|
|
1641
|
+
};
|
|
1642
|
+
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("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
|
|
1423
1643
|
const isScoreOnly = flags.score && !flags.prompt;
|
|
1424
1644
|
const shouldCopyPromptOutput = flags.prompt;
|
|
1425
1645
|
if (shouldCopyPromptOutput) startLoggerCapture();
|
|
1426
1646
|
try {
|
|
1427
1647
|
const resolvedDirectory = path.resolve(directory);
|
|
1648
|
+
const userConfig = loadConfig(resolvedDirectory);
|
|
1428
1649
|
if (!isScoreOnly) {
|
|
1429
1650
|
logger.log(`react-doctor v${VERSION}`);
|
|
1430
1651
|
logger.break();
|
|
1431
1652
|
}
|
|
1653
|
+
const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
|
|
1432
1654
|
const scanOptions = {
|
|
1433
|
-
lint: flags.lint,
|
|
1434
|
-
deadCode: flags.deadCode,
|
|
1435
|
-
verbose: flags.prompt || Boolean(flags.verbose),
|
|
1655
|
+
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
|
|
1656
|
+
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
|
|
1657
|
+
verbose: flags.prompt || (isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false),
|
|
1436
1658
|
scoreOnly: isScoreOnly
|
|
1437
1659
|
};
|
|
1438
1660
|
const isAutomatedEnvironment = [
|
|
@@ -1446,12 +1668,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1446
1668
|
].some(Boolean);
|
|
1447
1669
|
const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
|
|
1448
1670
|
const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
|
|
1671
|
+
const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
|
|
1672
|
+
const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
|
|
1673
|
+
const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
|
|
1674
|
+
const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
|
|
1675
|
+
if (isDiffMode && diffInfo && !isScoreOnly) {
|
|
1676
|
+
logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
|
|
1677
|
+
logger.break();
|
|
1678
|
+
}
|
|
1449
1679
|
for (const projectDirectory of projectDirectories) {
|
|
1680
|
+
let includePaths;
|
|
1681
|
+
if (isDiffMode) {
|
|
1682
|
+
const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch);
|
|
1683
|
+
if (projectDiffInfo) {
|
|
1684
|
+
const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
|
|
1685
|
+
if (changedSourceFiles.length === 0) {
|
|
1686
|
+
if (!isScoreOnly) {
|
|
1687
|
+
logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
|
|
1688
|
+
logger.break();
|
|
1689
|
+
}
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
includePaths = changedSourceFiles;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1450
1695
|
if (!isScoreOnly) {
|
|
1451
1696
|
logger.dim(`Scanning ${projectDirectory}...`);
|
|
1452
1697
|
logger.break();
|
|
1453
1698
|
}
|
|
1454
|
-
await scan(projectDirectory,
|
|
1699
|
+
await scan(projectDirectory, {
|
|
1700
|
+
...scanOptions,
|
|
1701
|
+
includePaths
|
|
1702
|
+
});
|
|
1455
1703
|
if (!isScoreOnly) logger.break();
|
|
1456
1704
|
}
|
|
1457
1705
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|