react-doctor 0.0.17 → 0.0.19

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
  });
@@ -242,23 +212,34 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
242
212
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
243
213
  return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
244
214
  };
245
- const findDependencyInfoFromAncestors = (startDirectory) => {
215
+ const isMonorepoRoot = (directory) => {
216
+ if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
217
+ const packageJsonPath = path.join(directory, "package.json");
218
+ if (!fs.existsSync(packageJsonPath)) return false;
219
+ const packageJson = readPackageJson(packageJsonPath);
220
+ return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
221
+ };
222
+ const findMonorepoRoot$1 = (startDirectory) => {
246
223
  let currentDirectory = path.dirname(startDirectory);
247
- const result = {
248
- reactVersion: null,
249
- framework: "unknown"
250
- };
251
224
  while (currentDirectory !== path.dirname(currentDirectory)) {
252
- const packageJsonPath = path.join(currentDirectory, "package.json");
253
- if (fs.existsSync(packageJsonPath)) {
254
- const info = extractDependencyInfo(readPackageJson(packageJsonPath));
255
- if (!result.reactVersion && info.reactVersion) result.reactVersion = info.reactVersion;
256
- if (result.framework === "unknown" && info.framework !== "unknown") result.framework = info.framework;
257
- if (result.reactVersion && result.framework !== "unknown") return result;
258
- }
225
+ if (isMonorepoRoot(currentDirectory)) return currentDirectory;
259
226
  currentDirectory = path.dirname(currentDirectory);
260
227
  }
261
- return result;
228
+ return null;
229
+ };
230
+ const findDependencyInfoFromMonorepoRoot = (directory) => {
231
+ const monorepoRoot = findMonorepoRoot$1(directory);
232
+ if (!monorepoRoot) return {
233
+ reactVersion: null,
234
+ framework: "unknown"
235
+ };
236
+ const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
237
+ const rootInfo = extractDependencyInfo(rootPackageJson);
238
+ const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
239
+ return {
240
+ reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
241
+ framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
242
+ };
262
243
  };
263
244
  const findReactInWorkspaces = (rootDirectory, packageJson) => {
264
245
  const patterns = getWorkspacePatterns(rootDirectory, packageJson);
@@ -355,10 +336,10 @@ const discoverProject = (directory) => {
355
336
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
356
337
  if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
357
338
  }
358
- if (!reactVersion || framework === "unknown") {
359
- const ancestorInfo = findDependencyInfoFromAncestors(directory);
360
- if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
361
- if (framework === "unknown") framework = ancestorInfo.framework;
339
+ if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(directory)) {
340
+ const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory);
341
+ if (!reactVersion) reactVersion = monorepoInfo.reactVersion;
342
+ if (framework === "unknown") framework = monorepoInfo.framework;
362
343
  }
363
344
  const projectName = packageJson.name ?? path.basename(directory);
364
345
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
@@ -375,6 +356,49 @@ const discoverProject = (directory) => {
375
356
  };
376
357
  };
377
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
+
378
402
  //#endregion
379
403
  //#region src/utils/group-by.ts
380
404
  const groupBy = (items, keyFn) => {
@@ -389,48 +413,100 @@ const groupBy = (items, keyFn) => {
389
413
  };
390
414
 
391
415
  //#endregion
392
- //#region src/plugin/constants.ts
393
- 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
+ };
394
424
 
395
425
  //#endregion
396
- //#region src/utils/check-reduced-motion.ts
397
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
398
- const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
399
- const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
400
- filePath: "package.json",
401
- plugin: "react-doctor",
402
- rule: "require-reduced-motion",
403
- severity: "error",
404
- message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
405
- help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
406
- line: 0,
407
- column: 0,
408
- category: "Accessibility",
409
- weight: 2
410
- };
411
- 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
+ }
412
448
  const packageJsonPath = path.join(rootDirectory, "package.json");
413
- if (!fs.existsSync(packageJsonPath)) return [];
414
- let hasMotionLibrary = false;
415
- try {
416
- const packageJson = readPackageJson(packageJsonPath);
417
- const allDependencies = {
418
- ...packageJson.dependencies,
419
- ...packageJson.devDependencies
420
- };
421
- 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;
422
453
  } catch {
423
- return [];
454
+ return null;
424
455
  }
425
- if (!hasMotionLibrary) return [];
426
- try {
427
- execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
428
- cwd: rootDirectory,
429
- stdio: "pipe"
430
- });
431
- return [];
432
- } catch {
433
- 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("");
434
510
  }
435
511
  };
436
512
 
@@ -474,15 +550,18 @@ const silenced = async (fn) => {
474
550
  const originalLog = console.log;
475
551
  const originalInfo = console.info;
476
552
  const originalWarn = console.warn;
553
+ const originalError = console.error;
477
554
  console.log = () => {};
478
555
  console.info = () => {};
479
556
  console.warn = () => {};
557
+ console.error = () => {};
480
558
  try {
481
559
  return await fn();
482
560
  } finally {
483
561
  console.log = originalLog;
484
562
  console.info = originalInfo;
485
563
  console.warn = originalWarn;
564
+ console.error = originalError;
486
565
  }
487
566
  };
488
567
  const findMonorepoRoot = (directory) => {
@@ -498,13 +577,26 @@ const findMonorepoRoot = (directory) => {
498
577
  }
499
578
  return null;
500
579
  };
580
+ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
581
+ const extractFailedPluginName = (error) => {
582
+ return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
583
+ };
584
+ const MAX_KNIP_RETRIES = 5;
501
585
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
502
586
  const options = await silenced(() => createOptions({
503
587
  cwd: knipCwd,
504
588
  isShowProgress: false,
505
589
  ...workspaceName ? { workspace: workspaceName } : {}
506
590
  }));
507
- return await silenced(() => main(options));
591
+ const parsedConfig = options.parsedConfig;
592
+ for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
593
+ return await silenced(() => main(options));
594
+ } catch (error) {
595
+ const failedPlugin = extractFailedPluginName(error);
596
+ if (!failedPlugin || attempt === MAX_KNIP_RETRIES) throw error;
597
+ parsedConfig[failedPlugin] = false;
598
+ }
599
+ throw new Error("Unreachable");
508
600
  };
509
601
  const hasNodeModules = (directory) => {
510
602
  const nodeModulesPath = path.join(directory, "node_modules");
@@ -834,7 +926,8 @@ const resolvePluginPath = () => {
834
926
  const resolveDiagnosticCategory = (plugin, rule) => {
835
927
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
836
928
  };
837
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
929
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
930
+ if (includePaths !== void 0 && includePaths.length === 0) return [];
838
931
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
839
932
  const config = createOxlintConfig({
840
933
  pluginPath: resolvePluginPath(),
@@ -851,7 +944,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
851
944
  "json"
852
945
  ];
853
946
  if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
854
- args.push(".");
947
+ if (includePaths !== void 0) args.push(...includePaths);
948
+ else args.push(".");
855
949
  const stdout = await new Promise((resolve, reject) => {
856
950
  const child = spawn(process.execPath, args, { cwd: rootDirectory });
857
951
  const stdoutBuffers = [];
@@ -878,7 +972,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
878
972
  } catch {
879
973
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
880
974
  }
881
- return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
975
+ return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
882
976
  const { plugin, rule } = parseRuleCode(diagnostic.code);
883
977
  const primaryLabel = diagnostic.labels[0];
884
978
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -930,10 +1024,6 @@ const spinner = (text) => ({ start() {
930
1024
  };
931
1025
  } });
932
1026
 
933
- //#endregion
934
- //#region src/utils/indent-multiline-text.ts
935
- const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
936
-
937
1027
  //#endregion
938
1028
  //#region src/scan.ts
939
1029
  const SEVERITY_ORDER = {
@@ -1138,9 +1228,19 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1138
1228
  logger.break();
1139
1229
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1140
1230
  };
1141
- const scan = async (directory, options) => {
1231
+ const scan = async (directory, inputOptions = {}) => {
1142
1232
  const startTime = performance.now();
1143
1233
  const projectInfo = discoverProject(directory);
1234
+ const userConfig = loadConfig(directory);
1235
+ const options = {
1236
+ lint: inputOptions.lint ?? userConfig?.lint ?? true,
1237
+ deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
1238
+ verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
1239
+ scoreOnly: inputOptions.scoreOnly ?? false,
1240
+ includePaths: inputOptions.includePaths
1241
+ };
1242
+ const includePaths = options.includePaths ?? [];
1243
+ const isDiffMode = includePaths.length > 0;
1144
1244
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
1145
1245
  if (!options.scoreOnly) {
1146
1246
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -1152,13 +1252,16 @@ const scan = async (directory, options) => {
1152
1252
  completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
1153
1253
  completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1154
1254
  completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1155
- completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1255
+ if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
1256
+ else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1257
+ if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
1156
1258
  logger.break();
1157
1259
  }
1260
+ const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
1158
1261
  const lintPromise = options.lint ? (async () => {
1159
1262
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1160
1263
  try {
1161
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
1264
+ const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
1162
1265
  lintSpinner?.succeed("Running lint checks.");
1163
1266
  return lintDiagnostics;
1164
1267
  } catch (error) {
@@ -1167,7 +1270,7 @@ const scan = async (directory, options) => {
1167
1270
  return [];
1168
1271
  }
1169
1272
  })() : Promise.resolve([]);
1170
- const deadCodePromise = options.deadCode ? (async () => {
1273
+ const deadCodePromise = options.deadCode && !isDiffMode ? (async () => {
1171
1274
  const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
1172
1275
  try {
1173
1276
  const knipDiagnostics = await runKnip(directory);
@@ -1180,11 +1283,12 @@ const scan = async (directory, options) => {
1180
1283
  }
1181
1284
  })() : Promise.resolve([]);
1182
1285
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
1183
- const diagnostics = [
1286
+ const allDiagnostics = [
1184
1287
  ...lintDiagnostics,
1185
1288
  ...deadCodeDiagnostics,
1186
- ...checkReducedMotion(directory)
1289
+ ...isDiffMode ? [] : checkReducedMotion(directory)
1187
1290
  ];
1291
+ const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
1188
1292
  const elapsedMilliseconds = performance.now() - startTime;
1189
1293
  const scoreResult = await calculateScore(diagnostics);
1190
1294
  if (options.scoreOnly) {
@@ -1202,7 +1306,157 @@ const scan = async (directory, options) => {
1202
1306
  return;
1203
1307
  }
1204
1308
  printDiagnostics(diagnostics, options.verbose);
1205
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, projectInfo.sourceFileCount);
1309
+ const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1310
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount);
1311
+ };
1312
+
1313
+ //#endregion
1314
+ //#region src/utils/copy-to-clipboard.ts
1315
+ const getClipboardCommands = () => {
1316
+ if (process.platform === "darwin") return [{
1317
+ command: "pbcopy",
1318
+ args: []
1319
+ }];
1320
+ if (process.platform === "win32") return [{
1321
+ command: "clip",
1322
+ args: []
1323
+ }];
1324
+ return [
1325
+ {
1326
+ command: "wl-copy",
1327
+ args: []
1328
+ },
1329
+ {
1330
+ command: "xclip",
1331
+ args: ["-selection", "clipboard"]
1332
+ },
1333
+ {
1334
+ command: "xsel",
1335
+ args: ["--clipboard", "--input"]
1336
+ }
1337
+ ];
1338
+ };
1339
+ const copyToClipboard = (text) => {
1340
+ const clipboardCommands = getClipboardCommands();
1341
+ for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
1342
+ input: text,
1343
+ stdio: [
1344
+ "pipe",
1345
+ "ignore",
1346
+ "ignore"
1347
+ ],
1348
+ encoding: "utf8"
1349
+ }).status === 0) return true;
1350
+ return false;
1351
+ };
1352
+
1353
+ //#endregion
1354
+ //#region src/utils/get-diff-files.ts
1355
+ const getCurrentBranch = (directory) => {
1356
+ try {
1357
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1358
+ cwd: directory,
1359
+ stdio: "pipe"
1360
+ }).toString().trim();
1361
+ return branch === "HEAD" ? null : branch;
1362
+ } catch {
1363
+ return null;
1364
+ }
1365
+ };
1366
+ const detectDefaultBranch = (directory) => {
1367
+ try {
1368
+ return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1369
+ cwd: directory,
1370
+ stdio: "pipe"
1371
+ }).toString().trim().replace("refs/remotes/origin/", "");
1372
+ } catch {
1373
+ for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
1374
+ execSync(`git rev-parse --verify ${candidate}`, {
1375
+ cwd: directory,
1376
+ stdio: "pipe"
1377
+ });
1378
+ return candidate;
1379
+ } catch {}
1380
+ return null;
1381
+ }
1382
+ };
1383
+ const getChangedFilesSinceBranch = (directory, baseBranch) => {
1384
+ try {
1385
+ const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
1386
+ cwd: directory,
1387
+ stdio: "pipe"
1388
+ }).toString().trim()}`, {
1389
+ cwd: directory,
1390
+ stdio: "pipe"
1391
+ }).toString().trim();
1392
+ if (!output) return [];
1393
+ return output.split("\n").filter(Boolean);
1394
+ } catch {
1395
+ return [];
1396
+ }
1397
+ };
1398
+ const getDiffInfo = (directory, explicitBaseBranch) => {
1399
+ const currentBranch = getCurrentBranch(directory);
1400
+ if (!currentBranch) return null;
1401
+ const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
1402
+ if (!baseBranch) return null;
1403
+ if (currentBranch === baseBranch) return null;
1404
+ return {
1405
+ currentBranch,
1406
+ baseBranch,
1407
+ changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
1408
+ };
1409
+ };
1410
+ const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
1411
+
1412
+ //#endregion
1413
+ //#region src/utils/global-install.ts
1414
+ const isGloballyInstalled = () => {
1415
+ try {
1416
+ return !execSync("which react-doctor", {
1417
+ stdio: "pipe",
1418
+ encoding: "utf-8"
1419
+ }).trim().includes("/_npx/");
1420
+ } catch {
1421
+ return false;
1422
+ }
1423
+ };
1424
+ const maybeInstallGlobally = () => {
1425
+ try {
1426
+ if (isGloballyInstalled()) return;
1427
+ const child = spawn("npm", [
1428
+ "install",
1429
+ "-g",
1430
+ "react-doctor@latest"
1431
+ ], {
1432
+ detached: true,
1433
+ stdio: "ignore"
1434
+ });
1435
+ child.on("error", () => {});
1436
+ child.unref();
1437
+ } catch {}
1438
+ };
1439
+
1440
+ //#endregion
1441
+ //#region src/utils/handle-error.ts
1442
+ const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
1443
+ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
1444
+ logger.break();
1445
+ logger.error("Something went wrong. Please check the error below for more details.");
1446
+ logger.error("If the problem persists, please open an issue on GitHub.");
1447
+ logger.error("");
1448
+ if (error instanceof Error) logger.error(error.message);
1449
+ logger.break();
1450
+ if (options.shouldExit) process.exit(1);
1451
+ process.exitCode = 1;
1452
+ };
1453
+
1454
+ //#endregion
1455
+ //#region src/utils/should-auto-select-current-choice.ts
1456
+ const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
1457
+ if (choiceStates.some((choiceState) => choiceState.selected)) return false;
1458
+ const currentChoice = choiceStates[cursor];
1459
+ return Boolean(currentChoice) && !currentChoice.disabled;
1206
1460
  };
1207
1461
 
1208
1462
  //#endregion
@@ -1216,6 +1470,7 @@ const shouldSelectAllChoices = (choiceStates) => {
1216
1470
  const require = createRequire(import.meta.url);
1217
1471
  const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
1218
1472
  let didPatchMultiselectToggleAll = false;
1473
+ let didPatchMultiselectSubmit = false;
1219
1474
  const onCancel = () => {
1220
1475
  logger.break();
1221
1476
  logger.log("Cancelled.");
@@ -1240,8 +1495,19 @@ const patchMultiselectToggleAll = () => {
1240
1495
  this.render();
1241
1496
  };
1242
1497
  };
1498
+ const patchMultiselectSubmit = () => {
1499
+ if (didPatchMultiselectSubmit) return;
1500
+ didPatchMultiselectSubmit = true;
1501
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
1502
+ const originalSubmit = multiselectPromptConstructor.prototype.submit;
1503
+ multiselectPromptConstructor.prototype.submit = function() {
1504
+ if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
1505
+ originalSubmit.call(this);
1506
+ };
1507
+ };
1243
1508
  const prompts = (questions) => {
1244
1509
  patchMultiselectToggleAll();
1510
+ patchMultiselectSubmit();
1245
1511
  return basePrompts(questions, { onCancel });
1246
1512
  };
1247
1513
 
@@ -1346,93 +1612,49 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1346
1612
  }
1347
1613
  };
1348
1614
 
1349
- //#endregion
1350
- //#region src/utils/global-install.ts
1351
- const isGloballyInstalled = () => {
1352
- try {
1353
- return !execSync("which react-doctor", {
1354
- stdio: "pipe",
1355
- encoding: "utf-8"
1356
- }).trim().includes("/_npx/");
1357
- } catch {
1358
- return false;
1359
- }
1360
- };
1361
- const maybeInstallGlobally = () => {
1362
- try {
1363
- if (isGloballyInstalled()) return;
1364
- const child = spawn("npm", [
1365
- "install",
1366
- "-g",
1367
- "react-doctor@latest"
1368
- ], {
1369
- detached: true,
1370
- stdio: "ignore"
1371
- });
1372
- child.on("error", () => {});
1373
- child.unref();
1374
- } catch {}
1375
- };
1376
-
1377
- //#endregion
1378
- //#region src/utils/copy-to-clipboard.ts
1379
- const getClipboardCommands = () => {
1380
- if (process.platform === "darwin") return [{
1381
- command: "pbcopy",
1382
- args: []
1383
- }];
1384
- if (process.platform === "win32") return [{
1385
- command: "clip",
1386
- args: []
1387
- }];
1388
- return [
1389
- {
1390
- command: "wl-copy",
1391
- args: []
1392
- },
1393
- {
1394
- command: "xclip",
1395
- args: ["-selection", "clipboard"]
1396
- },
1397
- {
1398
- command: "xsel",
1399
- args: ["--clipboard", "--input"]
1400
- }
1401
- ];
1402
- };
1403
- const copyToClipboard = (text) => {
1404
- const clipboardCommands = getClipboardCommands();
1405
- for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
1406
- input: text,
1407
- stdio: [
1408
- "pipe",
1409
- "ignore",
1410
- "ignore"
1411
- ],
1412
- encoding: "utf8"
1413
- }).status === 0) return true;
1414
- return false;
1415
- };
1416
-
1417
1615
  //#endregion
1418
1616
  //#region src/cli.ts
1419
- const VERSION = "0.0.17";
1617
+ const VERSION = "0.0.19";
1420
1618
  process.on("SIGINT", () => process.exit(0));
1421
1619
  process.on("SIGTERM", () => process.exit(0));
1422
- 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) => {
1620
+ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
1621
+ if (effectiveDiff !== void 0 && effectiveDiff !== false) {
1622
+ if (diffInfo) return true;
1623
+ if (!isScoreOnly) {
1624
+ logger.warn("Not on a feature branch or could not determine base branch. Running full scan.");
1625
+ logger.break();
1626
+ }
1627
+ return false;
1628
+ }
1629
+ if (effectiveDiff === false || !diffInfo) return false;
1630
+ const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
1631
+ if (changedSourceFiles.length === 0) return false;
1632
+ if (shouldSkipPrompts) return true;
1633
+ if (isScoreOnly) return false;
1634
+ const { shouldScanBranchOnly } = await prompts({
1635
+ type: "confirm",
1636
+ name: "shouldScanBranchOnly",
1637
+ message: `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`,
1638
+ initial: true
1639
+ });
1640
+ return Boolean(shouldScanBranchOnly);
1641
+ };
1642
+ 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) => {
1423
1643
  const isScoreOnly = flags.score && !flags.prompt;
1424
1644
  const shouldCopyPromptOutput = flags.prompt;
1425
1645
  if (shouldCopyPromptOutput) startLoggerCapture();
1426
1646
  try {
1427
1647
  const resolvedDirectory = path.resolve(directory);
1648
+ const userConfig = loadConfig(resolvedDirectory);
1428
1649
  if (!isScoreOnly) {
1429
1650
  logger.log(`react-doctor v${VERSION}`);
1430
1651
  logger.break();
1431
1652
  }
1653
+ const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
1432
1654
  const scanOptions = {
1433
- lint: flags.lint,
1434
- deadCode: flags.deadCode,
1435
- verbose: flags.prompt || Boolean(flags.verbose),
1655
+ lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
1656
+ deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
1657
+ verbose: flags.prompt || (isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false),
1436
1658
  scoreOnly: isScoreOnly
1437
1659
  };
1438
1660
  const isAutomatedEnvironment = [
@@ -1446,12 +1668,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1446
1668
  ].some(Boolean);
1447
1669
  const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
1448
1670
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
1671
+ const effectiveDiff = isCliOverride("diff") ? flags.diff : userConfig?.diff;
1672
+ const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
1673
+ const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
1674
+ const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
1675
+ if (isDiffMode && diffInfo && !isScoreOnly) {
1676
+ logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
1677
+ logger.break();
1678
+ }
1449
1679
  for (const projectDirectory of projectDirectories) {
1680
+ let includePaths;
1681
+ if (isDiffMode) {
1682
+ const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch);
1683
+ if (projectDiffInfo) {
1684
+ const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
1685
+ if (changedSourceFiles.length === 0) {
1686
+ if (!isScoreOnly) {
1687
+ logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
1688
+ logger.break();
1689
+ }
1690
+ continue;
1691
+ }
1692
+ includePaths = changedSourceFiles;
1693
+ }
1694
+ }
1450
1695
  if (!isScoreOnly) {
1451
1696
  logger.dim(`Scanning ${projectDirectory}...`);
1452
1697
  logger.break();
1453
1698
  }
1454
- await scan(projectDirectory, scanOptions);
1699
+ await scan(projectDirectory, {
1700
+ ...scanOptions,
1701
+ includePaths
1702
+ });
1455
1703
  if (!isScoreOnly) logger.break();
1456
1704
  }
1457
1705
  if (flags.fix) openAmiToFix(resolvedDirectory);