react-doctor 0.0.7 → 0.0.9

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 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,58 +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
- console.log(highlighter.error(args.join(" ")));
75
+ writeLogLine(highlighter.error(args.join(" ")));
31
76
  },
32
77
  warn(...args) {
33
- console.log(highlighter.warn(args.join(" ")));
78
+ writeLogLine(highlighter.warn(args.join(" ")));
34
79
  },
35
80
  info(...args) {
36
- console.log(highlighter.info(args.join(" ")));
81
+ writeLogLine(highlighter.info(args.join(" ")));
37
82
  },
38
83
  success(...args) {
39
- console.log(highlighter.success(args.join(" ")));
84
+ writeLogLine(highlighter.success(args.join(" ")));
40
85
  },
41
86
  dim(...args) {
42
- console.log(highlighter.dim(args.join(" ")));
87
+ writeLogLine(highlighter.dim(args.join(" ")));
43
88
  },
44
89
  log(...args) {
45
- console.log(args.join(" "));
90
+ writeLogLine(args.join(" "));
46
91
  },
47
92
  break() {
48
- console.log("");
93
+ writeLogLine("");
49
94
  }
50
95
  };
51
96
 
52
97
  //#endregion
53
98
  //#region src/utils/handle-error.ts
54
- const handleError = (error) => {
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 OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
77
-
78
111
  //#endregion
79
112
  //#region src/utils/calculate-score.ts
80
113
  const calculateScore = async (diagnostics) => {
@@ -325,11 +358,13 @@ const discoverProject = (directory) => {
325
358
  if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
326
359
  if (framework === "unknown") framework = ancestorInfo.framework;
327
360
  }
361
+ const projectName = packageJson.name ?? path.basename(directory);
328
362
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
329
363
  const sourceFileCount = countSourceFiles(directory);
330
364
  const hasReactCompiler = detectReactCompiler(directory, packageJson);
331
365
  return {
332
366
  rootDirectory: directory,
367
+ projectName,
333
368
  reactVersion,
334
369
  framework,
335
370
  hasTypeScript,
@@ -448,12 +483,45 @@ const silenced = async (fn) => {
448
483
  console.warn = originalWarn;
449
484
  }
450
485
  };
451
- const runKnip = async (rootDirectory) => {
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) => {
452
500
  const options = await silenced(() => createOptions({
453
- cwd: rootDirectory,
454
- isShowProgress: false
501
+ cwd: knipCwd,
502
+ isShowProgress: false,
503
+ ...workspaceName ? { workspace: workspaceName } : {}
455
504
  }));
456
- const { issues } = await silenced(() => main(options));
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;
457
525
  const diagnostics = [];
458
526
  for (const unusedFile of issues.files) diagnostics.push({
459
527
  filePath: path.relative(rootDirectory, unusedFile),
@@ -482,7 +550,7 @@ const NEXTJS_RULES = {
482
550
  "react-doctor/nextjs-no-img-element": "warn",
483
551
  "react-doctor/nextjs-async-client-component": "error",
484
552
  "react-doctor/nextjs-no-a-element": "warn",
485
- "react-doctor/nextjs-no-use-search-params-without-suspense": "error",
553
+ "react-doctor/nextjs-no-use-search-params-without-suspense": "warn",
486
554
  "react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
487
555
  "react-doctor/nextjs-missing-metadata": "warn",
488
556
  "react-doctor/nextjs-no-client-side-redirect": "warn",
@@ -514,12 +582,6 @@ const REACT_COMPILER_RULES = {
514
582
  "react-hooks-js/incompatible-library": "error",
515
583
  "react-hooks-js/todo": "error"
516
584
  };
517
- const REACT_PERF_RULES = {
518
- "react-perf/jsx-no-new-object-as-prop": "warn",
519
- "react-perf/jsx-no-new-array-as-prop": "warn",
520
- "react-perf/jsx-no-new-function-as-prop": "warn",
521
- "react-perf/jsx-no-jsx-as-prop": "warn"
522
- };
523
585
  const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
524
586
  categories: {
525
587
  correctness: "off",
@@ -549,7 +611,6 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
549
611
  "react/jsx-no-script-url": "error",
550
612
  "react/no-render-return-value": "warn",
551
613
  "react/no-string-refs": "warn",
552
- "react/no-unescaped-entities": "warn",
553
614
  "react/no-is-mounted": "warn",
554
615
  "react/require-render-return": "error",
555
616
  "react/no-unknown-property": "warn",
@@ -568,7 +629,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
568
629
  "jsx-a11y/label-has-associated-control": "warn",
569
630
  "jsx-a11y/no-distracting-elements": "error",
570
631
  "jsx-a11y/iframe-has-title": "warn",
571
- ...hasReactCompiler ? REACT_COMPILER_RULES : REACT_PERF_RULES,
632
+ ...hasReactCompiler ? REACT_COMPILER_RULES : {},
572
633
  "react-doctor/no-derived-state-effect": "error",
573
634
  "react-doctor/no-fetch-in-effect": "error",
574
635
  "react-doctor/no-cascading-set-state": "warn",
@@ -578,7 +639,6 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
578
639
  "react-doctor/rerender-lazy-state-init": "warn",
579
640
  "react-doctor/rerender-functional-setstate": "warn",
580
641
  "react-doctor/rerender-dependencies": "error",
581
- "react-doctor/no-generic-handler-names": "warn",
582
642
  "react-doctor/no-giant-component": "warn",
583
643
  "react-doctor/no-render-in-render": "warn",
584
644
  "react-doctor/no-nested-component-definition": "error",
@@ -586,7 +646,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
586
646
  "react-doctor/no-layout-property-animation": "error",
587
647
  "react-doctor/rerender-memo-with-default-value": "warn",
588
648
  "react-doctor/rendering-animate-svg-wrapper": "warn",
589
- "react-doctor/rendering-usetransition-loading": "warn",
649
+ "react-doctor/no-inline-prop-on-memo-component": "warn",
590
650
  "react-doctor/rendering-hydration-no-flicker": "warn",
591
651
  "react-doctor/no-transition-all": "warn",
592
652
  "react-doctor/no-global-css-variable-animation": "error",
@@ -868,12 +928,17 @@ const spinner = (text) => ({ start() {
868
928
  };
869
929
  } });
870
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
+
871
935
  //#endregion
872
936
  //#region src/scan.ts
873
937
  const SEVERITY_ORDER = {
874
938
  error: 0,
875
939
  warning: 1
876
940
  };
941
+ const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
877
942
  const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
878
943
  return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
879
944
  });
@@ -891,11 +956,11 @@ const printDiagnostics = (diagnostics, isVerbose) => {
891
956
  const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
892
957
  for (const [, ruleDiagnostics] of sortedRuleGroups) {
893
958
  const firstDiagnostic = ruleDiagnostics[0];
894
- const icon = firstDiagnostic.severity === "error" ? highlighter.error("✗") : highlighter.warn("⚠");
959
+ const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
895
960
  const count = ruleDiagnostics.length;
896
- const countLabel = count > 1 ? ` (${count})` : "";
961
+ const countLabel = count > 1 ? colorizeBySeverity(` (${count})`, firstDiagnostic.severity) : "";
897
962
  logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
898
- if (firstDiagnostic.help) logger.dim(` ${firstDiagnostic.help}`);
963
+ if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
899
964
  if (isVerbose) {
900
965
  const fileLines = buildFileLineMap(ruleDiagnostics);
901
966
  for (const [filePath, lines] of fileLines) {
@@ -942,12 +1007,39 @@ const colorizeByScore = (text, score) => {
942
1007
  if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
943
1008
  return highlighter.error(text);
944
1009
  };
945
- const buildScoreBar = (score) => {
1010
+ const createFramedLine = (plainText, renderedText = plainText) => ({
1011
+ plainText,
1012
+ renderedText
1013
+ });
1014
+ const buildScoreBarSegments = (score) => {
946
1015
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
947
1016
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
948
- const filled = "█".repeat(filledCount);
949
- const empty = "".repeat(emptyCount);
950
- return colorizeByScore(filled, score) + highlighter.dim(empty);
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}┘`)}`);
951
1043
  };
952
1044
  const printScoreGauge = (score, label) => {
953
1045
  const scoreDisplay = colorizeByScore(`${score}`, score);
@@ -974,25 +1066,65 @@ const printBranding = (score) => {
974
1066
  logger.log(` React Doctor ${highlighter.dim("(www.react.doctor)")}`);
975
1067
  logger.break();
976
1068
  };
977
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
1069
+ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
1070
+ const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
1071
+ const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
1072
+ const affectedFileCount = collectAffectedFiles(diagnostics).size;
1073
+ const params = new URLSearchParams();
1074
+ params.set("p", projectName);
1075
+ if (scoreResult) params.set("s", String(scoreResult.score));
1076
+ if (errorCount > 0) params.set("e", String(errorCount));
1077
+ if (warningCount > 0) params.set("w", String(warningCount));
1078
+ if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
1079
+ return `${SHARE_BASE_URL}?${params.toString()}`;
1080
+ };
1081
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName) => {
978
1082
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
979
1083
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
980
1084
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
981
1085
  const elapsed = formatElapsedTime(elapsedMilliseconds);
982
- logger.log("─".repeat(SEPARATOR_LENGTH_CHARS));
983
- logger.break();
984
- printBranding(scoreResult?.score);
985
- if (scoreResult) printScoreGauge(scoreResult.score, scoreResult.label);
986
- else {
987
- logger.dim(` ${OFFLINE_MESSAGE}`);
988
- logger.break();
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));
989
1092
  }
990
- const parts = [];
991
- if (errorCount > 0) parts.push(highlighter.error(`${errorCount} error${errorCount === 1 ? "" : "s"}`));
992
- if (warningCount > 0) parts.push(highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`));
993
- parts.push(highlighter.dim(`across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`));
994
- parts.push(highlighter.dim(`in ${elapsed}`));
995
- logger.log(` ${parts.join(" ")}`);
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(""));
1125
+ }
1126
+ summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
1127
+ printFramedBox(summaryFramedLines);
996
1128
  try {
997
1129
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
998
1130
  logger.break();
@@ -1000,6 +1132,9 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
1000
1132
  } catch {
1001
1133
  logger.break();
1002
1134
  }
1135
+ const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1136
+ logger.break();
1137
+ logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1003
1138
  };
1004
1139
  const scan = async (directory, options) => {
1005
1140
  const startTime = performance.now();
@@ -1063,18 +1198,46 @@ const scan = async (directory, options) => {
1063
1198
  return;
1064
1199
  }
1065
1200
  printDiagnostics(diagnostics, options.verbose);
1066
- 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);
1067
1208
  };
1068
1209
 
1069
1210
  //#endregion
1070
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;
1071
1215
  const onCancel = () => {
1072
1216
  logger.break();
1073
1217
  logger.log("Cancelled.");
1074
1218
  logger.break();
1075
1219
  process.exit(0);
1076
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
+ };
1077
1239
  const prompts = (questions) => {
1240
+ patchMultiselectToggleAll();
1078
1241
  return basePrompts(questions, { onCancel });
1079
1242
  };
1080
1243
 
@@ -1084,10 +1247,14 @@ const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
1084
1247
  let packages = listWorkspacePackages(rootDirectory);
1085
1248
  if (packages.length === 0) packages = discoverReactSubprojects(rootDirectory);
1086
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
+ }
1087
1254
  if (projectFlag) return resolveProjectFlag(projectFlag, packages);
1088
1255
  if (skipPrompts) {
1089
1256
  printDiscoveredProjects(packages);
1090
- process.exit(0);
1257
+ return packages.map((workspacePackage) => workspacePackage.directory);
1091
1258
  }
1092
1259
  return promptProjectSelection(packages, rootDirectory);
1093
1260
  };
@@ -1150,9 +1317,10 @@ const writeConfig = (config) => {
1150
1317
  const installSkill = () => {
1151
1318
  try {
1152
1319
  execSync(`npx -y skills add ${SKILL_REPO}`, { stdio: "inherit" });
1153
- return true;
1154
1320
  } catch {
1155
- return false;
1321
+ logger.break();
1322
+ logger.dim("Skill install failed. You can install manually:");
1323
+ logger.dim(` npx skills add ${SKILL_REPO}`);
1156
1324
  }
1157
1325
  };
1158
1326
  const maybePromptSkillInstall = async (shouldSkipPrompts) => {
@@ -1160,8 +1328,9 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1160
1328
  if (config.skillPromptDismissed) return;
1161
1329
  if (shouldSkipPrompts) return;
1162
1330
  logger.break();
1163
- logger.log(`${highlighter.info("💡")} Install the ${highlighter.info("react-doctor")} skill for your coding agent?`);
1164
- logger.dim(" Adds React best practices to Cursor, Claude Code, Copilot, and more.");
1331
+ logger.log(`${highlighter.info("💡")} Have your coding agent fix these issues automatically?`);
1332
+ logger.dim(` Install the ${highlighter.info("react-doctor")} skill to teach Cursor, Claude Code, Copilot,`);
1333
+ logger.dim(" Ami, and other AI agents how to diagnose and fix these React issues.");
1165
1334
  logger.break();
1166
1335
  const { shouldInstall } = await prompts({
1167
1336
  type: "confirm",
@@ -1171,14 +1340,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1171
1340
  });
1172
1341
  if (shouldInstall) {
1173
1342
  logger.break();
1174
- if (installSkill()) {
1175
- logger.break();
1176
- logger.success("Skill installed!");
1177
- } else {
1178
- logger.break();
1179
- logger.dim("Skill install failed. You can install manually:");
1180
- logger.dim(` npx skills add ${SKILL_REPO}`);
1181
- }
1343
+ installSkill();
1182
1344
  }
1183
1345
  writeConfig({
1184
1346
  ...config,
@@ -1186,15 +1348,85 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1186
1348
  });
1187
1349
  };
1188
1350
 
1351
+ //#endregion
1352
+ //#region src/utils/global-install.ts
1353
+ const isGloballyInstalled = () => {
1354
+ try {
1355
+ return !execSync("which react-doctor", {
1356
+ stdio: "pipe",
1357
+ encoding: "utf-8"
1358
+ }).trim().includes("/_npx/");
1359
+ } catch {
1360
+ return false;
1361
+ }
1362
+ };
1363
+ const maybeInstallGlobally = () => {
1364
+ try {
1365
+ if (isGloballyInstalled()) return;
1366
+ const child = spawn("npm", [
1367
+ "install",
1368
+ "-g",
1369
+ "react-doctor@latest"
1370
+ ], {
1371
+ detached: true,
1372
+ stdio: "ignore"
1373
+ });
1374
+ child.on("error", () => {});
1375
+ child.unref();
1376
+ } catch {}
1377
+ };
1378
+
1379
+ //#endregion
1380
+ //#region src/utils/copy-to-clipboard.ts
1381
+ const getClipboardCommands = () => {
1382
+ if (process.platform === "darwin") return [{
1383
+ command: "pbcopy",
1384
+ args: []
1385
+ }];
1386
+ if (process.platform === "win32") return [{
1387
+ command: "clip",
1388
+ args: []
1389
+ }];
1390
+ return [
1391
+ {
1392
+ command: "wl-copy",
1393
+ args: []
1394
+ },
1395
+ {
1396
+ command: "xclip",
1397
+ args: ["-selection", "clipboard"]
1398
+ },
1399
+ {
1400
+ command: "xsel",
1401
+ args: ["--clipboard", "--input"]
1402
+ }
1403
+ ];
1404
+ };
1405
+ const copyToClipboard = (text) => {
1406
+ const clipboardCommands = getClipboardCommands();
1407
+ for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
1408
+ input: text,
1409
+ stdio: [
1410
+ "pipe",
1411
+ "ignore",
1412
+ "ignore"
1413
+ ],
1414
+ encoding: "utf8"
1415
+ }).status === 0) return true;
1416
+ return false;
1417
+ };
1418
+
1189
1419
  //#endregion
1190
1420
  //#region src/cli.ts
1191
- const VERSION = "0.0.7";
1421
+ const VERSION = "0.0.9";
1192
1422
  process.on("SIGINT", () => process.exit(0));
1193
1423
  process.on("SIGTERM", () => process.exit(0));
1194
- 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) => {
1424
+ 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) => {
1425
+ const isScoreOnly = flags.score && !flags.prompt;
1426
+ const shouldCopyPromptOutput = flags.prompt;
1427
+ if (shouldCopyPromptOutput) startLoggerCapture();
1195
1428
  try {
1196
1429
  const resolvedDirectory = path.resolve(directory);
1197
- const isScoreOnly = flags.score;
1198
1430
  if (!isScoreOnly) {
1199
1431
  logger.log(`react-doctor v${VERSION}`);
1200
1432
  logger.break();
@@ -1202,10 +1434,20 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1202
1434
  const scanOptions = {
1203
1435
  lint: flags.lint,
1204
1436
  deadCode: flags.deadCode,
1205
- verbose: Boolean(flags.verbose),
1437
+ verbose: flags.prompt || Boolean(flags.verbose),
1206
1438
  scoreOnly: isScoreOnly
1207
1439
  };
1208
- const shouldSkipPrompts = flags.yes || Boolean(process.env.CI) || Boolean(process.env.CLAUDECODE) || Boolean(process.env.AMI) || !process.stdin.isTTY;
1440
+ const isAutomatedEnvironment = [
1441
+ process.env.CI,
1442
+ process.env.CLAUDECODE,
1443
+ process.env.CURSOR_TRACE_ID,
1444
+ process.env.CURSOR_AGENT,
1445
+ process.env.CODEX_CI,
1446
+ process.env.OPENCODE,
1447
+ process.env.AMP_HOME,
1448
+ process.env.AMI
1449
+ ].some(Boolean);
1450
+ const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
1209
1451
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
1210
1452
  for (const projectDirectory of projectDirectories) {
1211
1453
  if (!isScoreOnly) {
@@ -1216,17 +1458,23 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1216
1458
  if (!isScoreOnly) logger.break();
1217
1459
  }
1218
1460
  if (flags.fix) openAmiToFix(resolvedDirectory);
1219
- if (!isScoreOnly) await maybePromptSkillInstall(shouldSkipPrompts);
1461
+ if (!isScoreOnly && !flags.prompt) {
1462
+ await maybePromptSkillInstall(shouldSkipPrompts);
1463
+ if (!shouldSkipPrompts && !flags.fix) await maybePromptAmiFix(resolvedDirectory);
1464
+ }
1220
1465
  } catch (error) {
1221
- handleError(error);
1466
+ handleError(error, { shouldExit: !shouldCopyPromptOutput });
1467
+ } finally {
1468
+ if (shouldCopyPromptOutput) copyPromptToClipboard(stopLoggerCapture(), !isScoreOnly);
1222
1469
  }
1223
1470
  }).addHelpText("after", `
1224
1471
  ${highlighter.dim("Learn more:")}
1225
1472
  ${highlighter.info("https://github.com/aidenybai/react-doctor")}
1226
1473
  `);
1227
1474
  const AMI_INSTALL_URL = "https://ami.dev/install.sh";
1228
- const AMI_FIX_PROMPT = "Run npx -y react-doctor@latest . --verbose, read every diagnostic, then fix all issues one by one. After fixing, re-run react-doctor to verify the score improved.";
1229
- const OPEN_PROJECT_DELAY_S = 2;
1475
+ 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.";
1476
+ const REACT_DOCTOR_OUTPUT_LABEL = "react-doctor output";
1477
+ const SCAN_SUMMARY_SEPARATOR = "─".repeat(SEPARATOR_LENGTH_CHARS);
1230
1478
  const isAmiInstalled = () => {
1231
1479
  try {
1232
1480
  execSync("ls /Applications/Ami.app", { stdio: "ignore" });
@@ -1250,19 +1498,45 @@ const openAmiToFix = (directory) => {
1250
1498
  const resolvedDirectory = path.resolve(directory);
1251
1499
  if (!isAmiInstalled()) installAmi();
1252
1500
  logger.log("Opening Ami to fix react-doctor issues...");
1253
- const encodedDirectory = encodeURIComponent(resolvedDirectory);
1254
- const encodedPrompt = encodeURIComponent(AMI_FIX_PROMPT);
1255
- const openProjectDeeplink = `ami://open-project?cwd=${encodedDirectory}`;
1256
- const newChatDeeplink = `ami://new-chat?prompt=${encodedPrompt}&mode=agent&send=true`;
1501
+ const deeplink = `ami://open-project?cwd=${encodeURIComponent(resolvedDirectory)}&prompt=${encodeURIComponent(FIX_PROMPT)}&mode=agent`;
1257
1502
  try {
1258
- execSync(`open "${openProjectDeeplink}" && sleep ${OPEN_PROJECT_DELAY_S} && open "${newChatDeeplink}"`, { stdio: "ignore" });
1503
+ execSync(`open "${deeplink}"`, { stdio: "ignore" });
1259
1504
  logger.success("Opened Ami with react-doctor fix prompt.");
1260
1505
  } catch {
1261
1506
  logger.break();
1262
- logger.dim("Could not open Ami automatically. Open these URLs manually:");
1263
- logger.info(openProjectDeeplink);
1264
- logger.info(newChatDeeplink);
1507
+ logger.dim("Could not open Ami automatically. Open this URL manually:");
1508
+ logger.info(deeplink);
1509
+ }
1510
+ };
1511
+ const buildPromptWithOutput = (reactDoctorOutput) => {
1512
+ const summaryStartIndex = reactDoctorOutput.indexOf(SCAN_SUMMARY_SEPARATOR);
1513
+ const normalizedReactDoctorOutput = (summaryStartIndex === -1 ? reactDoctorOutput : reactDoctorOutput.slice(0, summaryStartIndex).trimEnd()).trim();
1514
+ return `${FIX_PROMPT}\n\n${REACT_DOCTOR_OUTPUT_LABEL}:\n\`\`\`\n${normalizedReactDoctorOutput.length > 0 ? normalizedReactDoctorOutput : "No output captured."}\n\`\`\``;
1515
+ };
1516
+ const copyPromptToClipboard = (reactDoctorOutput, shouldLogResult) => {
1517
+ const promptWithOutput = buildPromptWithOutput(reactDoctorOutput);
1518
+ const didCopyPromptToClipboard = copyToClipboard(promptWithOutput);
1519
+ if (!shouldLogResult) return;
1520
+ if (didCopyPromptToClipboard) {
1521
+ logger.success("Copied latest scan output to clipboard");
1522
+ return;
1265
1523
  }
1524
+ logger.warn("Could not copy prompt to clipboard automatically. Use this prompt:");
1525
+ logger.info(promptWithOutput);
1526
+ };
1527
+ const maybePromptAmiFix = async (directory) => {
1528
+ logger.break();
1529
+ logger.log(`Fix these issues with ${highlighter.info("Ami")}?`);
1530
+ logger.dim(" Ami is a coding agent built to understand your codebase and fix issues");
1531
+ logger.dim(` automatically. Learn more at ${highlighter.info("https://ami.dev")}`);
1532
+ logger.break();
1533
+ const { shouldFix } = await prompts({
1534
+ type: "confirm",
1535
+ name: "shouldFix",
1536
+ message: "Open Ami to fix?",
1537
+ initial: true
1538
+ });
1539
+ if (shouldFix) openAmiToFix(directory);
1266
1540
  };
1267
1541
  const fixAction = (directory) => {
1268
1542
  try {
@@ -1272,10 +1546,11 @@ const fixAction = (directory) => {
1272
1546
  }
1273
1547
  };
1274
1548
  const fixCommand = new Command("fix").description("Open Ami to auto-fix react-doctor issues").argument("[directory]", "project directory", ".").action(fixAction);
1275
- const installAmiCommand = new Command("install-ami").description("Open Ami to auto-fix react-doctor issues").argument("[directory]", "project directory", ".").action(fixAction);
1549
+ const installAmiCommand = new Command("install-ami").description("Install Ami and open it to auto-fix issues").argument("[directory]", "project directory", ".").action(fixAction);
1276
1550
  program.addCommand(fixCommand);
1277
1551
  program.addCommand(installAmiCommand);
1278
1552
  const main$1 = async () => {
1553
+ maybeInstallGlobally();
1279
1554
  await program.parseAsync();
1280
1555
  };
1281
1556
  main$1();