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/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/plugin/constants.ts
404
- const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
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/check-reduced-motion.ts
408
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
409
- const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
410
- const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
411
- filePath: "package.json",
412
- plugin: "react-doctor",
413
- rule: "require-reduced-motion",
414
- severity: "error",
415
- message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
416
- help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
417
- line: 0,
418
- column: 0,
419
- category: "Accessibility",
420
- weight: 2
421
- };
422
- const checkReducedMotion = (rootDirectory) => {
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 (!fs.existsSync(packageJsonPath)) return [];
425
- let hasMotionLibrary = false;
426
- try {
427
- const packageJson = readPackageJson(packageJsonPath);
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
- if (!hasMotionLibrary) return [];
437
- try {
438
- execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
439
- cwd: rootDirectory,
440
- stdio: "pipe"
441
- });
442
- return [];
443
- } catch {
444
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
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": "Compute during render: `const derived = computeFrom(dep1, dep2)` no useEffect needed",
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, options) => {
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(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
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
- logger.error(String(error));
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 diagnostics = [
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
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, projectInfo.sourceFileCount);
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.18";
1662
+ const VERSION = "0.0.20";
1467
1663
  process.on("SIGINT", () => process.exit(0));
1468
1664
  process.on("SIGTERM", () => process.exit(0));
1469
- 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("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
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, scanOptions);
1744
+ await scan(projectDirectory, {
1745
+ ...scanOptions,
1746
+ includePaths
1747
+ });
1502
1748
  if (!isScoreOnly) logger.break();
1503
1749
  }
1504
1750
  if (flags.fix) openAmiToFix(resolvedDirectory);