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/README.md +0 -2
- package/dist/cli.js +226 -434
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +108 -79
- package/dist/index.js.map +1 -1
- package/dist/react-doctor-plugin.js +41 -10
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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 {
|
|
122
|
+
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
123
|
+
const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
139
124
|
return {
|
|
140
|
-
score
|
|
141
|
-
label:
|
|
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
|
|
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
|
|
318
|
-
|
|
319
|
-
return
|
|
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
|
-
|
|
288
|
+
cache.set(filePath, lines);
|
|
330
289
|
return lines;
|
|
331
290
|
} catch {
|
|
332
|
-
|
|
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
|
|
348
|
-
if (
|
|
349
|
-
const nextLineMatch =
|
|
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
|
|
885
|
-
const configFilePath = path.join(
|
|
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(
|
|
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
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|
|
2402
|
-
process.on("SIGTERM",
|
|
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("--
|
|
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
|
-
|
|
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
|
-
|
|
2496
|
-
if (shouldFailForDiagnostics(allDiagnostics,
|
|
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
|
};
|