react-doctor 0.0.8 → 0.0.10
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/dist/cli.js +320 -89
- package/dist/cli.js.map +1 -1
- package/dist/react-doctor-plugin.d.ts.map +1 -1
- package/dist/react-doctor-plugin.js +218 -75
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,23 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import ora from "ora";
|
|
15
15
|
import basePrompts from "prompts";
|
|
16
16
|
|
|
17
|
+
//#region src/constants.ts
|
|
18
|
+
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
19
|
+
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
20
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
21
|
+
const ERROR_PREVIEW_LENGTH_CHARS = 200;
|
|
22
|
+
const PERFECT_SCORE = 100;
|
|
23
|
+
const SCORE_GOOD_THRESHOLD = 75;
|
|
24
|
+
const SCORE_OK_THRESHOLD = 50;
|
|
25
|
+
const SCORE_BAR_WIDTH_CHARS = 50;
|
|
26
|
+
const SEPARATOR_LENGTH_CHARS = 40;
|
|
27
|
+
const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1;
|
|
28
|
+
const SUMMARY_BOX_OUTER_INDENT_CHARS = 2;
|
|
29
|
+
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
30
|
+
const SHARE_BASE_URL = "https://www.react.doctor/share";
|
|
31
|
+
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
17
34
|
//#region src/utils/highlighter.ts
|
|
18
35
|
const highlighter = {
|
|
19
36
|
error: pc.red,
|
|
@@ -23,59 +40,74 @@ const highlighter = {
|
|
|
23
40
|
dim: pc.dim
|
|
24
41
|
};
|
|
25
42
|
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/utils/strip-ansi.ts
|
|
45
|
+
const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
|
|
46
|
+
const ANSI_ESCAPE_PATTERN = new RegExp(ANSI_ESCAPE_SEQUENCE, "g");
|
|
47
|
+
const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
48
|
+
|
|
26
49
|
//#endregion
|
|
27
50
|
//#region src/utils/logger.ts
|
|
51
|
+
const loggerCaptureState = {
|
|
52
|
+
isEnabled: false,
|
|
53
|
+
lines: []
|
|
54
|
+
};
|
|
55
|
+
const captureLogLine = (text) => {
|
|
56
|
+
if (!loggerCaptureState.isEnabled) return;
|
|
57
|
+
loggerCaptureState.lines.push(stripAnsi(text));
|
|
58
|
+
};
|
|
59
|
+
const writeLogLine = (text) => {
|
|
60
|
+
console.log(text);
|
|
61
|
+
captureLogLine(text);
|
|
62
|
+
};
|
|
63
|
+
const startLoggerCapture = () => {
|
|
64
|
+
loggerCaptureState.isEnabled = true;
|
|
65
|
+
loggerCaptureState.lines = [];
|
|
66
|
+
};
|
|
67
|
+
const stopLoggerCapture = () => {
|
|
68
|
+
const capturedOutput = loggerCaptureState.lines.join("\n");
|
|
69
|
+
loggerCaptureState.isEnabled = false;
|
|
70
|
+
loggerCaptureState.lines = [];
|
|
71
|
+
return capturedOutput;
|
|
72
|
+
};
|
|
28
73
|
const logger = {
|
|
29
74
|
error(...args) {
|
|
30
|
-
|
|
75
|
+
writeLogLine(highlighter.error(args.join(" ")));
|
|
31
76
|
},
|
|
32
77
|
warn(...args) {
|
|
33
|
-
|
|
78
|
+
writeLogLine(highlighter.warn(args.join(" ")));
|
|
34
79
|
},
|
|
35
80
|
info(...args) {
|
|
36
|
-
|
|
81
|
+
writeLogLine(highlighter.info(args.join(" ")));
|
|
37
82
|
},
|
|
38
83
|
success(...args) {
|
|
39
|
-
|
|
84
|
+
writeLogLine(highlighter.success(args.join(" ")));
|
|
40
85
|
},
|
|
41
86
|
dim(...args) {
|
|
42
|
-
|
|
87
|
+
writeLogLine(highlighter.dim(args.join(" ")));
|
|
43
88
|
},
|
|
44
89
|
log(...args) {
|
|
45
|
-
|
|
90
|
+
writeLogLine(args.join(" "));
|
|
46
91
|
},
|
|
47
92
|
break() {
|
|
48
|
-
|
|
93
|
+
writeLogLine("");
|
|
49
94
|
}
|
|
50
95
|
};
|
|
51
96
|
|
|
52
97
|
//#endregion
|
|
53
98
|
//#region src/utils/handle-error.ts
|
|
54
|
-
const
|
|
99
|
+
const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
|
|
100
|
+
const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
55
101
|
logger.break();
|
|
56
102
|
logger.error("Something went wrong. Please check the error below for more details.");
|
|
57
103
|
logger.error("If the problem persists, please open an issue on GitHub.");
|
|
58
104
|
logger.error("");
|
|
59
105
|
if (error instanceof Error) logger.error(error.message);
|
|
60
106
|
logger.break();
|
|
61
|
-
process.exit(1);
|
|
107
|
+
if (options.shouldExit) process.exit(1);
|
|
108
|
+
process.exitCode = 1;
|
|
62
109
|
};
|
|
63
110
|
|
|
64
|
-
//#endregion
|
|
65
|
-
//#region src/constants.ts
|
|
66
|
-
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
67
|
-
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
68
|
-
const MILLISECONDS_PER_SECOND = 1e3;
|
|
69
|
-
const SEPARATOR_LENGTH_CHARS = 62;
|
|
70
|
-
const ERROR_PREVIEW_LENGTH_CHARS = 200;
|
|
71
|
-
const PERFECT_SCORE = 100;
|
|
72
|
-
const SCORE_GOOD_THRESHOLD = 75;
|
|
73
|
-
const SCORE_OK_THRESHOLD = 50;
|
|
74
|
-
const SCORE_BAR_WIDTH_CHARS = 50;
|
|
75
|
-
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
76
|
-
const SHARE_BASE_URL = "https://www.react.doctor/share";
|
|
77
|
-
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
78
|
-
|
|
79
111
|
//#endregion
|
|
80
112
|
//#region src/utils/calculate-score.ts
|
|
81
113
|
const calculateScore = async (diagnostics) => {
|
|
@@ -326,11 +358,13 @@ const discoverProject = (directory) => {
|
|
|
326
358
|
if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
|
|
327
359
|
if (framework === "unknown") framework = ancestorInfo.framework;
|
|
328
360
|
}
|
|
361
|
+
const projectName = packageJson.name ?? path.basename(directory);
|
|
329
362
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
330
363
|
const sourceFileCount = countSourceFiles(directory);
|
|
331
364
|
const hasReactCompiler = detectReactCompiler(directory, packageJson);
|
|
332
365
|
return {
|
|
333
366
|
rootDirectory: directory,
|
|
367
|
+
projectName,
|
|
334
368
|
reactVersion,
|
|
335
369
|
framework,
|
|
336
370
|
hasTypeScript,
|
|
@@ -449,12 +483,45 @@ const silenced = async (fn) => {
|
|
|
449
483
|
console.warn = originalWarn;
|
|
450
484
|
}
|
|
451
485
|
};
|
|
452
|
-
const
|
|
486
|
+
const findMonorepoRoot = (directory) => {
|
|
487
|
+
let currentDirectory = path.dirname(directory);
|
|
488
|
+
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
489
|
+
if (fs.existsSync(path.join(currentDirectory, "pnpm-workspace.yaml")) || (() => {
|
|
490
|
+
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
491
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
492
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
493
|
+
return Array.isArray(packageJson.workspaces) || packageJson.workspaces?.packages;
|
|
494
|
+
})()) return currentDirectory;
|
|
495
|
+
currentDirectory = path.dirname(currentDirectory);
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
};
|
|
499
|
+
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
453
500
|
const options = await silenced(() => createOptions({
|
|
454
|
-
cwd:
|
|
455
|
-
isShowProgress: false
|
|
501
|
+
cwd: knipCwd,
|
|
502
|
+
isShowProgress: false,
|
|
503
|
+
...workspaceName ? { workspace: workspaceName } : {}
|
|
456
504
|
}));
|
|
457
|
-
|
|
505
|
+
return await silenced(() => main(options));
|
|
506
|
+
};
|
|
507
|
+
const hasNodeModules = (directory) => {
|
|
508
|
+
const nodeModulesPath = path.join(directory, "node_modules");
|
|
509
|
+
return fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory();
|
|
510
|
+
};
|
|
511
|
+
const runKnip = async (rootDirectory) => {
|
|
512
|
+
const monorepoRoot = findMonorepoRoot(rootDirectory);
|
|
513
|
+
if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
|
|
514
|
+
let knipResult;
|
|
515
|
+
if (monorepoRoot) {
|
|
516
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
517
|
+
const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
|
|
518
|
+
try {
|
|
519
|
+
knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
|
|
520
|
+
} catch {
|
|
521
|
+
knipResult = await runKnipWithOptions(rootDirectory);
|
|
522
|
+
}
|
|
523
|
+
} else knipResult = await runKnipWithOptions(rootDirectory);
|
|
524
|
+
const { issues } = knipResult;
|
|
458
525
|
const diagnostics = [];
|
|
459
526
|
for (const unusedFile of issues.files) diagnostics.push({
|
|
460
527
|
filePath: path.relative(rootDirectory, unusedFile),
|
|
@@ -483,7 +550,7 @@ const NEXTJS_RULES = {
|
|
|
483
550
|
"react-doctor/nextjs-no-img-element": "warn",
|
|
484
551
|
"react-doctor/nextjs-async-client-component": "error",
|
|
485
552
|
"react-doctor/nextjs-no-a-element": "warn",
|
|
486
|
-
"react-doctor/nextjs-no-use-search-params-without-suspense": "
|
|
553
|
+
"react-doctor/nextjs-no-use-search-params-without-suspense": "warn",
|
|
487
554
|
"react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
|
|
488
555
|
"react-doctor/nextjs-missing-metadata": "warn",
|
|
489
556
|
"react-doctor/nextjs-no-client-side-redirect": "warn",
|
|
@@ -515,12 +582,6 @@ const REACT_COMPILER_RULES = {
|
|
|
515
582
|
"react-hooks-js/incompatible-library": "error",
|
|
516
583
|
"react-hooks-js/todo": "error"
|
|
517
584
|
};
|
|
518
|
-
const REACT_PERF_RULES = {
|
|
519
|
-
"react-perf/jsx-no-new-object-as-prop": "warn",
|
|
520
|
-
"react-perf/jsx-no-new-array-as-prop": "warn",
|
|
521
|
-
"react-perf/jsx-no-new-function-as-prop": "warn",
|
|
522
|
-
"react-perf/jsx-no-jsx-as-prop": "warn"
|
|
523
|
-
};
|
|
524
585
|
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
525
586
|
categories: {
|
|
526
587
|
correctness: "off",
|
|
@@ -550,7 +611,6 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
550
611
|
"react/jsx-no-script-url": "error",
|
|
551
612
|
"react/no-render-return-value": "warn",
|
|
552
613
|
"react/no-string-refs": "warn",
|
|
553
|
-
"react/no-unescaped-entities": "warn",
|
|
554
614
|
"react/no-is-mounted": "warn",
|
|
555
615
|
"react/require-render-return": "error",
|
|
556
616
|
"react/no-unknown-property": "warn",
|
|
@@ -569,7 +629,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
569
629
|
"jsx-a11y/label-has-associated-control": "warn",
|
|
570
630
|
"jsx-a11y/no-distracting-elements": "error",
|
|
571
631
|
"jsx-a11y/iframe-has-title": "warn",
|
|
572
|
-
...hasReactCompiler ? REACT_COMPILER_RULES :
|
|
632
|
+
...hasReactCompiler ? REACT_COMPILER_RULES : {},
|
|
573
633
|
"react-doctor/no-derived-state-effect": "error",
|
|
574
634
|
"react-doctor/no-fetch-in-effect": "error",
|
|
575
635
|
"react-doctor/no-cascading-set-state": "warn",
|
|
@@ -579,7 +639,6 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
579
639
|
"react-doctor/rerender-lazy-state-init": "warn",
|
|
580
640
|
"react-doctor/rerender-functional-setstate": "warn",
|
|
581
641
|
"react-doctor/rerender-dependencies": "error",
|
|
582
|
-
"react-doctor/no-generic-handler-names": "warn",
|
|
583
642
|
"react-doctor/no-giant-component": "warn",
|
|
584
643
|
"react-doctor/no-render-in-render": "warn",
|
|
585
644
|
"react-doctor/no-nested-component-definition": "error",
|
|
@@ -587,7 +646,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
587
646
|
"react-doctor/no-layout-property-animation": "error",
|
|
588
647
|
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
589
648
|
"react-doctor/rendering-animate-svg-wrapper": "warn",
|
|
590
|
-
"react-doctor/
|
|
649
|
+
"react-doctor/no-inline-prop-on-memo-component": "warn",
|
|
591
650
|
"react-doctor/rendering-hydration-no-flicker": "warn",
|
|
592
651
|
"react-doctor/no-transition-all": "warn",
|
|
593
652
|
"react-doctor/no-global-css-variable-animation": "error",
|
|
@@ -869,12 +928,17 @@ const spinner = (text) => ({ start() {
|
|
|
869
928
|
};
|
|
870
929
|
} });
|
|
871
930
|
|
|
931
|
+
//#endregion
|
|
932
|
+
//#region src/utils/indent-multiline-text.ts
|
|
933
|
+
const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
|
|
934
|
+
|
|
872
935
|
//#endregion
|
|
873
936
|
//#region src/scan.ts
|
|
874
937
|
const SEVERITY_ORDER = {
|
|
875
938
|
error: 0,
|
|
876
939
|
warning: 1
|
|
877
940
|
};
|
|
941
|
+
const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
|
|
878
942
|
const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
|
|
879
943
|
return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
|
|
880
944
|
});
|
|
@@ -892,11 +956,11 @@ const printDiagnostics = (diagnostics, isVerbose) => {
|
|
|
892
956
|
const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
|
|
893
957
|
for (const [, ruleDiagnostics] of sortedRuleGroups) {
|
|
894
958
|
const firstDiagnostic = ruleDiagnostics[0];
|
|
895
|
-
const icon = firstDiagnostic.severity === "error" ?
|
|
959
|
+
const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
|
|
896
960
|
const count = ruleDiagnostics.length;
|
|
897
|
-
const countLabel = count > 1 ? ` (${count})
|
|
961
|
+
const countLabel = count > 1 ? colorizeBySeverity(` (${count})`, firstDiagnostic.severity) : "";
|
|
898
962
|
logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
|
|
899
|
-
if (firstDiagnostic.help) logger.dim(
|
|
963
|
+
if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
|
|
900
964
|
if (isVerbose) {
|
|
901
965
|
const fileLines = buildFileLineMap(ruleDiagnostics);
|
|
902
966
|
for (const [filePath, lines] of fileLines) {
|
|
@@ -943,12 +1007,39 @@ const colorizeByScore = (text, score) => {
|
|
|
943
1007
|
if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
|
|
944
1008
|
return highlighter.error(text);
|
|
945
1009
|
};
|
|
946
|
-
const
|
|
1010
|
+
const createFramedLine = (plainText, renderedText = plainText) => ({
|
|
1011
|
+
plainText,
|
|
1012
|
+
renderedText
|
|
1013
|
+
});
|
|
1014
|
+
const buildScoreBarSegments = (score) => {
|
|
947
1015
|
const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
|
|
948
1016
|
const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1017
|
+
return {
|
|
1018
|
+
filledSegment: "█".repeat(filledCount),
|
|
1019
|
+
emptySegment: "░".repeat(emptyCount)
|
|
1020
|
+
};
|
|
1021
|
+
};
|
|
1022
|
+
const buildPlainScoreBar = (score) => {
|
|
1023
|
+
const { filledSegment, emptySegment } = buildScoreBarSegments(score);
|
|
1024
|
+
return `${filledSegment}${emptySegment}`;
|
|
1025
|
+
};
|
|
1026
|
+
const buildScoreBar = (score) => {
|
|
1027
|
+
const { filledSegment, emptySegment } = buildScoreBarSegments(score);
|
|
1028
|
+
return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
|
|
1029
|
+
};
|
|
1030
|
+
const printFramedBox = (framedLines) => {
|
|
1031
|
+
if (framedLines.length === 0) return;
|
|
1032
|
+
const borderColorizer = highlighter.dim;
|
|
1033
|
+
const outerIndent = " ".repeat(SUMMARY_BOX_OUTER_INDENT_CHARS);
|
|
1034
|
+
const horizontalPadding = " ".repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS);
|
|
1035
|
+
const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
|
|
1036
|
+
const borderLine = "─".repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2);
|
|
1037
|
+
logger.log(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
|
|
1038
|
+
for (const framedLine of framedLines) {
|
|
1039
|
+
const trailingSpaces = " ".repeat(maximumLineLength - framedLine.plainText.length);
|
|
1040
|
+
logger.log(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer("│")}`);
|
|
1041
|
+
}
|
|
1042
|
+
logger.log(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
|
|
952
1043
|
};
|
|
953
1044
|
const printScoreGauge = (score, label) => {
|
|
954
1045
|
const scoreDisplay = colorizeByScore(`${score}`, score);
|
|
@@ -975,36 +1066,65 @@ const printBranding = (score) => {
|
|
|
975
1066
|
logger.log(` React Doctor ${highlighter.dim("(www.react.doctor)")}`);
|
|
976
1067
|
logger.break();
|
|
977
1068
|
};
|
|
978
|
-
const buildShareUrl = (diagnostics, scoreResult) => {
|
|
1069
|
+
const buildShareUrl = (diagnostics, scoreResult, projectName) => {
|
|
979
1070
|
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
980
1071
|
const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
|
|
981
1072
|
const affectedFileCount = collectAffectedFiles(diagnostics).size;
|
|
982
1073
|
const params = new URLSearchParams();
|
|
1074
|
+
params.set("p", projectName);
|
|
983
1075
|
if (scoreResult) params.set("s", String(scoreResult.score));
|
|
984
1076
|
if (errorCount > 0) params.set("e", String(errorCount));
|
|
985
1077
|
if (warningCount > 0) params.set("w", String(warningCount));
|
|
986
1078
|
if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
|
|
987
1079
|
return `${SHARE_BASE_URL}?${params.toString()}`;
|
|
988
1080
|
};
|
|
989
|
-
const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
|
|
1081
|
+
const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName) => {
|
|
990
1082
|
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
991
1083
|
const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
|
|
992
1084
|
const affectedFileCount = collectAffectedFiles(diagnostics).size;
|
|
993
1085
|
const elapsed = formatElapsedTime(elapsedMilliseconds);
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1086
|
+
const summaryLineParts = [];
|
|
1087
|
+
const summaryLinePartsPlain = [];
|
|
1088
|
+
if (errorCount > 0) {
|
|
1089
|
+
const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
|
|
1090
|
+
summaryLinePartsPlain.push(errorText);
|
|
1091
|
+
summaryLineParts.push(highlighter.error(errorText));
|
|
1092
|
+
}
|
|
1093
|
+
if (warningCount > 0) {
|
|
1094
|
+
const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
|
|
1095
|
+
summaryLinePartsPlain.push(warningText);
|
|
1096
|
+
summaryLineParts.push(highlighter.warn(warningText));
|
|
1097
|
+
}
|
|
1098
|
+
const fileCountText = `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
|
|
1099
|
+
const elapsedTimeText = `in ${elapsed}`;
|
|
1100
|
+
summaryLinePartsPlain.push(fileCountText);
|
|
1101
|
+
summaryLinePartsPlain.push(elapsedTimeText);
|
|
1102
|
+
summaryLineParts.push(highlighter.dim(fileCountText));
|
|
1103
|
+
summaryLineParts.push(highlighter.dim(elapsedTimeText));
|
|
1104
|
+
const summaryFramedLines = [];
|
|
1105
|
+
if (scoreResult) {
|
|
1106
|
+
const [eyes, mouth] = getDoctorFace(scoreResult.score);
|
|
1107
|
+
const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
|
|
1108
|
+
summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
|
|
1109
|
+
summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
|
|
1110
|
+
summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
|
|
1111
|
+
summaryFramedLines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
|
|
1112
|
+
summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
|
|
1113
|
+
summaryFramedLines.push(createFramedLine(""));
|
|
1114
|
+
const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
|
|
1115
|
+
const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
|
|
1116
|
+
summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
|
|
1117
|
+
summaryFramedLines.push(createFramedLine(""));
|
|
1118
|
+
summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
|
|
1119
|
+
summaryFramedLines.push(createFramedLine(""));
|
|
1120
|
+
} else {
|
|
1121
|
+
summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
|
|
1122
|
+
summaryFramedLines.push(createFramedLine(""));
|
|
1123
|
+
summaryFramedLines.push(createFramedLine(OFFLINE_MESSAGE, highlighter.dim(OFFLINE_MESSAGE)));
|
|
1124
|
+
summaryFramedLines.push(createFramedLine(""));
|
|
1001
1125
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
if (warningCount > 0) parts.push(highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`));
|
|
1005
|
-
parts.push(highlighter.dim(`across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`));
|
|
1006
|
-
parts.push(highlighter.dim(`in ${elapsed}`));
|
|
1007
|
-
logger.log(` ${parts.join(" ")}`);
|
|
1126
|
+
summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
|
|
1127
|
+
printFramedBox(summaryFramedLines);
|
|
1008
1128
|
try {
|
|
1009
1129
|
const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
|
|
1010
1130
|
logger.break();
|
|
@@ -1012,7 +1132,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
|
|
|
1012
1132
|
} catch {
|
|
1013
1133
|
logger.break();
|
|
1014
1134
|
}
|
|
1015
|
-
const shareUrl = buildShareUrl(diagnostics, scoreResult);
|
|
1135
|
+
const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
|
|
1016
1136
|
logger.break();
|
|
1017
1137
|
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1018
1138
|
};
|
|
@@ -1078,18 +1198,46 @@ const scan = async (directory, options) => {
|
|
|
1078
1198
|
return;
|
|
1079
1199
|
}
|
|
1080
1200
|
printDiagnostics(diagnostics, options.verbose);
|
|
1081
|
-
printSummary(diagnostics, elapsedMilliseconds, scoreResult);
|
|
1201
|
+
printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName);
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/utils/should-select-all-choices.ts
|
|
1206
|
+
const shouldSelectAllChoices = (choiceStates) => {
|
|
1207
|
+
return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
|
|
1082
1208
|
};
|
|
1083
1209
|
|
|
1084
1210
|
//#endregion
|
|
1085
1211
|
//#region src/utils/prompts.ts
|
|
1212
|
+
const require = createRequire(import.meta.url);
|
|
1213
|
+
const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
|
|
1214
|
+
let didPatchMultiselectToggleAll = false;
|
|
1086
1215
|
const onCancel = () => {
|
|
1087
1216
|
logger.break();
|
|
1088
1217
|
logger.log("Cancelled.");
|
|
1089
1218
|
logger.break();
|
|
1090
1219
|
process.exit(0);
|
|
1091
1220
|
};
|
|
1221
|
+
const patchMultiselectToggleAll = () => {
|
|
1222
|
+
if (didPatchMultiselectToggleAll) return;
|
|
1223
|
+
didPatchMultiselectToggleAll = true;
|
|
1224
|
+
const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
|
|
1225
|
+
multiselectPromptConstructor.prototype.toggleAll = function() {
|
|
1226
|
+
const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
|
|
1227
|
+
if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
|
|
1228
|
+
this.bell();
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
|
|
1232
|
+
for (const choiceState of this.value) {
|
|
1233
|
+
if (choiceState.disabled) continue;
|
|
1234
|
+
choiceState.selected = shouldSelectAllEnabledChoices;
|
|
1235
|
+
}
|
|
1236
|
+
this.render();
|
|
1237
|
+
};
|
|
1238
|
+
};
|
|
1092
1239
|
const prompts = (questions) => {
|
|
1240
|
+
patchMultiselectToggleAll();
|
|
1093
1241
|
return basePrompts(questions, { onCancel });
|
|
1094
1242
|
};
|
|
1095
1243
|
|
|
@@ -1099,10 +1247,14 @@ const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
|
1099
1247
|
let packages = listWorkspacePackages(rootDirectory);
|
|
1100
1248
|
if (packages.length === 0) packages = discoverReactSubprojects(rootDirectory);
|
|
1101
1249
|
if (packages.length === 0) return [rootDirectory];
|
|
1250
|
+
if (packages.length === 1) {
|
|
1251
|
+
logger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages[0].name}`);
|
|
1252
|
+
return [packages[0].directory];
|
|
1253
|
+
}
|
|
1102
1254
|
if (projectFlag) return resolveProjectFlag(projectFlag, packages);
|
|
1103
1255
|
if (skipPrompts) {
|
|
1104
1256
|
printDiscoveredProjects(packages);
|
|
1105
|
-
|
|
1257
|
+
return packages.map((workspacePackage) => workspacePackage.directory);
|
|
1106
1258
|
}
|
|
1107
1259
|
return promptProjectSelection(packages, rootDirectory);
|
|
1108
1260
|
};
|
|
@@ -1120,13 +1272,7 @@ const resolveProjectFlag = (projectFlag, workspacePackages) => {
|
|
|
1120
1272
|
return resolvedDirectories;
|
|
1121
1273
|
};
|
|
1122
1274
|
const printDiscoveredProjects = (packages) => {
|
|
1123
|
-
logger.log(`${highlighter.success("✔")}
|
|
1124
|
-
logger.break();
|
|
1125
|
-
for (const workspacePackage of packages) logger.log(` ${highlighter.dim("─")} ${workspacePackage.directory}`);
|
|
1126
|
-
logger.break();
|
|
1127
|
-
logger.dim(`Run with a specific path to scan a project:`);
|
|
1128
|
-
logger.dim(` npx -y react-doctor@latest <path>`);
|
|
1129
|
-
logger.break();
|
|
1275
|
+
logger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages.map((workspacePackage) => workspacePackage.name).join(", ")}`);
|
|
1130
1276
|
};
|
|
1131
1277
|
const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
1132
1278
|
const { selectedDirectories } = await prompts({
|
|
@@ -1165,9 +1311,10 @@ const writeConfig = (config) => {
|
|
|
1165
1311
|
const installSkill = () => {
|
|
1166
1312
|
try {
|
|
1167
1313
|
execSync(`npx -y skills add ${SKILL_REPO}`, { stdio: "inherit" });
|
|
1168
|
-
return true;
|
|
1169
1314
|
} catch {
|
|
1170
|
-
|
|
1315
|
+
logger.break();
|
|
1316
|
+
logger.dim("Skill install failed. You can install manually:");
|
|
1317
|
+
logger.dim(` npx skills add ${SKILL_REPO}`);
|
|
1171
1318
|
}
|
|
1172
1319
|
};
|
|
1173
1320
|
const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
@@ -1187,14 +1334,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
1187
1334
|
});
|
|
1188
1335
|
if (shouldInstall) {
|
|
1189
1336
|
logger.break();
|
|
1190
|
-
|
|
1191
|
-
logger.break();
|
|
1192
|
-
logger.success("Skill installed!");
|
|
1193
|
-
} else {
|
|
1194
|
-
logger.break();
|
|
1195
|
-
logger.dim("Skill install failed. You can install manually:");
|
|
1196
|
-
logger.dim(` npx skills add ${SKILL_REPO}`);
|
|
1197
|
-
}
|
|
1337
|
+
installSkill();
|
|
1198
1338
|
}
|
|
1199
1339
|
writeConfig({
|
|
1200
1340
|
...config,
|
|
@@ -1202,15 +1342,85 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
1202
1342
|
});
|
|
1203
1343
|
};
|
|
1204
1344
|
|
|
1345
|
+
//#endregion
|
|
1346
|
+
//#region src/utils/global-install.ts
|
|
1347
|
+
const isGloballyInstalled = () => {
|
|
1348
|
+
try {
|
|
1349
|
+
return !execSync("which react-doctor", {
|
|
1350
|
+
stdio: "pipe",
|
|
1351
|
+
encoding: "utf-8"
|
|
1352
|
+
}).trim().includes("/_npx/");
|
|
1353
|
+
} catch {
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
const maybeInstallGlobally = () => {
|
|
1358
|
+
try {
|
|
1359
|
+
if (isGloballyInstalled()) return;
|
|
1360
|
+
const child = spawn("npm", [
|
|
1361
|
+
"install",
|
|
1362
|
+
"-g",
|
|
1363
|
+
"react-doctor@latest"
|
|
1364
|
+
], {
|
|
1365
|
+
detached: true,
|
|
1366
|
+
stdio: "ignore"
|
|
1367
|
+
});
|
|
1368
|
+
child.on("error", () => {});
|
|
1369
|
+
child.unref();
|
|
1370
|
+
} catch {}
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
//#endregion
|
|
1374
|
+
//#region src/utils/copy-to-clipboard.ts
|
|
1375
|
+
const getClipboardCommands = () => {
|
|
1376
|
+
if (process.platform === "darwin") return [{
|
|
1377
|
+
command: "pbcopy",
|
|
1378
|
+
args: []
|
|
1379
|
+
}];
|
|
1380
|
+
if (process.platform === "win32") return [{
|
|
1381
|
+
command: "clip",
|
|
1382
|
+
args: []
|
|
1383
|
+
}];
|
|
1384
|
+
return [
|
|
1385
|
+
{
|
|
1386
|
+
command: "wl-copy",
|
|
1387
|
+
args: []
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
command: "xclip",
|
|
1391
|
+
args: ["-selection", "clipboard"]
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
command: "xsel",
|
|
1395
|
+
args: ["--clipboard", "--input"]
|
|
1396
|
+
}
|
|
1397
|
+
];
|
|
1398
|
+
};
|
|
1399
|
+
const copyToClipboard = (text) => {
|
|
1400
|
+
const clipboardCommands = getClipboardCommands();
|
|
1401
|
+
for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
|
|
1402
|
+
input: text,
|
|
1403
|
+
stdio: [
|
|
1404
|
+
"pipe",
|
|
1405
|
+
"ignore",
|
|
1406
|
+
"ignore"
|
|
1407
|
+
],
|
|
1408
|
+
encoding: "utf8"
|
|
1409
|
+
}).status === 0) return true;
|
|
1410
|
+
return false;
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1205
1413
|
//#endregion
|
|
1206
1414
|
//#region src/cli.ts
|
|
1207
|
-
const VERSION = "0.0.
|
|
1415
|
+
const VERSION = "0.0.10";
|
|
1208
1416
|
process.on("SIGINT", () => process.exit(0));
|
|
1209
1417
|
process.on("SIGTERM", () => process.exit(0));
|
|
1210
|
-
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("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
|
|
1418
|
+
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("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
|
|
1419
|
+
const isScoreOnly = flags.score && !flags.prompt;
|
|
1420
|
+
const shouldCopyPromptOutput = flags.prompt;
|
|
1421
|
+
if (shouldCopyPromptOutput) startLoggerCapture();
|
|
1211
1422
|
try {
|
|
1212
1423
|
const resolvedDirectory = path.resolve(directory);
|
|
1213
|
-
const isScoreOnly = flags.score;
|
|
1214
1424
|
if (!isScoreOnly) {
|
|
1215
1425
|
logger.log(`react-doctor v${VERSION}`);
|
|
1216
1426
|
logger.break();
|
|
@@ -1218,7 +1428,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1218
1428
|
const scanOptions = {
|
|
1219
1429
|
lint: flags.lint,
|
|
1220
1430
|
deadCode: flags.deadCode,
|
|
1221
|
-
verbose: Boolean(flags.verbose),
|
|
1431
|
+
verbose: flags.prompt || Boolean(flags.verbose),
|
|
1222
1432
|
scoreOnly: isScoreOnly
|
|
1223
1433
|
};
|
|
1224
1434
|
const isAutomatedEnvironment = [
|
|
@@ -1242,19 +1452,23 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1242
1452
|
if (!isScoreOnly) logger.break();
|
|
1243
1453
|
}
|
|
1244
1454
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|
|
1245
|
-
if (!isScoreOnly) {
|
|
1455
|
+
if (!isScoreOnly && !flags.prompt) {
|
|
1246
1456
|
await maybePromptSkillInstall(shouldSkipPrompts);
|
|
1247
1457
|
if (!shouldSkipPrompts && !flags.fix) await maybePromptAmiFix(resolvedDirectory);
|
|
1248
1458
|
}
|
|
1249
1459
|
} catch (error) {
|
|
1250
|
-
handleError(error);
|
|
1460
|
+
handleError(error, { shouldExit: !shouldCopyPromptOutput });
|
|
1461
|
+
} finally {
|
|
1462
|
+
if (shouldCopyPromptOutput) copyPromptToClipboard(stopLoggerCapture(), !isScoreOnly);
|
|
1251
1463
|
}
|
|
1252
1464
|
}).addHelpText("after", `
|
|
1253
1465
|
${highlighter.dim("Learn more:")}
|
|
1254
1466
|
${highlighter.info("https://github.com/aidenybai/react-doctor")}
|
|
1255
1467
|
`);
|
|
1256
1468
|
const AMI_INSTALL_URL = "https://ami.dev/install.sh";
|
|
1257
|
-
const
|
|
1469
|
+
const FIX_PROMPT = "Fix all issues reported in the react-doctor diagnostics below, one by one. After applying fixes, run `react-dcotor` again to verify the results improved.";
|
|
1470
|
+
const REACT_DOCTOR_OUTPUT_LABEL = "react-doctor output";
|
|
1471
|
+
const SCAN_SUMMARY_SEPARATOR = "─".repeat(SEPARATOR_LENGTH_CHARS);
|
|
1258
1472
|
const isAmiInstalled = () => {
|
|
1259
1473
|
try {
|
|
1260
1474
|
execSync("ls /Applications/Ami.app", { stdio: "ignore" });
|
|
@@ -1278,7 +1492,7 @@ const openAmiToFix = (directory) => {
|
|
|
1278
1492
|
const resolvedDirectory = path.resolve(directory);
|
|
1279
1493
|
if (!isAmiInstalled()) installAmi();
|
|
1280
1494
|
logger.log("Opening Ami to fix react-doctor issues...");
|
|
1281
|
-
const deeplink = `ami://open-project?cwd=${encodeURIComponent(resolvedDirectory)}&prompt=${encodeURIComponent(
|
|
1495
|
+
const deeplink = `ami://open-project?cwd=${encodeURIComponent(resolvedDirectory)}&prompt=${encodeURIComponent(FIX_PROMPT)}&mode=agent`;
|
|
1282
1496
|
try {
|
|
1283
1497
|
execSync(`open "${deeplink}"`, { stdio: "ignore" });
|
|
1284
1498
|
logger.success("Opened Ami with react-doctor fix prompt.");
|
|
@@ -1288,6 +1502,22 @@ const openAmiToFix = (directory) => {
|
|
|
1288
1502
|
logger.info(deeplink);
|
|
1289
1503
|
}
|
|
1290
1504
|
};
|
|
1505
|
+
const buildPromptWithOutput = (reactDoctorOutput) => {
|
|
1506
|
+
const summaryStartIndex = reactDoctorOutput.indexOf(SCAN_SUMMARY_SEPARATOR);
|
|
1507
|
+
const normalizedReactDoctorOutput = (summaryStartIndex === -1 ? reactDoctorOutput : reactDoctorOutput.slice(0, summaryStartIndex).trimEnd()).trim();
|
|
1508
|
+
return `${FIX_PROMPT}\n\n${REACT_DOCTOR_OUTPUT_LABEL}:\n\`\`\`\n${normalizedReactDoctorOutput.length > 0 ? normalizedReactDoctorOutput : "No output captured."}\n\`\`\``;
|
|
1509
|
+
};
|
|
1510
|
+
const copyPromptToClipboard = (reactDoctorOutput, shouldLogResult) => {
|
|
1511
|
+
const promptWithOutput = buildPromptWithOutput(reactDoctorOutput);
|
|
1512
|
+
const didCopyPromptToClipboard = copyToClipboard(promptWithOutput);
|
|
1513
|
+
if (!shouldLogResult) return;
|
|
1514
|
+
if (didCopyPromptToClipboard) {
|
|
1515
|
+
logger.success("Copied latest scan output to clipboard");
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
logger.warn("Could not copy prompt to clipboard automatically. Use this prompt:");
|
|
1519
|
+
logger.info(promptWithOutput);
|
|
1520
|
+
};
|
|
1291
1521
|
const maybePromptAmiFix = async (directory) => {
|
|
1292
1522
|
logger.break();
|
|
1293
1523
|
logger.log(`Fix these issues with ${highlighter.info("Ami")}?`);
|
|
@@ -1314,6 +1544,7 @@ const installAmiCommand = new Command("install-ami").description("Install Ami an
|
|
|
1314
1544
|
program.addCommand(fixCommand);
|
|
1315
1545
|
program.addCommand(installAmiCommand);
|
|
1316
1546
|
const main$1 = async () => {
|
|
1547
|
+
maybeInstallGlobally();
|
|
1317
1548
|
await program.parseAsync();
|
|
1318
1549
|
};
|
|
1319
1550
|
main$1();
|