react-doctor 0.0.21 → 0.0.23

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 CHANGED
@@ -15,6 +15,15 @@ One command scans your codebase for security, performance, correctness, and arch
15
15
 
16
16
  https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570
17
17
 
18
+ ## How it works
19
+
20
+ React Doctor detects your framework (Next.js, Vite, Remix, etc.), React version, and compiler setup, then runs two analysis passes **in parallel**:
21
+
22
+ 1. **Lint**: Checks 60+ rules across state & effects, performance, architecture, bundle size, security, correctness, accessibility, and framework-specific categories (Next.js, React Native). Rules are toggled automatically based on your project setup.
23
+ 2. **Dead code**: Detects unused files, exports, types, and duplicates.
24
+
25
+ Diagnostics are filtered through your config, then scored by severity (errors weigh more than warnings) to produce a **0–100 health score** (75+ Great, 50–74 Needs work, <50 Critical).
26
+
18
27
  ## Install
19
28
 
20
29
  Run this at your project root:
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ const SEPARATOR_LENGTH_CHARS = 40;
27
27
  const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1;
28
28
  const SUMMARY_BOX_OUTER_INDENT_CHARS = 2;
29
29
  const SCORE_API_URL = "https://www.react.doctor/api/score";
30
+ const ESTIMATE_SCORE_API_URL = "https://www.react.doctor/api/estimate-score";
30
31
  const SHARE_BASE_URL = "https://www.react.doctor/share";
31
32
  const OPEN_BASE_URL = "https://www.react.doctor/open";
32
33
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
@@ -36,17 +37,54 @@ const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
36
37
 
37
38
  //#endregion
38
39
  //#region src/utils/calculate-score.ts
40
+ const ERROR_RULE_PENALTY = 1.5;
41
+ const WARNING_RULE_PENALTY = .75;
42
+ const ERROR_ESTIMATED_FIX_RATE = .85;
43
+ const WARNING_ESTIMATED_FIX_RATE = .8;
44
+ const buildDiagnosticPayload = (diagnostics) => diagnostics.map((diagnostic) => ({
45
+ plugin: diagnostic.plugin,
46
+ rule: diagnostic.rule,
47
+ severity: diagnostic.severity
48
+ }));
49
+ const getScoreLabel = (score) => {
50
+ if (score >= SCORE_GOOD_THRESHOLD) return "Great";
51
+ if (score >= SCORE_OK_THRESHOLD) return "Needs work";
52
+ return "Critical";
53
+ };
54
+ const countUniqueRules = (diagnostics) => {
55
+ const errorRules = /* @__PURE__ */ new Set();
56
+ const warningRules = /* @__PURE__ */ new Set();
57
+ for (const diagnostic of diagnostics) {
58
+ const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
59
+ if (diagnostic.severity === "error") errorRules.add(ruleKey);
60
+ else warningRules.add(ruleKey);
61
+ }
62
+ return {
63
+ errorRuleCount: errorRules.size,
64
+ warningRuleCount: warningRules.size
65
+ };
66
+ };
67
+ const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
68
+ const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
69
+ return Math.max(0, Math.round(PERFECT_SCORE - penalty));
70
+ };
71
+ const estimateScoreLocally = (diagnostics) => {
72
+ const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
73
+ const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
74
+ const estimatedScore = scoreFromRuleCounts(Math.round(errorRuleCount * (1 - ERROR_ESTIMATED_FIX_RATE)), Math.round(warningRuleCount * (1 - WARNING_ESTIMATED_FIX_RATE)));
75
+ return {
76
+ currentScore,
77
+ currentLabel: getScoreLabel(currentScore),
78
+ estimatedScore,
79
+ estimatedLabel: getScoreLabel(estimatedScore)
80
+ };
81
+ };
39
82
  const calculateScore = async (diagnostics) => {
40
- const payload = diagnostics.map((diagnostic) => ({
41
- plugin: diagnostic.plugin,
42
- rule: diagnostic.rule,
43
- severity: diagnostic.severity
44
- }));
45
83
  try {
46
84
  const response = await fetch(SCORE_API_URL, {
47
85
  method: "POST",
48
86
  headers: { "Content-Type": "application/json" },
49
- body: JSON.stringify({ diagnostics: payload })
87
+ body: JSON.stringify({ diagnostics: buildDiagnosticPayload(diagnostics) })
50
88
  });
51
89
  if (!response.ok) return null;
52
90
  return await response.json();
@@ -54,6 +92,19 @@ const calculateScore = async (diagnostics) => {
54
92
  return null;
55
93
  }
56
94
  };
95
+ const fetchEstimatedScore = async (diagnostics) => {
96
+ try {
97
+ const response = await fetch(ESTIMATE_SCORE_API_URL, {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({ diagnostics: buildDiagnosticPayload(diagnostics) })
101
+ });
102
+ if (!response.ok) return estimateScoreLocally(diagnostics);
103
+ return await response.json();
104
+ } catch {
105
+ return estimateScoreLocally(diagnostics);
106
+ }
107
+ };
57
108
 
58
109
  //#endregion
59
110
  //#region src/plugin/constants.ts
@@ -395,25 +446,12 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
395
446
  return diagnostics.filter((diagnostic) => {
396
447
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
397
448
  if (ignoredRules.has(ruleIdentifier)) return false;
398
- const normalizedPath = diagnostic.filePath.replace(/\\/g, "/");
449
+ const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
399
450
  if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
400
451
  return true;
401
452
  });
402
453
  };
403
454
 
404
- //#endregion
405
- //#region src/utils/group-by.ts
406
- const groupBy = (items, keyFn) => {
407
- const groups = /* @__PURE__ */ new Map();
408
- for (const item of items) {
409
- const key = keyFn(item);
410
- const existing = groups.get(key) ?? [];
411
- existing.push(item);
412
- groups.set(key, existing);
413
- }
414
- return groups;
415
- };
416
-
417
455
  //#endregion
418
456
  //#region src/utils/highlighter.ts
419
457
  const highlighter = {
@@ -424,40 +462,6 @@ const highlighter = {
424
462
  dim: pc.dim
425
463
  };
426
464
 
427
- //#endregion
428
- //#region src/utils/indent-multiline-text.ts
429
- const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
430
-
431
- //#endregion
432
- //#region src/utils/load-config.ts
433
- const CONFIG_FILENAME = "react-doctor.config.json";
434
- const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
435
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
436
- const loadConfig = (rootDirectory) => {
437
- const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
438
- if (fs.existsSync(configFilePath)) try {
439
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
440
- const parsed = JSON.parse(fileContent);
441
- if (!isPlainObject(parsed)) {
442
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
443
- return null;
444
- }
445
- return parsed;
446
- } catch (error) {
447
- console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
448
- return null;
449
- }
450
- const packageJsonPath = path.join(rootDirectory, "package.json");
451
- if (fs.existsSync(packageJsonPath)) try {
452
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
453
- const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
454
- if (isPlainObject(embeddedConfig)) return embeddedConfig;
455
- } catch {
456
- return null;
457
- }
458
- return null;
459
- };
460
-
461
465
  //#endregion
462
466
  //#region src/utils/strip-ansi.ts
463
467
  const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
@@ -512,6 +516,80 @@ const logger = {
512
516
  }
513
517
  };
514
518
 
519
+ //#endregion
520
+ //#region src/utils/framed-box.ts
521
+ const createFramedLine = (plainText, renderedText = plainText) => ({
522
+ plainText,
523
+ renderedText
524
+ });
525
+ const renderFramedBoxString = (framedLines) => {
526
+ if (framedLines.length === 0) return "";
527
+ const borderColorizer = highlighter.dim;
528
+ const outerIndent = " ".repeat(SUMMARY_BOX_OUTER_INDENT_CHARS);
529
+ const horizontalPadding = " ".repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS);
530
+ const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
531
+ const borderLine = "─".repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2);
532
+ const lines = [];
533
+ lines.push(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
534
+ for (const framedLine of framedLines) {
535
+ const trailingSpaces = " ".repeat(maximumLineLength - framedLine.plainText.length);
536
+ lines.push(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer("│")}`);
537
+ }
538
+ lines.push(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
539
+ return lines.join("\n");
540
+ };
541
+ const printFramedBox = (framedLines) => {
542
+ const rendered = renderFramedBoxString(framedLines);
543
+ if (rendered) logger.log(rendered);
544
+ };
545
+
546
+ //#endregion
547
+ //#region src/utils/group-by.ts
548
+ const groupBy = (items, keyFn) => {
549
+ const groups = /* @__PURE__ */ new Map();
550
+ for (const item of items) {
551
+ const key = keyFn(item);
552
+ const existing = groups.get(key) ?? [];
553
+ existing.push(item);
554
+ groups.set(key, existing);
555
+ }
556
+ return groups;
557
+ };
558
+
559
+ //#endregion
560
+ //#region src/utils/indent-multiline-text.ts
561
+ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
562
+
563
+ //#endregion
564
+ //#region src/utils/load-config.ts
565
+ const CONFIG_FILENAME = "react-doctor.config.json";
566
+ const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
567
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
568
+ const loadConfig = (rootDirectory) => {
569
+ const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
570
+ if (fs.existsSync(configFilePath)) try {
571
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
572
+ const parsed = JSON.parse(fileContent);
573
+ if (!isPlainObject(parsed)) {
574
+ console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
575
+ return null;
576
+ }
577
+ return parsed;
578
+ } catch (error) {
579
+ console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
580
+ return null;
581
+ }
582
+ const packageJsonPath = path.join(rootDirectory, "package.json");
583
+ if (fs.existsSync(packageJsonPath)) try {
584
+ const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
585
+ const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
586
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
587
+ } catch {
588
+ return null;
589
+ }
590
+ return null;
591
+ };
592
+
515
593
  //#endregion
516
594
  //#region src/utils/run-knip.ts
517
595
  const KNIP_CATEGORY_MAP = {
@@ -1138,15 +1216,11 @@ const writeDiagnosticsDirectory = (diagnostics) => {
1138
1216
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
1139
1217
  return outputDirectory;
1140
1218
  };
1141
- const colorizeByScore = (text, score) => {
1219
+ const colorizeByScore$1 = (text, score) => {
1142
1220
  if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1143
1221
  if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1144
1222
  return highlighter.error(text);
1145
1223
  };
1146
- const createFramedLine = (plainText, renderedText = plainText) => ({
1147
- plainText,
1148
- renderedText
1149
- });
1150
1224
  const buildScoreBarSegments = (score) => {
1151
1225
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
1152
1226
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
@@ -1161,25 +1235,11 @@ const buildPlainScoreBar = (score) => {
1161
1235
  };
1162
1236
  const buildScoreBar = (score) => {
1163
1237
  const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1164
- return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
1165
- };
1166
- const printFramedBox = (framedLines) => {
1167
- if (framedLines.length === 0) return;
1168
- const borderColorizer = highlighter.dim;
1169
- const outerIndent = " ".repeat(SUMMARY_BOX_OUTER_INDENT_CHARS);
1170
- const horizontalPadding = " ".repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS);
1171
- const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
1172
- const borderLine = "─".repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2);
1173
- logger.log(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
1174
- for (const framedLine of framedLines) {
1175
- const trailingSpaces = " ".repeat(maximumLineLength - framedLine.plainText.length);
1176
- logger.log(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer("│")}`);
1177
- }
1178
- logger.log(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
1238
+ return colorizeByScore$1(filledSegment, score) + highlighter.dim(emptySegment);
1179
1239
  };
1180
1240
  const printScoreGauge = (score, label) => {
1181
- const scoreDisplay = colorizeByScore(`${score}`, score);
1182
- const labelDisplay = colorizeByScore(label, score);
1241
+ const scoreDisplay = colorizeByScore$1(`${score}`, score);
1242
+ const labelDisplay = colorizeByScore$1(label, score);
1183
1243
  logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
1184
1244
  logger.break();
1185
1245
  logger.log(` ${buildScoreBar(score)}`);
@@ -1193,7 +1253,7 @@ const getDoctorFace = (score) => {
1193
1253
  const printBranding = (score) => {
1194
1254
  if (score !== void 0) {
1195
1255
  const [eyes, mouth] = getDoctorFace(score);
1196
- const colorize = (text) => colorizeByScore(text, score);
1256
+ const colorize = (text) => colorizeByScore$1(text, score);
1197
1257
  logger.log(colorize(" ┌─────┐"));
1198
1258
  logger.log(colorize(` │ ${eyes} │`));
1199
1259
  logger.log(colorize(` │ ${mouth} │`));
@@ -1240,7 +1300,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1240
1300
  const summaryFramedLines = [];
1241
1301
  if (scoreResult) {
1242
1302
  const [eyes, mouth] = getDoctorFace(scoreResult.score);
1243
- const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
1303
+ const scoreColorizer = (text) => colorizeByScore$1(text, scoreResult.score);
1244
1304
  summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1245
1305
  summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1246
1306
  summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
@@ -1248,7 +1308,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1248
1308
  summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1249
1309
  summaryFramedLines.push(createFramedLine(""));
1250
1310
  const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1251
- const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
1311
+ const scoreLineRenderedText = `${colorizeByScore$1(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore$1(scoreResult.label, scoreResult.score)}`;
1252
1312
  summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1253
1313
  summaryFramedLines.push(createFramedLine(""));
1254
1314
  summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
@@ -1343,7 +1403,10 @@ const scan = async (directory, inputOptions = {}) => {
1343
1403
  if (options.scoreOnly) {
1344
1404
  if (scoreResult) logger.log(`${scoreResult.score}`);
1345
1405
  else logger.dim(noScoreMessage);
1346
- return;
1406
+ return {
1407
+ diagnostics,
1408
+ scoreResult
1409
+ };
1347
1410
  }
1348
1411
  if (diagnostics.length === 0) {
1349
1412
  logger.success("No issues found!");
@@ -1352,11 +1415,18 @@ const scan = async (directory, inputOptions = {}) => {
1352
1415
  printBranding(scoreResult.score);
1353
1416
  printScoreGauge(scoreResult.score, scoreResult.label);
1354
1417
  } else logger.dim(` ${noScoreMessage}`);
1355
- return;
1418
+ return {
1419
+ diagnostics,
1420
+ scoreResult
1421
+ };
1356
1422
  }
1357
1423
  printDiagnostics(diagnostics, options.verbose);
1358
1424
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1359
1425
  printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
1426
+ return {
1427
+ diagnostics,
1428
+ scoreResult
1429
+ };
1360
1430
  };
1361
1431
 
1362
1432
  //#endregion
@@ -1518,11 +1588,21 @@ const shouldSelectAllChoices = (choiceStates) => {
1518
1588
  //#region src/utils/prompts.ts
1519
1589
  const require = createRequire(import.meta.url);
1520
1590
  const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
1591
+ const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
1521
1592
  let didPatchMultiselectToggleAll = false;
1522
1593
  let didPatchMultiselectSubmit = false;
1594
+ let didPatchSelectBanner = false;
1595
+ const selectBannerMap = /* @__PURE__ */ new Map();
1596
+ const setSelectBanner = (banner, targetIndex) => {
1597
+ selectBannerMap.set(targetIndex, banner);
1598
+ };
1599
+ const clearSelectBanner = () => {
1600
+ selectBannerMap.clear();
1601
+ };
1523
1602
  const onCancel = () => {
1524
1603
  logger.break();
1525
1604
  logger.log("Cancelled.");
1605
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
1526
1606
  logger.break();
1527
1607
  process.exit(0);
1528
1608
  };
@@ -1554,9 +1634,25 @@ const patchMultiselectSubmit = () => {
1554
1634
  originalSubmit.call(this);
1555
1635
  };
1556
1636
  };
1637
+ const patchSelectBanner = () => {
1638
+ if (didPatchSelectBanner) return;
1639
+ didPatchSelectBanner = true;
1640
+ const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
1641
+ const promptsClear = require("prompts/lib/util/clear");
1642
+ const originalRender = selectConstructor.prototype.render;
1643
+ selectConstructor.prototype.render = function() {
1644
+ originalRender.call(this);
1645
+ const banner = selectBannerMap.get(this.cursor);
1646
+ if (!banner || this.closed || this.done) return;
1647
+ this.out.write(promptsClear(this.outputText, this.out.columns));
1648
+ this.outputText = `${banner}\n\n${this.outputText}`;
1649
+ this.out.write(this.outputText);
1650
+ };
1651
+ };
1557
1652
  const prompts = (questions) => {
1558
1653
  patchMultiselectToggleAll();
1559
1654
  patchMultiselectSubmit();
1655
+ patchSelectBanner();
1560
1656
  return basePrompts(questions, { onCancel });
1561
1657
  };
1562
1658
 
@@ -1648,7 +1744,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1648
1744
  const { shouldInstall } = await prompts({
1649
1745
  type: "confirm",
1650
1746
  name: "shouldInstall",
1651
- message: "Install skill?",
1747
+ message: "Install skill? (recommended)",
1652
1748
  initial: true
1653
1749
  });
1654
1750
  if (shouldInstall) {
@@ -1663,9 +1759,16 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1663
1759
 
1664
1760
  //#endregion
1665
1761
  //#region src/cli.ts
1666
- const VERSION = "0.0.21";
1667
- process.on("SIGINT", () => process.exit(0));
1668
- process.on("SIGTERM", () => process.exit(0));
1762
+ const VERSION = "0.0.23";
1763
+ const exitWithFixHint = () => {
1764
+ logger.break();
1765
+ logger.log("Cancelled.");
1766
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
1767
+ logger.break();
1768
+ process.exit(0);
1769
+ };
1770
+ process.on("SIGINT", exitWithFixHint);
1771
+ process.on("SIGTERM", exitWithFixHint);
1669
1772
  const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
1670
1773
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
1671
1774
  if (diffInfo) return true;
@@ -1688,10 +1791,10 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
1688
1791
  });
1689
1792
  return Boolean(shouldScanBranchOnly);
1690
1793
  };
1691
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
1794
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
1692
1795
  const isScoreOnly = flags.score && !flags.prompt;
1693
1796
  const shouldCopyPromptOutput = flags.prompt;
1694
- if (shouldCopyPromptOutput) startLoggerCapture();
1797
+ startLoggerCapture();
1695
1798
  try {
1696
1799
  const resolvedDirectory = path.resolve(directory);
1697
1800
  const userConfig = loadConfig(resolvedDirectory);
@@ -1717,6 +1820,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1717
1820
  process.env.AMI
1718
1821
  ].some(Boolean);
1719
1822
  const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
1823
+ const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
1720
1824
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
1721
1825
  const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
1722
1826
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
@@ -1726,6 +1830,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1726
1830
  logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
1727
1831
  logger.break();
1728
1832
  }
1833
+ const allDiagnostics = [];
1729
1834
  for (const projectDirectory of projectDirectories) {
1730
1835
  let includePaths;
1731
1836
  if (isDiffMode) {
@@ -1746,28 +1851,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1746
1851
  logger.dim(`Scanning ${projectDirectory}...`);
1747
1852
  logger.break();
1748
1853
  }
1749
- await scan(projectDirectory, {
1854
+ const scanResult = await scan(projectDirectory, {
1750
1855
  ...scanOptions,
1751
1856
  includePaths
1752
1857
  });
1858
+ allDiagnostics.push(...scanResult.diagnostics);
1753
1859
  if (!isScoreOnly) logger.break();
1754
1860
  }
1861
+ const capturedScanOutput = stopLoggerCapture();
1755
1862
  if (flags.fix) openAmiToFix(resolvedDirectory);
1756
- if (!isScoreOnly && !flags.prompt) {
1757
- await maybePromptSkillInstall(shouldSkipPrompts);
1758
- if (!shouldSkipPrompts && !flags.fix) await maybePromptAmiFix(resolvedDirectory);
1863
+ if (shouldCopyPromptOutput) copyPromptToClipboard(capturedScanOutput, !isScoreOnly);
1864
+ else if (!isScoreOnly) {
1865
+ await maybePromptSkillInstall(shouldSkipAmiPrompts);
1866
+ if (!shouldSkipAmiPrompts && !flags.fix) await maybePromptFix(resolvedDirectory, allDiagnostics, flags.offline ? null : await fetchEstimatedScore(allDiagnostics), capturedScanOutput);
1759
1867
  }
1760
1868
  } catch (error) {
1761
1869
  handleError(error, { shouldExit: !shouldCopyPromptOutput });
1762
1870
  } finally {
1763
- if (shouldCopyPromptOutput) copyPromptToClipboard(stopLoggerCapture(), !isScoreOnly);
1871
+ const remainingOutput = stopLoggerCapture();
1872
+ if (shouldCopyPromptOutput && remainingOutput) copyPromptToClipboard(remainingOutput, !isScoreOnly);
1764
1873
  }
1765
1874
  }).addHelpText("after", `
1766
1875
  ${highlighter.dim("Learn more:")}
1767
1876
  ${highlighter.info("https://github.com/millionco/react-doctor")}
1768
1877
  `);
1769
- const AMI_INSTALL_URL = "https://ami.dev/install.sh";
1878
+ const AMI_WEBSITE_URL = "https://ami.dev";
1879
+ const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
1770
1880
  const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
1881
+ const colorizeByScore = (text, score) => {
1882
+ if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1883
+ if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1884
+ return highlighter.error(text);
1885
+ };
1771
1886
  const DEEPLINK_FIX_PROMPT = "Run `npx -y react-doctor@latest .` to diagnose issues, then fix all reported issues one by one. After applying fixes, run it again to verify the results improved.";
1772
1887
  const CLIPBOARD_FIX_PROMPT = "Fix all issues reported in the react-doctor diagnostics below, one by one. After applying fixes, run `npx -y react-doctor@latest .` again to verify the results improved.";
1773
1888
  const REACT_DOCTOR_OUTPUT_LABEL = "react-doctor output";
@@ -1786,12 +1901,12 @@ const isAmiInstalled = () => {
1786
1901
  }
1787
1902
  };
1788
1903
  const installAmi = () => {
1789
- logger.log("Ami not found. Installing...");
1904
+ logger.log("Installing Ami...");
1790
1905
  logger.break();
1791
1906
  try {
1792
1907
  execSync(`curl -fsSL ${AMI_INSTALL_URL} | bash`, { stdio: "inherit" });
1793
1908
  } catch {
1794
- logger.error("Failed to install Ami. Visit https://ami.dev to install manually.");
1909
+ logger.error(`Failed to install Ami. Visit ${AMI_WEBSITE_URL} to install manually.`);
1795
1910
  process.exit(1);
1796
1911
  }
1797
1912
  logger.break();
@@ -1820,23 +1935,23 @@ const openAmiToFix = (directory) => {
1820
1935
  if (!isInstalled) {
1821
1936
  if (process.platform === "darwin") {
1822
1937
  installAmi();
1823
- logger.success("Ami was installed and opened.");
1938
+ logger.success("Ami installed successfully.");
1824
1939
  } else {
1825
1940
  logger.error("Ami is not installed.");
1826
- logger.dim(`Download it at ${highlighter.info(AMI_RELEASES_URL)}`);
1941
+ logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);
1827
1942
  }
1828
1943
  logger.break();
1829
- logger.dim("Once Ami is running, open this link to start fixing:");
1944
+ logger.dim("Open this link to start fixing:");
1830
1945
  logger.info(webDeeplink);
1831
1946
  return;
1832
1947
  }
1833
- logger.log("Opening Ami to fix react-doctor issues...");
1948
+ logger.log("Opening Ami...");
1834
1949
  try {
1835
1950
  openUrl(deeplink);
1836
- logger.success("Opened Ami with react-doctor fix prompt.");
1951
+ logger.success("Ami opened. Fixing your issues now.");
1837
1952
  } catch {
1838
1953
  logger.break();
1839
- logger.dim("Could not open Ami automatically. Open this URL manually:");
1954
+ logger.dim("Could not open Ami automatically. Open this link instead:");
1840
1955
  logger.info(webDeeplink);
1841
1956
  }
1842
1957
  };
@@ -1856,24 +1971,78 @@ const copyPromptToClipboard = (reactDoctorOutput, shouldLogResult) => {
1856
1971
  logger.warn("Could not copy prompt to clipboard automatically. Use this prompt:");
1857
1972
  logger.info(promptWithOutput);
1858
1973
  };
1859
- const maybePromptAmiFix = async (directory) => {
1860
- const isInstalled = isAmiInstalled();
1861
- logger.break();
1862
- logger.log(`Fix these issues with ${highlighter.info("Ami")}?`);
1863
- logger.dim(" Ami is a coding agent built to understand your codebase and fix issues");
1864
- logger.dim(` automatically. Learn more at ${highlighter.info("https://ami.dev")}`);
1974
+ const FIX_METHOD_AMI = "ami";
1975
+ const FIX_METHOD_CLIPBOARD = "clipboard";
1976
+ const FIX_COMMAND_HINT = "npx react-doctor@latest --fix";
1977
+ const buildAmiBanner = (issueCount, currentScore, estimatedScore) => {
1978
+ const currentScoreDisplay = colorizeByScore(String(currentScore), currentScore);
1979
+ const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore);
1980
+ const issueLabel = issueCount === 1 ? "issue" : "issues";
1981
+ return renderFramedBoxString([
1982
+ createFramedLine(`Score: ${currentScore} → ~${estimatedScore}`, `Score: ${currentScoreDisplay} ${highlighter.dim("→")} ${estimatedScoreDisplay}`),
1983
+ createFramedLine(""),
1984
+ createFramedLine(`Ami is a coding agent built for React. It reads`, `${highlighter.info("Ami")} is a coding agent built for React. It reads`),
1985
+ createFramedLine("your react-doctor report, understands your codebase,"),
1986
+ createFramedLine(`and fixes ${issueCount} ${issueLabel} one by one — then re-runs the`, `and fixes ${highlighter.warn(String(issueCount))} ${issueLabel} one by one — then re-runs the`),
1987
+ createFramedLine("scan to verify the score improved."),
1988
+ createFramedLine(""),
1989
+ createFramedLine(`Free to use. ${AMI_WEBSITE_URL}`, `Free to use. ${highlighter.info(AMI_WEBSITE_URL)}`)
1990
+ ]);
1991
+ };
1992
+ const buildClipboardWarningBanner = () => renderFramedBoxString([
1993
+ createFramedLine("⚠ Other agents may not fix these issues well.", `${highlighter.warn("⚠")} Other agents may not fix these issues well.`),
1994
+ createFramedLine(""),
1995
+ createFramedLine("react-doctor diagnostics require React-specific context"),
1996
+ createFramedLine("that general-purpose agents often miss, leading to"),
1997
+ createFramedLine("incomplete or incorrect fixes.")
1998
+ ]);
1999
+ const buildSkipBanner = (issueCount, estimatedScore) => {
2000
+ const issueLabel = issueCount === 1 ? "issue" : "issues";
2001
+ const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore);
2002
+ return renderFramedBoxString([
2003
+ createFramedLine(`Skip fixing ${issueCount} ${issueLabel} and reaching ~${estimatedScore}?`, `Skip fixing ${highlighter.warn(String(issueCount))} ${issueLabel} and reaching ${estimatedScoreDisplay}?`),
2004
+ createFramedLine(""),
2005
+ createFramedLine(`Run ${FIX_COMMAND_HINT} anytime to come back.`, `Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to come back.`)
2006
+ ]);
2007
+ };
2008
+ const configureFixBanners = (issueCount, estimatedScoreResult) => {
2009
+ const { currentScore, estimatedScore } = estimatedScoreResult;
2010
+ setSelectBanner(buildAmiBanner(issueCount, currentScore, estimatedScore), 0);
2011
+ setSelectBanner(buildClipboardWarningBanner(), 1);
2012
+ setSelectBanner(buildSkipBanner(issueCount, estimatedScore), 2);
2013
+ };
2014
+ const maybePromptFix = async (directory, diagnostics, estimatedScoreResult, capturedScanOutput) => {
2015
+ if (diagnostics.length === 0) return;
1865
2016
  logger.break();
1866
- if (!isInstalled && process.platform !== "darwin") {
1867
- logger.dim(`Download Ami at ${highlighter.info(AMI_RELEASES_URL)}`);
1868
- return;
1869
- }
1870
- const { shouldFix } = await prompts({
1871
- type: "confirm",
1872
- name: "shouldFix",
1873
- message: isInstalled ? "Open Ami to fix?" : "Install Ami to fix?",
1874
- initial: true
2017
+ if (estimatedScoreResult) configureFixBanners(diagnostics.length, estimatedScoreResult);
2018
+ const { fixMethod } = await prompts({
2019
+ type: "select",
2020
+ name: "fixMethod",
2021
+ message: "Fix issues?",
2022
+ choices: [
2023
+ {
2024
+ title: "Use ami.dev (recommended)",
2025
+ description: "Optimized coding agent for React Doctor",
2026
+ value: FIX_METHOD_AMI
2027
+ },
2028
+ {
2029
+ title: "Copy to clipboard",
2030
+ description: "Other agents may lack context for react-doctor fixes",
2031
+ value: FIX_METHOD_CLIPBOARD
2032
+ },
2033
+ {
2034
+ title: "Skip",
2035
+ value: "skip"
2036
+ }
2037
+ ]
1875
2038
  });
1876
- if (shouldFix) openAmiToFix(directory);
2039
+ clearSelectBanner();
2040
+ if (fixMethod === FIX_METHOD_AMI) openAmiToFix(directory);
2041
+ else if (fixMethod === FIX_METHOD_CLIPBOARD) copyPromptToClipboard(capturedScanOutput, true);
2042
+ else {
2043
+ logger.break();
2044
+ logger.dim(` Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to fix issues.`);
2045
+ }
1877
2046
  };
1878
2047
  const fixAction = (directory) => {
1879
2048
  try {