react-doctor 0.0.31 → 0.0.33

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,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { execSync, spawn, spawnSync } from "node:child_process";
4
- import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
- import os, { homedir, tmpdir } from "node:os";
3
+ import fs, { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
4
+ import os, { tmpdir } from "node:os";
6
5
  import path, { join } from "node:path";
7
6
  import { Command } from "commander";
8
7
  import { randomUUID } from "node:crypto";
9
8
  import { performance } from "node:perf_hooks";
9
+ import { execSync, spawn, spawnSync } from "node:child_process";
10
10
  import pc from "picocolors";
11
11
  import basePrompts from "prompts";
12
12
  import { main } from "knip";
@@ -26,30 +26,25 @@ const SCORE_BAR_WIDTH_CHARS = 50;
26
26
  const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1;
27
27
  const SUMMARY_BOX_OUTER_INDENT_CHARS = 2;
28
28
  const SCORE_API_URL = "https://www.react.doctor/api/score";
29
- const ESTIMATE_SCORE_API_URL = "https://www.react.doctor/api/estimate-score";
30
29
  const SHARE_BASE_URL = "https://www.react.doctor/share";
31
- const OPEN_BASE_URL = "https://www.react.doctor/open";
32
30
  const FETCH_TIMEOUT_MS = 1e4;
33
31
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
34
32
  const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
33
+ const OXLINT_MAX_FILES_PER_BATCH = 500;
35
34
  const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
36
35
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
37
36
  const ERROR_RULE_PENALTY = 1.5;
38
37
  const WARNING_RULE_PENALTY = .75;
39
- const ERROR_ESTIMATED_FIX_RATE = .85;
40
- const WARNING_ESTIMATED_FIX_RATE = .8;
41
38
  const MAX_KNIP_RETRIES = 5;
42
39
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
43
40
  const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
41
+ const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
44
42
  const IGNORED_DIRECTORIES = new Set([
45
43
  "node_modules",
46
44
  "dist",
47
45
  "build",
48
46
  "coverage"
49
47
  ]);
50
- const AMI_WEBSITE_URL = "https://ami.dev";
51
- const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
52
- const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
53
48
 
54
49
  //#endregion
55
50
  //#region src/utils/proxy-fetch.ts
@@ -123,22 +118,12 @@ const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
123
118
  const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
124
119
  return Math.max(0, Math.round(PERFECT_SCORE - penalty));
125
120
  };
126
- const estimateScoreLocally = (diagnostics) => {
127
- const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
128
- const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
129
- const estimatedScore = scoreFromRuleCounts(Math.round(errorRuleCount * (1 - ERROR_ESTIMATED_FIX_RATE)), Math.round(warningRuleCount * (1 - WARNING_ESTIMATED_FIX_RATE)));
130
- return {
131
- currentScore,
132
- currentLabel: getScoreLabel(currentScore),
133
- estimatedScore,
134
- estimatedLabel: getScoreLabel(estimatedScore)
135
- };
136
- };
137
121
  const calculateScoreLocally = (diagnostics) => {
138
- const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
122
+ const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
123
+ const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
139
124
  return {
140
- score: currentScore,
141
- label: currentLabel
125
+ score,
126
+ label: getScoreLabel(score)
142
127
  };
143
128
  };
144
129
  const calculateScore = async (diagnostics) => {
@@ -154,19 +139,6 @@ const calculateScore = async (diagnostics) => {
154
139
  return calculateScoreLocally(diagnostics);
155
140
  }
156
141
  };
157
- const fetchEstimatedScore = async (diagnostics) => {
158
- try {
159
- const response = await proxyFetch(ESTIMATE_SCORE_API_URL, {
160
- method: "POST",
161
- headers: { "Content-Type": "application/json" },
162
- body: JSON.stringify({ diagnostics })
163
- });
164
- if (!response.ok) return estimateScoreLocally(diagnostics);
165
- return await response.json();
166
- } catch {
167
- return estimateScoreLocally(diagnostics);
168
- }
169
- };
170
142
 
171
143
  //#endregion
172
144
  //#region src/utils/highlighter.ts
@@ -206,6 +178,7 @@ const readPackageJson = (packageJsonPath) => {
206
178
  try {
207
179
  return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
208
180
  } catch (error) {
181
+ if (error instanceof SyntaxError) return {};
209
182
  if (error instanceof Error && "code" in error) {
210
183
  const { code } = error;
211
184
  if (code === "EISDIR" || code === "EACCES") return {};
@@ -301,38 +274,58 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
301
274
 
302
275
  //#endregion
303
276
  //#region src/utils/filter-diagnostics.ts
304
- const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
305
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
306
- const ignoredFilePatterns = compileIgnoredFilePatterns(config);
307
- if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
308
- return diagnostics.filter((diagnostic) => {
309
- const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
310
- if (ignoredRules.has(ruleIdentifier)) return false;
311
- if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
312
- return true;
313
- });
314
- };
277
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
315
278
  const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
316
279
  const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
317
- const isRuleSuppressed = (commentRules, ruleId) => {
318
- if (!commentRules?.trim()) return true;
319
- return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
320
- };
321
- const filterInlineSuppressions = (diagnostics, rootDirectory) => {
322
- const fileLineCache = /* @__PURE__ */ new Map();
323
- const getFileLines = (filePath) => {
324
- const cached = fileLineCache.get(filePath);
280
+ const createFileLinesCache = (rootDirectory) => {
281
+ const cache = /* @__PURE__ */ new Map();
282
+ return (filePath) => {
283
+ const cached = cache.get(filePath);
325
284
  if (cached !== void 0) return cached;
326
285
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
327
286
  try {
328
287
  const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
329
- fileLineCache.set(filePath, lines);
288
+ cache.set(filePath, lines);
330
289
  return lines;
331
290
  } catch {
332
- fileLineCache.set(filePath, null);
291
+ cache.set(filePath, null);
333
292
  return null;
334
293
  }
335
294
  };
295
+ };
296
+ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
297
+ for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
298
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
299
+ if (!match) continue;
300
+ const fullTagName = match[1];
301
+ const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
302
+ return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
303
+ }
304
+ return false;
305
+ };
306
+ const isRuleSuppressed = (commentRules, ruleId) => {
307
+ if (!commentRules?.trim()) return true;
308
+ return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
309
+ };
310
+ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
311
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
312
+ const ignoredFilePatterns = compileIgnoredFilePatterns(config);
313
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
314
+ const hasTextComponents = textComponentNames.size > 0;
315
+ const getFileLines = createFileLinesCache(rootDirectory);
316
+ return diagnostics.filter((diagnostic) => {
317
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
318
+ if (ignoredRules.has(ruleIdentifier)) return false;
319
+ if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
320
+ if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
321
+ const lines = getFileLines(diagnostic.filePath);
322
+ if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
323
+ }
324
+ return true;
325
+ });
326
+ };
327
+ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
328
+ const getFileLines = createFileLinesCache(rootDirectory);
336
329
  return diagnostics.filter((diagnostic) => {
337
330
  if (diagnostic.line <= 0) return true;
338
331
  const lines = getFileLines(diagnostic.filePath);
@@ -344,9 +337,9 @@ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
344
337
  if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
345
338
  }
346
339
  if (diagnostic.line >= 2) {
347
- const prevLine = lines[diagnostic.line - 2];
348
- if (prevLine) {
349
- const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
340
+ const previousLine = lines[diagnostic.line - 2];
341
+ if (previousLine) {
342
+ const nextLineMatch = previousLine.match(DISABLE_NEXT_LINE_PATTERN);
350
343
  if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
351
344
  }
352
345
  }
@@ -881,8 +874,8 @@ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText
881
874
  //#region src/utils/load-config.ts
882
875
  const CONFIG_FILENAME = "react-doctor.config.json";
883
876
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
884
- const loadConfig = (rootDirectory) => {
885
- const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
877
+ const loadConfigFromDirectory = (directory) => {
878
+ const configFilePath = path.join(directory, CONFIG_FILENAME);
886
879
  if (isFile(configFilePath)) try {
887
880
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
888
881
  const parsed = JSON.parse(fileContent);
@@ -891,7 +884,7 @@ const loadConfig = (rootDirectory) => {
891
884
  } catch (error) {
892
885
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
893
886
  }
894
- const packageJsonPath = path.join(rootDirectory, "package.json");
887
+ const packageJsonPath = path.join(directory, "package.json");
895
888
  if (isFile(packageJsonPath)) try {
896
889
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
897
890
  const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
@@ -901,6 +894,17 @@ const loadConfig = (rootDirectory) => {
901
894
  }
902
895
  return null;
903
896
  };
897
+ const loadConfig = (rootDirectory) => {
898
+ const localConfig = loadConfigFromDirectory(rootDirectory);
899
+ if (localConfig) return localConfig;
900
+ let ancestorDirectory = path.dirname(rootDirectory);
901
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
902
+ const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
903
+ if (ancestorConfig) return ancestorConfig;
904
+ ancestorDirectory = path.dirname(ancestorDirectory);
905
+ }
906
+ return null;
907
+ };
904
908
 
905
909
  //#endregion
906
910
  //#region src/utils/should-auto-select-current-choice.ts
@@ -925,12 +929,6 @@ let didPatchMultiselectToggleAll = false;
925
929
  let didPatchMultiselectSubmit = false;
926
930
  let didPatchSelectBanner = false;
927
931
  const selectBannerMap = /* @__PURE__ */ new Map();
928
- const setSelectBanner = (banner, targetIndex) => {
929
- selectBannerMap.set(targetIndex, banner);
930
- };
931
- const clearSelectBanner = () => {
932
- selectBannerMap.clear();
933
- };
934
932
  const onCancel = () => {
935
933
  logger.break();
936
934
  logger.log("Cancelled.");
@@ -1272,7 +1270,37 @@ const REACT_COMPILER_RULES = {
1272
1270
  "react-hooks-js/incompatible-library": "error",
1273
1271
  "react-hooks-js/todo": "error"
1274
1272
  };
1275
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
1273
+ const BUILTIN_REACT_RULES = {
1274
+ "react/rules-of-hooks": "error",
1275
+ "react/no-direct-mutation-state": "error",
1276
+ "react/jsx-no-duplicate-props": "error",
1277
+ "react/jsx-key": "error",
1278
+ "react/no-children-prop": "warn",
1279
+ "react/no-danger": "warn",
1280
+ "react/jsx-no-script-url": "error",
1281
+ "react/no-render-return-value": "warn",
1282
+ "react/no-string-refs": "warn",
1283
+ "react/no-is-mounted": "warn",
1284
+ "react/require-render-return": "error",
1285
+ "react/no-unknown-property": "warn"
1286
+ };
1287
+ const BUILTIN_A11Y_RULES = {
1288
+ "jsx-a11y/alt-text": "error",
1289
+ "jsx-a11y/anchor-is-valid": "warn",
1290
+ "jsx-a11y/click-events-have-key-events": "warn",
1291
+ "jsx-a11y/no-static-element-interactions": "warn",
1292
+ "jsx-a11y/role-has-required-aria-props": "error",
1293
+ "jsx-a11y/no-autofocus": "warn",
1294
+ "jsx-a11y/heading-has-content": "warn",
1295
+ "jsx-a11y/html-has-lang": "warn",
1296
+ "jsx-a11y/no-redundant-roles": "warn",
1297
+ "jsx-a11y/scope": "warn",
1298
+ "jsx-a11y/tabindex-no-positive": "warn",
1299
+ "jsx-a11y/label-has-associated-control": "warn",
1300
+ "jsx-a11y/no-distracting-elements": "error",
1301
+ "jsx-a11y/iframe-has-title": "warn"
1302
+ };
1303
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRulesOnly = false }) => ({
1276
1304
  categories: {
1277
1305
  correctness: "off",
1278
1306
  suspicious: "off",
@@ -1287,39 +1315,14 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
1287
1315
  "jsx-a11y",
1288
1316
  ...hasReactCompiler ? [] : ["react-perf"]
1289
1317
  ],
1290
- jsPlugins: [...hasReactCompiler ? [{
1318
+ jsPlugins: [...hasReactCompiler && !customRulesOnly ? [{
1291
1319
  name: "react-hooks-js",
1292
1320
  specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1293
1321
  }] : [], pluginPath],
1294
1322
  rules: {
1295
- "react/rules-of-hooks": "error",
1296
- "react/no-direct-mutation-state": "error",
1297
- "react/jsx-no-duplicate-props": "error",
1298
- "react/jsx-key": "error",
1299
- "react/no-children-prop": "warn",
1300
- "react/no-danger": "warn",
1301
- "react/jsx-no-script-url": "error",
1302
- "react/no-render-return-value": "warn",
1303
- "react/no-string-refs": "warn",
1304
- "react/no-is-mounted": "warn",
1305
- "react/require-render-return": "error",
1306
- "react/no-unknown-property": "warn",
1307
- "jsx-a11y/alt-text": "error",
1308
- "jsx-a11y/anchor-is-valid": "warn",
1309
- "jsx-a11y/click-events-have-key-events": "warn",
1310
- "jsx-a11y/no-static-element-interactions": "warn",
1311
- "jsx-a11y/no-noninteractive-element-interactions": "warn",
1312
- "jsx-a11y/role-has-required-aria-props": "error",
1313
- "jsx-a11y/no-autofocus": "warn",
1314
- "jsx-a11y/heading-has-content": "warn",
1315
- "jsx-a11y/html-has-lang": "warn",
1316
- "jsx-a11y/no-redundant-roles": "warn",
1317
- "jsx-a11y/scope": "warn",
1318
- "jsx-a11y/tabindex-no-positive": "warn",
1319
- "jsx-a11y/label-has-associated-control": "warn",
1320
- "jsx-a11y/no-distracting-elements": "error",
1321
- "jsx-a11y/iframe-has-title": "warn",
1322
- ...hasReactCompiler ? REACT_COMPILER_RULES : {},
1323
+ ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1324
+ ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1325
+ ...hasReactCompiler && !customRulesOnly ? REACT_COMPILER_RULES : {},
1323
1326
  "react-doctor/no-derived-state-effect": "error",
1324
1327
  "react-doctor/no-fetch-in-effect": "error",
1325
1328
  "react-doctor/no-cascading-set-state": "warn",
@@ -1590,7 +1593,9 @@ const batchIncludePaths = (baseArgs, includePaths) => {
1590
1593
  let currentBatchLength = baseArgsLength;
1591
1594
  for (const filePath of includePaths) {
1592
1595
  const entryLength = filePath.length + 1;
1593
- if (currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS) {
1596
+ const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS;
1597
+ const exceedsFileCount = currentBatch.length >= OXLINT_MAX_FILES_PER_BATCH;
1598
+ if (exceedsArgLength || exceedsFileCount) {
1594
1599
  batches.push(currentBatch);
1595
1600
  currentBatch = [];
1596
1601
  currentBatchLength = baseArgsLength;
@@ -1652,13 +1657,14 @@ const parseOxlintOutput = (stdout) => {
1652
1657
  };
1653
1658
  });
1654
1659
  };
1655
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath) => {
1660
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false) => {
1656
1661
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1657
1662
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
1658
1663
  const config = createOxlintConfig({
1659
1664
  pluginPath: resolvePluginPath(),
1660
1665
  framework,
1661
- hasReactCompiler
1666
+ hasReactCompiler,
1667
+ customRulesOnly
1662
1668
  });
1663
1669
  const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
1664
1670
  try {
@@ -1945,7 +1951,9 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
1945
1951
  verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1946
1952
  scoreOnly: inputOptions.scoreOnly ?? false,
1947
1953
  offline: inputOptions.offline ?? false,
1948
- includePaths: inputOptions.includePaths ?? []
1954
+ includePaths: inputOptions.includePaths ?? [],
1955
+ customRulesOnly: userConfig?.customRulesOnly ?? false,
1956
+ share: userConfig?.share ?? true
1949
1957
  });
1950
1958
  const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
1951
1959
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -1965,7 +1973,7 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
1965
1973
  const scan = async (directory, inputOptions = {}) => {
1966
1974
  const startTime = performance.now();
1967
1975
  const projectInfo = discoverProject(directory);
1968
- const userConfig = loadConfig(directory);
1976
+ const userConfig = inputOptions.configOverride !== void 0 ? inputOptions.configOverride : loadConfig(directory);
1969
1977
  const options = mergeScanOptions(inputOptions, userConfig);
1970
1978
  const { includePaths } = options;
1971
1979
  const isDiffMode = includePaths.length > 0;
@@ -1980,7 +1988,7 @@ const scan = async (directory, inputOptions = {}) => {
1980
1988
  const lintPromise = resolvedNodeBinaryPath ? (async () => {
1981
1989
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1982
1990
  try {
1983
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath);
1991
+ const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath, options.customRulesOnly);
1984
1992
  lintSpinner?.succeed("Running lint checks.");
1985
1993
  return lintDiagnostics;
1986
1994
  } catch (error) {
@@ -2052,7 +2060,8 @@ const scan = async (directory, inputOptions = {}) => {
2052
2060
  }
2053
2061
  printDiagnostics(diagnostics, options.verbose);
2054
2062
  const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
2055
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, options.offline);
2063
+ const shouldShowShareLink = !options.offline && options.share;
2064
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, !shouldShowShareLink);
2056
2065
  if (hasSkippedChecks) {
2057
2066
  const skippedLabel = skippedChecks.join(" and ");
2058
2067
  logger.break();
@@ -2145,6 +2154,68 @@ const getDiffInfo = (directory, explicitBaseBranch) => {
2145
2154
  };
2146
2155
  const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
2147
2156
 
2157
+ //#endregion
2158
+ //#region src/utils/get-staged-files.ts
2159
+ const getStagedFilePaths = (directory) => {
2160
+ const result = spawnSync("git", [
2161
+ "diff",
2162
+ "--cached",
2163
+ "--name-only",
2164
+ "--diff-filter=ACMR",
2165
+ "--relative"
2166
+ ], {
2167
+ cwd: directory,
2168
+ stdio: "pipe",
2169
+ maxBuffer: GIT_SHOW_MAX_BUFFER_BYTES
2170
+ });
2171
+ if (result.error || result.status !== 0) return [];
2172
+ const output = result.stdout.toString().trim();
2173
+ if (!output) return [];
2174
+ return output.split("\n").filter(Boolean);
2175
+ };
2176
+ const readStagedContent = (directory, relativePath) => {
2177
+ const result = spawnSync("git", ["show", `:${relativePath}`], {
2178
+ cwd: directory,
2179
+ stdio: "pipe",
2180
+ maxBuffer: GIT_SHOW_MAX_BUFFER_BYTES
2181
+ });
2182
+ if (result.error || result.status !== 0) return null;
2183
+ return result.stdout.toString();
2184
+ };
2185
+ const getStagedSourceFiles = (directory) => getStagedFilePaths(directory).filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
2186
+ const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2187
+ const materializedFiles = [];
2188
+ for (const relativePath of stagedFiles) {
2189
+ const content = readStagedContent(directory, relativePath);
2190
+ if (content === null) continue;
2191
+ const targetPath = path.join(tempDirectory, relativePath);
2192
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
2193
+ fs.writeFileSync(targetPath, content);
2194
+ materializedFiles.push(relativePath);
2195
+ }
2196
+ for (const configFilename of [
2197
+ "tsconfig.json",
2198
+ "package.json",
2199
+ "react-doctor.config.json"
2200
+ ]) {
2201
+ const sourcePath = path.join(directory, configFilename);
2202
+ const targetPath = path.join(tempDirectory, configFilename);
2203
+ if (fs.existsSync(sourcePath) && !fs.existsSync(targetPath)) fs.cpSync(sourcePath, targetPath);
2204
+ }
2205
+ return {
2206
+ tempDirectory,
2207
+ stagedFiles: materializedFiles,
2208
+ cleanup: () => {
2209
+ try {
2210
+ fs.rmSync(tempDirectory, {
2211
+ recursive: true,
2212
+ force: true
2213
+ });
2214
+ } catch {}
2215
+ }
2216
+ };
2217
+ };
2218
+
2148
2219
  //#endregion
2149
2220
  //#region src/utils/handle-error.ts
2150
2221
  const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
@@ -2207,179 +2278,9 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2207
2278
  return selectedDirectories;
2208
2279
  };
2209
2280
 
2210
- //#endregion
2211
- //#region src/utils/skill-prompt.ts
2212
- const HOME_DIRECTORY = homedir();
2213
- const CONFIG_DIRECTORY = join(HOME_DIRECTORY, ".react-doctor");
2214
- const CONFIG_FILE = join(CONFIG_DIRECTORY, "config.json");
2215
- const SKILL_NAME = "react-doctor";
2216
- const WINDSURF_MARKER = "# React Doctor";
2217
- 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.";
2218
- const SKILL_BODY = `Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.
2219
-
2220
- ## Usage
2221
-
2222
- \`\`\`bash
2223
- npx -y react-doctor@latest . --verbose --diff
2224
- \`\`\`
2225
-
2226
- ## Workflow
2227
-
2228
- Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.`;
2229
- const SKILL_CONTENT = `---
2230
- name: ${SKILL_NAME}
2231
- description: ${SKILL_DESCRIPTION}
2232
- version: 1.0.0
2233
- ---
2234
-
2235
- # React Doctor
2236
-
2237
- ${SKILL_BODY}
2238
- `;
2239
- const AGENTS_CONTENT = `# React Doctor
2240
-
2241
- ${SKILL_DESCRIPTION}
2242
-
2243
- ${SKILL_BODY}
2244
- `;
2245
- const CODEX_AGENT_CONFIG = `interface:
2246
- display_name: "${SKILL_NAME}"
2247
- short_description: "Diagnose and fix React codebase health issues"
2248
- `;
2249
- const readSkillPromptConfig = () => {
2250
- try {
2251
- if (!existsSync(CONFIG_FILE)) return {};
2252
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
2253
- } catch {
2254
- return {};
2255
- }
2256
- };
2257
- const writeSkillPromptConfig = (config) => {
2258
- try {
2259
- mkdirSync(CONFIG_DIRECTORY, { recursive: true });
2260
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
2261
- } catch {}
2262
- };
2263
- const writeSkillFiles = (directory) => {
2264
- mkdirSync(directory, { recursive: true });
2265
- writeFileSync(join(directory, "SKILL.md"), SKILL_CONTENT);
2266
- writeFileSync(join(directory, "AGENTS.md"), AGENTS_CONTENT);
2267
- };
2268
- const isCommandAvailable = (command) => {
2269
- try {
2270
- execSync(`${process.platform === "win32" ? "where" : "which"} ${command}`, { stdio: "ignore" });
2271
- return true;
2272
- } catch {
2273
- return false;
2274
- }
2275
- };
2276
- const SKILL_TARGETS = [
2277
- {
2278
- name: "Claude Code",
2279
- detect: () => existsSync(join(HOME_DIRECTORY, ".claude")),
2280
- install: () => writeSkillFiles(join(HOME_DIRECTORY, ".claude", "skills", SKILL_NAME))
2281
- },
2282
- {
2283
- name: "Amp Code",
2284
- detect: () => existsSync(join(HOME_DIRECTORY, ".amp")),
2285
- install: () => writeSkillFiles(join(HOME_DIRECTORY, ".config", "amp", "skills", SKILL_NAME))
2286
- },
2287
- {
2288
- name: "Cursor",
2289
- detect: () => existsSync(join(HOME_DIRECTORY, ".cursor")),
2290
- install: () => writeSkillFiles(join(HOME_DIRECTORY, ".cursor", "skills", SKILL_NAME))
2291
- },
2292
- {
2293
- name: "OpenCode",
2294
- detect: () => isCommandAvailable("opencode") || existsSync(join(HOME_DIRECTORY, ".config", "opencode")),
2295
- install: () => writeSkillFiles(join(HOME_DIRECTORY, ".config", "opencode", "skills", SKILL_NAME))
2296
- },
2297
- {
2298
- name: "Windsurf",
2299
- detect: () => existsSync(join(HOME_DIRECTORY, ".codeium")) || existsSync(join(HOME_DIRECTORY, "Library", "Application Support", "Windsurf")),
2300
- install: () => {
2301
- const memoriesDirectory = join(HOME_DIRECTORY, ".codeium", "windsurf", "memories");
2302
- mkdirSync(memoriesDirectory, { recursive: true });
2303
- const rulesFile = join(memoriesDirectory, "global_rules.md");
2304
- if (existsSync(rulesFile)) {
2305
- if (readFileSync(rulesFile, "utf-8").includes(WINDSURF_MARKER)) return;
2306
- appendFileSync(rulesFile, `\n${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
2307
- } else writeFileSync(rulesFile, `${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
2308
- }
2309
- },
2310
- {
2311
- name: "Antigravity",
2312
- detect: () => isCommandAvailable("agy") || existsSync(join(HOME_DIRECTORY, ".gemini", "antigravity")),
2313
- install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "antigravity", "skills", SKILL_NAME))
2314
- },
2315
- {
2316
- name: "Gemini CLI",
2317
- detect: () => isCommandAvailable("gemini") || existsSync(join(HOME_DIRECTORY, ".gemini")),
2318
- install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "skills", SKILL_NAME))
2319
- },
2320
- {
2321
- name: "Codex",
2322
- detect: () => isCommandAvailable("codex") || existsSync(join(HOME_DIRECTORY, ".codex")),
2323
- install: () => {
2324
- const skillDirectory = join(HOME_DIRECTORY, ".codex", "skills", SKILL_NAME);
2325
- writeSkillFiles(skillDirectory);
2326
- const agentsDirectory = join(skillDirectory, "agents");
2327
- mkdirSync(agentsDirectory, { recursive: true });
2328
- writeFileSync(join(agentsDirectory, "openai.yaml"), CODEX_AGENT_CONFIG);
2329
- }
2330
- }
2331
- ];
2332
- const installSkill = () => {
2333
- let installedCount = 0;
2334
- for (const target of SKILL_TARGETS) {
2335
- if (!target.detect()) continue;
2336
- try {
2337
- target.install();
2338
- logger.log(` ${highlighter.success("✔")} ${target.name}`);
2339
- installedCount++;
2340
- } catch {
2341
- logger.dim(` ✗ ${target.name} (failed)`);
2342
- }
2343
- }
2344
- try {
2345
- writeSkillFiles(join(".agents", SKILL_NAME));
2346
- logger.log(` ${highlighter.success("✔")} .agents/`);
2347
- installedCount++;
2348
- } catch {
2349
- logger.dim(" ✗ .agents/ (failed)");
2350
- }
2351
- logger.break();
2352
- if (installedCount === 0) logger.dim("No supported tools detected.");
2353
- else logger.success("Done! The skill will activate when working on React projects.");
2354
- };
2355
- const maybePromptSkillInstall = async (shouldSkipPrompts) => {
2356
- const config = readSkillPromptConfig();
2357
- if (config.skillPromptDismissed) return;
2358
- if (shouldSkipPrompts) return;
2359
- logger.break();
2360
- logger.log(`${highlighter.info("💡")} Have your coding agent fix these issues automatically?`);
2361
- logger.dim(` Install the ${highlighter.info("react-doctor")} skill to teach Cursor, Claude Code,`);
2362
- logger.dim(" Ami, and other AI agents how to diagnose and fix React issues.");
2363
- logger.break();
2364
- const { shouldInstall } = await prompts({
2365
- type: "confirm",
2366
- name: "shouldInstall",
2367
- message: "Install skill? (recommended)",
2368
- initial: true
2369
- });
2370
- if (shouldInstall) {
2371
- logger.break();
2372
- installSkill();
2373
- }
2374
- writeSkillPromptConfig({
2375
- ...config,
2376
- skillPromptDismissed: true
2377
- });
2378
- };
2379
-
2380
2281
  //#endregion
2381
2282
  //#region src/cli.ts
2382
- const VERSION = "0.0.31";
2283
+ const VERSION = "0.0.33";
2383
2284
  const VALID_FAIL_ON_LEVELS = new Set([
2384
2285
  "error",
2385
2286
  "warning",
@@ -2391,23 +2292,33 @@ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2391
2292
  if (failOnLevel === "warning") return diagnostics.length > 0;
2392
2293
  return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2393
2294
  };
2394
- const exitWithFixHint = () => {
2295
+ const resolveFailOnLevel = (programInstance, flags, userConfig) => {
2296
+ const resolvedFailOn = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2297
+ return isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none";
2298
+ };
2299
+ const printAnnotations = (diagnostics) => {
2300
+ for (const diagnostic of diagnostics) {
2301
+ const level = diagnostic.severity === "error" ? "error" : "warning";
2302
+ const title = `${diagnostic.plugin}/${diagnostic.rule}`;
2303
+ const fileLocation = diagnostic.line > 0 ? `file=${diagnostic.filePath},line=${diagnostic.line}` : `file=${diagnostic.filePath}`;
2304
+ console.log(`::${level} ${fileLocation},title=${title}::${diagnostic.message}`);
2305
+ }
2306
+ };
2307
+ const exitGracefully = () => {
2395
2308
  logger.break();
2396
2309
  logger.log("Cancelled.");
2397
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
2398
2310
  logger.break();
2399
2311
  process.exit(0);
2400
2312
  };
2401
- process.on("SIGINT", exitWithFixHint);
2402
- process.on("SIGTERM", exitWithFixHint);
2313
+ process.on("SIGINT", exitGracefully);
2314
+ process.on("SIGTERM", exitGracefully);
2403
2315
  const AUTOMATED_ENVIRONMENT_VARIABLES = [
2404
2316
  "CI",
2405
2317
  "CLAUDECODE",
2406
2318
  "CURSOR_AGENT",
2407
2319
  "CODEX_CI",
2408
2320
  "OPENCODE",
2409
- "AMP_HOME",
2410
- "AMI"
2321
+ "AMP_HOME"
2411
2322
  ];
2412
2323
  const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
2413
2324
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
@@ -2442,7 +2353,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2442
2353
  });
2443
2354
  return Boolean(shouldScanChangedOnly);
2444
2355
  };
2445
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--ami", "enable Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
2356
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("-n, --no", "skip prompts, always run a full scan (decline diff-only)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--annotations", "output diagnostics as GitHub Actions annotations").action(async (directory, flags) => {
2446
2357
  const isScoreOnly = flags.score;
2447
2358
  try {
2448
2359
  const resolvedDirectory = path.resolve(directory);
@@ -2452,8 +2363,34 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2452
2363
  logger.break();
2453
2364
  }
2454
2365
  const scanOptions = resolveCliScanOptions(flags, userConfig, program);
2455
- const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY;
2456
- const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
2366
+ const shouldSkipPrompts = flags.yes || flags.no || isAutomatedEnvironment() || !process.stdin.isTTY;
2367
+ if (flags.staged) {
2368
+ const stagedFiles = getStagedSourceFiles(resolvedDirectory);
2369
+ if (stagedFiles.length === 0) {
2370
+ if (!isScoreOnly) logger.dim("No staged source files found.");
2371
+ return;
2372
+ }
2373
+ if (!isScoreOnly) {
2374
+ logger.log(`Scanning ${highlighter.info(`${stagedFiles.length}`)} staged files...`);
2375
+ logger.break();
2376
+ }
2377
+ const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, mkdtempSync(path.join(tmpdir(), "react-doctor-staged-")));
2378
+ try {
2379
+ const remappedDiagnostics = (await scan(snapshot.tempDirectory, {
2380
+ ...scanOptions,
2381
+ includePaths: snapshot.stagedFiles,
2382
+ configOverride: userConfig
2383
+ })).diagnostics.map((diagnostic) => ({
2384
+ ...diagnostic,
2385
+ filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replace(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
2386
+ }));
2387
+ if (flags.annotations) printAnnotations(remappedDiagnostics);
2388
+ if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2389
+ } finally {
2390
+ snapshot.cleanup();
2391
+ }
2392
+ return;
2393
+ }
2457
2394
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
2458
2395
  const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
2459
2396
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
@@ -2492,13 +2429,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2492
2429
  allDiagnostics.push(...scanResult.diagnostics);
2493
2430
  if (!isScoreOnly) logger.break();
2494
2431
  }
2495
- const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2496
- if (shouldFailForDiagnostics(allDiagnostics, isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none")) process.exitCode = 1;
2497
- if (flags.fix) openAmiToFix(resolvedDirectory);
2498
- if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
2499
- await maybePromptSkillInstall(shouldSkipAmiPrompts);
2500
- await maybePromptFix(resolvedDirectory, allDiagnostics, flags.offline ? null : await fetchEstimatedScore(allDiagnostics));
2501
- }
2432
+ if (flags.annotations) printAnnotations(allDiagnostics);
2433
+ if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2502
2434
  } catch (error) {
2503
2435
  handleError(error);
2504
2436
  }
@@ -2506,146 +2438,6 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2506
2438
  ${highlighter.dim("Learn more:")}
2507
2439
  ${highlighter.info("https://github.com/millionco/react-doctor")}
2508
2440
  `);
2509
- const DEEPLINK_FIX_PROMPT = "/{slash-command:ami:react-doctor}";
2510
- const isAmiInstalled = () => {
2511
- if (process.platform === "darwin") return existsSync("/Applications/Ami.app") || existsSync(path.join(os.homedir(), "Applications", "Ami.app"));
2512
- if (process.platform === "win32") {
2513
- const { LOCALAPPDATA, PROGRAMFILES } = process.env;
2514
- return Boolean(LOCALAPPDATA && existsSync(path.join(LOCALAPPDATA, "Programs", "Ami", "Ami.exe"))) || Boolean(PROGRAMFILES && existsSync(path.join(PROGRAMFILES, "Ami", "Ami.exe")));
2515
- }
2516
- try {
2517
- execSync("which ami", { stdio: "ignore" });
2518
- return true;
2519
- } catch {
2520
- return false;
2521
- }
2522
- };
2523
- const installAmi = () => {
2524
- logger.log("Installing Ami...");
2525
- logger.break();
2526
- try {
2527
- execSync(`curl -fsSL ${AMI_INSTALL_URL} | bash`, { stdio: "inherit" });
2528
- } catch {
2529
- logger.error(`Failed to install Ami. Visit ${AMI_WEBSITE_URL} to install manually.`);
2530
- process.exit(1);
2531
- }
2532
- logger.break();
2533
- };
2534
- const openUrl = (url) => {
2535
- if (process.platform === "win32") {
2536
- execSync(`start "" "${url.replace(/%/g, "%%")}"`, { stdio: "ignore" });
2537
- return;
2538
- }
2539
- execSync(process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`, { stdio: "ignore" });
2540
- };
2541
- const buildDeeplinkParams = (directory) => {
2542
- const params = new URLSearchParams();
2543
- params.set("cwd", path.resolve(directory));
2544
- params.set("prompt", DEEPLINK_FIX_PROMPT);
2545
- params.set("mode", "agent");
2546
- params.set("autoSubmit", "true");
2547
- params.set("source", "react-doctor");
2548
- return params;
2549
- };
2550
- const buildDeeplink = (directory) => `ami://open-project?${buildDeeplinkParams(directory).toString()}`;
2551
- const buildWebDeeplink = (directory) => `${OPEN_BASE_URL}?${buildDeeplinkParams(directory).toString()}`;
2552
- const openAmiToFix = (directory) => {
2553
- const isInstalled = isAmiInstalled();
2554
- const deeplink = buildDeeplink(directory);
2555
- const webDeeplink = buildWebDeeplink(directory);
2556
- if (!isInstalled) {
2557
- if (process.platform === "darwin") {
2558
- installAmi();
2559
- if (isAmiInstalled()) logger.success("Ami installed successfully.");
2560
- else {
2561
- logger.error("Installation could not be verified.");
2562
- logger.dim(`Install manually at ${highlighter.info(AMI_WEBSITE_URL)}`);
2563
- }
2564
- } else {
2565
- logger.error("Ami is not installed.");
2566
- logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);
2567
- }
2568
- logger.break();
2569
- logger.dim("Open this link to start fixing:");
2570
- logger.info(webDeeplink);
2571
- return;
2572
- }
2573
- logger.log("Opening Ami...");
2574
- try {
2575
- openUrl(deeplink);
2576
- logger.success("Ami opened. Fixing your issues now.");
2577
- } catch {
2578
- logger.break();
2579
- logger.dim("Could not open Ami automatically. Open this link instead:");
2580
- logger.info(webDeeplink);
2581
- }
2582
- };
2583
- const FIX_METHOD_AMI = "ami";
2584
- const FIX_COMMAND_HINT = "npx react-doctor@latest --fix";
2585
- const buildAmiBanner = (issueCount, currentScore, estimatedScore) => {
2586
- const currentScoreDisplay = colorizeByScore(String(currentScore), currentScore);
2587
- const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore);
2588
- const issueLabel = issueCount === 1 ? "issue" : "issues";
2589
- return renderFramedBoxString([
2590
- createFramedLine(`Score: ${currentScore} → ~${estimatedScore}`, `Score: ${currentScoreDisplay} ${highlighter.dim("→")} ${estimatedScoreDisplay}`),
2591
- createFramedLine(""),
2592
- createFramedLine(`Ami is a coding agent built for React. It reads`, `${highlighter.info("Ami")} is a coding agent built for React. It reads`),
2593
- createFramedLine("your react-doctor report, understands your codebase,"),
2594
- createFramedLine(`and fixes ${issueCount} ${issueLabel} one by one — then re-runs the`, `and fixes ${highlighter.warn(String(issueCount))} ${issueLabel} one by one — then re-runs the`),
2595
- createFramedLine("scan to verify the score improved."),
2596
- createFramedLine(""),
2597
- createFramedLine(`Free to use. ${AMI_WEBSITE_URL}`, `Free to use. ${highlighter.info(AMI_WEBSITE_URL)}`)
2598
- ]);
2599
- };
2600
- const buildSkipBanner = (issueCount, estimatedScore) => {
2601
- const issueLabel = issueCount === 1 ? "issue" : "issues";
2602
- const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore);
2603
- return renderFramedBoxString([
2604
- createFramedLine(`Skip fixing ${issueCount} ${issueLabel} and reaching ~${estimatedScore}?`, `Skip fixing ${highlighter.warn(String(issueCount))} ${issueLabel} and reaching ${estimatedScoreDisplay}?`),
2605
- createFramedLine(""),
2606
- createFramedLine(`Run ${FIX_COMMAND_HINT} anytime to come back.`, `Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to come back.`)
2607
- ]);
2608
- };
2609
- const configureFixBanners = (issueCount, estimatedScoreResult) => {
2610
- const { currentScore, estimatedScore } = estimatedScoreResult;
2611
- setSelectBanner(buildAmiBanner(issueCount, currentScore, estimatedScore), 0);
2612
- setSelectBanner(buildSkipBanner(issueCount, estimatedScore), 1);
2613
- };
2614
- const maybePromptFix = async (directory, diagnostics, estimatedScoreResult) => {
2615
- if (diagnostics.length === 0) return;
2616
- logger.break();
2617
- if (estimatedScoreResult) configureFixBanners(diagnostics.length, estimatedScoreResult);
2618
- const { fixMethod } = await prompts({
2619
- type: "select",
2620
- name: "fixMethod",
2621
- message: "Fix issues?",
2622
- choices: [{
2623
- title: "Use Ami (recommended)",
2624
- description: "Optimized coding agent for React Doctor",
2625
- value: FIX_METHOD_AMI
2626
- }, {
2627
- title: "Skip",
2628
- value: "skip"
2629
- }]
2630
- });
2631
- clearSelectBanner();
2632
- if (fixMethod === FIX_METHOD_AMI) openAmiToFix(directory);
2633
- else {
2634
- logger.break();
2635
- logger.dim(` Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to fix issues.`);
2636
- }
2637
- };
2638
- const fixAction = (directory) => {
2639
- try {
2640
- openAmiToFix(directory);
2641
- } catch (error) {
2642
- handleError(error);
2643
- }
2644
- };
2645
- const fixCommand = new Command("fix").description("Open Ami to auto-fix react-doctor issues").argument("[directory]", "project directory", ".").action(fixAction);
2646
- const installAmiCommand = new Command("install-ami").description("Install Ami and open it to auto-fix issues").argument("[directory]", "project directory", ".").action(fixAction);
2647
- program.addCommand(fixCommand);
2648
- program.addCommand(installAmiCommand);
2649
2441
  const main$1 = async () => {
2650
2442
  await program.parseAsync();
2651
2443
  };