react-doctor 0.0.18 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/cli.js +452 -206
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +194 -10
- package/dist/index.js.map +1 -1
- package/dist/react-doctor-plugin.js +5 -2
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,9 +5,9 @@ import fs, { 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";
|
|
8
|
-
import pc from "picocolors";
|
|
9
8
|
import { randomUUID } from "node:crypto";
|
|
10
9
|
import { performance } from "node:perf_hooks";
|
|
10
|
+
import pc from "picocolors";
|
|
11
11
|
import { main } from "knip";
|
|
12
12
|
import { createOptions } from "knip/session";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -30,84 +30,7 @@ const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
|
30
30
|
const SHARE_BASE_URL = "https://www.react.doctor/share";
|
|
31
31
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
32
32
|
const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
|
|
33
|
-
|
|
34
|
-
//#endregion
|
|
35
|
-
//#region src/utils/highlighter.ts
|
|
36
|
-
const highlighter = {
|
|
37
|
-
error: pc.red,
|
|
38
|
-
warn: pc.yellow,
|
|
39
|
-
info: pc.cyan,
|
|
40
|
-
success: pc.green,
|
|
41
|
-
dim: pc.dim
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
//#endregion
|
|
45
|
-
//#region src/utils/strip-ansi.ts
|
|
46
|
-
const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
|
|
47
|
-
const ANSI_ESCAPE_PATTERN = new RegExp(ANSI_ESCAPE_SEQUENCE, "g");
|
|
48
|
-
const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
49
|
-
|
|
50
|
-
//#endregion
|
|
51
|
-
//#region src/utils/logger.ts
|
|
52
|
-
const loggerCaptureState = {
|
|
53
|
-
isEnabled: false,
|
|
54
|
-
lines: []
|
|
55
|
-
};
|
|
56
|
-
const captureLogLine = (text) => {
|
|
57
|
-
if (!loggerCaptureState.isEnabled) return;
|
|
58
|
-
loggerCaptureState.lines.push(stripAnsi(text));
|
|
59
|
-
};
|
|
60
|
-
const writeLogLine = (text) => {
|
|
61
|
-
console.log(text);
|
|
62
|
-
captureLogLine(text);
|
|
63
|
-
};
|
|
64
|
-
const startLoggerCapture = () => {
|
|
65
|
-
loggerCaptureState.isEnabled = true;
|
|
66
|
-
loggerCaptureState.lines = [];
|
|
67
|
-
};
|
|
68
|
-
const stopLoggerCapture = () => {
|
|
69
|
-
const capturedOutput = loggerCaptureState.lines.join("\n");
|
|
70
|
-
loggerCaptureState.isEnabled = false;
|
|
71
|
-
loggerCaptureState.lines = [];
|
|
72
|
-
return capturedOutput;
|
|
73
|
-
};
|
|
74
|
-
const logger = {
|
|
75
|
-
error(...args) {
|
|
76
|
-
writeLogLine(highlighter.error(args.join(" ")));
|
|
77
|
-
},
|
|
78
|
-
warn(...args) {
|
|
79
|
-
writeLogLine(highlighter.warn(args.join(" ")));
|
|
80
|
-
},
|
|
81
|
-
info(...args) {
|
|
82
|
-
writeLogLine(highlighter.info(args.join(" ")));
|
|
83
|
-
},
|
|
84
|
-
success(...args) {
|
|
85
|
-
writeLogLine(highlighter.success(args.join(" ")));
|
|
86
|
-
},
|
|
87
|
-
dim(...args) {
|
|
88
|
-
writeLogLine(highlighter.dim(args.join(" ")));
|
|
89
|
-
},
|
|
90
|
-
log(...args) {
|
|
91
|
-
writeLogLine(args.join(" "));
|
|
92
|
-
},
|
|
93
|
-
break() {
|
|
94
|
-
writeLogLine("");
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
//#endregion
|
|
99
|
-
//#region src/utils/handle-error.ts
|
|
100
|
-
const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
|
|
101
|
-
const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
102
|
-
logger.break();
|
|
103
|
-
logger.error("Something went wrong. Please check the error below for more details.");
|
|
104
|
-
logger.error("If the problem persists, please open an issue on GitHub.");
|
|
105
|
-
logger.error("");
|
|
106
|
-
if (error instanceof Error) logger.error(error.message);
|
|
107
|
-
logger.break();
|
|
108
|
-
if (options.shouldExit) process.exit(1);
|
|
109
|
-
process.exitCode = 1;
|
|
110
|
-
};
|
|
33
|
+
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
111
34
|
|
|
112
35
|
//#endregion
|
|
113
36
|
//#region src/utils/calculate-score.ts
|
|
@@ -130,10 +53,56 @@ const calculateScore = async (diagnostics) => {
|
|
|
130
53
|
}
|
|
131
54
|
};
|
|
132
55
|
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/plugin/constants.ts
|
|
58
|
+
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
59
|
+
|
|
133
60
|
//#endregion
|
|
134
61
|
//#region src/utils/read-package-json.ts
|
|
135
62
|
const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
136
63
|
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/utils/check-reduced-motion.ts
|
|
66
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
|
|
67
|
+
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
68
|
+
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
69
|
+
filePath: "package.json",
|
|
70
|
+
plugin: "react-doctor",
|
|
71
|
+
rule: "require-reduced-motion",
|
|
72
|
+
severity: "error",
|
|
73
|
+
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
74
|
+
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
75
|
+
line: 0,
|
|
76
|
+
column: 0,
|
|
77
|
+
category: "Accessibility",
|
|
78
|
+
weight: 2
|
|
79
|
+
};
|
|
80
|
+
const checkReducedMotion = (rootDirectory) => {
|
|
81
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
82
|
+
if (!fs.existsSync(packageJsonPath)) return [];
|
|
83
|
+
let hasMotionLibrary = false;
|
|
84
|
+
try {
|
|
85
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
86
|
+
const allDependencies = {
|
|
87
|
+
...packageJson.dependencies,
|
|
88
|
+
...packageJson.devDependencies
|
|
89
|
+
};
|
|
90
|
+
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
if (!hasMotionLibrary) return [];
|
|
95
|
+
try {
|
|
96
|
+
execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
|
|
97
|
+
cwd: rootDirectory,
|
|
98
|
+
stdio: "pipe"
|
|
99
|
+
});
|
|
100
|
+
return [];
|
|
101
|
+
} catch {
|
|
102
|
+
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
137
106
|
//#endregion
|
|
138
107
|
//#region src/utils/discover-project.ts
|
|
139
108
|
const REACT_COMPILER_PACKAGES = new Set([
|
|
@@ -193,6 +162,7 @@ const countSourceFiles = (rootDirectory) => {
|
|
|
193
162
|
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
194
163
|
};
|
|
195
164
|
const collectAllDependencies = (packageJson) => ({
|
|
165
|
+
...packageJson.peerDependencies,
|
|
196
166
|
...packageJson.dependencies,
|
|
197
167
|
...packageJson.devDependencies
|
|
198
168
|
});
|
|
@@ -386,6 +356,49 @@ const discoverProject = (directory) => {
|
|
|
386
356
|
};
|
|
387
357
|
};
|
|
388
358
|
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/utils/match-glob-pattern.ts
|
|
361
|
+
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
362
|
+
const compileGlobPattern = (pattern) => {
|
|
363
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
364
|
+
let regexSource = "^";
|
|
365
|
+
let characterIndex = 0;
|
|
366
|
+
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
367
|
+
regexSource += "(?:.+/)?";
|
|
368
|
+
characterIndex += 3;
|
|
369
|
+
} else {
|
|
370
|
+
regexSource += ".*";
|
|
371
|
+
characterIndex += 2;
|
|
372
|
+
}
|
|
373
|
+
else if (normalizedPattern[characterIndex] === "*") {
|
|
374
|
+
regexSource += "[^/]*";
|
|
375
|
+
characterIndex++;
|
|
376
|
+
} else if (normalizedPattern[characterIndex] === "?") {
|
|
377
|
+
regexSource += "[^/]";
|
|
378
|
+
characterIndex++;
|
|
379
|
+
} else {
|
|
380
|
+
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
381
|
+
characterIndex++;
|
|
382
|
+
}
|
|
383
|
+
regexSource += "$";
|
|
384
|
+
return new RegExp(regexSource);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/utils/filter-diagnostics.ts
|
|
389
|
+
const filterIgnoredDiagnostics = (diagnostics, config) => {
|
|
390
|
+
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
|
|
391
|
+
const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
|
|
392
|
+
if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
|
|
393
|
+
return diagnostics.filter((diagnostic) => {
|
|
394
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
395
|
+
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
396
|
+
const normalizedPath = diagnostic.filePath.replace(/\\/g, "/");
|
|
397
|
+
if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
389
402
|
//#endregion
|
|
390
403
|
//#region src/utils/group-by.ts
|
|
391
404
|
const groupBy = (items, keyFn) => {
|
|
@@ -400,48 +413,100 @@ const groupBy = (items, keyFn) => {
|
|
|
400
413
|
};
|
|
401
414
|
|
|
402
415
|
//#endregion
|
|
403
|
-
//#region src/
|
|
404
|
-
const
|
|
416
|
+
//#region src/utils/highlighter.ts
|
|
417
|
+
const highlighter = {
|
|
418
|
+
error: pc.red,
|
|
419
|
+
warn: pc.yellow,
|
|
420
|
+
info: pc.cyan,
|
|
421
|
+
success: pc.green,
|
|
422
|
+
dim: pc.dim
|
|
423
|
+
};
|
|
405
424
|
|
|
406
425
|
//#endregion
|
|
407
|
-
//#region src/utils/
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
};
|
|
422
|
-
|
|
426
|
+
//#region src/utils/indent-multiline-text.ts
|
|
427
|
+
const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
|
|
428
|
+
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/utils/load-config.ts
|
|
431
|
+
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
432
|
+
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
433
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
434
|
+
const loadConfig = (rootDirectory) => {
|
|
435
|
+
const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
|
|
436
|
+
if (fs.existsSync(configFilePath)) try {
|
|
437
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
438
|
+
const parsed = JSON.parse(fileContent);
|
|
439
|
+
if (!isPlainObject(parsed)) {
|
|
440
|
+
console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
return parsed;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
423
448
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const allDependencies = {
|
|
429
|
-
...packageJson.dependencies,
|
|
430
|
-
...packageJson.devDependencies
|
|
431
|
-
};
|
|
432
|
-
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
449
|
+
if (fs.existsSync(packageJsonPath)) try {
|
|
450
|
+
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
451
|
+
const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
|
|
452
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
433
453
|
} catch {
|
|
434
|
-
return
|
|
454
|
+
return null;
|
|
435
455
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
456
|
+
return null;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/utils/strip-ansi.ts
|
|
461
|
+
const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
|
|
462
|
+
const ANSI_ESCAPE_PATTERN = new RegExp(ANSI_ESCAPE_SEQUENCE, "g");
|
|
463
|
+
const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
464
|
+
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/utils/logger.ts
|
|
467
|
+
const loggerCaptureState = {
|
|
468
|
+
isEnabled: false,
|
|
469
|
+
lines: []
|
|
470
|
+
};
|
|
471
|
+
const captureLogLine = (text) => {
|
|
472
|
+
if (!loggerCaptureState.isEnabled) return;
|
|
473
|
+
loggerCaptureState.lines.push(stripAnsi(text));
|
|
474
|
+
};
|
|
475
|
+
const writeLogLine = (text) => {
|
|
476
|
+
console.log(text);
|
|
477
|
+
captureLogLine(text);
|
|
478
|
+
};
|
|
479
|
+
const startLoggerCapture = () => {
|
|
480
|
+
loggerCaptureState.isEnabled = true;
|
|
481
|
+
loggerCaptureState.lines = [];
|
|
482
|
+
};
|
|
483
|
+
const stopLoggerCapture = () => {
|
|
484
|
+
const capturedOutput = loggerCaptureState.lines.join("\n");
|
|
485
|
+
loggerCaptureState.isEnabled = false;
|
|
486
|
+
loggerCaptureState.lines = [];
|
|
487
|
+
return capturedOutput;
|
|
488
|
+
};
|
|
489
|
+
const logger = {
|
|
490
|
+
error(...args) {
|
|
491
|
+
writeLogLine(highlighter.error(args.join(" ")));
|
|
492
|
+
},
|
|
493
|
+
warn(...args) {
|
|
494
|
+
writeLogLine(highlighter.warn(args.join(" ")));
|
|
495
|
+
},
|
|
496
|
+
info(...args) {
|
|
497
|
+
writeLogLine(highlighter.info(args.join(" ")));
|
|
498
|
+
},
|
|
499
|
+
success(...args) {
|
|
500
|
+
writeLogLine(highlighter.success(args.join(" ")));
|
|
501
|
+
},
|
|
502
|
+
dim(...args) {
|
|
503
|
+
writeLogLine(highlighter.dim(args.join(" ")));
|
|
504
|
+
},
|
|
505
|
+
log(...args) {
|
|
506
|
+
writeLogLine(args.join(" "));
|
|
507
|
+
},
|
|
508
|
+
break() {
|
|
509
|
+
writeLogLine("");
|
|
445
510
|
}
|
|
446
511
|
};
|
|
447
512
|
|
|
@@ -700,6 +765,46 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
700
765
|
}
|
|
701
766
|
});
|
|
702
767
|
|
|
768
|
+
//#endregion
|
|
769
|
+
//#region src/utils/neutralize-disable-directives.ts
|
|
770
|
+
const findFilesWithDisableDirectives = (rootDirectory) => {
|
|
771
|
+
const result = spawnSync("git", [
|
|
772
|
+
"grep",
|
|
773
|
+
"-l",
|
|
774
|
+
"--untracked",
|
|
775
|
+
"-E",
|
|
776
|
+
"(eslint|oxlint)-disable"
|
|
777
|
+
], {
|
|
778
|
+
cwd: rootDirectory,
|
|
779
|
+
encoding: "utf-8",
|
|
780
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
781
|
+
});
|
|
782
|
+
if (result.error || result.status === null) return [];
|
|
783
|
+
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
784
|
+
};
|
|
785
|
+
const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
|
|
786
|
+
const neutralizeDisableDirectives = (rootDirectory) => {
|
|
787
|
+
const filePaths = findFilesWithDisableDirectives(rootDirectory);
|
|
788
|
+
const originalContents = /* @__PURE__ */ new Map();
|
|
789
|
+
for (const relativePath of filePaths) {
|
|
790
|
+
const absolutePath = path.join(rootDirectory, relativePath);
|
|
791
|
+
let originalContent;
|
|
792
|
+
try {
|
|
793
|
+
originalContent = fs.readFileSync(absolutePath, "utf-8");
|
|
794
|
+
} catch {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const neutralizedContent = neutralizeContent(originalContent);
|
|
798
|
+
if (neutralizedContent !== originalContent) {
|
|
799
|
+
originalContents.set(absolutePath, originalContent);
|
|
800
|
+
fs.writeFileSync(absolutePath, neutralizedContent);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return () => {
|
|
804
|
+
for (const [absolutePath, originalContent] of originalContents) fs.writeFileSync(absolutePath, originalContent);
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
|
|
703
808
|
//#endregion
|
|
704
809
|
//#region src/utils/run-oxlint.ts
|
|
705
810
|
const esmRequire = createRequire(import.meta.url);
|
|
@@ -767,7 +872,7 @@ const RULE_CATEGORY_MAP = {
|
|
|
767
872
|
"react-doctor/async-parallel": "Performance"
|
|
768
873
|
};
|
|
769
874
|
const RULE_HELP_MAP = {
|
|
770
|
-
"no-derived-state-effect": "
|
|
875
|
+
"no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`",
|
|
771
876
|
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
772
877
|
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
773
878
|
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
@@ -861,13 +966,15 @@ const resolvePluginPath = () => {
|
|
|
861
966
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
862
967
|
return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
|
|
863
968
|
};
|
|
864
|
-
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
|
|
969
|
+
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
|
|
970
|
+
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
865
971
|
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
|
|
866
972
|
const config = createOxlintConfig({
|
|
867
973
|
pluginPath: resolvePluginPath(),
|
|
868
974
|
framework,
|
|
869
975
|
hasReactCompiler
|
|
870
976
|
});
|
|
977
|
+
const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
|
|
871
978
|
try {
|
|
872
979
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
873
980
|
const args = [
|
|
@@ -878,7 +985,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
878
985
|
"json"
|
|
879
986
|
];
|
|
880
987
|
if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
|
|
881
|
-
args.push(
|
|
988
|
+
if (includePaths !== void 0) args.push(...includePaths);
|
|
989
|
+
else args.push(".");
|
|
882
990
|
const stdout = await new Promise((resolve, reject) => {
|
|
883
991
|
const child = spawn(process.execPath, args, { cwd: rootDirectory });
|
|
884
992
|
const stdoutBuffers = [];
|
|
@@ -905,7 +1013,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
905
1013
|
} catch {
|
|
906
1014
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
|
|
907
1015
|
}
|
|
908
|
-
return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
1016
|
+
return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
909
1017
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
910
1018
|
const primaryLabel = diagnostic.labels[0];
|
|
911
1019
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -922,6 +1030,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
922
1030
|
};
|
|
923
1031
|
});
|
|
924
1032
|
} finally {
|
|
1033
|
+
restoreDisableDirectives();
|
|
925
1034
|
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
926
1035
|
}
|
|
927
1036
|
};
|
|
@@ -957,10 +1066,6 @@ const spinner = (text) => ({ start() {
|
|
|
957
1066
|
};
|
|
958
1067
|
} });
|
|
959
1068
|
|
|
960
|
-
//#endregion
|
|
961
|
-
//#region src/utils/indent-multiline-text.ts
|
|
962
|
-
const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
|
|
963
|
-
|
|
964
1069
|
//#endregion
|
|
965
1070
|
//#region src/scan.ts
|
|
966
1071
|
const SEVERITY_ORDER = {
|
|
@@ -1165,9 +1270,19 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
|
|
|
1165
1270
|
logger.break();
|
|
1166
1271
|
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1167
1272
|
};
|
|
1168
|
-
const scan = async (directory,
|
|
1273
|
+
const scan = async (directory, inputOptions = {}) => {
|
|
1169
1274
|
const startTime = performance.now();
|
|
1170
1275
|
const projectInfo = discoverProject(directory);
|
|
1276
|
+
const userConfig = loadConfig(directory);
|
|
1277
|
+
const options = {
|
|
1278
|
+
lint: inputOptions.lint ?? userConfig?.lint ?? true,
|
|
1279
|
+
deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
|
|
1280
|
+
verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
|
|
1281
|
+
scoreOnly: inputOptions.scoreOnly ?? false,
|
|
1282
|
+
includePaths: inputOptions.includePaths
|
|
1283
|
+
};
|
|
1284
|
+
const includePaths = options.includePaths ?? [];
|
|
1285
|
+
const isDiffMode = includePaths.length > 0;
|
|
1171
1286
|
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
1172
1287
|
if (!options.scoreOnly) {
|
|
1173
1288
|
const frameworkLabel = formatFrameworkName(projectInfo.framework);
|
|
@@ -1179,22 +1294,28 @@ const scan = async (directory, options) => {
|
|
|
1179
1294
|
completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
|
|
1180
1295
|
completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
|
|
1181
1296
|
completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
|
|
1182
|
-
completeStep(`
|
|
1297
|
+
if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
|
|
1298
|
+
else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
|
|
1299
|
+
if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
|
|
1183
1300
|
logger.break();
|
|
1184
1301
|
}
|
|
1302
|
+
const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
1185
1303
|
const lintPromise = options.lint ? (async () => {
|
|
1186
1304
|
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1187
1305
|
try {
|
|
1188
|
-
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
|
|
1306
|
+
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
|
|
1189
1307
|
lintSpinner?.succeed("Running lint checks.");
|
|
1190
1308
|
return lintDiagnostics;
|
|
1191
1309
|
} catch (error) {
|
|
1192
1310
|
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
|
|
1193
|
-
|
|
1311
|
+
if (error instanceof Error) {
|
|
1312
|
+
logger.error(error.message);
|
|
1313
|
+
if (error.stack) logger.dim(error.stack);
|
|
1314
|
+
} else logger.error(String(error));
|
|
1194
1315
|
return [];
|
|
1195
1316
|
}
|
|
1196
1317
|
})() : Promise.resolve([]);
|
|
1197
|
-
const deadCodePromise = options.deadCode ? (async () => {
|
|
1318
|
+
const deadCodePromise = options.deadCode && !isDiffMode ? (async () => {
|
|
1198
1319
|
const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
|
|
1199
1320
|
try {
|
|
1200
1321
|
const knipDiagnostics = await runKnip(directory);
|
|
@@ -1207,11 +1328,12 @@ const scan = async (directory, options) => {
|
|
|
1207
1328
|
}
|
|
1208
1329
|
})() : Promise.resolve([]);
|
|
1209
1330
|
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
1210
|
-
const
|
|
1331
|
+
const allDiagnostics = [
|
|
1211
1332
|
...lintDiagnostics,
|
|
1212
1333
|
...deadCodeDiagnostics,
|
|
1213
|
-
...checkReducedMotion(directory)
|
|
1334
|
+
...isDiffMode ? [] : checkReducedMotion(directory)
|
|
1214
1335
|
];
|
|
1336
|
+
const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
|
|
1215
1337
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
1216
1338
|
const scoreResult = await calculateScore(diagnostics);
|
|
1217
1339
|
if (options.scoreOnly) {
|
|
@@ -1229,7 +1351,149 @@ const scan = async (directory, options) => {
|
|
|
1229
1351
|
return;
|
|
1230
1352
|
}
|
|
1231
1353
|
printDiagnostics(diagnostics, options.verbose);
|
|
1232
|
-
|
|
1354
|
+
const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
|
|
1355
|
+
printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount);
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
//#endregion
|
|
1359
|
+
//#region src/utils/copy-to-clipboard.ts
|
|
1360
|
+
const getClipboardCommands = () => {
|
|
1361
|
+
if (process.platform === "darwin") return [{
|
|
1362
|
+
command: "pbcopy",
|
|
1363
|
+
args: []
|
|
1364
|
+
}];
|
|
1365
|
+
if (process.platform === "win32") return [{
|
|
1366
|
+
command: "clip",
|
|
1367
|
+
args: []
|
|
1368
|
+
}];
|
|
1369
|
+
return [
|
|
1370
|
+
{
|
|
1371
|
+
command: "wl-copy",
|
|
1372
|
+
args: []
|
|
1373
|
+
},
|
|
1374
|
+
{
|
|
1375
|
+
command: "xclip",
|
|
1376
|
+
args: ["-selection", "clipboard"]
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
command: "xsel",
|
|
1380
|
+
args: ["--clipboard", "--input"]
|
|
1381
|
+
}
|
|
1382
|
+
];
|
|
1383
|
+
};
|
|
1384
|
+
const copyToClipboard = (text) => {
|
|
1385
|
+
const clipboardCommands = getClipboardCommands();
|
|
1386
|
+
for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
|
|
1387
|
+
input: text,
|
|
1388
|
+
stdio: [
|
|
1389
|
+
"pipe",
|
|
1390
|
+
"ignore",
|
|
1391
|
+
"ignore"
|
|
1392
|
+
],
|
|
1393
|
+
encoding: "utf8"
|
|
1394
|
+
}).status === 0) return true;
|
|
1395
|
+
return false;
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region src/utils/get-diff-files.ts
|
|
1400
|
+
const getCurrentBranch = (directory) => {
|
|
1401
|
+
try {
|
|
1402
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1403
|
+
cwd: directory,
|
|
1404
|
+
stdio: "pipe"
|
|
1405
|
+
}).toString().trim();
|
|
1406
|
+
return branch === "HEAD" ? null : branch;
|
|
1407
|
+
} catch {
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
const detectDefaultBranch = (directory) => {
|
|
1412
|
+
try {
|
|
1413
|
+
return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1414
|
+
cwd: directory,
|
|
1415
|
+
stdio: "pipe"
|
|
1416
|
+
}).toString().trim().replace("refs/remotes/origin/", "");
|
|
1417
|
+
} catch {
|
|
1418
|
+
for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
|
|
1419
|
+
execSync(`git rev-parse --verify ${candidate}`, {
|
|
1420
|
+
cwd: directory,
|
|
1421
|
+
stdio: "pipe"
|
|
1422
|
+
});
|
|
1423
|
+
return candidate;
|
|
1424
|
+
} catch {}
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
const getChangedFilesSinceBranch = (directory, baseBranch) => {
|
|
1429
|
+
try {
|
|
1430
|
+
const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
|
|
1431
|
+
cwd: directory,
|
|
1432
|
+
stdio: "pipe"
|
|
1433
|
+
}).toString().trim()}`, {
|
|
1434
|
+
cwd: directory,
|
|
1435
|
+
stdio: "pipe"
|
|
1436
|
+
}).toString().trim();
|
|
1437
|
+
if (!output) return [];
|
|
1438
|
+
return output.split("\n").filter(Boolean);
|
|
1439
|
+
} catch {
|
|
1440
|
+
return [];
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
const getDiffInfo = (directory, explicitBaseBranch) => {
|
|
1444
|
+
const currentBranch = getCurrentBranch(directory);
|
|
1445
|
+
if (!currentBranch) return null;
|
|
1446
|
+
const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
|
|
1447
|
+
if (!baseBranch) return null;
|
|
1448
|
+
if (currentBranch === baseBranch) return null;
|
|
1449
|
+
return {
|
|
1450
|
+
currentBranch,
|
|
1451
|
+
baseBranch,
|
|
1452
|
+
changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
|
|
1453
|
+
};
|
|
1454
|
+
};
|
|
1455
|
+
const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
|
|
1456
|
+
|
|
1457
|
+
//#endregion
|
|
1458
|
+
//#region src/utils/global-install.ts
|
|
1459
|
+
const isGloballyInstalled = () => {
|
|
1460
|
+
try {
|
|
1461
|
+
return !execSync("which react-doctor", {
|
|
1462
|
+
stdio: "pipe",
|
|
1463
|
+
encoding: "utf-8"
|
|
1464
|
+
}).trim().includes("/_npx/");
|
|
1465
|
+
} catch {
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
const maybeInstallGlobally = () => {
|
|
1470
|
+
try {
|
|
1471
|
+
if (isGloballyInstalled()) return;
|
|
1472
|
+
const child = spawn("npm", [
|
|
1473
|
+
"install",
|
|
1474
|
+
"-g",
|
|
1475
|
+
"react-doctor@latest"
|
|
1476
|
+
], {
|
|
1477
|
+
detached: true,
|
|
1478
|
+
stdio: "ignore"
|
|
1479
|
+
});
|
|
1480
|
+
child.on("error", () => {});
|
|
1481
|
+
child.unref();
|
|
1482
|
+
} catch {}
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region src/utils/handle-error.ts
|
|
1487
|
+
const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
|
|
1488
|
+
const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
1489
|
+
logger.break();
|
|
1490
|
+
logger.error("Something went wrong. Please check the error below for more details.");
|
|
1491
|
+
logger.error("If the problem persists, please open an issue on GitHub.");
|
|
1492
|
+
logger.error("");
|
|
1493
|
+
if (error instanceof Error) logger.error(error.message);
|
|
1494
|
+
logger.break();
|
|
1495
|
+
if (options.shouldExit) process.exit(1);
|
|
1496
|
+
process.exitCode = 1;
|
|
1233
1497
|
};
|
|
1234
1498
|
|
|
1235
1499
|
//#endregion
|
|
@@ -1393,93 +1657,49 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
1393
1657
|
}
|
|
1394
1658
|
};
|
|
1395
1659
|
|
|
1396
|
-
//#endregion
|
|
1397
|
-
//#region src/utils/global-install.ts
|
|
1398
|
-
const isGloballyInstalled = () => {
|
|
1399
|
-
try {
|
|
1400
|
-
return !execSync("which react-doctor", {
|
|
1401
|
-
stdio: "pipe",
|
|
1402
|
-
encoding: "utf-8"
|
|
1403
|
-
}).trim().includes("/_npx/");
|
|
1404
|
-
} catch {
|
|
1405
|
-
return false;
|
|
1406
|
-
}
|
|
1407
|
-
};
|
|
1408
|
-
const maybeInstallGlobally = () => {
|
|
1409
|
-
try {
|
|
1410
|
-
if (isGloballyInstalled()) return;
|
|
1411
|
-
const child = spawn("npm", [
|
|
1412
|
-
"install",
|
|
1413
|
-
"-g",
|
|
1414
|
-
"react-doctor@latest"
|
|
1415
|
-
], {
|
|
1416
|
-
detached: true,
|
|
1417
|
-
stdio: "ignore"
|
|
1418
|
-
});
|
|
1419
|
-
child.on("error", () => {});
|
|
1420
|
-
child.unref();
|
|
1421
|
-
} catch {}
|
|
1422
|
-
};
|
|
1423
|
-
|
|
1424
|
-
//#endregion
|
|
1425
|
-
//#region src/utils/copy-to-clipboard.ts
|
|
1426
|
-
const getClipboardCommands = () => {
|
|
1427
|
-
if (process.platform === "darwin") return [{
|
|
1428
|
-
command: "pbcopy",
|
|
1429
|
-
args: []
|
|
1430
|
-
}];
|
|
1431
|
-
if (process.platform === "win32") return [{
|
|
1432
|
-
command: "clip",
|
|
1433
|
-
args: []
|
|
1434
|
-
}];
|
|
1435
|
-
return [
|
|
1436
|
-
{
|
|
1437
|
-
command: "wl-copy",
|
|
1438
|
-
args: []
|
|
1439
|
-
},
|
|
1440
|
-
{
|
|
1441
|
-
command: "xclip",
|
|
1442
|
-
args: ["-selection", "clipboard"]
|
|
1443
|
-
},
|
|
1444
|
-
{
|
|
1445
|
-
command: "xsel",
|
|
1446
|
-
args: ["--clipboard", "--input"]
|
|
1447
|
-
}
|
|
1448
|
-
];
|
|
1449
|
-
};
|
|
1450
|
-
const copyToClipboard = (text) => {
|
|
1451
|
-
const clipboardCommands = getClipboardCommands();
|
|
1452
|
-
for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
|
|
1453
|
-
input: text,
|
|
1454
|
-
stdio: [
|
|
1455
|
-
"pipe",
|
|
1456
|
-
"ignore",
|
|
1457
|
-
"ignore"
|
|
1458
|
-
],
|
|
1459
|
-
encoding: "utf8"
|
|
1460
|
-
}).status === 0) return true;
|
|
1461
|
-
return false;
|
|
1462
|
-
};
|
|
1463
|
-
|
|
1464
1660
|
//#endregion
|
|
1465
1661
|
//#region src/cli.ts
|
|
1466
|
-
const VERSION = "0.0.
|
|
1662
|
+
const VERSION = "0.0.20";
|
|
1467
1663
|
process.on("SIGINT", () => process.exit(0));
|
|
1468
1664
|
process.on("SIGTERM", () => process.exit(0));
|
|
1469
|
-
const
|
|
1665
|
+
const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
|
|
1666
|
+
if (effectiveDiff !== void 0 && effectiveDiff !== false) {
|
|
1667
|
+
if (diffInfo) return true;
|
|
1668
|
+
if (!isScoreOnly) {
|
|
1669
|
+
logger.warn("Not on a feature branch or could not determine base branch. Running full scan.");
|
|
1670
|
+
logger.break();
|
|
1671
|
+
}
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
if (effectiveDiff === false || !diffInfo) return false;
|
|
1675
|
+
const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
|
|
1676
|
+
if (changedSourceFiles.length === 0) return false;
|
|
1677
|
+
if (shouldSkipPrompts) return true;
|
|
1678
|
+
if (isScoreOnly) return false;
|
|
1679
|
+
const { shouldScanBranchOnly } = await prompts({
|
|
1680
|
+
type: "confirm",
|
|
1681
|
+
name: "shouldScanBranchOnly",
|
|
1682
|
+
message: `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`,
|
|
1683
|
+
initial: true
|
|
1684
|
+
});
|
|
1685
|
+
return Boolean(shouldScanBranchOnly);
|
|
1686
|
+
};
|
|
1687
|
+
const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
|
|
1470
1688
|
const isScoreOnly = flags.score && !flags.prompt;
|
|
1471
1689
|
const shouldCopyPromptOutput = flags.prompt;
|
|
1472
1690
|
if (shouldCopyPromptOutput) startLoggerCapture();
|
|
1473
1691
|
try {
|
|
1474
1692
|
const resolvedDirectory = path.resolve(directory);
|
|
1693
|
+
const userConfig = loadConfig(resolvedDirectory);
|
|
1475
1694
|
if (!isScoreOnly) {
|
|
1476
1695
|
logger.log(`react-doctor v${VERSION}`);
|
|
1477
1696
|
logger.break();
|
|
1478
1697
|
}
|
|
1698
|
+
const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
|
|
1479
1699
|
const scanOptions = {
|
|
1480
|
-
lint: flags.lint,
|
|
1481
|
-
deadCode: flags.deadCode,
|
|
1482
|
-
verbose: flags.prompt || Boolean(flags.verbose),
|
|
1700
|
+
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
|
|
1701
|
+
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
|
|
1702
|
+
verbose: flags.prompt || (isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false),
|
|
1483
1703
|
scoreOnly: isScoreOnly
|
|
1484
1704
|
};
|
|
1485
1705
|
const isAutomatedEnvironment = [
|
|
@@ -1493,12 +1713,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
1493
1713
|
].some(Boolean);
|
|
1494
1714
|
const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
|
|
1495
1715
|
const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
|
|
1716
|
+
const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
|
|
1717
|
+
const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
|
|
1718
|
+
const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
|
|
1719
|
+
const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
|
|
1720
|
+
if (isDiffMode && diffInfo && !isScoreOnly) {
|
|
1721
|
+
logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
|
|
1722
|
+
logger.break();
|
|
1723
|
+
}
|
|
1496
1724
|
for (const projectDirectory of projectDirectories) {
|
|
1725
|
+
let includePaths;
|
|
1726
|
+
if (isDiffMode) {
|
|
1727
|
+
const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch);
|
|
1728
|
+
if (projectDiffInfo) {
|
|
1729
|
+
const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
|
|
1730
|
+
if (changedSourceFiles.length === 0) {
|
|
1731
|
+
if (!isScoreOnly) {
|
|
1732
|
+
logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
|
|
1733
|
+
logger.break();
|
|
1734
|
+
}
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
includePaths = changedSourceFiles;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1497
1740
|
if (!isScoreOnly) {
|
|
1498
1741
|
logger.dim(`Scanning ${projectDirectory}...`);
|
|
1499
1742
|
logger.break();
|
|
1500
1743
|
}
|
|
1501
|
-
await scan(projectDirectory,
|
|
1744
|
+
await scan(projectDirectory, {
|
|
1745
|
+
...scanOptions,
|
|
1746
|
+
includePaths
|
|
1747
|
+
});
|
|
1502
1748
|
if (!isScoreOnly) logger.break();
|
|
1503
1749
|
}
|
|
1504
1750
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|