react-doctor 0.0.26 → 0.0.28

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,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { execSync, spawn, spawnSync } from "node:child_process";
4
- import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import os, { homedir, tmpdir } from "node:os";
6
6
  import path, { join } from "node:path";
7
7
  import { Command } from "commander";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { performance } from "node:perf_hooks";
10
10
  import pc from "picocolors";
11
+ import basePrompts from "prompts";
11
12
  import { main } from "knip";
12
13
  import { createOptions } from "knip/session";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import ora from "ora";
15
- import basePrompts from "prompts";
16
16
 
17
17
  //#region src/constants.ts
18
18
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
@@ -29,18 +29,73 @@ const SCORE_API_URL = "https://www.react.doctor/api/score";
29
29
  const ESTIMATE_SCORE_API_URL = "https://www.react.doctor/api/estimate-score";
30
30
  const SHARE_BASE_URL = "https://www.react.doctor/share";
31
31
  const OPEN_BASE_URL = "https://www.react.doctor/open";
32
- const INSTALL_SKILL_URL = "https://www.react.doctor/install-skill";
32
+ const FETCH_TIMEOUT_MS = 1e4;
33
33
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
34
+ const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
34
35
  const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
35
36
  const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
36
37
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
37
-
38
- //#endregion
39
- //#region src/utils/calculate-score.ts
40
38
  const ERROR_RULE_PENALTY = 1.5;
41
39
  const WARNING_RULE_PENALTY = .75;
42
40
  const ERROR_ESTIMATED_FIX_RATE = .85;
43
41
  const WARNING_ESTIMATED_FIX_RATE = .8;
42
+ const MAX_KNIP_RETRIES = 5;
43
+ const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
44
+ const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
45
+ const AMI_WEBSITE_URL = "https://ami.dev";
46
+ const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
47
+ const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
48
+
49
+ //#endregion
50
+ //#region src/utils/proxy-fetch.ts
51
+ const readNpmConfigValue = (key) => {
52
+ try {
53
+ const value = execSync(`npm config get ${key}`, {
54
+ encoding: "utf-8",
55
+ stdio: [
56
+ "pipe",
57
+ "pipe",
58
+ "ignore"
59
+ ]
60
+ }).trim();
61
+ if (value && value !== "null" && value !== "undefined") return value;
62
+ } catch {}
63
+ };
64
+ const resolveProxyUrl = () => process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? readNpmConfigValue("https-proxy") ?? readNpmConfigValue("proxy");
65
+ let isProxyUrlResolved = false;
66
+ let resolvedProxyUrl;
67
+ const getProxyUrl = () => {
68
+ if (isProxyUrlResolved) return resolvedProxyUrl;
69
+ isProxyUrlResolved = true;
70
+ resolvedProxyUrl = resolveProxyUrl();
71
+ return resolvedProxyUrl;
72
+ };
73
+ const createProxyDispatcher = async (proxyUrl) => {
74
+ try {
75
+ const { ProxyAgent } = await import("undici");
76
+ return new ProxyAgent(proxyUrl);
77
+ } catch {
78
+ return null;
79
+ }
80
+ };
81
+ const proxyFetch = async (url, init) => {
82
+ const controller = new AbortController();
83
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
84
+ try {
85
+ const proxyUrl = getProxyUrl();
86
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
87
+ return await fetch(url, {
88
+ ...init,
89
+ signal: controller.signal,
90
+ ...dispatcher ? { dispatcher } : {}
91
+ });
92
+ } finally {
93
+ clearTimeout(timeoutId);
94
+ }
95
+ };
96
+
97
+ //#endregion
98
+ //#region src/utils/calculate-score.ts
44
99
  const getScoreLabel = (score) => {
45
100
  if (score >= SCORE_GOOD_THRESHOLD) return "Great";
46
101
  if (score >= SCORE_OK_THRESHOLD) return "Needs work";
@@ -76,7 +131,7 @@ const estimateScoreLocally = (diagnostics) => {
76
131
  };
77
132
  const calculateScore = async (diagnostics) => {
78
133
  try {
79
- const response = await fetch(SCORE_API_URL, {
134
+ const response = await proxyFetch(SCORE_API_URL, {
80
135
  method: "POST",
81
136
  headers: { "Content-Type": "application/json" },
82
137
  body: JSON.stringify({ diagnostics })
@@ -89,7 +144,7 @@ const calculateScore = async (diagnostics) => {
89
144
  };
90
145
  const fetchEstimatedScore = async (diagnostics) => {
91
146
  try {
92
- const response = await fetch(ESTIMATE_SCORE_API_URL, {
147
+ const response = await proxyFetch(ESTIMATE_SCORE_API_URL, {
93
148
  method: "POST",
94
149
  headers: { "Content-Type": "application/json" },
95
150
  body: JSON.stringify({ diagnostics })
@@ -101,6 +156,24 @@ const fetchEstimatedScore = async (diagnostics) => {
101
156
  }
102
157
  };
103
158
 
159
+ //#endregion
160
+ //#region src/utils/highlighter.ts
161
+ const highlighter = {
162
+ error: pc.red,
163
+ warn: pc.yellow,
164
+ info: pc.cyan,
165
+ success: pc.green,
166
+ dim: pc.dim
167
+ };
168
+
169
+ //#endregion
170
+ //#region src/utils/colorize-by-score.ts
171
+ const colorizeByScore = (text, score) => {
172
+ if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
173
+ if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
174
+ return highlighter.error(text);
175
+ };
176
+
104
177
  //#endregion
105
178
  //#region src/plugin/constants.ts
106
179
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
@@ -151,6 +224,79 @@ const checkReducedMotion = (rootDirectory) => {
151
224
  }
152
225
  };
153
226
 
227
+ //#endregion
228
+ //#region src/utils/match-glob-pattern.ts
229
+ const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
230
+ const compileGlobPattern = (pattern) => {
231
+ const normalizedPattern = pattern.replace(/\\/g, "/");
232
+ let regexSource = "^";
233
+ let characterIndex = 0;
234
+ while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
235
+ regexSource += "(?:.+/)?";
236
+ characterIndex += 3;
237
+ } else {
238
+ regexSource += ".*";
239
+ characterIndex += 2;
240
+ }
241
+ else if (normalizedPattern[characterIndex] === "*") {
242
+ regexSource += "[^/]*";
243
+ characterIndex++;
244
+ } else if (normalizedPattern[characterIndex] === "?") {
245
+ regexSource += "[^/]";
246
+ characterIndex++;
247
+ } else {
248
+ regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
249
+ characterIndex++;
250
+ }
251
+ regexSource += "$";
252
+ return new RegExp(regexSource);
253
+ };
254
+
255
+ //#endregion
256
+ //#region src/utils/filter-diagnostics.ts
257
+ const filterIgnoredDiagnostics = (diagnostics, config) => {
258
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
259
+ const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
260
+ if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
261
+ return diagnostics.filter((diagnostic) => {
262
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
263
+ if (ignoredRules.has(ruleIdentifier)) return false;
264
+ const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
265
+ if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
266
+ return true;
267
+ });
268
+ };
269
+
270
+ //#endregion
271
+ //#region src/utils/combine-diagnostics.ts
272
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
273
+ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
274
+ const allDiagnostics = [
275
+ ...lintDiagnostics,
276
+ ...deadCodeDiagnostics,
277
+ ...isDiffMode ? [] : checkReducedMotion(directory)
278
+ ];
279
+ return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
280
+ };
281
+
282
+ //#endregion
283
+ //#region src/utils/find-monorepo-root.ts
284
+ const isMonorepoRoot = (directory) => {
285
+ if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
286
+ const packageJsonPath = path.join(directory, "package.json");
287
+ if (!fs.existsSync(packageJsonPath)) return false;
288
+ const packageJson = readPackageJson(packageJsonPath);
289
+ return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
290
+ };
291
+ const findMonorepoRoot = (startDirectory) => {
292
+ let currentDirectory = path.dirname(startDirectory);
293
+ while (currentDirectory !== path.dirname(currentDirectory)) {
294
+ if (isMonorepoRoot(currentDirectory)) return currentDirectory;
295
+ currentDirectory = path.dirname(currentDirectory);
296
+ }
297
+ return null;
298
+ };
299
+
154
300
  //#endregion
155
301
  //#region src/utils/discover-project.ts
156
302
  const REACT_COMPILER_PACKAGES = new Set([
@@ -261,27 +407,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
261
407
  if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
262
408
  return [];
263
409
  }
264
- const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, cleanPattern.indexOf("*")));
410
+ const wildcardIndex = cleanPattern.indexOf("*");
411
+ const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
412
+ const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
265
413
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
266
- return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
267
- };
268
- const isMonorepoRoot = (directory) => {
269
- if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
270
- const packageJsonPath = path.join(directory, "package.json");
271
- if (!fs.existsSync(packageJsonPath)) return false;
272
- const packageJson = readPackageJson(packageJsonPath);
273
- return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
274
- };
275
- const findMonorepoRoot$1 = (startDirectory) => {
276
- let currentDirectory = path.dirname(startDirectory);
277
- while (currentDirectory !== path.dirname(currentDirectory)) {
278
- if (isMonorepoRoot(currentDirectory)) return currentDirectory;
279
- currentDirectory = path.dirname(currentDirectory);
280
- }
281
- return null;
414
+ return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
282
415
  };
283
416
  const findDependencyInfoFromMonorepoRoot = (directory) => {
284
- const monorepoRoot = findMonorepoRoot$1(directory);
417
+ const monorepoRoot = findMonorepoRoot(directory);
285
418
  if (!monorepoRoot) return {
286
419
  reactVersion: null,
287
420
  framework: "unknown"
@@ -410,59 +543,6 @@ const discoverProject = (directory) => {
410
543
  };
411
544
  };
412
545
 
413
- //#endregion
414
- //#region src/utils/match-glob-pattern.ts
415
- const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
416
- const compileGlobPattern = (pattern) => {
417
- const normalizedPattern = pattern.replace(/\\/g, "/");
418
- let regexSource = "^";
419
- let characterIndex = 0;
420
- while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
421
- regexSource += "(?:.+/)?";
422
- characterIndex += 3;
423
- } else {
424
- regexSource += ".*";
425
- characterIndex += 2;
426
- }
427
- else if (normalizedPattern[characterIndex] === "*") {
428
- regexSource += "[^/]*";
429
- characterIndex++;
430
- } else if (normalizedPattern[characterIndex] === "?") {
431
- regexSource += "[^/]";
432
- characterIndex++;
433
- } else {
434
- regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
435
- characterIndex++;
436
- }
437
- regexSource += "$";
438
- return new RegExp(regexSource);
439
- };
440
-
441
- //#endregion
442
- //#region src/utils/filter-diagnostics.ts
443
- const filterIgnoredDiagnostics = (diagnostics, config) => {
444
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
445
- const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
446
- if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
447
- return diagnostics.filter((diagnostic) => {
448
- const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
449
- if (ignoredRules.has(ruleIdentifier)) return false;
450
- const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
451
- if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
452
- return true;
453
- });
454
- };
455
-
456
- //#endregion
457
- //#region src/utils/highlighter.ts
458
- const highlighter = {
459
- error: pc.red,
460
- warn: pc.yellow,
461
- info: pc.cyan,
462
- success: pc.green,
463
- dim: pc.dim
464
- };
465
-
466
546
  //#endregion
467
547
  //#region src/utils/logger.ts
468
548
  const logger = {
@@ -563,6 +643,167 @@ const loadConfig = (rootDirectory) => {
563
643
  return null;
564
644
  };
565
645
 
646
+ //#endregion
647
+ //#region src/utils/should-auto-select-current-choice.ts
648
+ const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
649
+ if (choiceStates.some((choiceState) => choiceState.selected)) return false;
650
+ const currentChoice = choiceStates[cursor];
651
+ return Boolean(currentChoice) && !currentChoice.disabled;
652
+ };
653
+
654
+ //#endregion
655
+ //#region src/utils/should-select-all-choices.ts
656
+ const shouldSelectAllChoices = (choiceStates) => {
657
+ return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
658
+ };
659
+
660
+ //#endregion
661
+ //#region src/utils/prompts.ts
662
+ const require = createRequire(import.meta.url);
663
+ const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
664
+ const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
665
+ let didPatchMultiselectToggleAll = false;
666
+ let didPatchMultiselectSubmit = false;
667
+ let didPatchSelectBanner = false;
668
+ const selectBannerMap = /* @__PURE__ */ new Map();
669
+ const setSelectBanner = (banner, targetIndex) => {
670
+ selectBannerMap.set(targetIndex, banner);
671
+ };
672
+ const clearSelectBanner = () => {
673
+ selectBannerMap.clear();
674
+ };
675
+ const onCancel = () => {
676
+ logger.break();
677
+ logger.log("Cancelled.");
678
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
679
+ logger.break();
680
+ process.exit(0);
681
+ };
682
+ const patchMultiselectToggleAll = () => {
683
+ if (didPatchMultiselectToggleAll) return;
684
+ didPatchMultiselectToggleAll = true;
685
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
686
+ multiselectPromptConstructor.prototype.toggleAll = function() {
687
+ const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
688
+ if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
689
+ this.bell();
690
+ return;
691
+ }
692
+ const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
693
+ for (const choiceState of this.value) {
694
+ if (choiceState.disabled) continue;
695
+ choiceState.selected = shouldSelectAllEnabledChoices;
696
+ }
697
+ this.render();
698
+ };
699
+ };
700
+ const patchMultiselectSubmit = () => {
701
+ if (didPatchMultiselectSubmit) return;
702
+ didPatchMultiselectSubmit = true;
703
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
704
+ const originalSubmit = multiselectPromptConstructor.prototype.submit;
705
+ multiselectPromptConstructor.prototype.submit = function() {
706
+ if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
707
+ originalSubmit.call(this);
708
+ };
709
+ };
710
+ const patchSelectBanner = () => {
711
+ if (didPatchSelectBanner) return;
712
+ didPatchSelectBanner = true;
713
+ const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
714
+ const promptsClear = require("prompts/lib/util/clear");
715
+ const originalRender = selectConstructor.prototype.render;
716
+ selectConstructor.prototype.render = function() {
717
+ originalRender.call(this);
718
+ const banner = selectBannerMap.get(this.cursor);
719
+ if (!banner || this.closed || this.done) return;
720
+ this.out.write(promptsClear(this.outputText, this.out.columns));
721
+ this.outputText = `${banner}\n\n${this.outputText}`;
722
+ this.out.write(this.outputText);
723
+ };
724
+ };
725
+ const prompts = (questions) => {
726
+ patchMultiselectToggleAll();
727
+ patchMultiselectSubmit();
728
+ patchSelectBanner();
729
+ return basePrompts(questions, { onCancel });
730
+ };
731
+
732
+ //#endregion
733
+ //#region src/utils/resolve-compatible-node.ts
734
+ const parseNodeVersion = (versionString) => {
735
+ const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
736
+ return {
737
+ major,
738
+ minor,
739
+ patch
740
+ };
741
+ };
742
+ const isNodeVersionCompatibleWithOxlint = ({ major, minor }) => {
743
+ if (major === 20 && minor >= 19) return true;
744
+ if (major === 22 && minor >= 12) return true;
745
+ if (major > 22) return true;
746
+ return false;
747
+ };
748
+ const isCurrentNodeCompatibleWithOxlint = () => isNodeVersionCompatibleWithOxlint(parseNodeVersion(process.version));
749
+ const getNvmDirectory = () => {
750
+ const envNvmDirectory = process.env.NVM_DIR;
751
+ if (envNvmDirectory && existsSync(envNvmDirectory)) return envNvmDirectory;
752
+ const defaultNvmDirectory = path.join(os.homedir(), ".nvm");
753
+ if (existsSync(defaultNvmDirectory)) return defaultNvmDirectory;
754
+ return null;
755
+ };
756
+ const isNvmInstalled = () => getNvmDirectory() !== null;
757
+ const findCompatibleNvmBinary = () => {
758
+ const nvmDirectory = getNvmDirectory();
759
+ if (!nvmDirectory) return null;
760
+ const versionsDirectory = path.join(nvmDirectory, "versions", "node");
761
+ if (!existsSync(versionsDirectory)) return null;
762
+ const compatibleVersions = readdirSync(versionsDirectory).filter((directoryName) => directoryName.startsWith("v")).map((directoryName) => ({
763
+ directoryName,
764
+ ...parseNodeVersion(directoryName)
765
+ })).filter((version) => isNodeVersionCompatibleWithOxlint(version)).sort((versionA, versionB) => versionB.major - versionA.major || versionB.minor - versionA.minor || versionB.patch - versionA.patch);
766
+ if (compatibleVersions.length === 0) return null;
767
+ const bestVersion = compatibleVersions[0];
768
+ const binaryPath = path.join(versionsDirectory, bestVersion.directoryName, "bin", "node");
769
+ return existsSync(binaryPath) ? binaryPath : null;
770
+ };
771
+ const getNodeVersionFromBinary = (binaryPath) => {
772
+ try {
773
+ return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
774
+ } catch {
775
+ return null;
776
+ }
777
+ };
778
+ const installNodeViaNvm = () => {
779
+ const nvmDirectory = getNvmDirectory();
780
+ if (!nvmDirectory) return false;
781
+ const nvmScript = path.join(nvmDirectory, "nvm.sh");
782
+ if (!existsSync(nvmScript)) return false;
783
+ try {
784
+ execSync(`bash -c ". '${nvmScript}' && nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}"`, { stdio: "inherit" });
785
+ return findCompatibleNvmBinary() !== null;
786
+ } catch {
787
+ return false;
788
+ }
789
+ };
790
+ const resolveNodeForOxlint = () => {
791
+ if (isCurrentNodeCompatibleWithOxlint()) return {
792
+ binaryPath: process.execPath,
793
+ isCurrentNode: true,
794
+ version: process.version
795
+ };
796
+ const nvmBinaryPath = findCompatibleNvmBinary();
797
+ if (!nvmBinaryPath) return null;
798
+ const version = getNodeVersionFromBinary(nvmBinaryPath);
799
+ if (!version) return null;
800
+ return {
801
+ binaryPath: nvmBinaryPath,
802
+ isCurrentNode: false,
803
+ version
804
+ };
805
+ };
806
+
566
807
  //#endregion
567
808
  //#region src/utils/run-knip.ts
568
809
  const KNIP_CATEGORY_MAP = {
@@ -617,24 +858,10 @@ const silenced = async (fn) => {
617
858
  console.error = originalError;
618
859
  }
619
860
  };
620
- const findMonorepoRoot = (directory) => {
621
- let currentDirectory = path.dirname(directory);
622
- while (currentDirectory !== path.dirname(currentDirectory)) {
623
- if (fs.existsSync(path.join(currentDirectory, "pnpm-workspace.yaml")) || (() => {
624
- const packageJsonPath = path.join(currentDirectory, "package.json");
625
- if (!fs.existsSync(packageJsonPath)) return false;
626
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
627
- return Array.isArray(packageJson.workspaces) || packageJson.workspaces?.packages;
628
- })()) return currentDirectory;
629
- currentDirectory = path.dirname(currentDirectory);
630
- }
631
- return null;
632
- };
633
861
  const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
634
862
  const extractFailedPluginName = (error) => {
635
863
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
636
864
  };
637
- const MAX_KNIP_RETRIES = 5;
638
865
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
639
866
  const options = await silenced(() => createOptions({
640
867
  cwd: knipCwd,
@@ -1019,7 +1246,70 @@ const resolvePluginPath = () => {
1019
1246
  const resolveDiagnosticCategory = (plugin, rule) => {
1020
1247
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1021
1248
  };
1022
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
1249
+ const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1250
+ const batchIncludePaths = (baseArgs, includePaths) => {
1251
+ const baseArgsLength = estimateArgsLength(baseArgs);
1252
+ const batches = [];
1253
+ let currentBatch = [];
1254
+ let currentBatchLength = baseArgsLength;
1255
+ for (const filePath of includePaths) {
1256
+ const entryLength = filePath.length + 1;
1257
+ if (currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS) {
1258
+ batches.push(currentBatch);
1259
+ currentBatch = [];
1260
+ currentBatchLength = baseArgsLength;
1261
+ }
1262
+ currentBatch.push(filePath);
1263
+ currentBatchLength += entryLength;
1264
+ }
1265
+ if (currentBatch.length > 0) batches.push(currentBatch);
1266
+ return batches;
1267
+ };
1268
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
1269
+ const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
1270
+ const stdoutBuffers = [];
1271
+ const stderrBuffers = [];
1272
+ child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1273
+ child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1274
+ child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1275
+ child.on("close", () => {
1276
+ const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1277
+ if (!output) {
1278
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1279
+ if (stderrOutput) {
1280
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
1281
+ return;
1282
+ }
1283
+ }
1284
+ resolve(output);
1285
+ });
1286
+ });
1287
+ const parseOxlintOutput = (stdout) => {
1288
+ if (!stdout) return [];
1289
+ let output;
1290
+ try {
1291
+ output = JSON.parse(stdout);
1292
+ } catch {
1293
+ throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
1294
+ }
1295
+ return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1296
+ const { plugin, rule } = parseRuleCode(diagnostic.code);
1297
+ const primaryLabel = diagnostic.labels[0];
1298
+ const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
1299
+ return {
1300
+ filePath: diagnostic.filename,
1301
+ plugin,
1302
+ rule,
1303
+ severity: diagnostic.severity,
1304
+ message: cleaned.message,
1305
+ help: cleaned.help,
1306
+ line: primaryLabel?.span.line ?? 0,
1307
+ column: primaryLabel?.span.column ?? 0,
1308
+ category: resolveDiagnosticCategory(plugin, rule)
1309
+ };
1310
+ });
1311
+ };
1312
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath) => {
1023
1313
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1024
1314
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
1025
1315
  const config = createOxlintConfig({
@@ -1030,58 +1320,21 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1030
1320
  const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
1031
1321
  try {
1032
1322
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1033
- const args = [
1323
+ const baseArgs = [
1034
1324
  resolveOxlintBinary(),
1035
1325
  "-c",
1036
1326
  configPath,
1037
1327
  "--format",
1038
1328
  "json"
1039
1329
  ];
1040
- if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
1041
- if (includePaths !== void 0) args.push(...includePaths);
1042
- else args.push(".");
1043
- const stdout = await new Promise((resolve, reject) => {
1044
- const child = spawn(process.execPath, args, { cwd: rootDirectory });
1045
- const stdoutBuffers = [];
1046
- const stderrBuffers = [];
1047
- child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1048
- child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1049
- child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1050
- child.on("close", () => {
1051
- const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1052
- if (!output) {
1053
- const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1054
- if (stderrOutput) {
1055
- reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
1056
- return;
1057
- }
1058
- }
1059
- resolve(output);
1060
- });
1061
- });
1062
- if (!stdout) return [];
1063
- let output;
1064
- try {
1065
- output = JSON.parse(stdout);
1066
- } catch {
1067
- throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
1330
+ if (hasTypeScript) baseArgs.push("--tsconfig", "./tsconfig.json");
1331
+ const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
1332
+ const allDiagnostics = [];
1333
+ for (const batch of fileBatches) {
1334
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
1335
+ allDiagnostics.push(...parseOxlintOutput(stdout));
1068
1336
  }
1069
- return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1070
- const { plugin, rule } = parseRuleCode(diagnostic.code);
1071
- const primaryLabel = diagnostic.labels[0];
1072
- const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
1073
- return {
1074
- filePath: diagnostic.filename,
1075
- plugin,
1076
- rule,
1077
- severity: diagnostic.severity,
1078
- message: cleaned.message,
1079
- help: cleaned.help,
1080
- line: primaryLabel?.span.line ?? 0,
1081
- column: primaryLabel?.span.column ?? 0,
1082
- category: resolveDiagnosticCategory(plugin, rule)
1083
- };
1084
- });
1337
+ return allDiagnostics;
1085
1338
  } finally {
1086
1339
  restoreDisableDirectives();
1087
1340
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
@@ -1189,11 +1442,6 @@ const writeDiagnosticsDirectory = (diagnostics) => {
1189
1442
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
1190
1443
  return outputDirectory;
1191
1444
  };
1192
- const colorizeByScore$1 = (text, score) => {
1193
- if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1194
- if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1195
- return highlighter.error(text);
1196
- };
1197
1445
  const buildScoreBarSegments = (score) => {
1198
1446
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
1199
1447
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
@@ -1208,11 +1456,11 @@ const buildPlainScoreBar = (score) => {
1208
1456
  };
1209
1457
  const buildScoreBar = (score) => {
1210
1458
  const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1211
- return colorizeByScore$1(filledSegment, score) + highlighter.dim(emptySegment);
1459
+ return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
1212
1460
  };
1213
1461
  const printScoreGauge = (score, label) => {
1214
- const scoreDisplay = colorizeByScore$1(`${score}`, score);
1215
- const labelDisplay = colorizeByScore$1(label, score);
1462
+ const scoreDisplay = colorizeByScore(`${score}`, score);
1463
+ const labelDisplay = colorizeByScore(label, score);
1216
1464
  logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
1217
1465
  logger.break();
1218
1466
  logger.log(` ${buildScoreBar(score)}`);
@@ -1226,7 +1474,7 @@ const getDoctorFace = (score) => {
1226
1474
  const printBranding = (score) => {
1227
1475
  if (score !== void 0) {
1228
1476
  const [eyes, mouth] = getDoctorFace(score);
1229
- const colorize = (text) => colorizeByScore$1(text, score);
1477
+ const colorize = (text) => colorizeByScore(text, score);
1230
1478
  logger.log(colorize(" ┌─────┐"));
1231
1479
  logger.log(colorize(` │ ${eyes} │`));
1232
1480
  logger.log(colorize(` │ ${mouth} │`));
@@ -1247,53 +1495,56 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
1247
1495
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
1248
1496
  return `${SHARE_BASE_URL}?${params.toString()}`;
1249
1497
  };
1250
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1498
+ const buildBrandingLines = (scoreResult, noScoreMessage) => {
1499
+ const lines = [];
1500
+ if (scoreResult) {
1501
+ const [eyes, mouth] = getDoctorFace(scoreResult.score);
1502
+ const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
1503
+ lines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1504
+ lines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1505
+ lines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
1506
+ lines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
1507
+ lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1508
+ lines.push(createFramedLine(""));
1509
+ const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1510
+ const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
1511
+ lines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1512
+ lines.push(createFramedLine(""));
1513
+ lines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
1514
+ lines.push(createFramedLine(""));
1515
+ } else {
1516
+ lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1517
+ lines.push(createFramedLine(""));
1518
+ lines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
1519
+ lines.push(createFramedLine(""));
1520
+ }
1521
+ return lines;
1522
+ };
1523
+ const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMilliseconds) => {
1251
1524
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
1252
1525
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
1253
1526
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
1254
1527
  const elapsed = formatElapsedTime(elapsedMilliseconds);
1255
- const summaryLineParts = [];
1256
- const summaryLinePartsPlain = [];
1528
+ const plainParts = [];
1529
+ const renderedParts = [];
1257
1530
  if (errorCount > 0) {
1258
1531
  const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
1259
- summaryLinePartsPlain.push(errorText);
1260
- summaryLineParts.push(highlighter.error(errorText));
1532
+ plainParts.push(errorText);
1533
+ renderedParts.push(highlighter.error(errorText));
1261
1534
  }
1262
1535
  if (warningCount > 0) {
1263
1536
  const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
1264
- summaryLinePartsPlain.push(warningText);
1265
- summaryLineParts.push(highlighter.warn(warningText));
1537
+ plainParts.push(warningText);
1538
+ renderedParts.push(highlighter.warn(warningText));
1266
1539
  }
1267
1540
  const fileCountText = totalSourceFileCount > 0 ? `across ${affectedFileCount}/${totalSourceFileCount} files` : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
1268
1541
  const elapsedTimeText = `in ${elapsed}`;
1269
- summaryLinePartsPlain.push(fileCountText);
1270
- summaryLinePartsPlain.push(elapsedTimeText);
1271
- summaryLineParts.push(highlighter.dim(fileCountText));
1272
- summaryLineParts.push(highlighter.dim(elapsedTimeText));
1273
- const summaryFramedLines = [];
1274
- if (scoreResult) {
1275
- const [eyes, mouth] = getDoctorFace(scoreResult.score);
1276
- const scoreColorizer = (text) => colorizeByScore$1(text, scoreResult.score);
1277
- summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1278
- summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1279
- summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
1280
- summaryFramedLines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
1281
- summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1282
- summaryFramedLines.push(createFramedLine(""));
1283
- const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1284
- const scoreLineRenderedText = `${colorizeByScore$1(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore$1(scoreResult.label, scoreResult.score)}`;
1285
- summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1286
- summaryFramedLines.push(createFramedLine(""));
1287
- summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
1288
- summaryFramedLines.push(createFramedLine(""));
1289
- } else {
1290
- summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1291
- summaryFramedLines.push(createFramedLine(""));
1292
- summaryFramedLines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
1293
- summaryFramedLines.push(createFramedLine(""));
1294
- }
1295
- summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
1296
- printFramedBox(summaryFramedLines);
1542
+ plainParts.push(fileCountText, elapsedTimeText);
1543
+ renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
1544
+ return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
1545
+ };
1546
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1547
+ printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
1297
1548
  try {
1298
1549
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
1299
1550
  logger.break();
@@ -1305,49 +1556,97 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1305
1556
  logger.break();
1306
1557
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1307
1558
  };
1559
+ const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
1560
+ if (!isLintEnabled) return null;
1561
+ const nodeResolution = resolveNodeForOxlint();
1562
+ if (nodeResolution) {
1563
+ if (!nodeResolution.isCurrentNode && !isScoreOnly) {
1564
+ logger.warn(`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`);
1565
+ logger.break();
1566
+ }
1567
+ return nodeResolution.binaryPath;
1568
+ }
1569
+ if (isScoreOnly) return null;
1570
+ logger.warn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
1571
+ if (isNvmInstalled() && process.stdin.isTTY) {
1572
+ const { shouldInstallNode } = await prompts({
1573
+ type: "confirm",
1574
+ name: "shouldInstallNode",
1575
+ message: `Install Node ${OXLINT_RECOMMENDED_NODE_MAJOR} via nvm to enable lint checks?`,
1576
+ initial: true
1577
+ });
1578
+ if (shouldInstallNode) {
1579
+ logger.break();
1580
+ const freshResolution = installNodeViaNvm() ? resolveNodeForOxlint() : null;
1581
+ if (freshResolution) {
1582
+ logger.break();
1583
+ logger.success(`Node ${freshResolution.version} installed. Using it for lint checks.`);
1584
+ logger.break();
1585
+ return freshResolution.binaryPath;
1586
+ }
1587
+ logger.break();
1588
+ logger.warn("Failed to install Node via nvm. Skipping lint checks.");
1589
+ logger.break();
1590
+ return null;
1591
+ }
1592
+ } else if (isNvmInstalled()) logger.dim(` Run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
1593
+ else logger.dim(` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
1594
+ logger.break();
1595
+ return null;
1596
+ };
1597
+ const mergeScanOptions = (inputOptions, userConfig) => ({
1598
+ lint: inputOptions.lint ?? userConfig?.lint ?? true,
1599
+ deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1600
+ verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1601
+ scoreOnly: inputOptions.scoreOnly ?? false,
1602
+ offline: inputOptions.offline ?? false,
1603
+ includePaths: inputOptions.includePaths ?? []
1604
+ });
1605
+ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths) => {
1606
+ const frameworkLabel = formatFrameworkName(projectInfo.framework);
1607
+ const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
1608
+ const completeStep = (message) => {
1609
+ spinner(message).start().succeed(message);
1610
+ };
1611
+ completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
1612
+ completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
1613
+ completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1614
+ completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1615
+ if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
1616
+ else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1617
+ if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
1618
+ logger.break();
1619
+ };
1308
1620
  const scan = async (directory, inputOptions = {}) => {
1309
1621
  const startTime = performance.now();
1310
1622
  const projectInfo = discoverProject(directory);
1311
1623
  const userConfig = loadConfig(directory);
1312
- const options = {
1313
- lint: inputOptions.lint ?? userConfig?.lint ?? true,
1314
- deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1315
- verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1316
- scoreOnly: inputOptions.scoreOnly ?? false,
1317
- offline: inputOptions.offline ?? false,
1318
- includePaths: inputOptions.includePaths
1319
- };
1320
- const includePaths = options.includePaths ?? [];
1624
+ const options = mergeScanOptions(inputOptions, userConfig);
1625
+ const { includePaths } = options;
1321
1626
  const isDiffMode = includePaths.length > 0;
1322
1627
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
1323
- if (!options.scoreOnly) {
1324
- const frameworkLabel = formatFrameworkName(projectInfo.framework);
1325
- const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
1326
- const completeStep = (message) => {
1327
- spinner(message).start().succeed(message);
1328
- };
1329
- completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
1330
- completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
1331
- completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1332
- completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1333
- if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
1334
- else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1335
- if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
1336
- logger.break();
1337
- }
1338
- const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
1339
- const lintPromise = options.lint ? (async () => {
1628
+ if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths);
1629
+ const jsxIncludePaths = computeJsxIncludePaths(includePaths);
1630
+ let didLintFail = false;
1631
+ let didDeadCodeFail = false;
1632
+ const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
1633
+ if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
1634
+ const lintPromise = resolvedNodeBinaryPath ? (async () => {
1340
1635
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1341
1636
  try {
1342
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
1637
+ const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths, resolvedNodeBinaryPath);
1343
1638
  lintSpinner?.succeed("Running lint checks.");
1344
1639
  return lintDiagnostics;
1345
1640
  } catch (error) {
1346
- lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1347
- if (error instanceof Error) {
1348
- logger.error(error.message);
1349
- if (error.stack) logger.dim(error.stack);
1350
- } else logger.error(String(error));
1641
+ didLintFail = true;
1642
+ const errorMessage = error instanceof Error ? error.message : String(error);
1643
+ if (errorMessage.includes("native binding")) {
1644
+ lintSpinner?.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
1645
+ logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
1646
+ } else {
1647
+ lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1648
+ logger.error(errorMessage);
1649
+ }
1351
1650
  return [];
1352
1651
  }
1353
1652
  })() : Promise.resolve([]);
@@ -1358,19 +1657,19 @@ const scan = async (directory, inputOptions = {}) => {
1358
1657
  deadCodeSpinner?.succeed("Detecting dead code.");
1359
1658
  return knipDiagnostics;
1360
1659
  } catch (error) {
1660
+ didDeadCodeFail = true;
1361
1661
  deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1362
1662
  logger.error(String(error));
1363
1663
  return [];
1364
1664
  }
1365
1665
  })() : Promise.resolve([]);
1366
1666
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
1367
- const allDiagnostics = [
1368
- ...lintDiagnostics,
1369
- ...deadCodeDiagnostics,
1370
- ...isDiffMode ? [] : checkReducedMotion(directory)
1371
- ];
1372
- const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
1667
+ const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
1373
1668
  const elapsedMilliseconds = performance.now() - startTime;
1669
+ const skippedChecks = [];
1670
+ if (didLintFail) skippedChecks.push("lint");
1671
+ if (didDeadCodeFail) skippedChecks.push("dead code");
1672
+ const hasSkippedChecks = skippedChecks.length > 0;
1374
1673
  const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1375
1674
  const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
1376
1675
  if (options.scoreOnly) {
@@ -1378,27 +1677,41 @@ const scan = async (directory, inputOptions = {}) => {
1378
1677
  else logger.dim(noScoreMessage);
1379
1678
  return {
1380
1679
  diagnostics,
1381
- scoreResult
1680
+ scoreResult,
1681
+ skippedChecks
1382
1682
  };
1383
1683
  }
1384
1684
  if (diagnostics.length === 0) {
1385
- logger.success("No issues found!");
1685
+ if (hasSkippedChecks) {
1686
+ const skippedLabel = skippedChecks.join(" and ");
1687
+ logger.warn(`No issues detected, but ${skippedLabel} checks failed — results are incomplete.`);
1688
+ } else logger.success("No issues found!");
1386
1689
  logger.break();
1387
- if (scoreResult) {
1690
+ if (hasSkippedChecks) {
1691
+ printBranding();
1692
+ logger.dim(" Score not shown — some checks could not complete.");
1693
+ } else if (scoreResult) {
1388
1694
  printBranding(scoreResult.score);
1389
1695
  printScoreGauge(scoreResult.score, scoreResult.label);
1390
1696
  } else logger.dim(` ${noScoreMessage}`);
1391
1697
  return {
1392
1698
  diagnostics,
1393
- scoreResult
1699
+ scoreResult,
1700
+ skippedChecks
1394
1701
  };
1395
1702
  }
1396
1703
  printDiagnostics(diagnostics, options.verbose);
1397
1704
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1398
1705
  printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
1706
+ if (hasSkippedChecks) {
1707
+ const skippedLabel = skippedChecks.join(" and ");
1708
+ logger.break();
1709
+ logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
1710
+ }
1399
1711
  return {
1400
1712
  diagnostics,
1401
- scoreResult
1713
+ scoreResult,
1714
+ skippedChecks
1402
1715
  };
1403
1716
  };
1404
1717
 
@@ -1496,92 +1809,6 @@ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
1496
1809
  process.exitCode = 1;
1497
1810
  };
1498
1811
 
1499
- //#endregion
1500
- //#region src/utils/should-auto-select-current-choice.ts
1501
- const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
1502
- if (choiceStates.some((choiceState) => choiceState.selected)) return false;
1503
- const currentChoice = choiceStates[cursor];
1504
- return Boolean(currentChoice) && !currentChoice.disabled;
1505
- };
1506
-
1507
- //#endregion
1508
- //#region src/utils/should-select-all-choices.ts
1509
- const shouldSelectAllChoices = (choiceStates) => {
1510
- return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
1511
- };
1512
-
1513
- //#endregion
1514
- //#region src/utils/prompts.ts
1515
- const require = createRequire(import.meta.url);
1516
- const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
1517
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
1518
- let didPatchMultiselectToggleAll = false;
1519
- let didPatchMultiselectSubmit = false;
1520
- let didPatchSelectBanner = false;
1521
- const selectBannerMap = /* @__PURE__ */ new Map();
1522
- const setSelectBanner = (banner, targetIndex) => {
1523
- selectBannerMap.set(targetIndex, banner);
1524
- };
1525
- const clearSelectBanner = () => {
1526
- selectBannerMap.clear();
1527
- };
1528
- const onCancel = () => {
1529
- logger.break();
1530
- logger.log("Cancelled.");
1531
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
1532
- logger.break();
1533
- process.exit(0);
1534
- };
1535
- const patchMultiselectToggleAll = () => {
1536
- if (didPatchMultiselectToggleAll) return;
1537
- didPatchMultiselectToggleAll = true;
1538
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
1539
- multiselectPromptConstructor.prototype.toggleAll = function() {
1540
- const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
1541
- if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
1542
- this.bell();
1543
- return;
1544
- }
1545
- const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
1546
- for (const choiceState of this.value) {
1547
- if (choiceState.disabled) continue;
1548
- choiceState.selected = shouldSelectAllEnabledChoices;
1549
- }
1550
- this.render();
1551
- };
1552
- };
1553
- const patchMultiselectSubmit = () => {
1554
- if (didPatchMultiselectSubmit) return;
1555
- didPatchMultiselectSubmit = true;
1556
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
1557
- const originalSubmit = multiselectPromptConstructor.prototype.submit;
1558
- multiselectPromptConstructor.prototype.submit = function() {
1559
- if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
1560
- originalSubmit.call(this);
1561
- };
1562
- };
1563
- const patchSelectBanner = () => {
1564
- if (didPatchSelectBanner) return;
1565
- didPatchSelectBanner = true;
1566
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
1567
- const promptsClear = require("prompts/lib/util/clear");
1568
- const originalRender = selectConstructor.prototype.render;
1569
- selectConstructor.prototype.render = function() {
1570
- originalRender.call(this);
1571
- const banner = selectBannerMap.get(this.cursor);
1572
- if (!banner || this.closed || this.done) return;
1573
- this.out.write(promptsClear(this.outputText, this.out.columns));
1574
- this.outputText = `${banner}\n\n${this.outputText}`;
1575
- this.out.write(this.outputText);
1576
- };
1577
- };
1578
- const prompts = (questions) => {
1579
- patchMultiselectToggleAll();
1580
- patchMultiselectSubmit();
1581
- patchSelectBanner();
1582
- return basePrompts(questions, { onCancel });
1583
- };
1584
-
1585
1812
  //#endregion
1586
1813
  //#region src/utils/select-projects.ts
1587
1814
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
@@ -1632,8 +1859,43 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
1632
1859
 
1633
1860
  //#endregion
1634
1861
  //#region src/utils/skill-prompt.ts
1635
- const CONFIG_DIRECTORY = join(homedir(), ".react-doctor");
1862
+ const HOME_DIRECTORY = homedir();
1863
+ const CONFIG_DIRECTORY = join(HOME_DIRECTORY, ".react-doctor");
1636
1864
  const CONFIG_FILE = join(CONFIG_DIRECTORY, "config.json");
1865
+ const SKILL_NAME = "react-doctor";
1866
+ const WINDSURF_MARKER = "# React Doctor";
1867
+ const SKILL_DESCRIPTION = "Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project.";
1868
+ const SKILL_BODY = `Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.
1869
+
1870
+ ## Usage
1871
+
1872
+ \`\`\`bash
1873
+ npx -y react-doctor@latest . --verbose --diff
1874
+ \`\`\`
1875
+
1876
+ ## Workflow
1877
+
1878
+ Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.`;
1879
+ const SKILL_CONTENT = `---
1880
+ name: ${SKILL_NAME}
1881
+ description: ${SKILL_DESCRIPTION}
1882
+ version: 1.0.0
1883
+ ---
1884
+
1885
+ # React Doctor
1886
+
1887
+ ${SKILL_BODY}
1888
+ `;
1889
+ const AGENTS_CONTENT = `# React Doctor
1890
+
1891
+ ${SKILL_DESCRIPTION}
1892
+
1893
+ ${SKILL_BODY}
1894
+ `;
1895
+ const CODEX_AGENT_CONFIG = `interface:
1896
+ display_name: "${SKILL_NAME}"
1897
+ short_description: "Diagnose and fix React codebase health issues"
1898
+ `;
1637
1899
  const readSkillPromptConfig = () => {
1638
1900
  try {
1639
1901
  if (!existsSync(CONFIG_FILE)) return {};
@@ -1644,18 +1906,101 @@ const readSkillPromptConfig = () => {
1644
1906
  };
1645
1907
  const writeSkillPromptConfig = (config) => {
1646
1908
  try {
1647
- if (!existsSync(CONFIG_DIRECTORY)) mkdirSync(CONFIG_DIRECTORY, { recursive: true });
1909
+ mkdirSync(CONFIG_DIRECTORY, { recursive: true });
1648
1910
  writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
1649
1911
  } catch {}
1650
1912
  };
1913
+ const writeSkillFiles = (directory) => {
1914
+ mkdirSync(directory, { recursive: true });
1915
+ writeFileSync(join(directory, "SKILL.md"), SKILL_CONTENT);
1916
+ writeFileSync(join(directory, "AGENTS.md"), AGENTS_CONTENT);
1917
+ };
1918
+ const isCommandAvailable = (command) => {
1919
+ try {
1920
+ execSync(`${process.platform === "win32" ? "where" : "which"} ${command}`, { stdio: "ignore" });
1921
+ return true;
1922
+ } catch {
1923
+ return false;
1924
+ }
1925
+ };
1926
+ const SKILL_TARGETS = [
1927
+ {
1928
+ name: "Claude Code",
1929
+ detect: () => existsSync(join(HOME_DIRECTORY, ".claude")),
1930
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".claude", "skills", SKILL_NAME))
1931
+ },
1932
+ {
1933
+ name: "Amp Code",
1934
+ detect: () => existsSync(join(HOME_DIRECTORY, ".amp")),
1935
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".config", "amp", "skills", SKILL_NAME))
1936
+ },
1937
+ {
1938
+ name: "Cursor",
1939
+ detect: () => existsSync(join(HOME_DIRECTORY, ".cursor")),
1940
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".cursor", "skills", SKILL_NAME))
1941
+ },
1942
+ {
1943
+ name: "OpenCode",
1944
+ detect: () => isCommandAvailable("opencode") || existsSync(join(HOME_DIRECTORY, ".config", "opencode")),
1945
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".config", "opencode", "skills", SKILL_NAME))
1946
+ },
1947
+ {
1948
+ name: "Windsurf",
1949
+ detect: () => existsSync(join(HOME_DIRECTORY, ".codeium")) || existsSync(join(HOME_DIRECTORY, "Library", "Application Support", "Windsurf")),
1950
+ install: () => {
1951
+ const memoriesDirectory = join(HOME_DIRECTORY, ".codeium", "windsurf", "memories");
1952
+ mkdirSync(memoriesDirectory, { recursive: true });
1953
+ const rulesFile = join(memoriesDirectory, "global_rules.md");
1954
+ if (existsSync(rulesFile)) {
1955
+ if (readFileSync(rulesFile, "utf-8").includes(WINDSURF_MARKER)) return;
1956
+ appendFileSync(rulesFile, `\n${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
1957
+ } else writeFileSync(rulesFile, `${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
1958
+ }
1959
+ },
1960
+ {
1961
+ name: "Antigravity",
1962
+ detect: () => isCommandAvailable("agy") || existsSync(join(HOME_DIRECTORY, ".gemini", "antigravity")),
1963
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "antigravity", "skills", SKILL_NAME))
1964
+ },
1965
+ {
1966
+ name: "Gemini CLI",
1967
+ detect: () => isCommandAvailable("gemini") || existsSync(join(HOME_DIRECTORY, ".gemini")),
1968
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "skills", SKILL_NAME))
1969
+ },
1970
+ {
1971
+ name: "Codex",
1972
+ detect: () => isCommandAvailable("codex") || existsSync(join(HOME_DIRECTORY, ".codex")),
1973
+ install: () => {
1974
+ const skillDirectory = join(HOME_DIRECTORY, ".codex", "skills", SKILL_NAME);
1975
+ writeSkillFiles(skillDirectory);
1976
+ const agentsDirectory = join(skillDirectory, "agents");
1977
+ mkdirSync(agentsDirectory, { recursive: true });
1978
+ writeFileSync(join(agentsDirectory, "openai.yaml"), CODEX_AGENT_CONFIG);
1979
+ }
1980
+ }
1981
+ ];
1651
1982
  const installSkill = () => {
1983
+ let installedCount = 0;
1984
+ for (const target of SKILL_TARGETS) {
1985
+ if (!target.detect()) continue;
1986
+ try {
1987
+ target.install();
1988
+ logger.log(` ${highlighter.success("✔")} ${target.name}`);
1989
+ installedCount++;
1990
+ } catch {
1991
+ logger.dim(` ✗ ${target.name} (failed)`);
1992
+ }
1993
+ }
1652
1994
  try {
1653
- execSync(`curl -fsSL ${INSTALL_SKILL_URL} | bash`, { stdio: "inherit" });
1995
+ writeSkillFiles(join(".agents", SKILL_NAME));
1996
+ logger.log(` ${highlighter.success("✔")} .agents/`);
1997
+ installedCount++;
1654
1998
  } catch {
1655
- logger.break();
1656
- logger.dim("Skill install failed. You can install manually:");
1657
- logger.dim(` curl -fsSL ${INSTALL_SKILL_URL} | bash`);
1999
+ logger.dim(" ✗ .agents/ (failed)");
1658
2000
  }
2001
+ logger.break();
2002
+ if (installedCount === 0) logger.dim("No supported tools detected.");
2003
+ else logger.success("Done! The skill will activate when working on React projects.");
1659
2004
  };
1660
2005
  const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1661
2006
  const config = readSkillPromptConfig();
@@ -1684,7 +2029,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1684
2029
 
1685
2030
  //#endregion
1686
2031
  //#region src/cli.ts
1687
- const VERSION = "0.0.26";
2032
+ const VERSION = "0.0.28";
1688
2033
  const exitWithFixHint = () => {
1689
2034
  logger.break();
1690
2035
  logger.log("Cancelled.");
@@ -1694,6 +2039,26 @@ const exitWithFixHint = () => {
1694
2039
  };
1695
2040
  process.on("SIGINT", exitWithFixHint);
1696
2041
  process.on("SIGTERM", exitWithFixHint);
2042
+ const AUTOMATED_ENVIRONMENT_VARIABLES = [
2043
+ "CI",
2044
+ "CLAUDECODE",
2045
+ "CURSOR_AGENT",
2046
+ "CODEX_CI",
2047
+ "OPENCODE",
2048
+ "AMP_HOME",
2049
+ "AMI"
2050
+ ];
2051
+ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
2052
+ const resolveCliScanOptions = (flags, userConfig, programInstance) => {
2053
+ const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
2054
+ return {
2055
+ lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
2056
+ deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
2057
+ verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
2058
+ scoreOnly: flags.score,
2059
+ offline: flags.offline
2060
+ };
2061
+ };
1697
2062
  const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
1698
2063
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
1699
2064
  if (diffInfo) return true;
@@ -1725,27 +2090,11 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1725
2090
  logger.log(`react-doctor v${VERSION}`);
1726
2091
  logger.break();
1727
2092
  }
1728
- const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
1729
- const scanOptions = {
1730
- lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
1731
- deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
1732
- verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
1733
- scoreOnly: isScoreOnly,
1734
- offline: flags.offline
1735
- };
1736
- const isAutomatedEnvironment = [
1737
- process.env.CI,
1738
- process.env.CLAUDECODE,
1739
- process.env.CURSOR_AGENT,
1740
- process.env.CODEX_CI,
1741
- process.env.OPENCODE,
1742
- process.env.AMP_HOME,
1743
- process.env.AMI
1744
- ].some(Boolean);
1745
- const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
2093
+ const scanOptions = resolveCliScanOptions(flags, userConfig, program);
2094
+ const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY;
1746
2095
  const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
1747
2096
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
1748
- const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
2097
+ const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
1749
2098
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
1750
2099
  const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
1751
2100
  const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
@@ -1794,15 +2143,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1794
2143
  ${highlighter.dim("Learn more:")}
1795
2144
  ${highlighter.info("https://github.com/millionco/react-doctor")}
1796
2145
  `);
1797
- const AMI_WEBSITE_URL = "https://ami.dev";
1798
- const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
1799
- const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
1800
- const colorizeByScore = (text, score) => {
1801
- if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1802
- if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1803
- return highlighter.error(text);
1804
- };
1805
- const DEEPLINK_FIX_PROMPT = "Run `npx -y react-doctor@latest .` to diagnose issues, then fix all reported issues one by one. After applying fixes, run it again to verify the results improved.";
2146
+ const DEEPLINK_FIX_PROMPT = "/{slash-command:ami:react-doctor}";
1806
2147
  const isAmiInstalled = () => {
1807
2148
  if (process.platform === "darwin") return existsSync("/Applications/Ami.app") || existsSync(path.join(os.homedir(), "Applications", "Ami.app"));
1808
2149
  if (process.platform === "win32") {
@@ -1840,6 +2181,7 @@ const buildDeeplinkParams = (directory) => {
1840
2181
  params.set("prompt", DEEPLINK_FIX_PROMPT);
1841
2182
  params.set("mode", "agent");
1842
2183
  params.set("autoSubmit", "true");
2184
+ params.set("source", "react-doctor");
1843
2185
  return params;
1844
2186
  };
1845
2187
  const buildDeeplink = (directory) => `ami://open-project?${buildDeeplinkParams(directory).toString()}`;
@@ -1911,7 +2253,7 @@ const maybePromptFix = async (directory, diagnostics, estimatedScoreResult) => {
1911
2253
  name: "fixMethod",
1912
2254
  message: "Fix issues?",
1913
2255
  choices: [{
1914
- title: "Use ami.dev (recommended)",
2256
+ title: "Use Ami (recommended)",
1915
2257
  description: "Optimized coding agent for React Doctor",
1916
2258
  value: FIX_METHOD_AMI
1917
2259
  }, {