react-doctor 0.0.4 → 0.0.6
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
3
4
|
import path, { join } from "node:path";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
import pc from "picocolors";
|
|
@@ -7,7 +8,6 @@ import { randomUUID } from "node:crypto";
|
|
|
7
8
|
import fs, { mkdirSync, writeFileSync } from "node:fs";
|
|
8
9
|
import os, { tmpdir } from "node:os";
|
|
9
10
|
import { performance } from "node:perf_hooks";
|
|
10
|
-
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { main } from "knip";
|
|
12
12
|
import { createOptions } from "knip/session";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -65,8 +65,36 @@ const handleError = (error) => {
|
|
|
65
65
|
//#region src/constants.ts
|
|
66
66
|
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
67
67
|
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
68
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
68
69
|
const SEPARATOR_LENGTH_CHARS = 62;
|
|
69
70
|
const ERROR_PREVIEW_LENGTH_CHARS = 200;
|
|
71
|
+
const PERFECT_SCORE = 100;
|
|
72
|
+
const SCORE_GOOD_THRESHOLD = 75;
|
|
73
|
+
const SCORE_OK_THRESHOLD = 50;
|
|
74
|
+
const SCORE_BAR_WIDTH_CHARS = 50;
|
|
75
|
+
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
76
|
+
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/utils/calculate-score.ts
|
|
80
|
+
const calculateScore = async (diagnostics) => {
|
|
81
|
+
const payload = diagnostics.map((diagnostic) => ({
|
|
82
|
+
plugin: diagnostic.plugin,
|
|
83
|
+
rule: diagnostic.rule,
|
|
84
|
+
severity: diagnostic.severity
|
|
85
|
+
}));
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(SCORE_API_URL, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ diagnostics: payload })
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) return null;
|
|
93
|
+
return await response.json();
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
70
98
|
|
|
71
99
|
//#endregion
|
|
72
100
|
//#region src/utils/read-package-json.ts
|
|
@@ -149,15 +177,15 @@ const parsePnpmWorkspacePatterns = (rootDirectory) => {
|
|
|
149
177
|
if (!fs.existsSync(workspacePath)) return [];
|
|
150
178
|
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
151
179
|
const patterns = [];
|
|
152
|
-
let
|
|
180
|
+
let isInsidePackagesBlock = false;
|
|
153
181
|
for (const line of content.split("\n")) {
|
|
154
182
|
const trimmed = line.trim();
|
|
155
183
|
if (trimmed === "packages:") {
|
|
156
|
-
|
|
184
|
+
isInsidePackagesBlock = true;
|
|
157
185
|
continue;
|
|
158
186
|
}
|
|
159
|
-
if (
|
|
160
|
-
else if (
|
|
187
|
+
if (isInsidePackagesBlock && trimmed.startsWith("-")) patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, ""));
|
|
188
|
+
else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) isInsidePackagesBlock = false;
|
|
161
189
|
}
|
|
162
190
|
return patterns;
|
|
163
191
|
};
|
|
@@ -218,6 +246,25 @@ const hasReactDependency = (packageJson) => {
|
|
|
218
246
|
const allDependencies = collectAllDependencies(packageJson);
|
|
219
247
|
return Object.keys(allDependencies).some((packageName) => packageName === "next" || packageName.includes("react"));
|
|
220
248
|
};
|
|
249
|
+
const discoverReactSubprojects = (rootDirectory) => {
|
|
250
|
+
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
251
|
+
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
252
|
+
const packages = [];
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
255
|
+
const subdirectory = path.join(rootDirectory, entry.name);
|
|
256
|
+
const packageJsonPath = path.join(subdirectory, "package.json");
|
|
257
|
+
if (!fs.existsSync(packageJsonPath)) continue;
|
|
258
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
259
|
+
if (!hasReactDependency(packageJson)) continue;
|
|
260
|
+
const name = packageJson.name ?? entry.name;
|
|
261
|
+
packages.push({
|
|
262
|
+
name,
|
|
263
|
+
directory: subdirectory
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return packages;
|
|
267
|
+
};
|
|
221
268
|
const listWorkspacePackages = (rootDirectory) => {
|
|
222
269
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
223
270
|
if (!fs.existsSync(packageJsonPath)) return [];
|
|
@@ -312,15 +359,33 @@ const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
|
312
359
|
//#region src/utils/check-reduced-motion.ts
|
|
313
360
|
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
|
|
314
361
|
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
362
|
+
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
363
|
+
filePath: "package.json",
|
|
364
|
+
plugin: "react-doctor",
|
|
365
|
+
rule: "require-reduced-motion",
|
|
366
|
+
severity: "error",
|
|
367
|
+
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
368
|
+
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
369
|
+
line: 0,
|
|
370
|
+
column: 0,
|
|
371
|
+
category: "Accessibility",
|
|
372
|
+
weight: 2
|
|
373
|
+
};
|
|
315
374
|
const checkReducedMotion = (rootDirectory) => {
|
|
316
375
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
317
376
|
if (!fs.existsSync(packageJsonPath)) return [];
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
377
|
+
let hasMotionLibrary = false;
|
|
378
|
+
try {
|
|
379
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
380
|
+
const allDependencies = {
|
|
381
|
+
...packageJson.dependencies,
|
|
382
|
+
...packageJson.devDependencies
|
|
383
|
+
};
|
|
384
|
+
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
385
|
+
} catch {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
if (!hasMotionLibrary) return [];
|
|
324
389
|
try {
|
|
325
390
|
execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
|
|
326
391
|
cwd: rootDirectory,
|
|
@@ -328,17 +393,7 @@ const checkReducedMotion = (rootDirectory) => {
|
|
|
328
393
|
});
|
|
329
394
|
return [];
|
|
330
395
|
} catch {
|
|
331
|
-
return [
|
|
332
|
-
filePath: "package.json",
|
|
333
|
-
plugin: "react-doctor",
|
|
334
|
-
rule: "require-reduced-motion",
|
|
335
|
-
severity: "error",
|
|
336
|
-
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
337
|
-
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
338
|
-
line: 0,
|
|
339
|
-
column: 0,
|
|
340
|
-
category: "Accessibility"
|
|
341
|
-
}];
|
|
396
|
+
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
342
397
|
}
|
|
343
398
|
};
|
|
344
399
|
|
|
@@ -373,7 +428,8 @@ const collectIssueRecords = (records, issueType, rootDirectory) => {
|
|
|
373
428
|
help: "",
|
|
374
429
|
line: 0,
|
|
375
430
|
column: 0,
|
|
376
|
-
category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code"
|
|
431
|
+
category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code",
|
|
432
|
+
weight: 1
|
|
377
433
|
});
|
|
378
434
|
return diagnostics;
|
|
379
435
|
};
|
|
@@ -408,7 +464,8 @@ const runKnip = async (rootDirectory) => {
|
|
|
408
464
|
help: "This file is not imported by any other file in the project.",
|
|
409
465
|
line: 0,
|
|
410
466
|
column: 0,
|
|
411
|
-
category: KNIP_CATEGORY_MAP["files"]
|
|
467
|
+
category: KNIP_CATEGORY_MAP["files"],
|
|
468
|
+
weight: 1
|
|
412
469
|
});
|
|
413
470
|
for (const issueType of [
|
|
414
471
|
"exports",
|
|
@@ -429,6 +486,11 @@ const NEXTJS_RULES = {
|
|
|
429
486
|
"react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
|
|
430
487
|
"react-doctor/nextjs-missing-metadata": "warn",
|
|
431
488
|
"react-doctor/nextjs-no-client-side-redirect": "warn",
|
|
489
|
+
"react-doctor/nextjs-no-redirect-in-try-catch": "warn",
|
|
490
|
+
"react-doctor/nextjs-image-missing-sizes": "warn",
|
|
491
|
+
"react-doctor/nextjs-no-native-script": "warn",
|
|
492
|
+
"react-doctor/nextjs-inline-script-missing-id": "warn",
|
|
493
|
+
"react-doctor/nextjs-no-font-link": "warn",
|
|
432
494
|
"react-doctor/nextjs-no-css-link": "warn",
|
|
433
495
|
"react-doctor/nextjs-no-polyfill-script": "warn",
|
|
434
496
|
"react-doctor/nextjs-no-head-import": "error"
|
|
@@ -698,7 +760,11 @@ const resolveOxlintBinary = () => {
|
|
|
698
760
|
};
|
|
699
761
|
const resolvePluginPath = () => {
|
|
700
762
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
701
|
-
|
|
763
|
+
const pluginPath = path.join(currentDirectory, "react-doctor-plugin.js");
|
|
764
|
+
if (fs.existsSync(pluginPath)) return pluginPath;
|
|
765
|
+
const distPluginPath = path.resolve(currentDirectory, "../../dist/react-doctor-plugin.js");
|
|
766
|
+
if (fs.existsSync(distPluginPath)) return distPluginPath;
|
|
767
|
+
return pluginPath;
|
|
702
768
|
};
|
|
703
769
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
704
770
|
return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
|
|
@@ -770,7 +836,34 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
770
836
|
|
|
771
837
|
//#endregion
|
|
772
838
|
//#region src/utils/spinner.ts
|
|
773
|
-
|
|
839
|
+
let sharedInstance = null;
|
|
840
|
+
let activeCount = 0;
|
|
841
|
+
const pendingTexts = /* @__PURE__ */ new Set();
|
|
842
|
+
const finalize = (method, originalText, displayText) => {
|
|
843
|
+
pendingTexts.delete(originalText);
|
|
844
|
+
activeCount--;
|
|
845
|
+
if (activeCount <= 0 || !sharedInstance) {
|
|
846
|
+
sharedInstance?.[method](displayText);
|
|
847
|
+
sharedInstance = null;
|
|
848
|
+
activeCount = 0;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
sharedInstance.stop();
|
|
852
|
+
ora(displayText).start()[method](displayText);
|
|
853
|
+
const [remainingText] = pendingTexts;
|
|
854
|
+
if (remainingText) sharedInstance.text = remainingText;
|
|
855
|
+
sharedInstance.start();
|
|
856
|
+
};
|
|
857
|
+
const spinner = (text) => ({ start() {
|
|
858
|
+
activeCount++;
|
|
859
|
+
pendingTexts.add(text);
|
|
860
|
+
if (!sharedInstance) sharedInstance = ora({ text }).start();
|
|
861
|
+
else sharedInstance.text = text;
|
|
862
|
+
return {
|
|
863
|
+
succeed: (displayText) => finalize("succeed", text, displayText),
|
|
864
|
+
fail: (displayText) => finalize("fail", text, displayText)
|
|
865
|
+
};
|
|
866
|
+
} });
|
|
774
867
|
|
|
775
868
|
//#endregion
|
|
776
869
|
//#region src/scan.ts
|
|
@@ -781,12 +874,17 @@ const SEVERITY_ORDER = {
|
|
|
781
874
|
const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
|
|
782
875
|
return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
|
|
783
876
|
});
|
|
784
|
-
const collectAffectedFiles = (diagnostics) =>
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
877
|
+
const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
|
|
878
|
+
const buildFileLineMap = (diagnostics) => {
|
|
879
|
+
const fileLines = /* @__PURE__ */ new Map();
|
|
880
|
+
for (const diagnostic of diagnostics) {
|
|
881
|
+
const lines = fileLines.get(diagnostic.filePath) ?? [];
|
|
882
|
+
if (diagnostic.line > 0) lines.push(diagnostic.line);
|
|
883
|
+
fileLines.set(diagnostic.filePath, lines);
|
|
884
|
+
}
|
|
885
|
+
return fileLines;
|
|
788
886
|
};
|
|
789
|
-
const printDiagnostics = (diagnostics) => {
|
|
887
|
+
const printDiagnostics = (diagnostics, isVerbose) => {
|
|
790
888
|
const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
|
|
791
889
|
for (const [, ruleDiagnostics] of sortedRuleGroups) {
|
|
792
890
|
const firstDiagnostic = ruleDiagnostics[0];
|
|
@@ -795,31 +893,23 @@ const printDiagnostics = (diagnostics) => {
|
|
|
795
893
|
const countLabel = count > 1 ? ` (${count})` : "";
|
|
796
894
|
logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
|
|
797
895
|
if (firstDiagnostic.help) logger.dim(` ${firstDiagnostic.help}`);
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
const lines
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
for (const [filePath, lines] of fileLines) {
|
|
805
|
-
const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
|
|
806
|
-
logger.dim(` ${filePath}${lineLabel}`);
|
|
896
|
+
if (isVerbose) {
|
|
897
|
+
const fileLines = buildFileLineMap(ruleDiagnostics);
|
|
898
|
+
for (const [filePath, lines] of fileLines) {
|
|
899
|
+
const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
|
|
900
|
+
logger.dim(` ${filePath}${lineLabel}`);
|
|
901
|
+
}
|
|
807
902
|
}
|
|
808
903
|
logger.break();
|
|
809
904
|
}
|
|
810
905
|
};
|
|
811
906
|
const formatElapsedTime = (elapsedMilliseconds) => {
|
|
812
|
-
if (elapsedMilliseconds <
|
|
813
|
-
return `${(elapsedMilliseconds /
|
|
907
|
+
if (elapsedMilliseconds < MILLISECONDS_PER_SECOND) return `${Math.round(elapsedMilliseconds)}ms`;
|
|
908
|
+
return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
|
|
814
909
|
};
|
|
815
910
|
const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
|
|
816
911
|
const firstDiagnostic = ruleDiagnostics[0];
|
|
817
|
-
const fileLines =
|
|
818
|
-
for (const diagnostic of ruleDiagnostics) {
|
|
819
|
-
const lines = fileLines.get(diagnostic.filePath) ?? [];
|
|
820
|
-
if (diagnostic.line > 0) lines.push(diagnostic.line);
|
|
821
|
-
fileLines.set(diagnostic.filePath, lines);
|
|
822
|
-
}
|
|
912
|
+
const fileLines = buildFileLineMap(ruleDiagnostics);
|
|
823
913
|
const sections = [
|
|
824
914
|
`Rule: ${ruleKey}`,
|
|
825
915
|
`Severity: ${firstDiagnostic.severity}`,
|
|
@@ -844,57 +934,133 @@ const writeDiagnosticsDirectory = (diagnostics) => {
|
|
|
844
934
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
|
|
845
935
|
return outputDirectory;
|
|
846
936
|
};
|
|
847
|
-
const
|
|
937
|
+
const colorizeByScore = (text, score) => {
|
|
938
|
+
if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
|
|
939
|
+
if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
|
|
940
|
+
return highlighter.error(text);
|
|
941
|
+
};
|
|
942
|
+
const buildScoreBar = (score) => {
|
|
943
|
+
const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
|
|
944
|
+
const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
|
|
945
|
+
const filled = "█".repeat(filledCount);
|
|
946
|
+
const empty = "░".repeat(emptyCount);
|
|
947
|
+
return colorizeByScore(filled, score) + highlighter.dim(empty);
|
|
948
|
+
};
|
|
949
|
+
const printScoreGauge = (score, label) => {
|
|
950
|
+
const scoreDisplay = colorizeByScore(`${score}`, score);
|
|
951
|
+
const labelDisplay = colorizeByScore(label, score);
|
|
952
|
+
logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
|
|
953
|
+
logger.break();
|
|
954
|
+
logger.log(` ${buildScoreBar(score)}`);
|
|
955
|
+
logger.break();
|
|
956
|
+
};
|
|
957
|
+
const getDoctorFace = (score) => {
|
|
958
|
+
if (score >= SCORE_GOOD_THRESHOLD) return ["◠ ◠", " ▽ "];
|
|
959
|
+
if (score >= SCORE_OK_THRESHOLD) return ["• •", " ─ "];
|
|
960
|
+
return ["x x", " ▽ "];
|
|
961
|
+
};
|
|
962
|
+
const printBranding = (score) => {
|
|
963
|
+
if (score !== void 0) {
|
|
964
|
+
const [eyes, mouth] = getDoctorFace(score);
|
|
965
|
+
const colorize = (text) => colorizeByScore(text, score);
|
|
966
|
+
logger.log(colorize(" ┌─────┐"));
|
|
967
|
+
logger.log(colorize(` │ ${eyes} │`));
|
|
968
|
+
logger.log(colorize(` │ ${mouth} │`));
|
|
969
|
+
logger.log(colorize(" └─────┘"));
|
|
970
|
+
}
|
|
971
|
+
logger.log(` React Doctor ${highlighter.dim("(www.react.doctor)")}`);
|
|
972
|
+
logger.break();
|
|
973
|
+
};
|
|
974
|
+
const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
|
|
848
975
|
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
849
976
|
const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
|
|
850
977
|
const affectedFileCount = collectAffectedFiles(diagnostics).size;
|
|
851
978
|
const elapsed = formatElapsedTime(elapsedMilliseconds);
|
|
852
979
|
logger.log("─".repeat(SEPARATOR_LENGTH_CHARS));
|
|
853
980
|
logger.break();
|
|
981
|
+
printBranding(scoreResult?.score);
|
|
982
|
+
if (scoreResult) printScoreGauge(scoreResult.score, scoreResult.label);
|
|
983
|
+
else {
|
|
984
|
+
logger.dim(` ${OFFLINE_MESSAGE}`);
|
|
985
|
+
logger.break();
|
|
986
|
+
}
|
|
854
987
|
const parts = [];
|
|
855
988
|
if (errorCount > 0) parts.push(highlighter.error(`${errorCount} error${errorCount === 1 ? "" : "s"}`));
|
|
856
989
|
if (warningCount > 0) parts.push(highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`));
|
|
857
990
|
parts.push(highlighter.dim(`across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`));
|
|
858
991
|
parts.push(highlighter.dim(`in ${elapsed}`));
|
|
859
|
-
logger.log(parts.join(" "));
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
992
|
+
logger.log(` ${parts.join(" ")}`);
|
|
993
|
+
try {
|
|
994
|
+
const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
|
|
995
|
+
logger.break();
|
|
996
|
+
logger.dim(` Full diagnostics written to ${diagnosticsDirectory}`);
|
|
997
|
+
} catch {
|
|
998
|
+
logger.break();
|
|
999
|
+
}
|
|
863
1000
|
};
|
|
864
1001
|
const scan = async (directory, options) => {
|
|
865
1002
|
const startTime = performance.now();
|
|
866
1003
|
const projectInfo = discoverProject(directory);
|
|
867
1004
|
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
if (options.lint) {
|
|
881
|
-
const lintSpinner = spinner("Running lint checks...").start();
|
|
882
|
-
diagnostics.push(...await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler));
|
|
883
|
-
lintSpinner.succeed("Running lint checks.");
|
|
884
|
-
}
|
|
885
|
-
if (options.deadCode) {
|
|
886
|
-
const deadCodeSpinner = spinner("Detecting dead code...").start();
|
|
887
|
-
diagnostics.push(...await runKnip(directory));
|
|
888
|
-
deadCodeSpinner.succeed("Detecting dead code.");
|
|
1005
|
+
if (!options.scoreOnly) {
|
|
1006
|
+
const frameworkLabel = formatFrameworkName(projectInfo.framework);
|
|
1007
|
+
const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
|
|
1008
|
+
const completeStep = (message) => {
|
|
1009
|
+
spinner(message).start().succeed(message);
|
|
1010
|
+
};
|
|
1011
|
+
completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
|
|
1012
|
+
completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
|
|
1013
|
+
completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
|
|
1014
|
+
completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
|
|
1015
|
+
completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
|
|
1016
|
+
logger.break();
|
|
889
1017
|
}
|
|
890
|
-
|
|
1018
|
+
const lintPromise = options.lint ? (async () => {
|
|
1019
|
+
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1020
|
+
try {
|
|
1021
|
+
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
|
|
1022
|
+
lintSpinner?.succeed("Running lint checks.");
|
|
1023
|
+
return lintDiagnostics;
|
|
1024
|
+
} catch {
|
|
1025
|
+
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
|
|
1026
|
+
return [];
|
|
1027
|
+
}
|
|
1028
|
+
})() : Promise.resolve([]);
|
|
1029
|
+
const deadCodePromise = options.deadCode ? (async () => {
|
|
1030
|
+
const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
|
|
1031
|
+
try {
|
|
1032
|
+
const knipDiagnostics = await runKnip(directory);
|
|
1033
|
+
deadCodeSpinner?.succeed("Detecting dead code.");
|
|
1034
|
+
return knipDiagnostics;
|
|
1035
|
+
} catch {
|
|
1036
|
+
deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
|
|
1037
|
+
return [];
|
|
1038
|
+
}
|
|
1039
|
+
})() : Promise.resolve([]);
|
|
1040
|
+
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
1041
|
+
const diagnostics = [
|
|
1042
|
+
...lintDiagnostics,
|
|
1043
|
+
...deadCodeDiagnostics,
|
|
1044
|
+
...checkReducedMotion(directory)
|
|
1045
|
+
];
|
|
891
1046
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
1047
|
+
const scoreResult = await calculateScore(diagnostics);
|
|
1048
|
+
if (options.scoreOnly) {
|
|
1049
|
+
if (scoreResult) logger.log(`${scoreResult.score}`);
|
|
1050
|
+
else logger.dim(OFFLINE_MESSAGE);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
892
1053
|
if (diagnostics.length === 0) {
|
|
893
1054
|
logger.success("No issues found!");
|
|
1055
|
+
logger.break();
|
|
1056
|
+
if (scoreResult) {
|
|
1057
|
+
printBranding(scoreResult.score);
|
|
1058
|
+
printScoreGauge(scoreResult.score, scoreResult.label);
|
|
1059
|
+
} else logger.dim(` ${OFFLINE_MESSAGE}`);
|
|
894
1060
|
return;
|
|
895
1061
|
}
|
|
896
|
-
printDiagnostics(diagnostics);
|
|
897
|
-
printSummary(diagnostics, elapsedMilliseconds);
|
|
1062
|
+
printDiagnostics(diagnostics, options.verbose);
|
|
1063
|
+
printSummary(diagnostics, elapsedMilliseconds, scoreResult);
|
|
898
1064
|
};
|
|
899
1065
|
|
|
900
1066
|
//#endregion
|
|
@@ -911,11 +1077,16 @@ const prompts = (questions) => {
|
|
|
911
1077
|
|
|
912
1078
|
//#endregion
|
|
913
1079
|
//#region src/utils/select-projects.ts
|
|
914
|
-
const selectProjects = async (rootDirectory, projectFlag) => {
|
|
915
|
-
|
|
916
|
-
if (
|
|
917
|
-
if (
|
|
918
|
-
return
|
|
1080
|
+
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
1081
|
+
let packages = listWorkspacePackages(rootDirectory);
|
|
1082
|
+
if (packages.length === 0) packages = discoverReactSubprojects(rootDirectory);
|
|
1083
|
+
if (packages.length === 0) return [rootDirectory];
|
|
1084
|
+
if (projectFlag) return resolveProjectFlag(projectFlag, packages);
|
|
1085
|
+
if (skipPrompts) {
|
|
1086
|
+
printDiscoveredProjects(packages);
|
|
1087
|
+
process.exit(0);
|
|
1088
|
+
}
|
|
1089
|
+
return promptProjectSelection(packages, rootDirectory);
|
|
919
1090
|
};
|
|
920
1091
|
const resolveProjectFlag = (projectFlag, workspacePackages) => {
|
|
921
1092
|
const requestedNames = projectFlag.split(",").map((name) => name.trim());
|
|
@@ -930,6 +1101,15 @@ const resolveProjectFlag = (projectFlag, workspacePackages) => {
|
|
|
930
1101
|
}
|
|
931
1102
|
return resolvedDirectories;
|
|
932
1103
|
};
|
|
1104
|
+
const printDiscoveredProjects = (packages) => {
|
|
1105
|
+
logger.log(`${highlighter.success("✔")} Found ${highlighter.info(`${packages.length}`)} React projects:`);
|
|
1106
|
+
logger.break();
|
|
1107
|
+
for (const workspacePackage of packages) logger.log(` ${highlighter.dim("─")} ${workspacePackage.directory}`);
|
|
1108
|
+
logger.break();
|
|
1109
|
+
logger.dim(`Run with a specific path to scan a project:`);
|
|
1110
|
+
logger.dim(` npx -y react-doctor@latest <path>`);
|
|
1111
|
+
logger.break();
|
|
1112
|
+
};
|
|
933
1113
|
const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
934
1114
|
const { selectedDirectories } = await prompts({
|
|
935
1115
|
type: "multiselect",
|
|
@@ -947,25 +1127,34 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
|
947
1127
|
|
|
948
1128
|
//#endregion
|
|
949
1129
|
//#region src/cli.ts
|
|
950
|
-
const VERSION = "0.0.
|
|
1130
|
+
const VERSION = "0.0.6";
|
|
951
1131
|
process.on("SIGINT", () => process.exit(0));
|
|
952
1132
|
process.on("SIGTERM", () => process.exit(0));
|
|
953
|
-
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("--project <name>", "select workspace project (comma-separated for multiple)").action(async (directory, flags) => {
|
|
1133
|
+
const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
|
|
954
1134
|
try {
|
|
955
1135
|
const resolvedDirectory = path.resolve(directory);
|
|
956
|
-
|
|
957
|
-
|
|
1136
|
+
const isScoreOnly = flags.score;
|
|
1137
|
+
if (!isScoreOnly) {
|
|
1138
|
+
logger.log(`react-doctor v${VERSION}`);
|
|
1139
|
+
logger.break();
|
|
1140
|
+
}
|
|
958
1141
|
const scanOptions = {
|
|
959
1142
|
lint: flags.lint,
|
|
960
|
-
deadCode: flags.deadCode
|
|
1143
|
+
deadCode: flags.deadCode,
|
|
1144
|
+
verbose: Boolean(flags.verbose),
|
|
1145
|
+
scoreOnly: isScoreOnly
|
|
961
1146
|
};
|
|
962
|
-
const
|
|
1147
|
+
const shouldSkipPrompts = flags.yes || Boolean(process.env.CI) || Boolean(process.env.CLAUDECODE) || Boolean(process.env.AMI) || !process.stdin.isTTY;
|
|
1148
|
+
const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
|
|
963
1149
|
for (const projectDirectory of projectDirectories) {
|
|
964
|
-
|
|
965
|
-
|
|
1150
|
+
if (!isScoreOnly) {
|
|
1151
|
+
logger.dim(`Scanning ${projectDirectory}...`);
|
|
1152
|
+
logger.break();
|
|
1153
|
+
}
|
|
966
1154
|
await scan(projectDirectory, scanOptions);
|
|
967
|
-
logger.break();
|
|
1155
|
+
if (!isScoreOnly) logger.break();
|
|
968
1156
|
}
|
|
1157
|
+
if (flags.fix) openAmiToFix(resolvedDirectory);
|
|
969
1158
|
} catch (error) {
|
|
970
1159
|
handleError(error);
|
|
971
1160
|
}
|
|
@@ -973,6 +1162,55 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
973
1162
|
${highlighter.dim("Learn more:")}
|
|
974
1163
|
${highlighter.info("https://github.com/aidenybai/react-doctor")}
|
|
975
1164
|
`);
|
|
1165
|
+
const AMI_INSTALL_URL = "https://ami.dev/install.sh";
|
|
1166
|
+
const AMI_FIX_PROMPT = "Run npx -y react-doctor@latest . --verbose, read every diagnostic, then fix all issues one by one. After fixing, re-run react-doctor to verify the score improved.";
|
|
1167
|
+
const buildAmiDeeplink = (projectDirectory) => {
|
|
1168
|
+
return `ami://new-chat?cwd=${encodeURIComponent(projectDirectory)}&prompt=${encodeURIComponent(AMI_FIX_PROMPT)}&mode=agent`;
|
|
1169
|
+
};
|
|
1170
|
+
const isAmiInstalled = () => {
|
|
1171
|
+
try {
|
|
1172
|
+
execSync("ls /Applications/Ami.app", { stdio: "ignore" });
|
|
1173
|
+
return true;
|
|
1174
|
+
} catch {
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
const installAmi = () => {
|
|
1179
|
+
logger.log("Ami not found. Installing...");
|
|
1180
|
+
logger.break();
|
|
1181
|
+
try {
|
|
1182
|
+
execSync(`curl -fsSL ${AMI_INSTALL_URL} | bash`, { stdio: "inherit" });
|
|
1183
|
+
} catch {
|
|
1184
|
+
logger.error("Failed to install Ami. Visit https://ami.dev to install manually.");
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1187
|
+
logger.break();
|
|
1188
|
+
};
|
|
1189
|
+
const openAmiToFix = (directory) => {
|
|
1190
|
+
const resolvedDirectory = path.resolve(directory);
|
|
1191
|
+
if (!isAmiInstalled()) installAmi();
|
|
1192
|
+
logger.log("Opening Ami to fix react-doctor issues...");
|
|
1193
|
+
const deeplink = buildAmiDeeplink(resolvedDirectory);
|
|
1194
|
+
try {
|
|
1195
|
+
execSync(`open "${deeplink}"`, { stdio: "ignore" });
|
|
1196
|
+
logger.success("Opened Ami with react-doctor fix prompt.");
|
|
1197
|
+
} catch {
|
|
1198
|
+
logger.break();
|
|
1199
|
+
logger.dim("Could not open Ami automatically. Open this URL manually:");
|
|
1200
|
+
logger.info(deeplink);
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
const fixAction = (directory) => {
|
|
1204
|
+
try {
|
|
1205
|
+
openAmiToFix(directory);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
handleError(error);
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
const fixCommand = new Command("fix").description("Open Ami to auto-fix react-doctor issues").argument("[directory]", "project directory", ".").action(fixAction);
|
|
1211
|
+
const installAmiCommand = new Command("install-ami").description("Open Ami to auto-fix react-doctor issues").argument("[directory]", "project directory", ".").action(fixAction);
|
|
1212
|
+
program.addCommand(fixCommand);
|
|
1213
|
+
program.addCommand(installAmiCommand);
|
|
976
1214
|
const main$1 = async () => {
|
|
977
1215
|
await program.parseAsync();
|
|
978
1216
|
};
|