react-doctor 0.0.20 → 0.0.22

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
@@ -27,24 +27,64 @@ 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";
32
+ const OPEN_BASE_URL = "https://www.react.doctor/open";
31
33
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
32
34
  const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
35
+ const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
33
36
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
34
37
 
35
38
  //#endregion
36
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
+ };
37
82
  const calculateScore = async (diagnostics) => {
38
- const payload = diagnostics.map((diagnostic) => ({
39
- plugin: diagnostic.plugin,
40
- rule: diagnostic.rule,
41
- severity: diagnostic.severity
42
- }));
43
83
  try {
44
84
  const response = await fetch(SCORE_API_URL, {
45
85
  method: "POST",
46
86
  headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({ diagnostics: payload })
87
+ body: JSON.stringify({ diagnostics: buildDiagnosticPayload(diagnostics) })
48
88
  });
49
89
  if (!response.ok) return null;
50
90
  return await response.json();
@@ -52,6 +92,19 @@ const calculateScore = async (diagnostics) => {
52
92
  return null;
53
93
  }
54
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
+ };
55
108
 
56
109
  //#endregion
57
110
  //#region src/plugin/constants.ts
@@ -399,19 +452,6 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
399
452
  });
400
453
  };
401
454
 
402
- //#endregion
403
- //#region src/utils/group-by.ts
404
- const groupBy = (items, keyFn) => {
405
- const groups = /* @__PURE__ */ new Map();
406
- for (const item of items) {
407
- const key = keyFn(item);
408
- const existing = groups.get(key) ?? [];
409
- existing.push(item);
410
- groups.set(key, existing);
411
- }
412
- return groups;
413
- };
414
-
415
455
  //#endregion
416
456
  //#region src/utils/highlighter.ts
417
457
  const highlighter = {
@@ -422,40 +462,6 @@ const highlighter = {
422
462
  dim: pc.dim
423
463
  };
424
464
 
425
- //#endregion
426
- //#region src/utils/indent-multiline-text.ts
427
- const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
428
-
429
- //#endregion
430
- //#region src/utils/load-config.ts
431
- const CONFIG_FILENAME = "react-doctor.config.json";
432
- const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
433
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
434
- const loadConfig = (rootDirectory) => {
435
- const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
436
- if (fs.existsSync(configFilePath)) try {
437
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
438
- const parsed = JSON.parse(fileContent);
439
- if (!isPlainObject(parsed)) {
440
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
441
- return null;
442
- }
443
- return parsed;
444
- } catch (error) {
445
- console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
446
- return null;
447
- }
448
- const packageJsonPath = path.join(rootDirectory, "package.json");
449
- if (fs.existsSync(packageJsonPath)) try {
450
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
451
- const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
452
- if (isPlainObject(embeddedConfig)) return embeddedConfig;
453
- } catch {
454
- return null;
455
- }
456
- return null;
457
- };
458
-
459
465
  //#endregion
460
466
  //#region src/utils/strip-ansi.ts
461
467
  const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
@@ -510,6 +516,80 @@ const logger = {
510
516
  }
511
517
  };
512
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
+
513
593
  //#endregion
514
594
  //#region src/utils/run-knip.ts
515
595
  const KNIP_CATEGORY_MAP = {
@@ -1136,15 +1216,11 @@ const writeDiagnosticsDirectory = (diagnostics) => {
1136
1216
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
1137
1217
  return outputDirectory;
1138
1218
  };
1139
- const colorizeByScore = (text, score) => {
1219
+ const colorizeByScore$1 = (text, score) => {
1140
1220
  if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1141
1221
  if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1142
1222
  return highlighter.error(text);
1143
1223
  };
1144
- const createFramedLine = (plainText, renderedText = plainText) => ({
1145
- plainText,
1146
- renderedText
1147
- });
1148
1224
  const buildScoreBarSegments = (score) => {
1149
1225
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
1150
1226
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
@@ -1159,25 +1235,11 @@ const buildPlainScoreBar = (score) => {
1159
1235
  };
1160
1236
  const buildScoreBar = (score) => {
1161
1237
  const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1162
- return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
1163
- };
1164
- const printFramedBox = (framedLines) => {
1165
- if (framedLines.length === 0) return;
1166
- const borderColorizer = highlighter.dim;
1167
- const outerIndent = " ".repeat(SUMMARY_BOX_OUTER_INDENT_CHARS);
1168
- const horizontalPadding = " ".repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS);
1169
- const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
1170
- const borderLine = "─".repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2);
1171
- logger.log(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
1172
- for (const framedLine of framedLines) {
1173
- const trailingSpaces = " ".repeat(maximumLineLength - framedLine.plainText.length);
1174
- logger.log(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer("│")}`);
1175
- }
1176
- logger.log(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
1238
+ return colorizeByScore$1(filledSegment, score) + highlighter.dim(emptySegment);
1177
1239
  };
1178
1240
  const printScoreGauge = (score, label) => {
1179
- const scoreDisplay = colorizeByScore(`${score}`, score);
1180
- const labelDisplay = colorizeByScore(label, score);
1241
+ const scoreDisplay = colorizeByScore$1(`${score}`, score);
1242
+ const labelDisplay = colorizeByScore$1(label, score);
1181
1243
  logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
1182
1244
  logger.break();
1183
1245
  logger.log(` ${buildScoreBar(score)}`);
@@ -1191,7 +1253,7 @@ const getDoctorFace = (score) => {
1191
1253
  const printBranding = (score) => {
1192
1254
  if (score !== void 0) {
1193
1255
  const [eyes, mouth] = getDoctorFace(score);
1194
- const colorize = (text) => colorizeByScore(text, score);
1256
+ const colorize = (text) => colorizeByScore$1(text, score);
1195
1257
  logger.log(colorize(" ┌─────┐"));
1196
1258
  logger.log(colorize(` │ ${eyes} │`));
1197
1259
  logger.log(colorize(` │ ${mouth} │`));
@@ -1212,7 +1274,7 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
1212
1274
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
1213
1275
  return `${SHARE_BASE_URL}?${params.toString()}`;
1214
1276
  };
1215
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount) => {
1277
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1216
1278
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
1217
1279
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
1218
1280
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
@@ -1238,7 +1300,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1238
1300
  const summaryFramedLines = [];
1239
1301
  if (scoreResult) {
1240
1302
  const [eyes, mouth] = getDoctorFace(scoreResult.score);
1241
- const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
1303
+ const scoreColorizer = (text) => colorizeByScore$1(text, scoreResult.score);
1242
1304
  summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1243
1305
  summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1244
1306
  summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
@@ -1246,7 +1308,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1246
1308
  summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1247
1309
  summaryFramedLines.push(createFramedLine(""));
1248
1310
  const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1249
- 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)}`;
1250
1312
  summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1251
1313
  summaryFramedLines.push(createFramedLine(""));
1252
1314
  summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
@@ -1254,7 +1316,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1254
1316
  } else {
1255
1317
  summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1256
1318
  summaryFramedLines.push(createFramedLine(""));
1257
- summaryFramedLines.push(createFramedLine(OFFLINE_MESSAGE, highlighter.dim(OFFLINE_MESSAGE)));
1319
+ summaryFramedLines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
1258
1320
  summaryFramedLines.push(createFramedLine(""));
1259
1321
  }
1260
1322
  summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
@@ -1279,6 +1341,7 @@ const scan = async (directory, inputOptions = {}) => {
1279
1341
  deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1280
1342
  verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1281
1343
  scoreOnly: inputOptions.scoreOnly ?? false,
1344
+ offline: inputOptions.offline ?? false,
1282
1345
  includePaths: inputOptions.includePaths
1283
1346
  };
1284
1347
  const includePaths = options.includePaths ?? [];
@@ -1335,11 +1398,15 @@ const scan = async (directory, inputOptions = {}) => {
1335
1398
  ];
1336
1399
  const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
1337
1400
  const elapsedMilliseconds = performance.now() - startTime;
1338
- const scoreResult = await calculateScore(diagnostics);
1401
+ const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1402
+ const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
1339
1403
  if (options.scoreOnly) {
1340
1404
  if (scoreResult) logger.log(`${scoreResult.score}`);
1341
- else logger.dim(OFFLINE_MESSAGE);
1342
- return;
1405
+ else logger.dim(noScoreMessage);
1406
+ return {
1407
+ diagnostics,
1408
+ scoreResult
1409
+ };
1343
1410
  }
1344
1411
  if (diagnostics.length === 0) {
1345
1412
  logger.success("No issues found!");
@@ -1347,12 +1414,19 @@ const scan = async (directory, inputOptions = {}) => {
1347
1414
  if (scoreResult) {
1348
1415
  printBranding(scoreResult.score);
1349
1416
  printScoreGauge(scoreResult.score, scoreResult.label);
1350
- } else logger.dim(` ${OFFLINE_MESSAGE}`);
1351
- return;
1417
+ } else logger.dim(` ${noScoreMessage}`);
1418
+ return {
1419
+ diagnostics,
1420
+ scoreResult
1421
+ };
1352
1422
  }
1353
1423
  printDiagnostics(diagnostics, options.verbose);
1354
1424
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1355
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount);
1425
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
1426
+ return {
1427
+ diagnostics,
1428
+ scoreResult
1429
+ };
1356
1430
  };
1357
1431
 
1358
1432
  //#endregion
@@ -1514,11 +1588,21 @@ const shouldSelectAllChoices = (choiceStates) => {
1514
1588
  //#region src/utils/prompts.ts
1515
1589
  const require = createRequire(import.meta.url);
1516
1590
  const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
1591
+ const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
1517
1592
  let didPatchMultiselectToggleAll = false;
1518
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
+ };
1519
1602
  const onCancel = () => {
1520
1603
  logger.break();
1521
1604
  logger.log("Cancelled.");
1605
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
1522
1606
  logger.break();
1523
1607
  process.exit(0);
1524
1608
  };
@@ -1550,9 +1634,25 @@ const patchMultiselectSubmit = () => {
1550
1634
  originalSubmit.call(this);
1551
1635
  };
1552
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
+ };
1553
1652
  const prompts = (questions) => {
1554
1653
  patchMultiselectToggleAll();
1555
1654
  patchMultiselectSubmit();
1655
+ patchSelectBanner();
1556
1656
  return basePrompts(questions, { onCancel });
1557
1657
  };
1558
1658
 
@@ -1644,7 +1744,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1644
1744
  const { shouldInstall } = await prompts({
1645
1745
  type: "confirm",
1646
1746
  name: "shouldInstall",
1647
- message: "Install skill?",
1747
+ message: "Install skill? (recommended)",
1648
1748
  initial: true
1649
1749
  });
1650
1750
  if (shouldInstall) {
@@ -1659,9 +1759,16 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1659
1759
 
1660
1760
  //#endregion
1661
1761
  //#region src/cli.ts
1662
- const VERSION = "0.0.20";
1663
- process.on("SIGINT", () => process.exit(0));
1664
- process.on("SIGTERM", () => process.exit(0));
1762
+ const VERSION = "0.0.22";
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);
1665
1772
  const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
1666
1773
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
1667
1774
  if (diffInfo) return true;
@@ -1684,10 +1791,10 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
1684
1791
  });
1685
1792
  return Boolean(shouldScanBranchOnly);
1686
1793
  };
1687
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
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("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
1688
1795
  const isScoreOnly = flags.score && !flags.prompt;
1689
1796
  const shouldCopyPromptOutput = flags.prompt;
1690
- if (shouldCopyPromptOutput) startLoggerCapture();
1797
+ startLoggerCapture();
1691
1798
  try {
1692
1799
  const resolvedDirectory = path.resolve(directory);
1693
1800
  const userConfig = loadConfig(resolvedDirectory);
@@ -1700,7 +1807,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1700
1807
  lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
1701
1808
  deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
1702
1809
  verbose: flags.prompt || (isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false),
1703
- scoreOnly: isScoreOnly
1810
+ scoreOnly: isScoreOnly,
1811
+ offline: flags.offline
1704
1812
  };
1705
1813
  const isAutomatedEnvironment = [
1706
1814
  process.env.CI,
@@ -1721,6 +1829,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1721
1829
  logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
1722
1830
  logger.break();
1723
1831
  }
1832
+ const allDiagnostics = [];
1724
1833
  for (const projectDirectory of projectDirectories) {
1725
1834
  let includePaths;
1726
1835
  if (isDiffMode) {
@@ -1741,28 +1850,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1741
1850
  logger.dim(`Scanning ${projectDirectory}...`);
1742
1851
  logger.break();
1743
1852
  }
1744
- await scan(projectDirectory, {
1853
+ const scanResult = await scan(projectDirectory, {
1745
1854
  ...scanOptions,
1746
1855
  includePaths
1747
1856
  });
1857
+ allDiagnostics.push(...scanResult.diagnostics);
1748
1858
  if (!isScoreOnly) logger.break();
1749
1859
  }
1860
+ const capturedScanOutput = stopLoggerCapture();
1750
1861
  if (flags.fix) openAmiToFix(resolvedDirectory);
1751
- if (!isScoreOnly && !flags.prompt) {
1862
+ if (shouldCopyPromptOutput) copyPromptToClipboard(capturedScanOutput, !isScoreOnly);
1863
+ else if (!isScoreOnly) {
1752
1864
  await maybePromptSkillInstall(shouldSkipPrompts);
1753
- if (!shouldSkipPrompts && !flags.fix) await maybePromptAmiFix(resolvedDirectory);
1865
+ if (!shouldSkipPrompts && !flags.fix) await maybePromptFix(resolvedDirectory, allDiagnostics, flags.offline ? null : await fetchEstimatedScore(allDiagnostics), capturedScanOutput);
1754
1866
  }
1755
1867
  } catch (error) {
1756
1868
  handleError(error, { shouldExit: !shouldCopyPromptOutput });
1757
1869
  } finally {
1758
- if (shouldCopyPromptOutput) copyPromptToClipboard(stopLoggerCapture(), !isScoreOnly);
1870
+ const remainingOutput = stopLoggerCapture();
1871
+ if (shouldCopyPromptOutput && remainingOutput) copyPromptToClipboard(remainingOutput, !isScoreOnly);
1759
1872
  }
1760
1873
  }).addHelpText("after", `
1761
1874
  ${highlighter.dim("Learn more:")}
1762
1875
  ${highlighter.info("https://github.com/millionco/react-doctor")}
1763
1876
  `);
1764
- const AMI_INSTALL_URL = "https://ami.dev/install.sh";
1877
+ const AMI_WEBSITE_URL = "https://ami.dev";
1878
+ const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
1765
1879
  const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
1880
+ const colorizeByScore = (text, score) => {
1881
+ if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1882
+ if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1883
+ return highlighter.error(text);
1884
+ };
1766
1885
  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.";
1767
1886
  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.";
1768
1887
  const REACT_DOCTOR_OUTPUT_LABEL = "react-doctor output";
@@ -1781,12 +1900,12 @@ const isAmiInstalled = () => {
1781
1900
  }
1782
1901
  };
1783
1902
  const installAmi = () => {
1784
- logger.log("Ami not found. Installing...");
1903
+ logger.log("Installing Ami...");
1785
1904
  logger.break();
1786
1905
  try {
1787
1906
  execSync(`curl -fsSL ${AMI_INSTALL_URL} | bash`, { stdio: "inherit" });
1788
1907
  } catch {
1789
- logger.error("Failed to install Ami. Visit https://ami.dev to install manually.");
1908
+ logger.error(`Failed to install Ami. Visit ${AMI_WEBSITE_URL} to install manually.`);
1790
1909
  process.exit(1);
1791
1910
  }
1792
1911
  logger.break();
@@ -1798,33 +1917,41 @@ const openUrl = (url) => {
1798
1917
  }
1799
1918
  execSync(process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`, { stdio: "ignore" });
1800
1919
  };
1801
- const buildDeeplink = (directory) => {
1802
- return `ami://open-project?cwd=${encodeURIComponent(path.resolve(directory))}&prompt=${encodeURIComponent(DEEPLINK_FIX_PROMPT)}&mode=agent&autoSubmit=true`;
1803
- };
1920
+ const buildDeeplinkParams = (directory) => {
1921
+ const params = new URLSearchParams();
1922
+ params.set("cwd", path.resolve(directory));
1923
+ params.set("prompt", DEEPLINK_FIX_PROMPT);
1924
+ params.set("mode", "agent");
1925
+ params.set("autoSubmit", "true");
1926
+ return params;
1927
+ };
1928
+ const buildDeeplink = (directory) => `ami://open-project?${buildDeeplinkParams(directory).toString()}`;
1929
+ const buildWebDeeplink = (directory) => `${OPEN_BASE_URL}?${buildDeeplinkParams(directory).toString()}`;
1804
1930
  const openAmiToFix = (directory) => {
1805
1931
  const isInstalled = isAmiInstalled();
1806
1932
  const deeplink = buildDeeplink(directory);
1933
+ const webDeeplink = buildWebDeeplink(directory);
1807
1934
  if (!isInstalled) {
1808
1935
  if (process.platform === "darwin") {
1809
1936
  installAmi();
1810
- logger.success("Ami was installed and opened.");
1937
+ logger.success("Ami installed successfully.");
1811
1938
  } else {
1812
1939
  logger.error("Ami is not installed.");
1813
- logger.dim(`Download it at ${highlighter.info(AMI_RELEASES_URL)}`);
1940
+ logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);
1814
1941
  }
1815
1942
  logger.break();
1816
- logger.dim("Once Ami is running, open this link to start fixing:");
1817
- logger.info(deeplink);
1943
+ logger.dim("Open this link to start fixing:");
1944
+ logger.info(webDeeplink);
1818
1945
  return;
1819
1946
  }
1820
- logger.log("Opening Ami to fix react-doctor issues...");
1947
+ logger.log("Opening Ami...");
1821
1948
  try {
1822
1949
  openUrl(deeplink);
1823
- logger.success("Opened Ami with react-doctor fix prompt.");
1950
+ logger.success("Ami opened. Fixing your issues now.");
1824
1951
  } catch {
1825
1952
  logger.break();
1826
- logger.dim("Could not open Ami automatically. Open this URL manually:");
1827
- logger.info(deeplink);
1953
+ logger.dim("Could not open Ami automatically. Open this link instead:");
1954
+ logger.info(webDeeplink);
1828
1955
  }
1829
1956
  };
1830
1957
  const buildPromptWithOutput = (reactDoctorOutput) => {
@@ -1843,24 +1970,78 @@ const copyPromptToClipboard = (reactDoctorOutput, shouldLogResult) => {
1843
1970
  logger.warn("Could not copy prompt to clipboard automatically. Use this prompt:");
1844
1971
  logger.info(promptWithOutput);
1845
1972
  };
1846
- const maybePromptAmiFix = async (directory) => {
1847
- const isInstalled = isAmiInstalled();
1848
- logger.break();
1849
- logger.log(`Fix these issues with ${highlighter.info("Ami")}?`);
1850
- logger.dim(" Ami is a coding agent built to understand your codebase and fix issues");
1851
- logger.dim(` automatically. Learn more at ${highlighter.info("https://ami.dev")}`);
1973
+ const FIX_METHOD_AMI = "ami";
1974
+ const FIX_METHOD_CLIPBOARD = "clipboard";
1975
+ const FIX_COMMAND_HINT = "npx react-doctor@latest --fix";
1976
+ const buildAmiBanner = (issueCount, currentScore, estimatedScore) => {
1977
+ const currentScoreDisplay = colorizeByScore(String(currentScore), currentScore);
1978
+ const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore);
1979
+ const issueLabel = issueCount === 1 ? "issue" : "issues";
1980
+ return renderFramedBoxString([
1981
+ createFramedLine(`Score: ${currentScore} → ~${estimatedScore}`, `Score: ${currentScoreDisplay} ${highlighter.dim("→")} ${estimatedScoreDisplay}`),
1982
+ createFramedLine(""),
1983
+ createFramedLine(`Ami is a coding agent built for React. It reads`, `${highlighter.info("Ami")} is a coding agent built for React. It reads`),
1984
+ createFramedLine("your react-doctor report, understands your codebase,"),
1985
+ 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`),
1986
+ createFramedLine("scan to verify the score improved."),
1987
+ createFramedLine(""),
1988
+ createFramedLine(`Free to use. ${AMI_WEBSITE_URL}`, `Free to use. ${highlighter.info(AMI_WEBSITE_URL)}`)
1989
+ ]);
1990
+ };
1991
+ const buildClipboardWarningBanner = () => renderFramedBoxString([
1992
+ createFramedLine("⚠ Other agents may not fix these issues well.", `${highlighter.warn("⚠")} Other agents may not fix these issues well.`),
1993
+ createFramedLine(""),
1994
+ createFramedLine("react-doctor diagnostics require React-specific context"),
1995
+ createFramedLine("that general-purpose agents often miss, leading to"),
1996
+ createFramedLine("incomplete or incorrect fixes.")
1997
+ ]);
1998
+ const buildSkipBanner = (issueCount, estimatedScore) => {
1999
+ const issueLabel = issueCount === 1 ? "issue" : "issues";
2000
+ const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore);
2001
+ return renderFramedBoxString([
2002
+ createFramedLine(`Skip fixing ${issueCount} ${issueLabel} and reaching ~${estimatedScore}?`, `Skip fixing ${highlighter.warn(String(issueCount))} ${issueLabel} and reaching ${estimatedScoreDisplay}?`),
2003
+ createFramedLine(""),
2004
+ createFramedLine(`Run ${FIX_COMMAND_HINT} anytime to come back.`, `Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to come back.`)
2005
+ ]);
2006
+ };
2007
+ const configureFixBanners = (issueCount, estimatedScoreResult) => {
2008
+ const { currentScore, estimatedScore } = estimatedScoreResult;
2009
+ setSelectBanner(buildAmiBanner(issueCount, currentScore, estimatedScore), 0);
2010
+ setSelectBanner(buildClipboardWarningBanner(), 1);
2011
+ setSelectBanner(buildSkipBanner(issueCount, estimatedScore), 2);
2012
+ };
2013
+ const maybePromptFix = async (directory, diagnostics, estimatedScoreResult, capturedScanOutput) => {
2014
+ if (diagnostics.length === 0) return;
1852
2015
  logger.break();
1853
- if (!isInstalled && process.platform !== "darwin") {
1854
- logger.dim(`Download Ami at ${highlighter.info(AMI_RELEASES_URL)}`);
1855
- return;
1856
- }
1857
- const { shouldFix } = await prompts({
1858
- type: "confirm",
1859
- name: "shouldFix",
1860
- message: isInstalled ? "Open Ami to fix?" : "Install Ami to fix?",
1861
- initial: true
2016
+ if (estimatedScoreResult) configureFixBanners(diagnostics.length, estimatedScoreResult);
2017
+ const { fixMethod } = await prompts({
2018
+ type: "select",
2019
+ name: "fixMethod",
2020
+ message: "Fix issues?",
2021
+ choices: [
2022
+ {
2023
+ title: "Use ami.dev (recommended)",
2024
+ description: "Optimized coding agent for React Doctor",
2025
+ value: FIX_METHOD_AMI
2026
+ },
2027
+ {
2028
+ title: "Copy to clipboard",
2029
+ description: "Other agents may lack context for react-doctor fixes",
2030
+ value: FIX_METHOD_CLIPBOARD
2031
+ },
2032
+ {
2033
+ title: "Skip",
2034
+ value: "skip"
2035
+ }
2036
+ ]
1862
2037
  });
1863
- if (shouldFix) openAmiToFix(directory);
2038
+ clearSelectBanner();
2039
+ if (fixMethod === FIX_METHOD_AMI) openAmiToFix(directory);
2040
+ else if (fixMethod === FIX_METHOD_CLIPBOARD) copyPromptToClipboard(capturedScanOutput, true);
2041
+ else {
2042
+ logger.break();
2043
+ logger.dim(` Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to fix issues.`);
2044
+ }
1864
2045
  };
1865
2046
  const fixAction = (directory) => {
1866
2047
  try {