react-doctor 0.0.4 → 0.0.5
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
|
@@ -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,113 @@ 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 printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
|
|
848
958
|
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
849
959
|
const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
|
|
850
960
|
const affectedFileCount = collectAffectedFiles(diagnostics).size;
|
|
851
961
|
const elapsed = formatElapsedTime(elapsedMilliseconds);
|
|
852
962
|
logger.log("─".repeat(SEPARATOR_LENGTH_CHARS));
|
|
853
963
|
logger.break();
|
|
964
|
+
if (scoreResult) printScoreGauge(scoreResult.score, scoreResult.label);
|
|
965
|
+
else {
|
|
966
|
+
logger.dim(` ${OFFLINE_MESSAGE}`);
|
|
967
|
+
logger.break();
|
|
968
|
+
}
|
|
854
969
|
const parts = [];
|
|
855
970
|
if (errorCount > 0) parts.push(highlighter.error(`${errorCount} error${errorCount === 1 ? "" : "s"}`));
|
|
856
971
|
if (warningCount > 0) parts.push(highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`));
|
|
857
972
|
parts.push(highlighter.dim(`across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`));
|
|
858
973
|
parts.push(highlighter.dim(`in ${elapsed}`));
|
|
859
|
-
logger.log(parts.join(" "));
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
974
|
+
logger.log(` ${parts.join(" ")}`);
|
|
975
|
+
try {
|
|
976
|
+
const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
|
|
977
|
+
logger.break();
|
|
978
|
+
logger.dim(` Full diagnostics written to ${diagnosticsDirectory}`);
|
|
979
|
+
} catch {
|
|
980
|
+
logger.break();
|
|
981
|
+
}
|
|
863
982
|
};
|
|
864
983
|
const scan = async (directory, options) => {
|
|
865
984
|
const startTime = performance.now();
|
|
866
985
|
const projectInfo = discoverProject(directory);
|
|
867
986
|
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.");
|
|
987
|
+
if (!options.scoreOnly) {
|
|
988
|
+
const frameworkLabel = formatFrameworkName(projectInfo.framework);
|
|
989
|
+
const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
|
|
990
|
+
const completeStep = (message) => {
|
|
991
|
+
spinner(message).start().succeed(message);
|
|
992
|
+
};
|
|
993
|
+
completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
|
|
994
|
+
completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
|
|
995
|
+
completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
|
|
996
|
+
completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
|
|
997
|
+
completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
|
|
998
|
+
logger.break();
|
|
889
999
|
}
|
|
890
|
-
|
|
1000
|
+
const lintPromise = options.lint ? (async () => {
|
|
1001
|
+
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1002
|
+
try {
|
|
1003
|
+
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
|
|
1004
|
+
lintSpinner?.succeed("Running lint checks.");
|
|
1005
|
+
return lintDiagnostics;
|
|
1006
|
+
} catch {
|
|
1007
|
+
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
|
|
1008
|
+
return [];
|
|
1009
|
+
}
|
|
1010
|
+
})() : Promise.resolve([]);
|
|
1011
|
+
const deadCodePromise = options.deadCode ? (async () => {
|
|
1012
|
+
const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
|
|
1013
|
+
try {
|
|
1014
|
+
const knipDiagnostics = await runKnip(directory);
|
|
1015
|
+
deadCodeSpinner?.succeed("Detecting dead code.");
|
|
1016
|
+
return knipDiagnostics;
|
|
1017
|
+
} catch {
|
|
1018
|
+
deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
|
|
1019
|
+
return [];
|
|
1020
|
+
}
|
|
1021
|
+
})() : Promise.resolve([]);
|
|
1022
|
+
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
1023
|
+
const diagnostics = [
|
|
1024
|
+
...lintDiagnostics,
|
|
1025
|
+
...deadCodeDiagnostics,
|
|
1026
|
+
...checkReducedMotion(directory)
|
|
1027
|
+
];
|
|
891
1028
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
1029
|
+
const scoreResult = await calculateScore(diagnostics);
|
|
1030
|
+
if (options.scoreOnly) {
|
|
1031
|
+
if (scoreResult) logger.log(`${scoreResult.score}`);
|
|
1032
|
+
else logger.dim(OFFLINE_MESSAGE);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
892
1035
|
if (diagnostics.length === 0) {
|
|
893
1036
|
logger.success("No issues found!");
|
|
1037
|
+
logger.break();
|
|
1038
|
+
if (scoreResult) printScoreGauge(scoreResult.score, scoreResult.label);
|
|
1039
|
+
else logger.dim(` ${OFFLINE_MESSAGE}`);
|
|
894
1040
|
return;
|
|
895
1041
|
}
|
|
896
|
-
printDiagnostics(diagnostics);
|
|
897
|
-
printSummary(diagnostics, elapsedMilliseconds);
|
|
1042
|
+
printDiagnostics(diagnostics, options.verbose);
|
|
1043
|
+
printSummary(diagnostics, elapsedMilliseconds, scoreResult);
|
|
898
1044
|
};
|
|
899
1045
|
|
|
900
1046
|
//#endregion
|
|
@@ -911,11 +1057,16 @@ const prompts = (questions) => {
|
|
|
911
1057
|
|
|
912
1058
|
//#endregion
|
|
913
1059
|
//#region src/utils/select-projects.ts
|
|
914
|
-
const selectProjects = async (rootDirectory, projectFlag) => {
|
|
915
|
-
const
|
|
916
|
-
if (
|
|
917
|
-
if (
|
|
918
|
-
return
|
|
1060
|
+
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
1061
|
+
const packages = listWorkspacePackages(rootDirectory);
|
|
1062
|
+
if (packages.length === 0) packages.push(...discoverReactSubprojects(rootDirectory));
|
|
1063
|
+
if (packages.length === 0) return [rootDirectory];
|
|
1064
|
+
if (projectFlag) return resolveProjectFlag(projectFlag, packages);
|
|
1065
|
+
if (skipPrompts) {
|
|
1066
|
+
printDiscoveredProjects(packages);
|
|
1067
|
+
process.exit(0);
|
|
1068
|
+
}
|
|
1069
|
+
return promptProjectSelection(packages, rootDirectory);
|
|
919
1070
|
};
|
|
920
1071
|
const resolveProjectFlag = (projectFlag, workspacePackages) => {
|
|
921
1072
|
const requestedNames = projectFlag.split(",").map((name) => name.trim());
|
|
@@ -930,6 +1081,15 @@ const resolveProjectFlag = (projectFlag, workspacePackages) => {
|
|
|
930
1081
|
}
|
|
931
1082
|
return resolvedDirectories;
|
|
932
1083
|
};
|
|
1084
|
+
const printDiscoveredProjects = (packages) => {
|
|
1085
|
+
logger.log(`${highlighter.success("✔")} Found ${highlighter.info(`${packages.length}`)} React projects:`);
|
|
1086
|
+
logger.break();
|
|
1087
|
+
for (const workspacePackage of packages) logger.log(` ${highlighter.dim("─")} ${workspacePackage.directory}`);
|
|
1088
|
+
logger.break();
|
|
1089
|
+
logger.dim(`Run with a specific path to scan a project:`);
|
|
1090
|
+
logger.dim(` npx react-doctor@latest <path>`);
|
|
1091
|
+
logger.break();
|
|
1092
|
+
};
|
|
933
1093
|
const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
934
1094
|
const { selectedDirectories } = await prompts({
|
|
935
1095
|
type: "multiselect",
|
|
@@ -947,24 +1107,32 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
|
947
1107
|
|
|
948
1108
|
//#endregion
|
|
949
1109
|
//#region src/cli.ts
|
|
950
|
-
const VERSION = "0.0.
|
|
1110
|
+
const VERSION = "0.0.5";
|
|
951
1111
|
process.on("SIGINT", () => process.exit(0));
|
|
952
1112
|
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) => {
|
|
1113
|
+
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("--no-verbose", "hide 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)").action(async (directory, flags) => {
|
|
954
1114
|
try {
|
|
955
1115
|
const resolvedDirectory = path.resolve(directory);
|
|
956
|
-
|
|
957
|
-
|
|
1116
|
+
const isScoreOnly = flags.score;
|
|
1117
|
+
if (!isScoreOnly) {
|
|
1118
|
+
logger.log(`react-doctor v${VERSION}`);
|
|
1119
|
+
logger.break();
|
|
1120
|
+
}
|
|
958
1121
|
const scanOptions = {
|
|
959
1122
|
lint: flags.lint,
|
|
960
|
-
deadCode: flags.deadCode
|
|
1123
|
+
deadCode: flags.deadCode,
|
|
1124
|
+
verbose: flags.verbose,
|
|
1125
|
+
scoreOnly: isScoreOnly
|
|
961
1126
|
};
|
|
962
|
-
const
|
|
1127
|
+
const shouldSkipPrompts = flags.yes || Boolean(process.env.CI) || Boolean(process.env.CLAUDECODE) || Boolean(process.env.AMI) || !process.stdin.isTTY;
|
|
1128
|
+
const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
|
|
963
1129
|
for (const projectDirectory of projectDirectories) {
|
|
964
|
-
|
|
965
|
-
|
|
1130
|
+
if (!isScoreOnly) {
|
|
1131
|
+
logger.dim(`Scanning ${projectDirectory}...`);
|
|
1132
|
+
logger.break();
|
|
1133
|
+
}
|
|
966
1134
|
await scan(projectDirectory, scanOptions);
|
|
967
|
-
logger.break();
|
|
1135
|
+
if (!isScoreOnly) logger.break();
|
|
968
1136
|
}
|
|
969
1137
|
} catch (error) {
|
|
970
1138
|
handleError(error);
|