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 +9 -0
- package/dist/cli.js +286 -117
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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:
|
|
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.
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
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 (
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
1938
|
+
logger.success("Ami installed successfully.");
|
|
1824
1939
|
} else {
|
|
1825
1940
|
logger.error("Ami is not installed.");
|
|
1826
|
-
logger.dim(`Download
|
|
1941
|
+
logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);
|
|
1827
1942
|
}
|
|
1828
1943
|
logger.break();
|
|
1829
|
-
logger.dim("
|
|
1944
|
+
logger.dim("Open this link to start fixing:");
|
|
1830
1945
|
logger.info(webDeeplink);
|
|
1831
1946
|
return;
|
|
1832
1947
|
}
|
|
1833
|
-
logger.log("Opening Ami
|
|
1948
|
+
logger.log("Opening Ami...");
|
|
1834
1949
|
try {
|
|
1835
1950
|
openUrl(deeplink);
|
|
1836
|
-
logger.success("
|
|
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
|
|
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
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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 (
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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
|
-
|
|
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 {
|