react-doctor 0.0.25 → 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 +526 -248
- 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,8 +1,8 @@
|
|
|
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, writeFileSync } from "node:fs";
|
|
5
|
-
import os, { tmpdir } from "node:os";
|
|
4
|
+
import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
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";
|
|
@@ -29,17 +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 FETCH_TIMEOUT_MS = 1e4;
|
|
32
33
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
34
|
+
const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
|
|
33
35
|
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
34
36
|
const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
|
|
35
37
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
36
|
-
|
|
37
|
-
//#endregion
|
|
38
|
-
//#region src/utils/calculate-score.ts
|
|
39
38
|
const ERROR_RULE_PENALTY = 1.5;
|
|
40
39
|
const WARNING_RULE_PENALTY = .75;
|
|
41
40
|
const ERROR_ESTIMATED_FIX_RATE = .85;
|
|
42
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
|
|
43
97
|
const getScoreLabel = (score) => {
|
|
44
98
|
if (score >= SCORE_GOOD_THRESHOLD) return "Great";
|
|
45
99
|
if (score >= SCORE_OK_THRESHOLD) return "Needs work";
|
|
@@ -75,7 +129,7 @@ const estimateScoreLocally = (diagnostics) => {
|
|
|
75
129
|
};
|
|
76
130
|
const calculateScore = async (diagnostics) => {
|
|
77
131
|
try {
|
|
78
|
-
const response = await
|
|
132
|
+
const response = await proxyFetch(SCORE_API_URL, {
|
|
79
133
|
method: "POST",
|
|
80
134
|
headers: { "Content-Type": "application/json" },
|
|
81
135
|
body: JSON.stringify({ diagnostics })
|
|
@@ -88,7 +142,7 @@ const calculateScore = async (diagnostics) => {
|
|
|
88
142
|
};
|
|
89
143
|
const fetchEstimatedScore = async (diagnostics) => {
|
|
90
144
|
try {
|
|
91
|
-
const response = await
|
|
145
|
+
const response = await proxyFetch(ESTIMATE_SCORE_API_URL, {
|
|
92
146
|
method: "POST",
|
|
93
147
|
headers: { "Content-Type": "application/json" },
|
|
94
148
|
body: JSON.stringify({ diagnostics })
|
|
@@ -100,6 +154,24 @@ const fetchEstimatedScore = async (diagnostics) => {
|
|
|
100
154
|
}
|
|
101
155
|
};
|
|
102
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
|
+
|
|
103
175
|
//#endregion
|
|
104
176
|
//#region src/plugin/constants.ts
|
|
105
177
|
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
@@ -150,6 +222,79 @@ const checkReducedMotion = (rootDirectory) => {
|
|
|
150
222
|
}
|
|
151
223
|
};
|
|
152
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
|
+
|
|
153
298
|
//#endregion
|
|
154
299
|
//#region src/utils/discover-project.ts
|
|
155
300
|
const REACT_COMPILER_PACKAGES = new Set([
|
|
@@ -260,27 +405,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
|
260
405
|
if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
|
|
261
406
|
return [];
|
|
262
407
|
}
|
|
263
|
-
const
|
|
408
|
+
const wildcardIndex = cleanPattern.indexOf("*");
|
|
409
|
+
const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
|
|
410
|
+
const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
|
|
264
411
|
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
265
|
-
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
|
|
266
|
-
};
|
|
267
|
-
const isMonorepoRoot = (directory) => {
|
|
268
|
-
if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
269
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
270
|
-
if (!fs.existsSync(packageJsonPath)) return false;
|
|
271
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
272
|
-
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
|
|
273
|
-
};
|
|
274
|
-
const findMonorepoRoot$1 = (startDirectory) => {
|
|
275
|
-
let currentDirectory = path.dirname(startDirectory);
|
|
276
|
-
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
277
|
-
if (isMonorepoRoot(currentDirectory)) return currentDirectory;
|
|
278
|
-
currentDirectory = path.dirname(currentDirectory);
|
|
279
|
-
}
|
|
280
|
-
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")));
|
|
281
413
|
};
|
|
282
414
|
const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
283
|
-
const monorepoRoot = findMonorepoRoot
|
|
415
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
284
416
|
if (!monorepoRoot) return {
|
|
285
417
|
reactVersion: null,
|
|
286
418
|
framework: "unknown"
|
|
@@ -409,59 +541,6 @@ const discoverProject = (directory) => {
|
|
|
409
541
|
};
|
|
410
542
|
};
|
|
411
543
|
|
|
412
|
-
//#endregion
|
|
413
|
-
//#region src/utils/match-glob-pattern.ts
|
|
414
|
-
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
415
|
-
const compileGlobPattern = (pattern) => {
|
|
416
|
-
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
417
|
-
let regexSource = "^";
|
|
418
|
-
let characterIndex = 0;
|
|
419
|
-
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
420
|
-
regexSource += "(?:.+/)?";
|
|
421
|
-
characterIndex += 3;
|
|
422
|
-
} else {
|
|
423
|
-
regexSource += ".*";
|
|
424
|
-
characterIndex += 2;
|
|
425
|
-
}
|
|
426
|
-
else if (normalizedPattern[characterIndex] === "*") {
|
|
427
|
-
regexSource += "[^/]*";
|
|
428
|
-
characterIndex++;
|
|
429
|
-
} else if (normalizedPattern[characterIndex] === "?") {
|
|
430
|
-
regexSource += "[^/]";
|
|
431
|
-
characterIndex++;
|
|
432
|
-
} else {
|
|
433
|
-
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
434
|
-
characterIndex++;
|
|
435
|
-
}
|
|
436
|
-
regexSource += "$";
|
|
437
|
-
return new RegExp(regexSource);
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
//#endregion
|
|
441
|
-
//#region src/utils/filter-diagnostics.ts
|
|
442
|
-
const filterIgnoredDiagnostics = (diagnostics, config) => {
|
|
443
|
-
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
|
|
444
|
-
const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
|
|
445
|
-
if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
|
|
446
|
-
return diagnostics.filter((diagnostic) => {
|
|
447
|
-
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
448
|
-
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
449
|
-
const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
450
|
-
if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
|
|
451
|
-
return true;
|
|
452
|
-
});
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
//#endregion
|
|
456
|
-
//#region src/utils/highlighter.ts
|
|
457
|
-
const highlighter = {
|
|
458
|
-
error: pc.red,
|
|
459
|
-
warn: pc.yellow,
|
|
460
|
-
info: pc.cyan,
|
|
461
|
-
success: pc.green,
|
|
462
|
-
dim: pc.dim
|
|
463
|
-
};
|
|
464
|
-
|
|
465
544
|
//#endregion
|
|
466
545
|
//#region src/utils/logger.ts
|
|
467
546
|
const logger = {
|
|
@@ -616,24 +695,10 @@ const silenced = async (fn) => {
|
|
|
616
695
|
console.error = originalError;
|
|
617
696
|
}
|
|
618
697
|
};
|
|
619
|
-
const findMonorepoRoot = (directory) => {
|
|
620
|
-
let currentDirectory = path.dirname(directory);
|
|
621
|
-
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
622
|
-
if (fs.existsSync(path.join(currentDirectory, "pnpm-workspace.yaml")) || (() => {
|
|
623
|
-
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
624
|
-
if (!fs.existsSync(packageJsonPath)) return false;
|
|
625
|
-
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
626
|
-
return Array.isArray(packageJson.workspaces) || packageJson.workspaces?.packages;
|
|
627
|
-
})()) return currentDirectory;
|
|
628
|
-
currentDirectory = path.dirname(currentDirectory);
|
|
629
|
-
}
|
|
630
|
-
return null;
|
|
631
|
-
};
|
|
632
698
|
const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
|
|
633
699
|
const extractFailedPluginName = (error) => {
|
|
634
700
|
return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
|
|
635
701
|
};
|
|
636
|
-
const MAX_KNIP_RETRIES = 5;
|
|
637
702
|
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
638
703
|
const options = await silenced(() => createOptions({
|
|
639
704
|
cwd: knipCwd,
|
|
@@ -1018,6 +1083,69 @@ const resolvePluginPath = () => {
|
|
|
1018
1083
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
1019
1084
|
return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
|
|
1020
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
|
+
};
|
|
1021
1149
|
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
|
|
1022
1150
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
1023
1151
|
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
|
|
@@ -1029,58 +1157,21 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
1029
1157
|
const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
|
|
1030
1158
|
try {
|
|
1031
1159
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
1032
|
-
const
|
|
1160
|
+
const baseArgs = [
|
|
1033
1161
|
resolveOxlintBinary(),
|
|
1034
1162
|
"-c",
|
|
1035
1163
|
configPath,
|
|
1036
1164
|
"--format",
|
|
1037
1165
|
"json"
|
|
1038
1166
|
];
|
|
1039
|
-
if (hasTypeScript)
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
const
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
const stderrBuffers = [];
|
|
1046
|
-
child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
|
|
1047
|
-
child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
|
|
1048
|
-
child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
|
|
1049
|
-
child.on("close", () => {
|
|
1050
|
-
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
|
|
1051
|
-
if (!output) {
|
|
1052
|
-
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
1053
|
-
if (stderrOutput) {
|
|
1054
|
-
reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
resolve(output);
|
|
1059
|
-
});
|
|
1060
|
-
});
|
|
1061
|
-
if (!stdout) return [];
|
|
1062
|
-
let output;
|
|
1063
|
-
try {
|
|
1064
|
-
output = JSON.parse(stdout);
|
|
1065
|
-
} catch {
|
|
1066
|
-
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));
|
|
1067
1173
|
}
|
|
1068
|
-
return
|
|
1069
|
-
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
1070
|
-
const primaryLabel = diagnostic.labels[0];
|
|
1071
|
-
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
1072
|
-
return {
|
|
1073
|
-
filePath: diagnostic.filename,
|
|
1074
|
-
plugin,
|
|
1075
|
-
rule,
|
|
1076
|
-
severity: diagnostic.severity,
|
|
1077
|
-
message: cleaned.message,
|
|
1078
|
-
help: cleaned.help,
|
|
1079
|
-
line: primaryLabel?.span.line ?? 0,
|
|
1080
|
-
column: primaryLabel?.span.column ?? 0,
|
|
1081
|
-
category: resolveDiagnosticCategory(plugin, rule)
|
|
1082
|
-
};
|
|
1083
|
-
});
|
|
1174
|
+
return allDiagnostics;
|
|
1084
1175
|
} finally {
|
|
1085
1176
|
restoreDisableDirectives();
|
|
1086
1177
|
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
@@ -1188,11 +1279,6 @@ const writeDiagnosticsDirectory = (diagnostics) => {
|
|
|
1188
1279
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
|
|
1189
1280
|
return outputDirectory;
|
|
1190
1281
|
};
|
|
1191
|
-
const colorizeByScore$1 = (text, score) => {
|
|
1192
|
-
if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
|
|
1193
|
-
if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
|
|
1194
|
-
return highlighter.error(text);
|
|
1195
|
-
};
|
|
1196
1282
|
const buildScoreBarSegments = (score) => {
|
|
1197
1283
|
const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
|
|
1198
1284
|
const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
|
|
@@ -1207,11 +1293,11 @@ const buildPlainScoreBar = (score) => {
|
|
|
1207
1293
|
};
|
|
1208
1294
|
const buildScoreBar = (score) => {
|
|
1209
1295
|
const { filledSegment, emptySegment } = buildScoreBarSegments(score);
|
|
1210
|
-
return colorizeByScore
|
|
1296
|
+
return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
|
|
1211
1297
|
};
|
|
1212
1298
|
const printScoreGauge = (score, label) => {
|
|
1213
|
-
const scoreDisplay = colorizeByScore
|
|
1214
|
-
const labelDisplay = colorizeByScore
|
|
1299
|
+
const scoreDisplay = colorizeByScore(`${score}`, score);
|
|
1300
|
+
const labelDisplay = colorizeByScore(label, score);
|
|
1215
1301
|
logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
|
|
1216
1302
|
logger.break();
|
|
1217
1303
|
logger.log(` ${buildScoreBar(score)}`);
|
|
@@ -1225,7 +1311,7 @@ const getDoctorFace = (score) => {
|
|
|
1225
1311
|
const printBranding = (score) => {
|
|
1226
1312
|
if (score !== void 0) {
|
|
1227
1313
|
const [eyes, mouth] = getDoctorFace(score);
|
|
1228
|
-
const colorize = (text) => colorizeByScore
|
|
1314
|
+
const colorize = (text) => colorizeByScore(text, score);
|
|
1229
1315
|
logger.log(colorize(" ┌─────┐"));
|
|
1230
1316
|
logger.log(colorize(` │ ${eyes} │`));
|
|
1231
1317
|
logger.log(colorize(` │ ${mouth} │`));
|
|
@@ -1246,53 +1332,56 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
|
|
|
1246
1332
|
if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
|
|
1247
1333
|
return `${SHARE_BASE_URL}?${params.toString()}`;
|
|
1248
1334
|
};
|
|
1249
|
-
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) => {
|
|
1250
1361
|
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
1251
1362
|
const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
|
|
1252
1363
|
const affectedFileCount = collectAffectedFiles(diagnostics).size;
|
|
1253
1364
|
const elapsed = formatElapsedTime(elapsedMilliseconds);
|
|
1254
|
-
const
|
|
1255
|
-
const
|
|
1365
|
+
const plainParts = [];
|
|
1366
|
+
const renderedParts = [];
|
|
1256
1367
|
if (errorCount > 0) {
|
|
1257
1368
|
const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
|
|
1258
|
-
|
|
1259
|
-
|
|
1369
|
+
plainParts.push(errorText);
|
|
1370
|
+
renderedParts.push(highlighter.error(errorText));
|
|
1260
1371
|
}
|
|
1261
1372
|
if (warningCount > 0) {
|
|
1262
1373
|
const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
|
|
1263
|
-
|
|
1264
|
-
|
|
1374
|
+
plainParts.push(warningText);
|
|
1375
|
+
renderedParts.push(highlighter.warn(warningText));
|
|
1265
1376
|
}
|
|
1266
1377
|
const fileCountText = totalSourceFileCount > 0 ? `across ${affectedFileCount}/${totalSourceFileCount} files` : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
|
|
1267
1378
|
const elapsedTimeText = `in ${elapsed}`;
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const [eyes, mouth] = getDoctorFace(scoreResult.score);
|
|
1275
|
-
const scoreColorizer = (text) => colorizeByScore$1(text, scoreResult.score);
|
|
1276
|
-
summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
|
|
1277
|
-
summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
|
|
1278
|
-
summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
|
|
1279
|
-
summaryFramedLines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
|
|
1280
|
-
summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
|
|
1281
|
-
summaryFramedLines.push(createFramedLine(""));
|
|
1282
|
-
const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
|
|
1283
|
-
const scoreLineRenderedText = `${colorizeByScore$1(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore$1(scoreResult.label, scoreResult.score)}`;
|
|
1284
|
-
summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
|
|
1285
|
-
summaryFramedLines.push(createFramedLine(""));
|
|
1286
|
-
summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
|
|
1287
|
-
summaryFramedLines.push(createFramedLine(""));
|
|
1288
|
-
} else {
|
|
1289
|
-
summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
|
|
1290
|
-
summaryFramedLines.push(createFramedLine(""));
|
|
1291
|
-
summaryFramedLines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
|
|
1292
|
-
summaryFramedLines.push(createFramedLine(""));
|
|
1293
|
-
}
|
|
1294
|
-
summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
|
|
1295
|
-
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)]);
|
|
1296
1385
|
try {
|
|
1297
1386
|
const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
|
|
1298
1387
|
logger.break();
|
|
@@ -1304,37 +1393,41 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
|
|
|
1304
1393
|
logger.break();
|
|
1305
1394
|
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1306
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
|
+
};
|
|
1307
1419
|
const scan = async (directory, inputOptions = {}) => {
|
|
1308
1420
|
const startTime = performance.now();
|
|
1309
1421
|
const projectInfo = discoverProject(directory);
|
|
1310
1422
|
const userConfig = loadConfig(directory);
|
|
1311
|
-
const options =
|
|
1312
|
-
|
|
1313
|
-
deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
|
|
1314
|
-
verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
|
|
1315
|
-
scoreOnly: inputOptions.scoreOnly ?? false,
|
|
1316
|
-
offline: inputOptions.offline ?? false,
|
|
1317
|
-
includePaths: inputOptions.includePaths
|
|
1318
|
-
};
|
|
1319
|
-
const includePaths = options.includePaths ?? [];
|
|
1423
|
+
const options = mergeScanOptions(inputOptions, userConfig);
|
|
1424
|
+
const { includePaths } = options;
|
|
1320
1425
|
const isDiffMode = includePaths.length > 0;
|
|
1321
1426
|
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
1322
|
-
if (!options.scoreOnly)
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
spinner(message).start().succeed(message);
|
|
1327
|
-
};
|
|
1328
|
-
completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
|
|
1329
|
-
completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
|
|
1330
|
-
completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
|
|
1331
|
-
completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
|
|
1332
|
-
if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
|
|
1333
|
-
else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
|
|
1334
|
-
if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
|
|
1335
|
-
logger.break();
|
|
1336
|
-
}
|
|
1337
|
-
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;
|
|
1338
1431
|
const lintPromise = options.lint ? (async () => {
|
|
1339
1432
|
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1340
1433
|
try {
|
|
@@ -1342,6 +1435,7 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1342
1435
|
lintSpinner?.succeed("Running lint checks.");
|
|
1343
1436
|
return lintDiagnostics;
|
|
1344
1437
|
} catch (error) {
|
|
1438
|
+
didLintFail = true;
|
|
1345
1439
|
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
|
|
1346
1440
|
if (error instanceof Error) {
|
|
1347
1441
|
logger.error(error.message);
|
|
@@ -1357,19 +1451,19 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1357
1451
|
deadCodeSpinner?.succeed("Detecting dead code.");
|
|
1358
1452
|
return knipDiagnostics;
|
|
1359
1453
|
} catch (error) {
|
|
1454
|
+
didDeadCodeFail = true;
|
|
1360
1455
|
deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
|
|
1361
1456
|
logger.error(String(error));
|
|
1362
1457
|
return [];
|
|
1363
1458
|
}
|
|
1364
1459
|
})() : Promise.resolve([]);
|
|
1365
1460
|
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
1366
|
-
const
|
|
1367
|
-
...lintDiagnostics,
|
|
1368
|
-
...deadCodeDiagnostics,
|
|
1369
|
-
...isDiffMode ? [] : checkReducedMotion(directory)
|
|
1370
|
-
];
|
|
1371
|
-
const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
|
|
1461
|
+
const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
|
|
1372
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;
|
|
1373
1467
|
const scoreResult = options.offline ? null : await calculateScore(diagnostics);
|
|
1374
1468
|
const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
|
|
1375
1469
|
if (options.scoreOnly) {
|
|
@@ -1377,27 +1471,41 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1377
1471
|
else logger.dim(noScoreMessage);
|
|
1378
1472
|
return {
|
|
1379
1473
|
diagnostics,
|
|
1380
|
-
scoreResult
|
|
1474
|
+
scoreResult,
|
|
1475
|
+
skippedChecks
|
|
1381
1476
|
};
|
|
1382
1477
|
}
|
|
1383
1478
|
if (diagnostics.length === 0) {
|
|
1384
|
-
|
|
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!");
|
|
1385
1483
|
logger.break();
|
|
1386
|
-
if (
|
|
1484
|
+
if (hasSkippedChecks) {
|
|
1485
|
+
printBranding();
|
|
1486
|
+
logger.dim(" Score not shown — some checks could not complete.");
|
|
1487
|
+
} else if (scoreResult) {
|
|
1387
1488
|
printBranding(scoreResult.score);
|
|
1388
1489
|
printScoreGauge(scoreResult.score, scoreResult.label);
|
|
1389
1490
|
} else logger.dim(` ${noScoreMessage}`);
|
|
1390
1491
|
return {
|
|
1391
1492
|
diagnostics,
|
|
1392
|
-
scoreResult
|
|
1493
|
+
scoreResult,
|
|
1494
|
+
skippedChecks
|
|
1393
1495
|
};
|
|
1394
1496
|
}
|
|
1395
1497
|
printDiagnostics(diagnostics, options.verbose);
|
|
1396
1498
|
const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
|
|
1397
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
|
+
}
|
|
1398
1505
|
return {
|
|
1399
1506
|
diagnostics,
|
|
1400
|
-
scoreResult
|
|
1507
|
+
scoreResult,
|
|
1508
|
+
skippedChecks
|
|
1401
1509
|
};
|
|
1402
1510
|
};
|
|
1403
1511
|
|
|
@@ -1629,9 +1737,179 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
|
1629
1737
|
return selectedDirectories;
|
|
1630
1738
|
};
|
|
1631
1739
|
|
|
1740
|
+
//#endregion
|
|
1741
|
+
//#region src/utils/skill-prompt.ts
|
|
1742
|
+
const HOME_DIRECTORY = homedir();
|
|
1743
|
+
const CONFIG_DIRECTORY = join(HOME_DIRECTORY, ".react-doctor");
|
|
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
|
+
`;
|
|
1779
|
+
const readSkillPromptConfig = () => {
|
|
1780
|
+
try {
|
|
1781
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
1782
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
1783
|
+
} catch {
|
|
1784
|
+
return {};
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
const writeSkillPromptConfig = (config) => {
|
|
1788
|
+
try {
|
|
1789
|
+
mkdirSync(CONFIG_DIRECTORY, { recursive: true });
|
|
1790
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
1791
|
+
} catch {}
|
|
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
|
+
];
|
|
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
|
+
}
|
|
1874
|
+
try {
|
|
1875
|
+
writeSkillFiles(join(".agents", SKILL_NAME));
|
|
1876
|
+
logger.log(` ${highlighter.success("✔")} .agents/`);
|
|
1877
|
+
installedCount++;
|
|
1878
|
+
} catch {
|
|
1879
|
+
logger.dim(" ✗ .agents/ (failed)");
|
|
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.");
|
|
1884
|
+
};
|
|
1885
|
+
const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
1886
|
+
const config = readSkillPromptConfig();
|
|
1887
|
+
if (config.skillPromptDismissed) return;
|
|
1888
|
+
if (shouldSkipPrompts) return;
|
|
1889
|
+
logger.break();
|
|
1890
|
+
logger.log(`${highlighter.info("💡")} Have your coding agent fix these issues automatically?`);
|
|
1891
|
+
logger.dim(` Install the ${highlighter.info("react-doctor")} skill to teach Cursor, Claude Code,`);
|
|
1892
|
+
logger.dim(" Ami, and other AI agents how to diagnose and fix React issues.");
|
|
1893
|
+
logger.break();
|
|
1894
|
+
const { shouldInstall } = await prompts({
|
|
1895
|
+
type: "confirm",
|
|
1896
|
+
name: "shouldInstall",
|
|
1897
|
+
message: "Install skill? (recommended)",
|
|
1898
|
+
initial: true
|
|
1899
|
+
});
|
|
1900
|
+
if (shouldInstall) {
|
|
1901
|
+
logger.break();
|
|
1902
|
+
installSkill();
|
|
1903
|
+
}
|
|
1904
|
+
writeSkillPromptConfig({
|
|
1905
|
+
...config,
|
|
1906
|
+
skillPromptDismissed: true
|
|
1907
|
+
});
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1632
1910
|
//#endregion
|
|
1633
1911
|
//#region src/cli.ts
|
|
1634
|
-
const VERSION = "0.0.
|
|
1912
|
+
const VERSION = "0.0.27";
|
|
1635
1913
|
const exitWithFixHint = () => {
|
|
1636
1914
|
logger.break();
|
|
1637
1915
|
logger.log("Cancelled.");
|
|
@@ -1641,6 +1919,26 @@ const exitWithFixHint = () => {
|
|
|
1641
1919
|
};
|
|
1642
1920
|
process.on("SIGINT", exitWithFixHint);
|
|
1643
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
|
+
};
|
|
1644
1942
|
const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
|
|
1645
1943
|
if (effectiveDiff !== void 0 && effectiveDiff !== false) {
|
|
1646
1944
|
if (diffInfo) return true;
|
|
@@ -1672,27 +1970,11 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1672
1970
|
logger.log(`react-doctor v${VERSION}`);
|
|
1673
1971
|
logger.break();
|
|
1674
1972
|
}
|
|
1675
|
-
const
|
|
1676
|
-
const
|
|
1677
|
-
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
|
|
1678
|
-
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
|
|
1679
|
-
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
|
|
1680
|
-
scoreOnly: isScoreOnly,
|
|
1681
|
-
offline: flags.offline
|
|
1682
|
-
};
|
|
1683
|
-
const isAutomatedEnvironment = [
|
|
1684
|
-
process.env.CI,
|
|
1685
|
-
process.env.CLAUDECODE,
|
|
1686
|
-
process.env.CURSOR_AGENT,
|
|
1687
|
-
process.env.CODEX_CI,
|
|
1688
|
-
process.env.OPENCODE,
|
|
1689
|
-
process.env.AMP_HOME,
|
|
1690
|
-
process.env.AMI
|
|
1691
|
-
].some(Boolean);
|
|
1692
|
-
const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
|
|
1973
|
+
const scanOptions = resolveCliScanOptions(flags, userConfig, program);
|
|
1974
|
+
const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY;
|
|
1693
1975
|
const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
|
|
1694
1976
|
const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
|
|
1695
|
-
const effectiveDiff =
|
|
1977
|
+
const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
|
|
1696
1978
|
const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
|
|
1697
1979
|
const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
|
|
1698
1980
|
const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
|
|
@@ -1730,7 +2012,10 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1730
2012
|
if (!isScoreOnly) logger.break();
|
|
1731
2013
|
}
|
|
1732
2014
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|
|
1733
|
-
if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix)
|
|
2015
|
+
if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
|
|
2016
|
+
await maybePromptSkillInstall(shouldSkipAmiPrompts);
|
|
2017
|
+
await maybePromptFix(resolvedDirectory, allDiagnostics, flags.offline ? null : await fetchEstimatedScore(allDiagnostics));
|
|
2018
|
+
}
|
|
1734
2019
|
} catch (error) {
|
|
1735
2020
|
handleError(error);
|
|
1736
2021
|
}
|
|
@@ -1738,15 +2023,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1738
2023
|
${highlighter.dim("Learn more:")}
|
|
1739
2024
|
${highlighter.info("https://github.com/millionco/react-doctor")}
|
|
1740
2025
|
`);
|
|
1741
|
-
const
|
|
1742
|
-
const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
|
|
1743
|
-
const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
|
|
1744
|
-
const colorizeByScore = (text, score) => {
|
|
1745
|
-
if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
|
|
1746
|
-
if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
|
|
1747
|
-
return highlighter.error(text);
|
|
1748
|
-
};
|
|
1749
|
-
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}";
|
|
1750
2027
|
const isAmiInstalled = () => {
|
|
1751
2028
|
if (process.platform === "darwin") return existsSync("/Applications/Ami.app") || existsSync(path.join(os.homedir(), "Applications", "Ami.app"));
|
|
1752
2029
|
if (process.platform === "win32") {
|
|
@@ -1784,6 +2061,7 @@ const buildDeeplinkParams = (directory) => {
|
|
|
1784
2061
|
params.set("prompt", DEEPLINK_FIX_PROMPT);
|
|
1785
2062
|
params.set("mode", "agent");
|
|
1786
2063
|
params.set("autoSubmit", "true");
|
|
2064
|
+
params.set("source", "react-doctor");
|
|
1787
2065
|
return params;
|
|
1788
2066
|
};
|
|
1789
2067
|
const buildDeeplink = (directory) => `ami://open-project?${buildDeeplinkParams(directory).toString()}`;
|
|
@@ -1855,7 +2133,7 @@ const maybePromptFix = async (directory, diagnostics, estimatedScoreResult) => {
|
|
|
1855
2133
|
name: "fixMethod",
|
|
1856
2134
|
message: "Fix issues?",
|
|
1857
2135
|
choices: [{
|
|
1858
|
-
title: "Use
|
|
2136
|
+
title: "Use Ami (recommended)",
|
|
1859
2137
|
description: "Optimized coding agent for React Doctor",
|
|
1860
2138
|
value: FIX_METHOD_AMI
|
|
1861
2139
|
}, {
|