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