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 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,133 @@ 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 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
- const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
861
- logger.break();
862
- logger.dim(`Full diagnostics written to ${diagnosticsDirectory}`);
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
- 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.");
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
- diagnostics.push(...checkReducedMotion(directory));
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
- const workspacePackages = listWorkspacePackages(rootDirectory);
916
- if (workspacePackages.length === 0) return [rootDirectory];
917
- if (projectFlag) return resolveProjectFlag(projectFlag, workspacePackages);
918
- return promptProjectSelection(workspacePackages, rootDirectory);
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.4";
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
- logger.log(`react-doctor v${VERSION}`);
957
- logger.break();
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 projectDirectories = await selectProjects(resolvedDirectory, flags.project);
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
- logger.dim(`Scanning ${projectDirectory}...`);
965
- logger.break();
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
  };