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