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 insidePackagesBlock = false;
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
- insidePackagesBlock = true;
184
+ isInsidePackagesBlock = true;
157
185
  continue;
158
186
  }
159
- if (insidePackagesBlock && trimmed.startsWith("-")) patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, ""));
160
- else if (insidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) insidePackagesBlock = false;
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
- const packageJson = readPackageJson(packageJsonPath);
319
- const allDependencies = {
320
- ...packageJson.dependencies,
321
- ...packageJson.devDependencies
322
- };
323
- if (!Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName))) return [];
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
- return path.join(currentDirectory, "react-doctor-plugin.js");
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
- const spinner = (text) => ora({ text });
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
- const files = /* @__PURE__ */ new Set();
786
- for (const diagnostic of diagnostics) files.add(diagnostic.filePath);
787
- return files;
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
- const fileLines = /* @__PURE__ */ new Map();
799
- for (const diagnostic of ruleDiagnostics) {
800
- const lines = fileLines.get(diagnostic.filePath) ?? [];
801
- if (diagnostic.line > 0) lines.push(diagnostic.line);
802
- fileLines.set(diagnostic.filePath, lines);
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 < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
813
- return `${(elapsedMilliseconds / 1e3).toFixed(1)}s`;
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 = /* @__PURE__ */ new Map();
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 printSummary = (diagnostics, elapsedMilliseconds) => {
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
- const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
861
- logger.break();
862
- logger.dim(`Full diagnostics written to ${diagnosticsDirectory}`);
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
- const frameworkLabel = formatFrameworkName(projectInfo.framework);
869
- const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
870
- const completeStep = (message) => {
871
- spinner(message).start().succeed(message);
872
- };
873
- completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
874
- completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
875
- completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
876
- completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
877
- completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
878
- logger.break();
879
- const diagnostics = [];
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
- diagnostics.push(...checkReducedMotion(directory));
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 workspacePackages = listWorkspacePackages(rootDirectory);
916
- if (workspacePackages.length === 0) return [rootDirectory];
917
- if (projectFlag) return resolveProjectFlag(projectFlag, workspacePackages);
918
- return promptProjectSelection(workspacePackages, rootDirectory);
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.4";
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
- logger.log(`react-doctor v${VERSION}`);
957
- logger.break();
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 projectDirectories = await selectProjects(resolvedDirectory, flags.project);
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
- logger.dim(`Scanning ${projectDirectory}...`);
965
- logger.break();
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);