react-doctor 0.0.18 → 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
  });
@@ -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
 
@@ -861,7 +926,8 @@ const resolvePluginPath = () => {
861
926
  const resolveDiagnosticCategory = (plugin, rule) => {
862
927
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
863
928
  };
864
- 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 [];
865
931
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
866
932
  const config = createOxlintConfig({
867
933
  pluginPath: resolvePluginPath(),
@@ -878,7 +944,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
878
944
  "json"
879
945
  ];
880
946
  if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
881
- args.push(".");
947
+ if (includePaths !== void 0) args.push(...includePaths);
948
+ else args.push(".");
882
949
  const stdout = await new Promise((resolve, reject) => {
883
950
  const child = spawn(process.execPath, args, { cwd: rootDirectory });
884
951
  const stdoutBuffers = [];
@@ -905,7 +972,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
905
972
  } catch {
906
973
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
907
974
  }
908
- 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) => {
909
976
  const { plugin, rule } = parseRuleCode(diagnostic.code);
910
977
  const primaryLabel = diagnostic.labels[0];
911
978
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -957,10 +1024,6 @@ const spinner = (text) => ({ start() {
957
1024
  };
958
1025
  } });
959
1026
 
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
1027
  //#endregion
965
1028
  //#region src/scan.ts
966
1029
  const SEVERITY_ORDER = {
@@ -1165,9 +1228,19 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1165
1228
  logger.break();
1166
1229
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1167
1230
  };
1168
- const scan = async (directory, options) => {
1231
+ const scan = async (directory, inputOptions = {}) => {
1169
1232
  const startTime = performance.now();
1170
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;
1171
1244
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
1172
1245
  if (!options.scoreOnly) {
1173
1246
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -1179,13 +1252,16 @@ const scan = async (directory, options) => {
1179
1252
  completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
1180
1253
  completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1181
1254
  completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1182
- 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")}.`);
1183
1258
  logger.break();
1184
1259
  }
1260
+ const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
1185
1261
  const lintPromise = options.lint ? (async () => {
1186
1262
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1187
1263
  try {
1188
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler);
1264
+ const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
1189
1265
  lintSpinner?.succeed("Running lint checks.");
1190
1266
  return lintDiagnostics;
1191
1267
  } catch (error) {
@@ -1194,7 +1270,7 @@ const scan = async (directory, options) => {
1194
1270
  return [];
1195
1271
  }
1196
1272
  })() : Promise.resolve([]);
1197
- const deadCodePromise = options.deadCode ? (async () => {
1273
+ const deadCodePromise = options.deadCode && !isDiffMode ? (async () => {
1198
1274
  const deadCodeSpinner = options.scoreOnly ? null : spinner("Detecting dead code...").start();
1199
1275
  try {
1200
1276
  const knipDiagnostics = await runKnip(directory);
@@ -1207,11 +1283,12 @@ const scan = async (directory, options) => {
1207
1283
  }
1208
1284
  })() : Promise.resolve([]);
1209
1285
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
1210
- const diagnostics = [
1286
+ const allDiagnostics = [
1211
1287
  ...lintDiagnostics,
1212
1288
  ...deadCodeDiagnostics,
1213
- ...checkReducedMotion(directory)
1289
+ ...isDiffMode ? [] : checkReducedMotion(directory)
1214
1290
  ];
1291
+ const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
1215
1292
  const elapsedMilliseconds = performance.now() - startTime;
1216
1293
  const scoreResult = await calculateScore(diagnostics);
1217
1294
  if (options.scoreOnly) {
@@ -1229,7 +1306,149 @@ const scan = async (directory, options) => {
1229
1306
  return;
1230
1307
  }
1231
1308
  printDiagnostics(diagnostics, options.verbose);
1232
- 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;
1233
1452
  };
1234
1453
 
1235
1454
  //#endregion
@@ -1393,93 +1612,49 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1393
1612
  }
1394
1613
  };
1395
1614
 
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
1615
  //#endregion
1465
1616
  //#region src/cli.ts
1466
- const VERSION = "0.0.18";
1617
+ const VERSION = "0.0.19";
1467
1618
  process.on("SIGINT", () => process.exit(0));
1468
1619
  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) => {
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) => {
1470
1643
  const isScoreOnly = flags.score && !flags.prompt;
1471
1644
  const shouldCopyPromptOutput = flags.prompt;
1472
1645
  if (shouldCopyPromptOutput) startLoggerCapture();
1473
1646
  try {
1474
1647
  const resolvedDirectory = path.resolve(directory);
1648
+ const userConfig = loadConfig(resolvedDirectory);
1475
1649
  if (!isScoreOnly) {
1476
1650
  logger.log(`react-doctor v${VERSION}`);
1477
1651
  logger.break();
1478
1652
  }
1653
+ const isCliOverride = (optionName) => program.getOptionValueSource(optionName) === "cli";
1479
1654
  const scanOptions = {
1480
- lint: flags.lint,
1481
- deadCode: flags.deadCode,
1482
- 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),
1483
1658
  scoreOnly: isScoreOnly
1484
1659
  };
1485
1660
  const isAutomatedEnvironment = [
@@ -1493,12 +1668,38 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1493
1668
  ].some(Boolean);
1494
1669
  const shouldSkipPrompts = flags.yes || isAutomatedEnvironment || !process.stdin.isTTY;
1495
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
+ }
1496
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
+ }
1497
1695
  if (!isScoreOnly) {
1498
1696
  logger.dim(`Scanning ${projectDirectory}...`);
1499
1697
  logger.break();
1500
1698
  }
1501
- await scan(projectDirectory, scanOptions);
1699
+ await scan(projectDirectory, {
1700
+ ...scanOptions,
1701
+ includePaths
1702
+ });
1502
1703
  if (!isScoreOnly) logger.break();
1503
1704
  }
1504
1705
  if (flags.fix) openAmiToFix(resolvedDirectory);