react-doctor 0.0.18 → 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 +405 -204
- 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 +151 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
});
|
|
@@ -386,6 +356,49 @@ const discoverProject = (directory) => {
|
|
|
386
356
|
};
|
|
387
357
|
};
|
|
388
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
|
+
|
|
389
402
|
//#endregion
|
|
390
403
|
//#region src/utils/group-by.ts
|
|
391
404
|
const groupBy = (items, keyFn) => {
|
|
@@ -400,48 +413,100 @@ const groupBy = (items, keyFn) => {
|
|
|
400
413
|
};
|
|
401
414
|
|
|
402
415
|
//#endregion
|
|
403
|
-
//#region src/
|
|
404
|
-
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
|
+
};
|
|
405
424
|
|
|
406
425
|
//#endregion
|
|
407
|
-
//#region src/utils/
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
};
|
|
422
|
-
|
|
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
|
+
}
|
|
423
448
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const allDependencies = {
|
|
429
|
-
...packageJson.dependencies,
|
|
430
|
-
...packageJson.devDependencies
|
|
431
|
-
};
|
|
432
|
-
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;
|
|
433
453
|
} catch {
|
|
434
|
-
return
|
|
454
|
+
return null;
|
|
435
455
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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("");
|
|
445
510
|
}
|
|
446
511
|
};
|
|
447
512
|
|
|
@@ -861,7 +926,8 @@ const resolvePluginPath = () => {
|
|
|
861
926
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
862
927
|
return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
|
|
863
928
|
};
|
|
864
|
-
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 [];
|
|
865
931
|
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
|
|
866
932
|
const config = createOxlintConfig({
|
|
867
933
|
pluginPath: resolvePluginPath(),
|
|
@@ -878,7 +944,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
878
944
|
"json"
|
|
879
945
|
];
|
|
880
946
|
if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
|
|
881
|
-
args.push(
|
|
947
|
+
if (includePaths !== void 0) args.push(...includePaths);
|
|
948
|
+
else args.push(".");
|
|
882
949
|
const stdout = await new Promise((resolve, reject) => {
|
|
883
950
|
const child = spawn(process.execPath, args, { cwd: rootDirectory });
|
|
884
951
|
const stdoutBuffers = [];
|
|
@@ -905,7 +972,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
905
972
|
} catch {
|
|
906
973
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
|
|
907
974
|
}
|
|
908
|
-
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) => {
|
|
909
976
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
910
977
|
const primaryLabel = diagnostic.labels[0];
|
|
911
978
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -957,10 +1024,6 @@ const spinner = (text) => ({ start() {
|
|
|
957
1024
|
};
|
|
958
1025
|
} });
|
|
959
1026
|
|
|
960
|
-
//#endregion
|
|
961
|
-
//#region src/utils/indent-multiline-text.ts
|
|
962
|
-
const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
|
|
963
|
-
|
|
964
1027
|
//#endregion
|
|
965
1028
|
//#region src/scan.ts
|
|
966
1029
|
const SEVERITY_ORDER = {
|
|
@@ -1165,9 +1228,19 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
|
|
|
1165
1228
|
logger.break();
|
|
1166
1229
|
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1167
1230
|
};
|
|
1168
|
-
const scan = async (directory,
|
|
1231
|
+
const scan = async (directory, inputOptions = {}) => {
|
|
1169
1232
|
const startTime = performance.now();
|
|
1170
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;
|
|
1171
1244
|
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
1172
1245
|
if (!options.scoreOnly) {
|
|
1173
1246
|
const frameworkLabel = formatFrameworkName(projectInfo.framework);
|
|
@@ -1179,13 +1252,16 @@ const scan = async (directory, options) => {
|
|
|
1179
1252
|
completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
|
|
1180
1253
|
completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
|
|
1181
1254
|
completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
|
|
1182
|
-
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")}.`);
|
|
1183
1258
|
logger.break();
|
|
1184
1259
|
}
|
|
1260
|
+
const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
1185
1261
|
const lintPromise = options.lint ? (async () => {
|
|
1186
1262
|
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1187
1263
|
try {
|
|
1188
|
-
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
|
|
1264
|
+
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
|
|
1189
1265
|
lintSpinner?.succeed("Running lint checks.");
|
|
1190
1266
|
return lintDiagnostics;
|
|
1191
1267
|
} catch (error) {
|
|
@@ -1194,7 +1270,7 @@ const scan = async (directory, options) => {
|
|
|
1194
1270
|
return [];
|
|
1195
1271
|
}
|
|
1196
1272
|
})() : Promise.resolve([]);
|
|
1197
|
-
const deadCodePromise = options.deadCode ? (async () => {
|
|
1273
|
+
const deadCodePromise = options.deadCode && !isDiffMode ? (async () => {
|
|
1198
1274
|
const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
|
|
1199
1275
|
try {
|
|
1200
1276
|
const knipDiagnostics = await runKnip(directory);
|
|
@@ -1207,11 +1283,12 @@ const scan = async (directory, options) => {
|
|
|
1207
1283
|
}
|
|
1208
1284
|
})() : Promise.resolve([]);
|
|
1209
1285
|
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
1210
|
-
const
|
|
1286
|
+
const allDiagnostics = [
|
|
1211
1287
|
...lintDiagnostics,
|
|
1212
1288
|
...deadCodeDiagnostics,
|
|
1213
|
-
...checkReducedMotion(directory)
|
|
1289
|
+
...isDiffMode ? [] : checkReducedMotion(directory)
|
|
1214
1290
|
];
|
|
1291
|
+
const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
|
|
1215
1292
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
1216
1293
|
const scoreResult = await calculateScore(diagnostics);
|
|
1217
1294
|
if (options.scoreOnly) {
|
|
@@ -1229,7 +1306,149 @@ const scan = async (directory, options) => {
|
|
|
1229
1306
|
return;
|
|
1230
1307
|
}
|
|
1231
1308
|
printDiagnostics(diagnostics, options.verbose);
|
|
1232
|
-
|
|
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;
|
|
1233
1452
|
};
|
|
1234
1453
|
|
|
1235
1454
|
//#endregion
|
|
@@ -1393,93 +1612,49 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
1393
1612
|
}
|
|
1394
1613
|
};
|
|
1395
1614
|
|
|
1396
|
-
//#endregion
|
|
1397
|
-
//#region src/utils/global-install.ts
|
|
1398
|
-
const isGloballyInstalled = () => {
|
|
1399
|
-
try {
|
|
1400
|
-
return !execSync("which react-doctor", {
|
|
1401
|
-
stdio: "pipe",
|
|
1402
|
-
encoding: "utf-8"
|
|
1403
|
-
}).trim().includes("/_npx/");
|
|
1404
|
-
} catch {
|
|
1405
|
-
return false;
|
|
1406
|
-
}
|
|
1407
|
-
};
|
|
1408
|
-
const maybeInstallGlobally = () => {
|
|
1409
|
-
try {
|
|
1410
|
-
if (isGloballyInstalled()) return;
|
|
1411
|
-
const child = spawn("npm", [
|
|
1412
|
-
"install",
|
|
1413
|
-
"-g",
|
|
1414
|
-
"react-doctor@latest"
|
|
1415
|
-
], {
|
|
1416
|
-
detached: true,
|
|
1417
|
-
stdio: "ignore"
|
|
1418
|
-
});
|
|
1419
|
-
child.on("error", () => {});
|
|
1420
|
-
child.unref();
|
|
1421
|
-
} catch {}
|
|
1422
|
-
};
|
|
1423
|
-
|
|
1424
|
-
//#endregion
|
|
1425
|
-
//#region src/utils/copy-to-clipboard.ts
|
|
1426
|
-
const getClipboardCommands = () => {
|
|
1427
|
-
if (process.platform === "darwin") return [{
|
|
1428
|
-
command: "pbcopy",
|
|
1429
|
-
args: []
|
|
1430
|
-
}];
|
|
1431
|
-
if (process.platform === "win32") return [{
|
|
1432
|
-
command: "clip",
|
|
1433
|
-
args: []
|
|
1434
|
-
}];
|
|
1435
|
-
return [
|
|
1436
|
-
{
|
|
1437
|
-
command: "wl-copy",
|
|
1438
|
-
args: []
|
|
1439
|
-
},
|
|
1440
|
-
{
|
|
1441
|
-
command: "xclip",
|
|
1442
|
-
args: ["-selection", "clipboard"]
|
|
1443
|
-
},
|
|
1444
|
-
{
|
|
1445
|
-
command: "xsel",
|
|
1446
|
-
args: ["--clipboard", "--input"]
|
|
1447
|
-
}
|
|
1448
|
-
];
|
|
1449
|
-
};
|
|
1450
|
-
const copyToClipboard = (text) => {
|
|
1451
|
-
const clipboardCommands = getClipboardCommands();
|
|
1452
|
-
for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
|
|
1453
|
-
input: text,
|
|
1454
|
-
stdio: [
|
|
1455
|
-
"pipe",
|
|
1456
|
-
"ignore",
|
|
1457
|
-
"ignore"
|
|
1458
|
-
],
|
|
1459
|
-
encoding: "utf8"
|
|
1460
|
-
}).status === 0) return true;
|
|
1461
|
-
return false;
|
|
1462
|
-
};
|
|
1463
|
-
|
|
1464
1615
|
//#endregion
|
|
1465
1616
|
//#region src/cli.ts
|
|
1466
|
-
const VERSION = "0.0.
|
|
1617
|
+
const VERSION = "0.0.19";
|
|
1467
1618
|
process.on("SIGINT", () => process.exit(0));
|
|
1468
1619
|
process.on("SIGTERM", () => process.exit(0));
|
|
1469
|
-
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) => {
|
|
1470
1643
|
const isScoreOnly = flags.score && !flags.prompt;
|
|
1471
1644
|
const shouldCopyPromptOutput = flags.prompt;
|
|
1472
1645
|
if (shouldCopyPromptOutput) startLoggerCapture();
|
|
1473
1646
|
try {
|
|
1474
1647
|
const resolvedDirectory = path.resolve(directory);
|
|
1648
|
+
const userConfig = loadConfig(resolvedDirectory);
|
|
1475
1649
|
if (!isScoreOnly) {
|
|
1476
1650
|
logger.log(`react-doctor v${VERSION}`);
|
|
1477
1651
|
logger.break();
|
|
1478
1652
|
}
|
|
1653
|
+
const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
|
|
1479
1654
|
const scanOptions = {
|
|
1480
|
-
lint: flags.lint,
|
|
1481
|
-
deadCode: flags.deadCode,
|
|
1482
|
-
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),
|
|
1483
1658
|
scoreOnly: isScoreOnly
|
|
1484
1659
|
};
|
|
1485
1660
|
const isAutomatedEnvironment = [
|
|
@@ -1493,12 +1668,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1493
1668
|
].some(Boolean);
|
|
1494
1669
|
const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
|
|
1495
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
|
+
}
|
|
1496
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
|
+
}
|
|
1497
1695
|
if (!isScoreOnly) {
|
|
1498
1696
|
logger.dim(`Scanning ${projectDirectory}...`);
|
|
1499
1697
|
logger.break();
|
|
1500
1698
|
}
|
|
1501
|
-
await scan(projectDirectory,
|
|
1699
|
+
await scan(projectDirectory, {
|
|
1700
|
+
...scanOptions,
|
|
1701
|
+
includePaths
|
|
1702
|
+
});
|
|
1502
1703
|
if (!isScoreOnly) logger.break();
|
|
1503
1704
|
}
|
|
1504
1705
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|