react-doctor 0.0.26 → 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,7 +1,7 @@
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, 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";
@@ -29,18 +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 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 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
44
97
  const getScoreLabel = (score) => {
45
98
  if (score >= SCORE_GOOD_THRESHOLD) return "Great";
46
99
  if (score >= SCORE_OK_THRESHOLD) return "Needs work";
@@ -76,7 +129,7 @@ const estimateScoreLocally = (diagnostics) => {
76
129
  };
77
130
  const calculateScore = async (diagnostics) => {
78
131
  try {
79
- const response = await fetch(SCORE_API_URL, {
132
+ const response = await proxyFetch(SCORE_API_URL, {
80
133
  method: "POST",
81
134
  headers: { "Content-Type": "application/json" },
82
135
  body: JSON.stringify({ diagnostics })
@@ -89,7 +142,7 @@ const calculateScore = async (diagnostics) => {
89
142
  };
90
143
  const fetchEstimatedScore = async (diagnostics) => {
91
144
  try {
92
- const response = await fetch(ESTIMATE_SCORE_API_URL, {
145
+ const response = await proxyFetch(ESTIMATE_SCORE_API_URL, {
93
146
  method: "POST",
94
147
  headers: { "Content-Type": "application/json" },
95
148
  body: JSON.stringify({ diagnostics })
@@ -101,6 +154,24 @@ const fetchEstimatedScore = async (diagnostics) => {
101
154
  }
102
155
  };
103
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
+
104
175
  //#endregion
105
176
  //#region src/plugin/constants.ts
106
177
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
@@ -151,6 +222,79 @@ const checkReducedMotion = (rootDirectory) => {
151
222
  }
152
223
  };
153
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
+
154
298
  //#endregion
155
299
  //#region src/utils/discover-project.ts
156
300
  const REACT_COMPILER_PACKAGES = new Set([
@@ -261,27 +405,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
261
405
  if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
262
406
  return [];
263
407
  }
264
- 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);
265
411
  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;
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")));
282
413
  };
283
414
  const findDependencyInfoFromMonorepoRoot = (directory) => {
284
- const monorepoRoot = findMonorepoRoot$1(directory);
415
+ const monorepoRoot = findMonorepoRoot(directory);
285
416
  if (!monorepoRoot) return {
286
417
  reactVersion: null,
287
418
  framework: "unknown"
@@ -410,59 +541,6 @@ const discoverProject = (directory) => {
410
541
  };
411
542
  };
412
543
 
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
544
  //#endregion
467
545
  //#region src/utils/logger.ts
468
546
  const logger = {
@@ -617,24 +695,10 @@ const silenced = async (fn) => {
617
695
  console.error = originalError;
618
696
  }
619
697
  };
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
698
  const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
634
699
  const extractFailedPluginName = (error) => {
635
700
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
636
701
  };
637
- const MAX_KNIP_RETRIES = 5;
638
702
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
639
703
  const options = await silenced(() => createOptions({
640
704
  cwd: knipCwd,
@@ -1019,6 +1083,69 @@ const resolvePluginPath = () => {
1019
1083
  const resolveDiagnosticCategory = (plugin, rule) => {
1020
1084
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1021
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
+ };
1022
1149
  const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
1023
1150
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1024
1151
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
@@ -1030,58 +1157,21 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1030
1157
  const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
1031
1158
  try {
1032
1159
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1033
- const args = [
1160
+ const baseArgs = [
1034
1161
  resolveOxlintBinary(),
1035
1162
  "-c",
1036
1163
  configPath,
1037
1164
  "--format",
1038
1165
  "json"
1039
1166
  ];
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)}`);
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));
1068
1173
  }
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
- });
1174
+ return allDiagnostics;
1085
1175
  } finally {
1086
1176
  restoreDisableDirectives();
1087
1177
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
@@ -1189,11 +1279,6 @@ const writeDiagnosticsDirectory = (diagnostics) => {
1189
1279
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
1190
1280
  return outputDirectory;
1191
1281
  };
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
1282
  const buildScoreBarSegments = (score) => {
1198
1283
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
1199
1284
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
@@ -1208,11 +1293,11 @@ const buildPlainScoreBar = (score) => {
1208
1293
  };
1209
1294
  const buildScoreBar = (score) => {
1210
1295
  const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1211
- return colorizeByScore$1(filledSegment, score) + highlighter.dim(emptySegment);
1296
+ return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
1212
1297
  };
1213
1298
  const printScoreGauge = (score, label) => {
1214
- const scoreDisplay = colorizeByScore$1(`${score}`, score);
1215
- const labelDisplay = colorizeByScore$1(label, score);
1299
+ const scoreDisplay = colorizeByScore(`${score}`, score);
1300
+ const labelDisplay = colorizeByScore(label, score);
1216
1301
  logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
1217
1302
  logger.break();
1218
1303
  logger.log(` ${buildScoreBar(score)}`);
@@ -1226,7 +1311,7 @@ const getDoctorFace = (score) => {
1226
1311
  const printBranding = (score) => {
1227
1312
  if (score !== void 0) {
1228
1313
  const [eyes, mouth] = getDoctorFace(score);
1229
- const colorize = (text) => colorizeByScore$1(text, score);
1314
+ const colorize = (text) => colorizeByScore(text, score);
1230
1315
  logger.log(colorize(" ┌─────┐"));
1231
1316
  logger.log(colorize(` │ ${eyes} │`));
1232
1317
  logger.log(colorize(` │ ${mouth} │`));
@@ -1247,53 +1332,56 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
1247
1332
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
1248
1333
  return `${SHARE_BASE_URL}?${params.toString()}`;
1249
1334
  };
1250
- 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) => {
1251
1361
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
1252
1362
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
1253
1363
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
1254
1364
  const elapsed = formatElapsedTime(elapsedMilliseconds);
1255
- const summaryLineParts = [];
1256
- const summaryLinePartsPlain = [];
1365
+ const plainParts = [];
1366
+ const renderedParts = [];
1257
1367
  if (errorCount > 0) {
1258
1368
  const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
1259
- summaryLinePartsPlain.push(errorText);
1260
- summaryLineParts.push(highlighter.error(errorText));
1369
+ plainParts.push(errorText);
1370
+ renderedParts.push(highlighter.error(errorText));
1261
1371
  }
1262
1372
  if (warningCount > 0) {
1263
1373
  const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
1264
- summaryLinePartsPlain.push(warningText);
1265
- summaryLineParts.push(highlighter.warn(warningText));
1374
+ plainParts.push(warningText);
1375
+ renderedParts.push(highlighter.warn(warningText));
1266
1376
  }
1267
1377
  const fileCountText = totalSourceFileCount > 0 ? `across ${affectedFileCount}/${totalSourceFileCount} files` : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
1268
1378
  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);
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)]);
1297
1385
  try {
1298
1386
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
1299
1387
  logger.break();
@@ -1305,37 +1393,41 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1305
1393
  logger.break();
1306
1394
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1307
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
+ };
1308
1419
  const scan = async (directory, inputOptions = {}) => {
1309
1420
  const startTime = performance.now();
1310
1421
  const projectInfo = discoverProject(directory);
1311
1422
  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 ?? [];
1423
+ const options = mergeScanOptions(inputOptions, userConfig);
1424
+ const { includePaths } = options;
1321
1425
  const isDiffMode = includePaths.length > 0;
1322
1426
  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;
1427
+ if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths);
1428
+ const jsxIncludePaths = computeJsxIncludePaths(includePaths);
1429
+ let didLintFail = false;
1430
+ let didDeadCodeFail = false;
1339
1431
  const lintPromise = options.lint ? (async () => {
1340
1432
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1341
1433
  try {
@@ -1343,6 +1435,7 @@ const scan = async (directory, inputOptions = {}) => {
1343
1435
  lintSpinner?.succeed("Running lint checks.");
1344
1436
  return lintDiagnostics;
1345
1437
  } catch (error) {
1438
+ didLintFail = true;
1346
1439
  lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1347
1440
  if (error instanceof Error) {
1348
1441
  logger.error(error.message);
@@ -1358,19 +1451,19 @@ const scan = async (directory, inputOptions = {}) => {
1358
1451
  deadCodeSpinner?.succeed("Detecting dead code.");
1359
1452
  return knipDiagnostics;
1360
1453
  } catch (error) {
1454
+ didDeadCodeFail = true;
1361
1455
  deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1362
1456
  logger.error(String(error));
1363
1457
  return [];
1364
1458
  }
1365
1459
  })() : Promise.resolve([]);
1366
1460
  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;
1461
+ const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
1373
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;
1374
1467
  const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1375
1468
  const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
1376
1469
  if (options.scoreOnly) {
@@ -1378,27 +1471,41 @@ const scan = async (directory, inputOptions = {}) => {
1378
1471
  else logger.dim(noScoreMessage);
1379
1472
  return {
1380
1473
  diagnostics,
1381
- scoreResult
1474
+ scoreResult,
1475
+ skippedChecks
1382
1476
  };
1383
1477
  }
1384
1478
  if (diagnostics.length === 0) {
1385
- 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!");
1386
1483
  logger.break();
1387
- if (scoreResult) {
1484
+ if (hasSkippedChecks) {
1485
+ printBranding();
1486
+ logger.dim(" Score not shown — some checks could not complete.");
1487
+ } else if (scoreResult) {
1388
1488
  printBranding(scoreResult.score);
1389
1489
  printScoreGauge(scoreResult.score, scoreResult.label);
1390
1490
  } else logger.dim(` ${noScoreMessage}`);
1391
1491
  return {
1392
1492
  diagnostics,
1393
- scoreResult
1493
+ scoreResult,
1494
+ skippedChecks
1394
1495
  };
1395
1496
  }
1396
1497
  printDiagnostics(diagnostics, options.verbose);
1397
1498
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1398
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
+ }
1399
1505
  return {
1400
1506
  diagnostics,
1401
- scoreResult
1507
+ scoreResult,
1508
+ skippedChecks
1402
1509
  };
1403
1510
  };
1404
1511
 
@@ -1632,8 +1739,43 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
1632
1739
 
1633
1740
  //#endregion
1634
1741
  //#region src/utils/skill-prompt.ts
1635
- const CONFIG_DIRECTORY = join(homedir(), ".react-doctor");
1742
+ const HOME_DIRECTORY = homedir();
1743
+ const CONFIG_DIRECTORY = join(HOME_DIRECTORY, ".react-doctor");
1636
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
+ `;
1637
1779
  const readSkillPromptConfig = () => {
1638
1780
  try {
1639
1781
  if (!existsSync(CONFIG_FILE)) return {};
@@ -1644,18 +1786,101 @@ const readSkillPromptConfig = () => {
1644
1786
  };
1645
1787
  const writeSkillPromptConfig = (config) => {
1646
1788
  try {
1647
- if (!existsSync(CONFIG_DIRECTORY)) mkdirSync(CONFIG_DIRECTORY, { recursive: true });
1789
+ mkdirSync(CONFIG_DIRECTORY, { recursive: true });
1648
1790
  writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
1649
1791
  } catch {}
1650
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
+ ];
1651
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
+ }
1652
1874
  try {
1653
- execSync(`curl -fsSL ${INSTALL_SKILL_URL} | bash`, { stdio: "inherit" });
1875
+ writeSkillFiles(join(".agents", SKILL_NAME));
1876
+ logger.log(` ${highlighter.success("✔")} .agents/`);
1877
+ installedCount++;
1654
1878
  } catch {
1655
- logger.break();
1656
- logger.dim("Skill install failed. You can install manually:");
1657
- logger.dim(` curl -fsSL ${INSTALL_SKILL_URL} | bash`);
1879
+ logger.dim(" ✗ .agents/ (failed)");
1658
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.");
1659
1884
  };
1660
1885
  const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1661
1886
  const config = readSkillPromptConfig();
@@ -1684,7 +1909,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1684
1909
 
1685
1910
  //#endregion
1686
1911
  //#region src/cli.ts
1687
- const VERSION = "0.0.26";
1912
+ const VERSION = "0.0.27";
1688
1913
  const exitWithFixHint = () => {
1689
1914
  logger.break();
1690
1915
  logger.log("Cancelled.");
@@ -1694,6 +1919,26 @@ const exitWithFixHint = () => {
1694
1919
  };
1695
1920
  process.on("SIGINT", exitWithFixHint);
1696
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
+ };
1697
1942
  const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
1698
1943
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
1699
1944
  if (diffInfo) return true;
@@ -1725,27 +1970,11 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1725
1970
  logger.log(`react-doctor v${VERSION}`);
1726
1971
  logger.break();
1727
1972
  }
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;
1973
+ const scanOptions = resolveCliScanOptions(flags, userConfig, program);
1974
+ const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY;
1746
1975
  const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
1747
1976
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
1748
- const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
1977
+ const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
1749
1978
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
1750
1979
  const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
1751
1980
  const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
@@ -1794,15 +2023,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1794
2023
  ${highlighter.dim("Learn more:")}
1795
2024
  ${highlighter.info("https://github.com/millionco/react-doctor")}
1796
2025
  `);
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.";
2026
+ const DEEPLINK_FIX_PROMPT = "/{slash-command:ami:react-doctor}";
1806
2027
  const isAmiInstalled = () => {
1807
2028
  if (process.platform === "darwin") return existsSync("/Applications/Ami.app") || existsSync(path.join(os.homedir(), "Applications", "Ami.app"));
1808
2029
  if (process.platform === "win32") {
@@ -1840,6 +2061,7 @@ const buildDeeplinkParams = (directory) => {
1840
2061
  params.set("prompt", DEEPLINK_FIX_PROMPT);
1841
2062
  params.set("mode", "agent");
1842
2063
  params.set("autoSubmit", "true");
2064
+ params.set("source", "react-doctor");
1843
2065
  return params;
1844
2066
  };
1845
2067
  const buildDeeplink = (directory) => `ami://open-project?${buildDeeplinkParams(directory).toString()}`;
@@ -1911,7 +2133,7 @@ const maybePromptFix = async (directory, diagnostics, estimatedScoreResult) => {
1911
2133
  name: "fixMethod",
1912
2134
  message: "Fix issues?",
1913
2135
  choices: [{
1914
- title: "Use ami.dev (recommended)",
2136
+ title: "Use Ami (recommended)",
1915
2137
  description: "Optimized coding agent for React Doctor",
1916
2138
  value: FIX_METHOD_AMI
1917
2139
  }, {