react-doctor 0.0.25 → 0.0.27

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,8 +1,8 @@
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, writeFileSync } from "node:fs";
5
- import os, { tmpdir } from "node:os";
4
+ import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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";
@@ -29,17 +29,71 @@ 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 FETCH_TIMEOUT_MS = 1e4;
32
33
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
34
+ const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
33
35
  const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
34
36
  const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
35
37
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
36
-
37
- //#endregion
38
- //#region src/utils/calculate-score.ts
39
38
  const ERROR_RULE_PENALTY = 1.5;
40
39
  const WARNING_RULE_PENALTY = .75;
41
40
  const ERROR_ESTIMATED_FIX_RATE = .85;
42
41
  const WARNING_ESTIMATED_FIX_RATE = .8;
42
+ const MAX_KNIP_RETRIES = 5;
43
+ const AMI_WEBSITE_URL = "https://ami.dev";
44
+ const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
45
+ const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
46
+
47
+ //#endregion
48
+ //#region src/utils/proxy-fetch.ts
49
+ const readNpmConfigValue = (key) => {
50
+ try {
51
+ const value = execSync(`npm config get ${key}`, {
52
+ encoding: "utf-8",
53
+ stdio: [
54
+ "pipe",
55
+ "pipe",
56
+ "ignore"
57
+ ]
58
+ }).trim();
59
+ if (value && value !== "null" && value !== "undefined") return value;
60
+ } catch {}
61
+ };
62
+ const resolveProxyUrl = () => process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? readNpmConfigValue("https-proxy") ?? readNpmConfigValue("proxy");
63
+ let isProxyUrlResolved = false;
64
+ let resolvedProxyUrl;
65
+ const getProxyUrl = () => {
66
+ if (isProxyUrlResolved) return resolvedProxyUrl;
67
+ isProxyUrlResolved = true;
68
+ resolvedProxyUrl = resolveProxyUrl();
69
+ return resolvedProxyUrl;
70
+ };
71
+ const createProxyDispatcher = async (proxyUrl) => {
72
+ try {
73
+ const { ProxyAgent } = await import("undici");
74
+ return new ProxyAgent(proxyUrl);
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ const proxyFetch = async (url, init) => {
80
+ const controller = new AbortController();
81
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
82
+ try {
83
+ const proxyUrl = getProxyUrl();
84
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
85
+ return await fetch(url, {
86
+ ...init,
87
+ signal: controller.signal,
88
+ ...dispatcher ? { dispatcher } : {}
89
+ });
90
+ } finally {
91
+ clearTimeout(timeoutId);
92
+ }
93
+ };
94
+
95
+ //#endregion
96
+ //#region src/utils/calculate-score.ts
43
97
  const getScoreLabel = (score) => {
44
98
  if (score >= SCORE_GOOD_THRESHOLD) return "Great";
45
99
  if (score >= SCORE_OK_THRESHOLD) return "Needs work";
@@ -75,7 +129,7 @@ const estimateScoreLocally = (diagnostics) => {
75
129
  };
76
130
  const calculateScore = async (diagnostics) => {
77
131
  try {
78
- const response = await fetch(SCORE_API_URL, {
132
+ const response = await proxyFetch(SCORE_API_URL, {
79
133
  method: "POST",
80
134
  headers: { "Content-Type": "application/json" },
81
135
  body: JSON.stringify({ diagnostics })
@@ -88,7 +142,7 @@ const calculateScore = async (diagnostics) => {
88
142
  };
89
143
  const fetchEstimatedScore = async (diagnostics) => {
90
144
  try {
91
- const response = await fetch(ESTIMATE_SCORE_API_URL, {
145
+ const response = await proxyFetch(ESTIMATE_SCORE_API_URL, {
92
146
  method: "POST",
93
147
  headers: { "Content-Type": "application/json" },
94
148
  body: JSON.stringify({ diagnostics })
@@ -100,6 +154,24 @@ const fetchEstimatedScore = async (diagnostics) => {
100
154
  }
101
155
  };
102
156
 
157
+ //#endregion
158
+ //#region src/utils/highlighter.ts
159
+ const highlighter = {
160
+ error: pc.red,
161
+ warn: pc.yellow,
162
+ info: pc.cyan,
163
+ success: pc.green,
164
+ dim: pc.dim
165
+ };
166
+
167
+ //#endregion
168
+ //#region src/utils/colorize-by-score.ts
169
+ const colorizeByScore = (text, score) => {
170
+ if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
171
+ if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
172
+ return highlighter.error(text);
173
+ };
174
+
103
175
  //#endregion
104
176
  //#region src/plugin/constants.ts
105
177
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
@@ -150,6 +222,79 @@ const checkReducedMotion = (rootDirectory) => {
150
222
  }
151
223
  };
152
224
 
225
+ //#endregion
226
+ //#region src/utils/match-glob-pattern.ts
227
+ const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
228
+ const compileGlobPattern = (pattern) => {
229
+ const normalizedPattern = pattern.replace(/\\/g, "/");
230
+ let regexSource = "^";
231
+ let characterIndex = 0;
232
+ while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
233
+ regexSource += "(?:.+/)?";
234
+ characterIndex += 3;
235
+ } else {
236
+ regexSource += ".*";
237
+ characterIndex += 2;
238
+ }
239
+ else if (normalizedPattern[characterIndex] === "*") {
240
+ regexSource += "[^/]*";
241
+ characterIndex++;
242
+ } else if (normalizedPattern[characterIndex] === "?") {
243
+ regexSource += "[^/]";
244
+ characterIndex++;
245
+ } else {
246
+ regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
247
+ characterIndex++;
248
+ }
249
+ regexSource += "$";
250
+ return new RegExp(regexSource);
251
+ };
252
+
253
+ //#endregion
254
+ //#region src/utils/filter-diagnostics.ts
255
+ const filterIgnoredDiagnostics = (diagnostics, config) => {
256
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
257
+ const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
258
+ if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
259
+ return diagnostics.filter((diagnostic) => {
260
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
261
+ if (ignoredRules.has(ruleIdentifier)) return false;
262
+ const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
263
+ if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
264
+ return true;
265
+ });
266
+ };
267
+
268
+ //#endregion
269
+ //#region src/utils/combine-diagnostics.ts
270
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
271
+ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
272
+ const allDiagnostics = [
273
+ ...lintDiagnostics,
274
+ ...deadCodeDiagnostics,
275
+ ...isDiffMode ? [] : checkReducedMotion(directory)
276
+ ];
277
+ return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
278
+ };
279
+
280
+ //#endregion
281
+ //#region src/utils/find-monorepo-root.ts
282
+ const isMonorepoRoot = (directory) => {
283
+ if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
284
+ const packageJsonPath = path.join(directory, "package.json");
285
+ if (!fs.existsSync(packageJsonPath)) return false;
286
+ const packageJson = readPackageJson(packageJsonPath);
287
+ return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
288
+ };
289
+ const findMonorepoRoot = (startDirectory) => {
290
+ let currentDirectory = path.dirname(startDirectory);
291
+ while (currentDirectory !== path.dirname(currentDirectory)) {
292
+ if (isMonorepoRoot(currentDirectory)) return currentDirectory;
293
+ currentDirectory = path.dirname(currentDirectory);
294
+ }
295
+ return null;
296
+ };
297
+
153
298
  //#endregion
154
299
  //#region src/utils/discover-project.ts
155
300
  const REACT_COMPILER_PACKAGES = new Set([
@@ -260,27 +405,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
260
405
  if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
261
406
  return [];
262
407
  }
263
- const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, cleanPattern.indexOf("*")));
408
+ const wildcardIndex = cleanPattern.indexOf("*");
409
+ const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
410
+ const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
264
411
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
265
- return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
266
- };
267
- const isMonorepoRoot = (directory) => {
268
- if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
269
- const packageJsonPath = path.join(directory, "package.json");
270
- if (!fs.existsSync(packageJsonPath)) return false;
271
- const packageJson = readPackageJson(packageJsonPath);
272
- return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
273
- };
274
- const findMonorepoRoot$1 = (startDirectory) => {
275
- let currentDirectory = path.dirname(startDirectory);
276
- while (currentDirectory !== path.dirname(currentDirectory)) {
277
- if (isMonorepoRoot(currentDirectory)) return currentDirectory;
278
- currentDirectory = path.dirname(currentDirectory);
279
- }
280
- return null;
412
+ 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")));
281
413
  };
282
414
  const findDependencyInfoFromMonorepoRoot = (directory) => {
283
- const monorepoRoot = findMonorepoRoot$1(directory);
415
+ const monorepoRoot = findMonorepoRoot(directory);
284
416
  if (!monorepoRoot) return {
285
417
  reactVersion: null,
286
418
  framework: "unknown"
@@ -409,59 +541,6 @@ const discoverProject = (directory) => {
409
541
  };
410
542
  };
411
543
 
412
- //#endregion
413
- //#region src/utils/match-glob-pattern.ts
414
- const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
415
- const compileGlobPattern = (pattern) => {
416
- const normalizedPattern = pattern.replace(/\\/g, "/");
417
- let regexSource = "^";
418
- let characterIndex = 0;
419
- while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
420
- regexSource += "(?:.+/)?";
421
- characterIndex += 3;
422
- } else {
423
- regexSource += ".*";
424
- characterIndex += 2;
425
- }
426
- else if (normalizedPattern[characterIndex] === "*") {
427
- regexSource += "[^/]*";
428
- characterIndex++;
429
- } else if (normalizedPattern[characterIndex] === "?") {
430
- regexSource += "[^/]";
431
- characterIndex++;
432
- } else {
433
- regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
434
- characterIndex++;
435
- }
436
- regexSource += "$";
437
- return new RegExp(regexSource);
438
- };
439
-
440
- //#endregion
441
- //#region src/utils/filter-diagnostics.ts
442
- const filterIgnoredDiagnostics = (diagnostics, config) => {
443
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
444
- const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
445
- if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
446
- return diagnostics.filter((diagnostic) => {
447
- const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
448
- if (ignoredRules.has(ruleIdentifier)) return false;
449
- const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
450
- if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
451
- return true;
452
- });
453
- };
454
-
455
- //#endregion
456
- //#region src/utils/highlighter.ts
457
- const highlighter = {
458
- error: pc.red,
459
- warn: pc.yellow,
460
- info: pc.cyan,
461
- success: pc.green,
462
- dim: pc.dim
463
- };
464
-
465
544
  //#endregion
466
545
  //#region src/utils/logger.ts
467
546
  const logger = {
@@ -616,24 +695,10 @@ const silenced = async (fn) => {
616
695
  console.error = originalError;
617
696
  }
618
697
  };
619
- const findMonorepoRoot = (directory) => {
620
- let currentDirectory = path.dirname(directory);
621
- while (currentDirectory !== path.dirname(currentDirectory)) {
622
- if (fs.existsSync(path.join(currentDirectory, "pnpm-workspace.yaml")) || (() => {
623
- const packageJsonPath = path.join(currentDirectory, "package.json");
624
- if (!fs.existsSync(packageJsonPath)) return false;
625
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
626
- return Array.isArray(packageJson.workspaces) || packageJson.workspaces?.packages;
627
- })()) return currentDirectory;
628
- currentDirectory = path.dirname(currentDirectory);
629
- }
630
- return null;
631
- };
632
698
  const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
633
699
  const extractFailedPluginName = (error) => {
634
700
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
635
701
  };
636
- const MAX_KNIP_RETRIES = 5;
637
702
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
638
703
  const options = await silenced(() => createOptions({
639
704
  cwd: knipCwd,
@@ -1018,6 +1083,69 @@ const resolvePluginPath = () => {
1018
1083
  const resolveDiagnosticCategory = (plugin, rule) => {
1019
1084
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1020
1085
  };
1086
+ const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1087
+ const batchIncludePaths = (baseArgs, includePaths) => {
1088
+ const baseArgsLength = estimateArgsLength(baseArgs);
1089
+ const batches = [];
1090
+ let currentBatch = [];
1091
+ let currentBatchLength = baseArgsLength;
1092
+ for (const filePath of includePaths) {
1093
+ const entryLength = filePath.length + 1;
1094
+ if (currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS) {
1095
+ batches.push(currentBatch);
1096
+ currentBatch = [];
1097
+ currentBatchLength = baseArgsLength;
1098
+ }
1099
+ currentBatch.push(filePath);
1100
+ currentBatchLength += entryLength;
1101
+ }
1102
+ if (currentBatch.length > 0) batches.push(currentBatch);
1103
+ return batches;
1104
+ };
1105
+ const spawnOxlint = (args, rootDirectory) => new Promise((resolve, reject) => {
1106
+ const child = spawn(process.execPath, args, { cwd: rootDirectory });
1107
+ const stdoutBuffers = [];
1108
+ const stderrBuffers = [];
1109
+ child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1110
+ child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1111
+ child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1112
+ child.on("close", () => {
1113
+ const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1114
+ if (!output) {
1115
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1116
+ if (stderrOutput) {
1117
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
1118
+ return;
1119
+ }
1120
+ }
1121
+ resolve(output);
1122
+ });
1123
+ });
1124
+ const parseOxlintOutput = (stdout) => {
1125
+ if (!stdout) return [];
1126
+ let output;
1127
+ try {
1128
+ output = JSON.parse(stdout);
1129
+ } catch {
1130
+ throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
1131
+ }
1132
+ return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1133
+ const { plugin, rule } = parseRuleCode(diagnostic.code);
1134
+ const primaryLabel = diagnostic.labels[0];
1135
+ const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
1136
+ return {
1137
+ filePath: diagnostic.filename,
1138
+ plugin,
1139
+ rule,
1140
+ severity: diagnostic.severity,
1141
+ message: cleaned.message,
1142
+ help: cleaned.help,
1143
+ line: primaryLabel?.span.line ?? 0,
1144
+ column: primaryLabel?.span.column ?? 0,
1145
+ category: resolveDiagnosticCategory(plugin, rule)
1146
+ };
1147
+ });
1148
+ };
1021
1149
  const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
1022
1150
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1023
1151
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
@@ -1029,58 +1157,21 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1029
1157
  const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
1030
1158
  try {
1031
1159
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1032
- const args = [
1160
+ const baseArgs = [
1033
1161
  resolveOxlintBinary(),
1034
1162
  "-c",
1035
1163
  configPath,
1036
1164
  "--format",
1037
1165
  "json"
1038
1166
  ];
1039
- if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
1040
- if (includePaths !== void 0) args.push(...includePaths);
1041
- else args.push(".");
1042
- const stdout = await new Promise((resolve, reject) => {
1043
- const child = spawn(process.execPath, args, { cwd: rootDirectory });
1044
- const stdoutBuffers = [];
1045
- const stderrBuffers = [];
1046
- child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1047
- child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1048
- child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1049
- child.on("close", () => {
1050
- const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1051
- if (!output) {
1052
- const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1053
- if (stderrOutput) {
1054
- reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
1055
- return;
1056
- }
1057
- }
1058
- resolve(output);
1059
- });
1060
- });
1061
- if (!stdout) return [];
1062
- let output;
1063
- try {
1064
- output = JSON.parse(stdout);
1065
- } catch {
1066
- throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
1167
+ if (hasTypeScript) baseArgs.push("--tsconfig", "./tsconfig.json");
1168
+ const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
1169
+ const allDiagnostics = [];
1170
+ for (const batch of fileBatches) {
1171
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory);
1172
+ allDiagnostics.push(...parseOxlintOutput(stdout));
1067
1173
  }
1068
- return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1069
- const { plugin, rule } = parseRuleCode(diagnostic.code);
1070
- const primaryLabel = diagnostic.labels[0];
1071
- const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
1072
- return {
1073
- filePath: diagnostic.filename,
1074
- plugin,
1075
- rule,
1076
- severity: diagnostic.severity,
1077
- message: cleaned.message,
1078
- help: cleaned.help,
1079
- line: primaryLabel?.span.line ?? 0,
1080
- column: primaryLabel?.span.column ?? 0,
1081
- category: resolveDiagnosticCategory(plugin, rule)
1082
- };
1083
- });
1174
+ return allDiagnostics;
1084
1175
  } finally {
1085
1176
  restoreDisableDirectives();
1086
1177
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
@@ -1188,11 +1279,6 @@ const writeDiagnosticsDirectory = (diagnostics) => {
1188
1279
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
1189
1280
  return outputDirectory;
1190
1281
  };
1191
- const colorizeByScore$1 = (text, score) => {
1192
- if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1193
- if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1194
- return highlighter.error(text);
1195
- };
1196
1282
  const buildScoreBarSegments = (score) => {
1197
1283
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
1198
1284
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
@@ -1207,11 +1293,11 @@ const buildPlainScoreBar = (score) => {
1207
1293
  };
1208
1294
  const buildScoreBar = (score) => {
1209
1295
  const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1210
- return colorizeByScore$1(filledSegment, score) + highlighter.dim(emptySegment);
1296
+ return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
1211
1297
  };
1212
1298
  const printScoreGauge = (score, label) => {
1213
- const scoreDisplay = colorizeByScore$1(`${score}`, score);
1214
- const labelDisplay = colorizeByScore$1(label, score);
1299
+ const scoreDisplay = colorizeByScore(`${score}`, score);
1300
+ const labelDisplay = colorizeByScore(label, score);
1215
1301
  logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
1216
1302
  logger.break();
1217
1303
  logger.log(` ${buildScoreBar(score)}`);
@@ -1225,7 +1311,7 @@ const getDoctorFace = (score) => {
1225
1311
  const printBranding = (score) => {
1226
1312
  if (score !== void 0) {
1227
1313
  const [eyes, mouth] = getDoctorFace(score);
1228
- const colorize = (text) => colorizeByScore$1(text, score);
1314
+ const colorize = (text) => colorizeByScore(text, score);
1229
1315
  logger.log(colorize(" ┌─────┐"));
1230
1316
  logger.log(colorize(` │ ${eyes} │`));
1231
1317
  logger.log(colorize(` │ ${mouth} │`));
@@ -1246,53 +1332,56 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
1246
1332
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
1247
1333
  return `${SHARE_BASE_URL}?${params.toString()}`;
1248
1334
  };
1249
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1335
+ const buildBrandingLines = (scoreResult, noScoreMessage) => {
1336
+ const lines = [];
1337
+ if (scoreResult) {
1338
+ const [eyes, mouth] = getDoctorFace(scoreResult.score);
1339
+ const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
1340
+ lines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1341
+ lines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1342
+ lines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
1343
+ lines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
1344
+ lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1345
+ lines.push(createFramedLine(""));
1346
+ const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1347
+ const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
1348
+ lines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1349
+ lines.push(createFramedLine(""));
1350
+ lines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
1351
+ lines.push(createFramedLine(""));
1352
+ } else {
1353
+ lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1354
+ lines.push(createFramedLine(""));
1355
+ lines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
1356
+ lines.push(createFramedLine(""));
1357
+ }
1358
+ return lines;
1359
+ };
1360
+ const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMilliseconds) => {
1250
1361
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
1251
1362
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
1252
1363
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
1253
1364
  const elapsed = formatElapsedTime(elapsedMilliseconds);
1254
- const summaryLineParts = [];
1255
- const summaryLinePartsPlain = [];
1365
+ const plainParts = [];
1366
+ const renderedParts = [];
1256
1367
  if (errorCount > 0) {
1257
1368
  const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
1258
- summaryLinePartsPlain.push(errorText);
1259
- summaryLineParts.push(highlighter.error(errorText));
1369
+ plainParts.push(errorText);
1370
+ renderedParts.push(highlighter.error(errorText));
1260
1371
  }
1261
1372
  if (warningCount > 0) {
1262
1373
  const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
1263
- summaryLinePartsPlain.push(warningText);
1264
- summaryLineParts.push(highlighter.warn(warningText));
1374
+ plainParts.push(warningText);
1375
+ renderedParts.push(highlighter.warn(warningText));
1265
1376
  }
1266
1377
  const fileCountText = totalSourceFileCount > 0 ? `across ${affectedFileCount}/${totalSourceFileCount} files` : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
1267
1378
  const elapsedTimeText = `in ${elapsed}`;
1268
- summaryLinePartsPlain.push(fileCountText);
1269
- summaryLinePartsPlain.push(elapsedTimeText);
1270
- summaryLineParts.push(highlighter.dim(fileCountText));
1271
- summaryLineParts.push(highlighter.dim(elapsedTimeText));
1272
- const summaryFramedLines = [];
1273
- if (scoreResult) {
1274
- const [eyes, mouth] = getDoctorFace(scoreResult.score);
1275
- const scoreColorizer = (text) => colorizeByScore$1(text, scoreResult.score);
1276
- summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1277
- summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1278
- summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
1279
- summaryFramedLines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
1280
- summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1281
- summaryFramedLines.push(createFramedLine(""));
1282
- const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1283
- const scoreLineRenderedText = `${colorizeByScore$1(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore$1(scoreResult.label, scoreResult.score)}`;
1284
- summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1285
- summaryFramedLines.push(createFramedLine(""));
1286
- summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
1287
- summaryFramedLines.push(createFramedLine(""));
1288
- } else {
1289
- summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1290
- summaryFramedLines.push(createFramedLine(""));
1291
- summaryFramedLines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
1292
- summaryFramedLines.push(createFramedLine(""));
1293
- }
1294
- summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
1295
- printFramedBox(summaryFramedLines);
1379
+ plainParts.push(fileCountText, elapsedTimeText);
1380
+ renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
1381
+ return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
1382
+ };
1383
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1384
+ printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
1296
1385
  try {
1297
1386
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
1298
1387
  logger.break();
@@ -1304,37 +1393,41 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1304
1393
  logger.break();
1305
1394
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1306
1395
  };
1396
+ const mergeScanOptions = (inputOptions, userConfig) => ({
1397
+ lint: inputOptions.lint ?? userConfig?.lint ?? true,
1398
+ deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1399
+ verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1400
+ scoreOnly: inputOptions.scoreOnly ?? false,
1401
+ offline: inputOptions.offline ?? false,
1402
+ includePaths: inputOptions.includePaths ?? []
1403
+ });
1404
+ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths) => {
1405
+ const frameworkLabel = formatFrameworkName(projectInfo.framework);
1406
+ const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
1407
+ const completeStep = (message) => {
1408
+ spinner(message).start().succeed(message);
1409
+ };
1410
+ completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
1411
+ completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
1412
+ completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1413
+ completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1414
+ if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
1415
+ else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1416
+ if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
1417
+ logger.break();
1418
+ };
1307
1419
  const scan = async (directory, inputOptions = {}) => {
1308
1420
  const startTime = performance.now();
1309
1421
  const projectInfo = discoverProject(directory);
1310
1422
  const userConfig = loadConfig(directory);
1311
- const options = {
1312
- lint: inputOptions.lint ?? userConfig?.lint ?? true,
1313
- deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1314
- verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1315
- scoreOnly: inputOptions.scoreOnly ?? false,
1316
- offline: inputOptions.offline ?? false,
1317
- includePaths: inputOptions.includePaths
1318
- };
1319
- const includePaths = options.includePaths ?? [];
1423
+ const options = mergeScanOptions(inputOptions, userConfig);
1424
+ const { includePaths } = options;
1320
1425
  const isDiffMode = includePaths.length > 0;
1321
1426
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
1322
- if (!options.scoreOnly) {
1323
- const frameworkLabel = formatFrameworkName(projectInfo.framework);
1324
- const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
1325
- const completeStep = (message) => {
1326
- spinner(message).start().succeed(message);
1327
- };
1328
- completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
1329
- completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
1330
- completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1331
- completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1332
- if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
1333
- else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1334
- if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
1335
- logger.break();
1336
- }
1337
- const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
1427
+ if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths);
1428
+ const jsxIncludePaths = computeJsxIncludePaths(includePaths);
1429
+ let didLintFail = false;
1430
+ let didDeadCodeFail = false;
1338
1431
  const lintPromise = options.lint ? (async () => {
1339
1432
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1340
1433
  try {
@@ -1342,6 +1435,7 @@ const scan = async (directory, inputOptions = {}) => {
1342
1435
  lintSpinner?.succeed("Running lint checks.");
1343
1436
  return lintDiagnostics;
1344
1437
  } catch (error) {
1438
+ didLintFail = true;
1345
1439
  lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1346
1440
  if (error instanceof Error) {
1347
1441
  logger.error(error.message);
@@ -1357,19 +1451,19 @@ const scan = async (directory, inputOptions = {}) => {
1357
1451
  deadCodeSpinner?.succeed("Detecting dead code.");
1358
1452
  return knipDiagnostics;
1359
1453
  } catch (error) {
1454
+ didDeadCodeFail = true;
1360
1455
  deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1361
1456
  logger.error(String(error));
1362
1457
  return [];
1363
1458
  }
1364
1459
  })() : Promise.resolve([]);
1365
1460
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
1366
- const allDiagnostics = [
1367
- ...lintDiagnostics,
1368
- ...deadCodeDiagnostics,
1369
- ...isDiffMode ? [] : checkReducedMotion(directory)
1370
- ];
1371
- const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
1461
+ const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
1372
1462
  const elapsedMilliseconds = performance.now() - startTime;
1463
+ const skippedChecks = [];
1464
+ if (didLintFail) skippedChecks.push("lint");
1465
+ if (didDeadCodeFail) skippedChecks.push("dead code");
1466
+ const hasSkippedChecks = skippedChecks.length > 0;
1373
1467
  const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1374
1468
  const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
1375
1469
  if (options.scoreOnly) {
@@ -1377,27 +1471,41 @@ const scan = async (directory, inputOptions = {}) => {
1377
1471
  else logger.dim(noScoreMessage);
1378
1472
  return {
1379
1473
  diagnostics,
1380
- scoreResult
1474
+ scoreResult,
1475
+ skippedChecks
1381
1476
  };
1382
1477
  }
1383
1478
  if (diagnostics.length === 0) {
1384
- logger.success("No issues found!");
1479
+ if (hasSkippedChecks) {
1480
+ const skippedLabel = skippedChecks.join(" and ");
1481
+ logger.warn(`No issues detected, but ${skippedLabel} checks failed — results are incomplete.`);
1482
+ } else logger.success("No issues found!");
1385
1483
  logger.break();
1386
- if (scoreResult) {
1484
+ if (hasSkippedChecks) {
1485
+ printBranding();
1486
+ logger.dim(" Score not shown — some checks could not complete.");
1487
+ } else if (scoreResult) {
1387
1488
  printBranding(scoreResult.score);
1388
1489
  printScoreGauge(scoreResult.score, scoreResult.label);
1389
1490
  } else logger.dim(` ${noScoreMessage}`);
1390
1491
  return {
1391
1492
  diagnostics,
1392
- scoreResult
1493
+ scoreResult,
1494
+ skippedChecks
1393
1495
  };
1394
1496
  }
1395
1497
  printDiagnostics(diagnostics, options.verbose);
1396
1498
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1397
1499
  printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
1500
+ if (hasSkippedChecks) {
1501
+ const skippedLabel = skippedChecks.join(" and ");
1502
+ logger.break();
1503
+ logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
1504
+ }
1398
1505
  return {
1399
1506
  diagnostics,
1400
- scoreResult
1507
+ scoreResult,
1508
+ skippedChecks
1401
1509
  };
1402
1510
  };
1403
1511
 
@@ -1629,9 +1737,179 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
1629
1737
  return selectedDirectories;
1630
1738
  };
1631
1739
 
1740
+ //#endregion
1741
+ //#region src/utils/skill-prompt.ts
1742
+ const HOME_DIRECTORY = homedir();
1743
+ const CONFIG_DIRECTORY = join(HOME_DIRECTORY, ".react-doctor");
1744
+ const CONFIG_FILE = join(CONFIG_DIRECTORY, "config.json");
1745
+ const SKILL_NAME = "react-doctor";
1746
+ const WINDSURF_MARKER = "# React Doctor";
1747
+ 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.";
1748
+ const SKILL_BODY = `Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.
1749
+
1750
+ ## Usage
1751
+
1752
+ \`\`\`bash
1753
+ npx -y react-doctor@latest . --verbose --diff
1754
+ \`\`\`
1755
+
1756
+ ## Workflow
1757
+
1758
+ Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.`;
1759
+ const SKILL_CONTENT = `---
1760
+ name: ${SKILL_NAME}
1761
+ description: ${SKILL_DESCRIPTION}
1762
+ version: 1.0.0
1763
+ ---
1764
+
1765
+ # React Doctor
1766
+
1767
+ ${SKILL_BODY}
1768
+ `;
1769
+ const AGENTS_CONTENT = `# React Doctor
1770
+
1771
+ ${SKILL_DESCRIPTION}
1772
+
1773
+ ${SKILL_BODY}
1774
+ `;
1775
+ const CODEX_AGENT_CONFIG = `interface:
1776
+ display_name: "${SKILL_NAME}"
1777
+ short_description: "Diagnose and fix React codebase health issues"
1778
+ `;
1779
+ const readSkillPromptConfig = () => {
1780
+ try {
1781
+ if (!existsSync(CONFIG_FILE)) return {};
1782
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
1783
+ } catch {
1784
+ return {};
1785
+ }
1786
+ };
1787
+ const writeSkillPromptConfig = (config) => {
1788
+ try {
1789
+ mkdirSync(CONFIG_DIRECTORY, { recursive: true });
1790
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
1791
+ } catch {}
1792
+ };
1793
+ const writeSkillFiles = (directory) => {
1794
+ mkdirSync(directory, { recursive: true });
1795
+ writeFileSync(join(directory, "SKILL.md"), SKILL_CONTENT);
1796
+ writeFileSync(join(directory, "AGENTS.md"), AGENTS_CONTENT);
1797
+ };
1798
+ const isCommandAvailable = (command) => {
1799
+ try {
1800
+ execSync(`${process.platform === "win32" ? "where" : "which"} ${command}`, { stdio: "ignore" });
1801
+ return true;
1802
+ } catch {
1803
+ return false;
1804
+ }
1805
+ };
1806
+ const SKILL_TARGETS = [
1807
+ {
1808
+ name: "Claude Code",
1809
+ detect: () => existsSync(join(HOME_DIRECTORY, ".claude")),
1810
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".claude", "skills", SKILL_NAME))
1811
+ },
1812
+ {
1813
+ name: "Amp Code",
1814
+ detect: () => existsSync(join(HOME_DIRECTORY, ".amp")),
1815
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".config", "amp", "skills", SKILL_NAME))
1816
+ },
1817
+ {
1818
+ name: "Cursor",
1819
+ detect: () => existsSync(join(HOME_DIRECTORY, ".cursor")),
1820
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".cursor", "skills", SKILL_NAME))
1821
+ },
1822
+ {
1823
+ name: "OpenCode",
1824
+ detect: () => isCommandAvailable("opencode") || existsSync(join(HOME_DIRECTORY, ".config", "opencode")),
1825
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".config", "opencode", "skills", SKILL_NAME))
1826
+ },
1827
+ {
1828
+ name: "Windsurf",
1829
+ detect: () => existsSync(join(HOME_DIRECTORY, ".codeium")) || existsSync(join(HOME_DIRECTORY, "Library", "Application Support", "Windsurf")),
1830
+ install: () => {
1831
+ const memoriesDirectory = join(HOME_DIRECTORY, ".codeium", "windsurf", "memories");
1832
+ mkdirSync(memoriesDirectory, { recursive: true });
1833
+ const rulesFile = join(memoriesDirectory, "global_rules.md");
1834
+ if (existsSync(rulesFile)) {
1835
+ if (readFileSync(rulesFile, "utf-8").includes(WINDSURF_MARKER)) return;
1836
+ appendFileSync(rulesFile, `\n${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
1837
+ } else writeFileSync(rulesFile, `${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
1838
+ }
1839
+ },
1840
+ {
1841
+ name: "Antigravity",
1842
+ detect: () => isCommandAvailable("agy") || existsSync(join(HOME_DIRECTORY, ".gemini", "antigravity")),
1843
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "antigravity", "skills", SKILL_NAME))
1844
+ },
1845
+ {
1846
+ name: "Gemini CLI",
1847
+ detect: () => isCommandAvailable("gemini") || existsSync(join(HOME_DIRECTORY, ".gemini")),
1848
+ install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "skills", SKILL_NAME))
1849
+ },
1850
+ {
1851
+ name: "Codex",
1852
+ detect: () => isCommandAvailable("codex") || existsSync(join(HOME_DIRECTORY, ".codex")),
1853
+ install: () => {
1854
+ const skillDirectory = join(HOME_DIRECTORY, ".codex", "skills", SKILL_NAME);
1855
+ writeSkillFiles(skillDirectory);
1856
+ const agentsDirectory = join(skillDirectory, "agents");
1857
+ mkdirSync(agentsDirectory, { recursive: true });
1858
+ writeFileSync(join(agentsDirectory, "openai.yaml"), CODEX_AGENT_CONFIG);
1859
+ }
1860
+ }
1861
+ ];
1862
+ const installSkill = () => {
1863
+ let installedCount = 0;
1864
+ for (const target of SKILL_TARGETS) {
1865
+ if (!target.detect()) continue;
1866
+ try {
1867
+ target.install();
1868
+ logger.log(` ${highlighter.success("✔")} ${target.name}`);
1869
+ installedCount++;
1870
+ } catch {
1871
+ logger.dim(` ✗ ${target.name} (failed)`);
1872
+ }
1873
+ }
1874
+ try {
1875
+ writeSkillFiles(join(".agents", SKILL_NAME));
1876
+ logger.log(` ${highlighter.success("✔")} .agents/`);
1877
+ installedCount++;
1878
+ } catch {
1879
+ logger.dim(" ✗ .agents/ (failed)");
1880
+ }
1881
+ logger.break();
1882
+ if (installedCount === 0) logger.dim("No supported tools detected.");
1883
+ else logger.success("Done! The skill will activate when working on React projects.");
1884
+ };
1885
+ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1886
+ const config = readSkillPromptConfig();
1887
+ if (config.skillPromptDismissed) return;
1888
+ if (shouldSkipPrompts) return;
1889
+ logger.break();
1890
+ logger.log(`${highlighter.info("💡")} Have your coding agent fix these issues automatically?`);
1891
+ logger.dim(` Install the ${highlighter.info("react-doctor")} skill to teach Cursor, Claude Code,`);
1892
+ logger.dim(" Ami, and other AI agents how to diagnose and fix React issues.");
1893
+ logger.break();
1894
+ const { shouldInstall } = await prompts({
1895
+ type: "confirm",
1896
+ name: "shouldInstall",
1897
+ message: "Install skill? (recommended)",
1898
+ initial: true
1899
+ });
1900
+ if (shouldInstall) {
1901
+ logger.break();
1902
+ installSkill();
1903
+ }
1904
+ writeSkillPromptConfig({
1905
+ ...config,
1906
+ skillPromptDismissed: true
1907
+ });
1908
+ };
1909
+
1632
1910
  //#endregion
1633
1911
  //#region src/cli.ts
1634
- const VERSION = "0.0.25";
1912
+ const VERSION = "0.0.27";
1635
1913
  const exitWithFixHint = () => {
1636
1914
  logger.break();
1637
1915
  logger.log("Cancelled.");
@@ -1641,6 +1919,26 @@ const exitWithFixHint = () => {
1641
1919
  };
1642
1920
  process.on("SIGINT", exitWithFixHint);
1643
1921
  process.on("SIGTERM", exitWithFixHint);
1922
+ const AUTOMATED_ENVIRONMENT_VARIABLES = [
1923
+ "CI",
1924
+ "CLAUDECODE",
1925
+ "CURSOR_AGENT",
1926
+ "CODEX_CI",
1927
+ "OPENCODE",
1928
+ "AMP_HOME",
1929
+ "AMI"
1930
+ ];
1931
+ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
1932
+ const resolveCliScanOptions = (flags, userConfig, programInstance) => {
1933
+ const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
1934
+ return {
1935
+ lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
1936
+ deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
1937
+ verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
1938
+ scoreOnly: flags.score,
1939
+ offline: flags.offline
1940
+ };
1941
+ };
1644
1942
  const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
1645
1943
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
1646
1944
  if (diffInfo) return true;
@@ -1672,27 +1970,11 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1672
1970
  logger.log(`react-doctor v${VERSION}`);
1673
1971
  logger.break();
1674
1972
  }
1675
- const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
1676
- const scanOptions = {
1677
- lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
1678
- deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
1679
- verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
1680
- scoreOnly: isScoreOnly,
1681
- offline: flags.offline
1682
- };
1683
- const isAutomatedEnvironment = [
1684
- process.env.CI,
1685
- process.env.CLAUDECODE,
1686
- process.env.CURSOR_AGENT,
1687
- process.env.CODEX_CI,
1688
- process.env.OPENCODE,
1689
- process.env.AMP_HOME,
1690
- process.env.AMI
1691
- ].some(Boolean);
1692
- const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
1973
+ const scanOptions = resolveCliScanOptions(flags, userConfig, program);
1974
+ const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY;
1693
1975
  const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
1694
1976
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
1695
- const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
1977
+ const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
1696
1978
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
1697
1979
  const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
1698
1980
  const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
@@ -1730,7 +2012,10 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1730
2012
  if (!isScoreOnly) logger.break();
1731
2013
  }
1732
2014
  if (flags.fix) openAmiToFix(resolvedDirectory);
1733
- if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) await maybePromptFix(resolvedDirectory, allDiagnostics, flags.offline ? null : await fetchEstimatedScore(allDiagnostics));
2015
+ if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
2016
+ await maybePromptSkillInstall(shouldSkipAmiPrompts);
2017
+ await maybePromptFix(resolvedDirectory, allDiagnostics, flags.offline ? null : await fetchEstimatedScore(allDiagnostics));
2018
+ }
1734
2019
  } catch (error) {
1735
2020
  handleError(error);
1736
2021
  }
@@ -1738,15 +2023,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1738
2023
  ${highlighter.dim("Learn more:")}
1739
2024
  ${highlighter.info("https://github.com/millionco/react-doctor")}
1740
2025
  `);
1741
- const AMI_WEBSITE_URL = "https://ami.dev";
1742
- const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
1743
- const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
1744
- const colorizeByScore = (text, score) => {
1745
- if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1746
- if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1747
- return highlighter.error(text);
1748
- };
1749
- 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.";
2026
+ const DEEPLINK_FIX_PROMPT = "/{slash-command:ami:react-doctor}";
1750
2027
  const isAmiInstalled = () => {
1751
2028
  if (process.platform === "darwin") return existsSync("/Applications/Ami.app") || existsSync(path.join(os.homedir(), "Applications", "Ami.app"));
1752
2029
  if (process.platform === "win32") {
@@ -1784,6 +2061,7 @@ const buildDeeplinkParams = (directory) => {
1784
2061
  params.set("prompt", DEEPLINK_FIX_PROMPT);
1785
2062
  params.set("mode", "agent");
1786
2063
  params.set("autoSubmit", "true");
2064
+ params.set("source", "react-doctor");
1787
2065
  return params;
1788
2066
  };
1789
2067
  const buildDeeplink = (directory) => `ami://open-project?${buildDeeplinkParams(directory).toString()}`;
@@ -1855,7 +2133,7 @@ const maybePromptFix = async (directory, diagnostics, estimatedScoreResult) => {
1855
2133
  name: "fixMethod",
1856
2134
  message: "Fix issues?",
1857
2135
  choices: [{
1858
- title: "Use ami.dev (recommended)",
2136
+ title: "Use Ami (recommended)",
1859
2137
  description: "Optimized coding agent for React Doctor",
1860
2138
  value: FIX_METHOD_AMI
1861
2139
  }, {