react-doctor 0.0.46 → 0.1.0

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
@@ -3,7 +3,7 @@ import fs, { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readdirS
3
3
  import os, { tmpdir } from "node:os";
4
4
  import path, { join } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
- import { Command } from "commander";
6
+ import { Command, Option } from "commander";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
9
9
  import pc from "picocolors";
@@ -35,6 +35,7 @@ const KNIP_CONFIG_LOCATIONS = [
35
35
  "knip.config.ts",
36
36
  "knip.config.js"
37
37
  ];
38
+ const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
38
39
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
39
40
  const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
40
41
  const IGNORED_DIRECTORIES = new Set([
@@ -295,7 +296,25 @@ const runInstallSkill = async (options = {}) => {
295
296
  }
296
297
  };
297
298
  //#endregion
298
- //#region src/core/calculate-score-locally.ts
299
+ //#region src/utils/build-hidden-diagnostics-summary.ts
300
+ const buildHiddenDiagnosticsSummary = (hiddenDiagnostics) => {
301
+ const errorCount = hiddenDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
302
+ const warningCount = hiddenDiagnostics.length - errorCount;
303
+ const parts = [];
304
+ if (errorCount > 0) parts.push({
305
+ severity: "error",
306
+ count: errorCount,
307
+ text: `✗ ${errorCount} more error${errorCount === 1 ? "" : "s"}`
308
+ });
309
+ if (warningCount > 0) parts.push({
310
+ severity: "warning",
311
+ count: warningCount,
312
+ text: `⚠ ${warningCount} more warning${warningCount === 1 ? "" : "s"}`
313
+ });
314
+ return parts;
315
+ };
316
+ //#endregion
317
+ //#region src/utils/calculate-score-locally.ts
299
318
  const getScoreLabel = (score) => {
300
319
  if (score >= 75) return "Great";
301
320
  if (score >= 50) return "Needs work";
@@ -327,7 +346,7 @@ const calculateScoreLocally = (diagnostics) => {
327
346
  };
328
347
  };
329
348
  //#endregion
330
- //#region src/core/try-score-from-api.ts
349
+ //#region src/utils/try-score-from-api.ts
331
350
  const parseScoreResult = (value) => {
332
351
  if (typeof value !== "object" || value === null) return null;
333
352
  if (!("score" in value) || !("label" in value)) return null;
@@ -370,10 +389,6 @@ const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
370
389
  }
371
390
  };
372
391
  //#endregion
373
- //#region src/utils/calculate-score-browser.ts
374
- const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
375
- const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
376
- //#endregion
377
392
  //#region src/utils/proxy-fetch.ts
378
393
  const getGlobalProcess = () => {
379
394
  const candidate = globalThis.process;
@@ -402,8 +417,8 @@ const proxyFetch = async (url, init) => {
402
417
  return fetch(url, fetchInit);
403
418
  };
404
419
  //#endregion
405
- //#region src/utils/calculate-score-node.ts
406
- const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
420
+ //#region src/utils/calculate-score.ts
421
+ const calculateScore = async (diagnostics) => await tryScoreFromApi(diagnostics, proxyFetch) ?? calculateScoreLocally(diagnostics);
407
422
  //#endregion
408
423
  //#region src/utils/colorize-by-score.ts
409
424
  const colorizeByScore = (text, score) => {
@@ -413,6 +428,85 @@ const colorizeByScore = (text, score) => {
413
428
  };
414
429
  //#endregion
415
430
  //#region src/plugin/constants.ts
431
+ const FETCH_CALLEE_NAMES = new Set([
432
+ "fetch",
433
+ "ky",
434
+ "got",
435
+ "wretch",
436
+ "ofetch"
437
+ ]);
438
+ const FETCH_MEMBER_OBJECTS = new Set([
439
+ "axios",
440
+ "ky",
441
+ "got",
442
+ "ofetch",
443
+ "wretch",
444
+ "request"
445
+ ]);
446
+ const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
447
+ "setTimeout",
448
+ "setInterval",
449
+ "requestAnimationFrame",
450
+ "requestIdleCallback",
451
+ "queueMicrotask"
452
+ ]);
453
+ const SUBSCRIPTION_METHOD_NAMES = new Set([
454
+ "subscribe",
455
+ "addEventListener",
456
+ "addListener",
457
+ "on",
458
+ "watch",
459
+ "listen",
460
+ "sub"
461
+ ]);
462
+ new Set([
463
+ ...new Set([
464
+ "unsubscribe",
465
+ "removeEventListener",
466
+ "removeListener",
467
+ "off",
468
+ "unwatch",
469
+ "unlisten",
470
+ "unsub"
471
+ ]),
472
+ "cleanup",
473
+ "dispose",
474
+ "destroy",
475
+ "teardown"
476
+ ]);
477
+ new Set([
478
+ ...SUBSCRIPTION_METHOD_NAMES,
479
+ "connect",
480
+ "disconnect",
481
+ "open",
482
+ "close",
483
+ "fetch",
484
+ "post",
485
+ "put",
486
+ "patch"
487
+ ]);
488
+ new Set([
489
+ ...FETCH_MEMBER_OBJECTS,
490
+ "api",
491
+ "client",
492
+ "http",
493
+ "fetcher"
494
+ ]);
495
+ new Set([...FETCH_CALLEE_NAMES, ...TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES]);
496
+ new Set([
497
+ ...FETCH_CALLEE_NAMES,
498
+ "post",
499
+ "put",
500
+ "patch",
501
+ "navigate",
502
+ "navigateTo",
503
+ "showNotification",
504
+ "toast",
505
+ "alert",
506
+ "confirm",
507
+ "logVisit",
508
+ "captureEvent"
509
+ ]);
416
510
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
417
511
  //#endregion
418
512
  //#region src/utils/is-file.ts
@@ -515,6 +609,13 @@ const createNodeReadFileLinesSync = (rootDirectory) => {
515
609
  };
516
610
  };
517
611
  //#endregion
612
+ //#region src/utils/is-plain-object.ts
613
+ const isPlainObject = (value) => {
614
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
615
+ const prototype = Object.getPrototypeOf(value);
616
+ return prototype === null || prototype === Object.prototype;
617
+ };
618
+ //#endregion
518
619
  //#region src/utils/match-glob-pattern.ts
519
620
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
520
621
  const compileGlobPattern = (pattern) => {
@@ -542,13 +643,232 @@ const compileGlobPattern = (pattern) => {
542
643
  return new RegExp(regexSource);
543
644
  };
544
645
  //#endregion
545
- //#region src/utils/is-ignored-file.ts
646
+ //#region src/utils/to-relative-path.ts
546
647
  const toRelativePath = (filePath, rootDirectory) => {
547
648
  const normalizedFilePath = filePath.replace(/\\/g, "/");
548
649
  const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
549
650
  if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
550
651
  return normalizedFilePath.replace(/^\.\//, "");
551
652
  };
653
+ //#endregion
654
+ //#region src/utils/apply-ignore-overrides.ts
655
+ const warnConfigField$1 = (message) => {
656
+ process.stderr.write(`[react-doctor] ${message}\n`);
657
+ };
658
+ const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
659
+ const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
660
+ const validateOverrideEntry = (entry, index) => {
661
+ if (!isPlainObject(entry)) {
662
+ warnConfigField$1(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
663
+ return null;
664
+ }
665
+ if (!isStringArray(entry.files)) {
666
+ warnConfigField$1(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
667
+ return null;
668
+ }
669
+ if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
670
+ warnConfigField$1(`ignore.overrides[${index}].rules must be an array of "plugin/rule" strings or omitted; treating as missing (override would suppress every rule for the matched files).`);
671
+ return { files: entry.files };
672
+ }
673
+ return entry.rules === void 0 ? { files: entry.files } : {
674
+ files: entry.files,
675
+ rules: entry.rules
676
+ };
677
+ };
678
+ const compileIgnoreOverrides = (userConfig) => {
679
+ const overrides = userConfig?.ignore?.overrides;
680
+ if (overrides === void 0) return [];
681
+ if (!Array.isArray(overrides)) {
682
+ warnConfigField$1(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
683
+ return [];
684
+ }
685
+ return overrides.flatMap((entry, index) => {
686
+ const validated = validateOverrideEntry(entry, index);
687
+ if (!validated) return [];
688
+ const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
689
+ if (filePatterns.length === 0) return [];
690
+ return [{
691
+ filePatterns,
692
+ ruleIds: new Set(collectStringList(validated.rules))
693
+ }];
694
+ });
695
+ };
696
+ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
697
+ if (overrides.length === 0) return false;
698
+ const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
699
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
700
+ return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
701
+ };
702
+ //#endregion
703
+ //#region src/utils/find-jsx-opener-span.ts
704
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
705
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
706
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
707
+ let stringDelimiter = null;
708
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
709
+ const character = line[charIndex];
710
+ if (stringDelimiter !== null) {
711
+ if (character === "\\") {
712
+ charIndex++;
713
+ continue;
714
+ }
715
+ if (character === stringDelimiter) stringDelimiter = null;
716
+ continue;
717
+ }
718
+ if (character === "\"" || character === "'" || character === "`") {
719
+ stringDelimiter = character;
720
+ continue;
721
+ }
722
+ if (character === "/" && line[charIndex + 1] === "/") return true;
723
+ }
724
+ return false;
725
+ };
726
+ const findOpenerTagOnLine = (line) => {
727
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
728
+ if (match.index === void 0) continue;
729
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
730
+ }
731
+ return null;
732
+ };
733
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
734
+ const openerLine = lines[openerLineIndex];
735
+ if (openerLine === void 0) return null;
736
+ const opener = findOpenerTagOnLine(openerLine);
737
+ if (!opener) return null;
738
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
739
+ let braceDepth = 0;
740
+ let innerAngleDepth = 0;
741
+ let stringDelimiter = null;
742
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
743
+ const currentLine = lines[lineIndex];
744
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
745
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
746
+ const character = currentLine[charIndex];
747
+ if (stringDelimiter !== null) {
748
+ if (character === "\\") {
749
+ charIndex++;
750
+ continue;
751
+ }
752
+ if (character === stringDelimiter) stringDelimiter = null;
753
+ continue;
754
+ }
755
+ if (character === "\"" || character === "'" || character === "`") {
756
+ stringDelimiter = character;
757
+ continue;
758
+ }
759
+ if (character === "{") {
760
+ braceDepth++;
761
+ continue;
762
+ }
763
+ if (character === "}") {
764
+ braceDepth--;
765
+ continue;
766
+ }
767
+ if (braceDepth !== 0) continue;
768
+ if (character === "<") {
769
+ const followCharacter = currentLine[charIndex + 1];
770
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
771
+ continue;
772
+ }
773
+ if (character !== ">") continue;
774
+ const previousCharacter = currentLine[charIndex - 1];
775
+ const nextCharacter = currentLine[charIndex + 1];
776
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
777
+ if (innerAngleDepth > 0) {
778
+ innerAngleDepth--;
779
+ continue;
780
+ }
781
+ return lineIndex;
782
+ }
783
+ }
784
+ return null;
785
+ };
786
+ //#endregion
787
+ //#region src/utils/find-enclosing-jsx-opener.ts
788
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
789
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
790
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
791
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
792
+ }
793
+ return null;
794
+ };
795
+ //#endregion
796
+ //#region src/utils/find-stacked-disable-comments.ts
797
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
798
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
799
+ const collected = [];
800
+ let isStillInChain = true;
801
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
802
+ const candidateLine = lines[candidateIndex];
803
+ if (candidateLine === void 0) break;
804
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
805
+ if (match) {
806
+ collected.push({
807
+ commentLineIndex: candidateIndex,
808
+ ruleList: match[1],
809
+ isInChain: isStillInChain
810
+ });
811
+ continue;
812
+ }
813
+ isStillInChain = false;
814
+ }
815
+ return collected;
816
+ };
817
+ //#endregion
818
+ //#region src/utils/is-rule-listed-in-comment.ts
819
+ const isRuleListedInComment = (ruleList, ruleId) => {
820
+ if (!ruleList?.trim()) return true;
821
+ return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
822
+ };
823
+ //#endregion
824
+ //#region src/utils/evaluate-suppression.ts
825
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
826
+ const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
827
+ const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
828
+ const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
829
+ const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
830
+ const buildAdjacentMismatchHint = (comment, ruleId) => {
831
+ const ruleListText = comment.ruleList?.trim() ?? "";
832
+ return `An adjacent react-doctor-disable-next-line at line ${comment.commentLineIndex + 1} lists "${ruleListText}" — ${ruleId} is not in that list. Use the comma form: react-doctor-disable-next-line ${ruleListText}, ${ruleId}`;
833
+ };
834
+ const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
835
+ const commentLineNumber = comment.commentLineIndex + 1;
836
+ const diagnosticLineNumber = diagnosticLineIndex + 1;
837
+ return `A react-doctor-disable-next-line for ${ruleId} sits at line ${commentLineNumber}, but ${formatLineGap(diagnosticLineNumber - commentLineNumber - 1)} of code separate it from the diagnostic on line ${diagnosticLineNumber}. Move the comment immediately above line ${diagnosticLineNumber}, or extract the surrounding code into a helper so the suppression is adjacent.`;
838
+ };
839
+ const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
840
+ for (const comments of commentsByAnchor) {
841
+ const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
842
+ if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
843
+ const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
844
+ if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
845
+ }
846
+ return null;
847
+ };
848
+ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
849
+ const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
850
+ if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
851
+ isSuppressed: true,
852
+ nearMissHint: null
853
+ };
854
+ const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
855
+ if (hasChainSuppressor(directComments, ruleId)) return {
856
+ isSuppressed: true,
857
+ nearMissHint: null
858
+ };
859
+ const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
860
+ const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
861
+ if (hasChainSuppressor(openerComments, ruleId)) return {
862
+ isSuppressed: true,
863
+ nearMissHint: null
864
+ };
865
+ return {
866
+ isSuppressed: false,
867
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
868
+ };
869
+ };
870
+ //#endregion
871
+ //#region src/utils/is-ignored-file.ts
552
872
  const compileIgnoredFilePatterns = (userConfig) => {
553
873
  const files = userConfig?.ignore?.files;
554
874
  if (!Array.isArray(files)) return [];
@@ -561,14 +881,12 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
561
881
  };
562
882
  //#endregion
563
883
  //#region src/utils/filter-diagnostics.ts
884
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
564
885
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
565
886
  const normalizedFile = filePath.replace(/\\/g, "/");
566
887
  if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
567
888
  return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
568
889
  };
569
- const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
570
- const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
571
- const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
572
890
  const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
573
891
  const cache = /* @__PURE__ */ new Map();
574
892
  return (filePath) => {
@@ -589,13 +907,10 @@ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
589
907
  }
590
908
  return false;
591
909
  };
592
- const isRuleSuppressed = (commentRules, ruleId) => {
593
- if (!commentRules?.trim()) return true;
594
- return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
595
- };
596
910
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
597
911
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
598
912
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
913
+ const compiledOverrides = compileIgnoreOverrides(config);
599
914
  const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
600
915
  const hasTextComponents = textComponentNames.size > 0;
601
916
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
@@ -603,6 +918,7 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLi
603
918
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
604
919
  if (ignoredRules.has(ruleIdentifier)) return false;
605
920
  if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
921
+ if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
606
922
  if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
607
923
  const lines = getFileLines(diagnostic.filePath);
608
924
  if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
@@ -612,41 +928,36 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLi
612
928
  };
613
929
  const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
614
930
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
615
- return diagnostics.filter((diagnostic) => {
616
- if (diagnostic.line <= 0) return true;
931
+ return diagnostics.flatMap((diagnostic) => {
932
+ if (diagnostic.line <= 0) return [diagnostic];
617
933
  const lines = getFileLines(diagnostic.filePath);
618
- if (!lines) return true;
619
- const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
620
- const currentLine = lines[diagnostic.line - 1];
621
- if (currentLine) {
622
- const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
623
- if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
624
- }
625
- if (diagnostic.line >= 2) {
626
- const previousLine = lines[diagnostic.line - 2];
627
- if (previousLine) {
628
- const nextLineMatch = previousLine.match(DISABLE_NEXT_LINE_PATTERN);
629
- if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
630
- }
631
- }
632
- return true;
934
+ if (!lines) return [diagnostic];
935
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
936
+ const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
937
+ if (evaluation.isSuppressed) return [];
938
+ return evaluation.nearMissHint ? [{
939
+ ...diagnostic,
940
+ suppressionHint: evaluation.nearMissHint
941
+ }] : [diagnostic];
633
942
  });
634
943
  };
635
944
  //#endregion
636
945
  //#region src/utils/merge-and-filter-diagnostics.ts
637
- const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
638
- return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
946
+ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
947
+ const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
948
+ if (options.respectInlineDisables === false) return filtered;
949
+ return filterInlineSuppressions(filtered, directory, readFileLinesSync);
639
950
  };
640
951
  //#endregion
641
952
  //#region src/utils/combine-diagnostics.ts
642
953
  const combineDiagnostics = (input) => {
643
- const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true } = input;
954
+ const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true, respectInlineDisables } = input;
644
955
  const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
645
956
  return mergeAndFilterDiagnostics([
646
957
  ...lintDiagnostics,
647
958
  ...deadCodeDiagnostics,
648
959
  ...extraDiagnostics
649
- ], directory, userConfig, readFileLinesSync);
960
+ ], directory, userConfig, readFileLinesSync, { respectInlineDisables });
650
961
  };
651
962
  //#endregion
652
963
  //#region src/utils/jsx-include-paths.ts
@@ -670,13 +981,6 @@ const findMonorepoRoot = (startDirectory) => {
670
981
  return null;
671
982
  };
672
983
  //#endregion
673
- //#region src/utils/is-plain-object.ts
674
- const isPlainObject = (value) => {
675
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
676
- const prototype = Object.getPrototypeOf(value);
677
- return prototype === null || prototype === Object.prototype;
678
- };
679
- //#endregion
680
984
  //#region src/utils/discover-project.ts
681
985
  const REACT_COMPILER_PACKAGES = new Set([
682
986
  "babel-plugin-react-compiler",
@@ -1074,7 +1378,7 @@ const hasCompilerInConfigFile = (filePath) => {
1074
1378
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
1075
1379
  };
1076
1380
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
1077
- const isProjectBoundary$1 = (directory) => {
1381
+ const isProjectBoundary$2 = (directory) => {
1078
1382
  if (fs.existsSync(path.join(directory, ".git"))) return true;
1079
1383
  return isMonorepoRoot(directory);
1080
1384
  };
@@ -1084,14 +1388,14 @@ const detectReactCompiler = (directory, packageJson) => {
1084
1388
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
1085
1389
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
1086
1390
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
1087
- if (isProjectBoundary$1(directory)) return false;
1391
+ if (isProjectBoundary$2(directory)) return false;
1088
1392
  let ancestorDirectory = path.dirname(directory);
1089
1393
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1090
1394
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
1091
1395
  if (isFile(ancestorPackagePath)) {
1092
1396
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
1093
1397
  }
1094
- if (isProjectBoundary$1(ancestorDirectory)) return false;
1398
+ if (isProjectBoundary$2(ancestorDirectory)) return false;
1095
1399
  ancestorDirectory = path.dirname(ancestorDirectory);
1096
1400
  }
1097
1401
  return false;
@@ -1206,7 +1510,8 @@ const BOOLEAN_FIELD_NAMES = [
1206
1510
  "verbose",
1207
1511
  "customRulesOnly",
1208
1512
  "share",
1209
- "respectInlineDisables"
1513
+ "respectInlineDisables",
1514
+ "adoptExistingLintConfig"
1210
1515
  ];
1211
1516
  const warnConfigField = (message) => {
1212
1517
  process.stderr.write(`[react-doctor] ${message}\n`);
@@ -1261,7 +1566,7 @@ const loadConfigFromDirectory = (directory) => {
1261
1566
  }
1262
1567
  return null;
1263
1568
  };
1264
- const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1569
+ const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1265
1570
  const cachedConfigs = /* @__PURE__ */ new Map();
1266
1571
  const loadConfig = (rootDirectory) => {
1267
1572
  const cached = cachedConfigs.get(rootDirectory);
@@ -1271,7 +1576,7 @@ const loadConfig = (rootDirectory) => {
1271
1576
  cachedConfigs.set(rootDirectory, localConfig);
1272
1577
  return localConfig;
1273
1578
  }
1274
- if (isProjectBoundary(rootDirectory)) {
1579
+ if (isProjectBoundary$1(rootDirectory)) {
1275
1580
  cachedConfigs.set(rootDirectory, null);
1276
1581
  return null;
1277
1582
  }
@@ -1282,7 +1587,7 @@ const loadConfig = (rootDirectory) => {
1282
1587
  cachedConfigs.set(rootDirectory, ancestorConfig);
1283
1588
  return ancestorConfig;
1284
1589
  }
1285
- if (isProjectBoundary(ancestorDirectory)) {
1590
+ if (isProjectBoundary$1(ancestorDirectory)) {
1286
1591
  cachedConfigs.set(rootDirectory, null);
1287
1592
  return null;
1288
1593
  }
@@ -1441,36 +1746,57 @@ const extractFailedPluginName = (error) => {
1441
1746
  //#region src/utils/has-knip-config.ts
1442
1747
  const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1443
1748
  //#endregion
1749
+ //#region src/utils/sanitize-knip-config-patterns.ts
1750
+ const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
1751
+ const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
1752
+ const sanitizeKnipConfigPatterns = (parsedConfig) => {
1753
+ for (const [key, value] of Object.entries(parsedConfig)) {
1754
+ if (typeof value === "string") {
1755
+ if (!isMeaningfulPattern(value)) delete parsedConfig[key];
1756
+ continue;
1757
+ }
1758
+ if (Array.isArray(value)) {
1759
+ if (value.length === 0) continue;
1760
+ const sanitized = sanitizeStringArray(value);
1761
+ if (sanitized.length === value.length) continue;
1762
+ if (sanitized.length === 0) delete parsedConfig[key];
1763
+ else parsedConfig[key] = sanitized;
1764
+ continue;
1765
+ }
1766
+ if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
1767
+ }
1768
+ };
1769
+ //#endregion
1444
1770
  //#region src/utils/run-knip.ts
1445
- const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1446
- files: {
1771
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
1772
+ ["files", {
1447
1773
  category: "Dead Code",
1448
1774
  message: "Unused file",
1449
1775
  severity: "warning"
1450
- },
1451
- exports: {
1776
+ }],
1777
+ ["exports", {
1452
1778
  category: "Dead Code",
1453
1779
  message: "Unused export",
1454
1780
  severity: "warning"
1455
- },
1456
- types: {
1781
+ }],
1782
+ ["types", {
1457
1783
  category: "Dead Code",
1458
1784
  message: "Unused type",
1459
1785
  severity: "warning"
1460
- },
1461
- duplicates: {
1786
+ }],
1787
+ ["duplicates", {
1462
1788
  category: "Dead Code",
1463
1789
  message: "Duplicate export",
1464
1790
  severity: "warning"
1465
- }
1466
- };
1791
+ }]
1792
+ ]);
1467
1793
  const FALLBACK_KNIP_DESCRIPTOR = {
1468
1794
  category: "Dead Code",
1469
1795
  message: "Issue",
1470
1796
  severity: "warning"
1471
1797
  };
1472
1798
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1473
- const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1799
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
1474
1800
  const diagnostics = [];
1475
1801
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1476
1802
  filePath: path.relative(rootDirectory, issue.filePath),
@@ -1508,7 +1834,7 @@ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1508
1834
  const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1509
1835
  const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1510
1836
  const failedPlugin = extractFailedPluginName(error);
1511
- if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
1837
+ if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
1512
1838
  disabledPlugins.add(failedPlugin);
1513
1839
  parsedConfig[failedPlugin] = false;
1514
1840
  return true;
@@ -1522,6 +1848,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1522
1848
  ...tsConfigFile ? { tsConfigFile } : {}
1523
1849
  }));
1524
1850
  const parsedConfig = options.parsedConfig;
1851
+ sanitizeKnipConfigPatterns(parsedConfig);
1525
1852
  const disabledPlugins = /* @__PURE__ */ new Set();
1526
1853
  let lastKnipError;
1527
1854
  for (let attempt = 0; attempt < 6; attempt++) try {
@@ -1553,7 +1880,7 @@ const runKnip = async (rootDirectory) => {
1553
1880
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1554
1881
  const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1555
1882
  const diagnostics = [];
1556
- const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1883
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
1557
1884
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1558
1885
  filePath: path.relative(rootDirectory, unusedFilePath),
1559
1886
  plugin: "knip",
@@ -1573,6 +1900,18 @@ const runKnip = async (rootDirectory) => {
1573
1900
  return diagnostics;
1574
1901
  };
1575
1902
  //#endregion
1903
+ //#region src/utils/parse-react-major.ts
1904
+ const parseReactMajor = (reactVersion) => {
1905
+ if (typeof reactVersion !== "string") return null;
1906
+ const trimmed = reactVersion.trim();
1907
+ if (trimmed.length === 0) return null;
1908
+ const match = trimmed.match(/(\d+)/);
1909
+ if (!match) return null;
1910
+ const major = Number.parseInt(match[1], 10);
1911
+ if (!Number.isFinite(major) || major <= 0) return null;
1912
+ return major;
1913
+ };
1914
+ //#endregion
1576
1915
  //#region src/utils/batch-include-paths.ts
1577
1916
  const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1578
1917
  const batchIncludePaths = (baseArgs, includePaths) => {
@@ -1596,6 +1935,80 @@ const batchIncludePaths = (baseArgs, includePaths) => {
1596
1935
  return batches;
1597
1936
  };
1598
1937
  //#endregion
1938
+ //#region src/utils/can-oxlint-extend-config.ts
1939
+ const EXTENDS_LOCAL_PATH_PREFIXES = [
1940
+ "./",
1941
+ "../",
1942
+ "/"
1943
+ ];
1944
+ const isLocalPathExtend = (entry) => {
1945
+ for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
1946
+ return false;
1947
+ };
1948
+ const stripJsoncComments = (raw) => {
1949
+ let result = "";
1950
+ let cursor = 0;
1951
+ let inString = false;
1952
+ let stringQuote = "";
1953
+ while (cursor < raw.length) {
1954
+ const character = raw[cursor];
1955
+ const nextCharacter = raw[cursor + 1];
1956
+ if (inString) {
1957
+ result += character;
1958
+ if (character === "\\" && cursor + 1 < raw.length) {
1959
+ result += nextCharacter;
1960
+ cursor += 2;
1961
+ continue;
1962
+ }
1963
+ if (character === stringQuote) inString = false;
1964
+ cursor += 1;
1965
+ continue;
1966
+ }
1967
+ if (character === "\"" || character === "'") {
1968
+ inString = true;
1969
+ stringQuote = character;
1970
+ result += character;
1971
+ cursor += 1;
1972
+ continue;
1973
+ }
1974
+ if (character === "/" && nextCharacter === "/") {
1975
+ const lineEndIndex = raw.indexOf("\n", cursor);
1976
+ cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
1977
+ continue;
1978
+ }
1979
+ if (character === "/" && nextCharacter === "*") {
1980
+ const blockEndIndex = raw.indexOf("*/", cursor + 2);
1981
+ cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
1982
+ continue;
1983
+ }
1984
+ result += character;
1985
+ cursor += 1;
1986
+ }
1987
+ return result;
1988
+ };
1989
+ const parseJsonOrJsonc = (raw) => {
1990
+ try {
1991
+ return JSON.parse(raw);
1992
+ } catch {
1993
+ return JSON.parse(stripJsoncComments(raw));
1994
+ }
1995
+ };
1996
+ const canOxlintExtendConfig = (configPath) => {
1997
+ if (!configPath.endsWith(".eslintrc.json")) return true;
1998
+ let parsed;
1999
+ try {
2000
+ parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
2001
+ } catch {
2002
+ return true;
2003
+ }
2004
+ if (!isPlainObject(parsed)) return true;
2005
+ const extendsValue = parsed.extends;
2006
+ if (extendsValue === void 0 || extendsValue === null) return true;
2007
+ const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
2008
+ if (extendsEntries.length === 0) return true;
2009
+ return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
2010
+ };
2011
+ //#endregion
1599
2012
  //#region src/utils/parse-gitattributes-linguist.ts
1600
2013
  const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
1601
2014
  const FALSY_VALUES = new Set([
@@ -1680,6 +2093,29 @@ const collectIgnorePatterns = (rootDirectory) => {
1680
2093
  return patterns;
1681
2094
  };
1682
2095
  //#endregion
2096
+ //#region src/utils/detect-user-lint-config.ts
2097
+ const findFirstLintConfigInDirectory = (directory) => {
2098
+ for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
2099
+ const candidatePath = path.join(directory, filename);
2100
+ if (isFile(candidatePath)) return candidatePath;
2101
+ }
2102
+ return null;
2103
+ };
2104
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
2105
+ const detectUserLintConfigPaths = (rootDirectory) => {
2106
+ const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
2107
+ if (directLintConfig) return [directLintConfig];
2108
+ if (isProjectBoundary(rootDirectory)) return [];
2109
+ let ancestorDirectory = path.dirname(rootDirectory);
2110
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
2111
+ const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
2112
+ if (ancestorLintConfig) return [ancestorLintConfig];
2113
+ if (isProjectBoundary(ancestorDirectory)) return [];
2114
+ ancestorDirectory = path.dirname(ancestorDirectory);
2115
+ }
2116
+ return [];
2117
+ };
2118
+ //#endregion
1683
2119
  //#region src/oxlint-config.ts
1684
2120
  const esmRequire$1 = createRequire(import.meta.url);
1685
2121
  const NEXTJS_RULES = {
@@ -1760,16 +2196,45 @@ const REACT_COMPILER_RULES = {
1760
2196
  "react-hooks-js/incompatible-library": "warn",
1761
2197
  "react-hooks-js/todo": "warn"
1762
2198
  };
2199
+ const readPluginRuleNames = (pluginSpecifier) => {
2200
+ try {
2201
+ const pluginModule = esmRequire$1(pluginSpecifier);
2202
+ const rules = pluginModule.rules ?? pluginModule.default?.rules;
2203
+ if (rules === void 0) return /* @__PURE__ */ new Set();
2204
+ return new Set(Object.keys(rules));
2205
+ } catch {
2206
+ return /* @__PURE__ */ new Set();
2207
+ }
2208
+ };
1763
2209
  const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
1764
2210
  if (!hasReactCompiler || customRulesOnly) return null;
2211
+ let pluginSpecifier;
1765
2212
  try {
1766
- return {
1767
- name: "react-hooks-js",
1768
- specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1769
- };
2213
+ pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
1770
2214
  } catch {
1771
2215
  return null;
1772
2216
  }
2217
+ return {
2218
+ entry: {
2219
+ name: "react-hooks-js",
2220
+ specifier: pluginSpecifier
2221
+ },
2222
+ availableRuleNames: readPluginRuleNames(pluginSpecifier)
2223
+ };
2224
+ };
2225
+ const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
2226
+ if (availableRuleNames.size === 0) return rules;
2227
+ const ruleKeyPrefix = `${pluginNamespace}/`;
2228
+ const filtered = {};
2229
+ for (const [ruleKey, severity] of Object.entries(rules)) {
2230
+ if (!ruleKey.startsWith(ruleKeyPrefix)) {
2231
+ filtered[ruleKey] = severity;
2232
+ continue;
2233
+ }
2234
+ const ruleName = ruleKey.slice(ruleKeyPrefix.length);
2235
+ if (availableRuleNames.has(ruleName)) filtered[ruleKey] = severity;
2236
+ }
2237
+ return filtered;
1773
2238
  };
1774
2239
  const TANSTACK_QUERY_RULES = {
1775
2240
  "react-doctor/query-stable-query-client": "warn",
@@ -1812,18 +2277,27 @@ const BUILTIN_A11Y_RULES = {
1812
2277
  const GLOBAL_REACT_DOCTOR_RULES = {
1813
2278
  "react-doctor/no-derived-state-effect": "warn",
1814
2279
  "react-doctor/no-fetch-in-effect": "warn",
2280
+ "react-doctor/no-mirror-prop-effect": "warn",
2281
+ "react-doctor/no-mutable-in-deps": "error",
1815
2282
  "react-doctor/no-cascading-set-state": "warn",
2283
+ "react-doctor/no-effect-chain": "warn",
1816
2284
  "react-doctor/no-effect-event-handler": "warn",
1817
2285
  "react-doctor/no-effect-event-in-deps": "error",
2286
+ "react-doctor/no-event-trigger-state": "warn",
1818
2287
  "react-doctor/no-prop-callback-in-effect": "warn",
1819
2288
  "react-doctor/no-derived-useState": "warn",
2289
+ "react-doctor/no-direct-state-mutation": "warn",
2290
+ "react-doctor/no-set-state-in-render": "warn",
2291
+ "react-doctor/prefer-use-effect-event": "warn",
1820
2292
  "react-doctor/prefer-useReducer": "warn",
2293
+ "react-doctor/prefer-use-sync-external-store": "warn",
1821
2294
  "react-doctor/rerender-lazy-state-init": "warn",
1822
2295
  "react-doctor/rerender-functional-setstate": "warn",
1823
2296
  "react-doctor/rerender-dependencies": "error",
1824
2297
  "react-doctor/rerender-state-only-in-handlers": "warn",
1825
2298
  "react-doctor/rerender-defer-reads-hook": "warn",
1826
2299
  "react-doctor/advanced-event-handler-refs": "warn",
2300
+ "react-doctor/effect-needs-cleanup": "error",
1827
2301
  "react-doctor/no-giant-component": "warn",
1828
2302
  "react-doctor/no-render-in-render": "warn",
1829
2303
  "react-doctor/no-many-boolean-props": "warn",
@@ -1831,6 +2305,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1831
2305
  "react-doctor/no-render-prop-children": "warn",
1832
2306
  "react-doctor/no-nested-component-definition": "error",
1833
2307
  "react-doctor/react-compiler-destructure-method": "warn",
2308
+ "react-doctor/no-legacy-class-lifecycles": "error",
2309
+ "react-doctor/no-legacy-context-api": "error",
2310
+ "react-doctor/no-default-props": "warn",
2311
+ "react-doctor/no-react-dom-deprecated-apis": "warn",
1834
2312
  "react-doctor/no-usememo-simple-expression": "warn",
1835
2313
  "react-doctor/no-layout-property-animation": "error",
1836
2314
  "react-doctor/rerender-memo-with-default-value": "warn",
@@ -1879,6 +2357,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1879
2357
  "react-doctor/rendering-conditional-render": "warn",
1880
2358
  "react-doctor/rendering-svg-precision": "warn",
1881
2359
  "react-doctor/no-prevent-default": "warn",
2360
+ "react-doctor/no-uncontrolled-input": "warn",
1882
2361
  "react-doctor/no-document-start-view-transition": "warn",
1883
2362
  "react-doctor/no-flush-sync": "warn",
1884
2363
  "react-doctor/server-auth-actions": "error",
@@ -1906,6 +2385,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1906
2385
  "react-doctor/no-disabled-zoom": "error",
1907
2386
  "react-doctor/no-outline-none": "warn",
1908
2387
  "react-doctor/no-long-transition-duration": "warn",
2388
+ "react-doctor/design-no-bold-heading": "warn",
2389
+ "react-doctor/design-no-redundant-padding-axes": "warn",
2390
+ "react-doctor/design-no-redundant-size-axes": "warn",
2391
+ "react-doctor/design-no-space-on-flex-children": "warn",
2392
+ "react-doctor/design-no-em-dash-in-jsx-text": "warn",
2393
+ "react-doctor/design-no-three-period-ellipsis": "warn",
2394
+ "react-doctor/design-no-default-tailwind-palette": "warn",
2395
+ "react-doctor/design-no-vague-button-label": "warn",
1909
2396
  "react-doctor/async-parallel": "warn"
1910
2397
  };
1911
2398
  const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
@@ -1915,9 +2402,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1915
2402
  ...Object.keys(TANSTACK_START_RULES),
1916
2403
  ...Object.keys(TANSTACK_QUERY_RULES)
1917
2404
  ]);
1918
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => {
2405
+ const VERSION_GATED_RULE_IDS = new Map([
2406
+ ["react-doctor/no-react19-deprecated-apis", {
2407
+ minMajor: 19,
2408
+ mode: "deprecation-warning"
2409
+ }],
2410
+ ["react-doctor/no-default-props", {
2411
+ minMajor: 19,
2412
+ mode: "deprecation-warning"
2413
+ }],
2414
+ ["react-doctor/no-react-dom-deprecated-apis", {
2415
+ minMajor: 18,
2416
+ mode: "deprecation-warning"
2417
+ }],
2418
+ ["react-doctor/prefer-use-effect-event", {
2419
+ minMajor: 19,
2420
+ mode: "prefer-newer-api"
2421
+ }]
2422
+ ]);
2423
+ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
2424
+ return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
2425
+ const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
2426
+ if (gate === void 0) return true;
2427
+ if (gate.mode === "deprecation-warning") return true;
2428
+ if (reactMajorVersion === null) return true;
2429
+ return reactMajorVersion >= gate.minMajor;
2430
+ }));
2431
+ };
2432
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
1919
2433
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
2434
+ const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
1920
2435
  return {
2436
+ ...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
1921
2437
  categories: {
1922
2438
  correctness: "off",
1923
2439
  suspicious: "off",
@@ -1928,12 +2444,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
1928
2444
  nursery: "off"
1929
2445
  },
1930
2446
  plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
1931
- jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin, pluginPath] : [pluginPath],
2447
+ jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin.entry, pluginPath] : [pluginPath],
1932
2448
  rules: {
1933
2449
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1934
2450
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1935
- ...reactHooksJsPlugin ? REACT_COMPILER_RULES : {},
1936
- ...GLOBAL_REACT_DOCTOR_RULES,
2451
+ ...reactCompilerRules,
2452
+ ...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
1937
2453
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1938
2454
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1939
2455
  ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
@@ -2045,23 +2561,43 @@ const PLUGIN_CATEGORY_MAP = {
2045
2561
  "react-hooks-js": "React Compiler",
2046
2562
  "react-doctor": "Other",
2047
2563
  "jsx-a11y": "Accessibility",
2048
- knip: "Dead Code"
2564
+ knip: "Dead Code",
2565
+ eslint: "Correctness",
2566
+ oxc: "Correctness",
2567
+ typescript: "Correctness",
2568
+ unicorn: "Correctness",
2569
+ import: "Bundle Size",
2570
+ promise: "Correctness",
2571
+ n: "Correctness",
2572
+ node: "Correctness",
2573
+ vitest: "Correctness",
2574
+ jest: "Correctness",
2575
+ nextjs: "Next.js"
2049
2576
  };
2050
2577
  const RULE_CATEGORY_MAP = {
2051
2578
  "react-doctor/no-derived-state-effect": "State & Effects",
2052
2579
  "react-doctor/no-fetch-in-effect": "State & Effects",
2580
+ "react-doctor/no-mirror-prop-effect": "State & Effects",
2581
+ "react-doctor/no-mutable-in-deps": "State & Effects",
2053
2582
  "react-doctor/no-cascading-set-state": "State & Effects",
2583
+ "react-doctor/no-effect-chain": "State & Effects",
2054
2584
  "react-doctor/no-effect-event-handler": "State & Effects",
2055
2585
  "react-doctor/no-effect-event-in-deps": "State & Effects",
2586
+ "react-doctor/no-event-trigger-state": "State & Effects",
2056
2587
  "react-doctor/no-prop-callback-in-effect": "State & Effects",
2057
2588
  "react-doctor/no-derived-useState": "State & Effects",
2589
+ "react-doctor/no-direct-state-mutation": "State & Effects",
2590
+ "react-doctor/no-set-state-in-render": "State & Effects",
2591
+ "react-doctor/prefer-use-effect-event": "State & Effects",
2058
2592
  "react-doctor/prefer-useReducer": "State & Effects",
2593
+ "react-doctor/prefer-use-sync-external-store": "State & Effects",
2059
2594
  "react-doctor/rerender-lazy-state-init": "Performance",
2060
2595
  "react-doctor/rerender-functional-setstate": "Performance",
2061
2596
  "react-doctor/rerender-dependencies": "State & Effects",
2062
2597
  "react-doctor/rerender-state-only-in-handlers": "Performance",
2063
2598
  "react-doctor/rerender-defer-reads-hook": "Performance",
2064
2599
  "react-doctor/advanced-event-handler-refs": "Performance",
2600
+ "react-doctor/effect-needs-cleanup": "State & Effects",
2065
2601
  "react-doctor/no-generic-handler-names": "Architecture",
2066
2602
  "react-doctor/no-giant-component": "Architecture",
2067
2603
  "react-doctor/no-many-boolean-props": "Architecture",
@@ -2070,6 +2606,10 @@ const RULE_CATEGORY_MAP = {
2070
2606
  "react-doctor/no-render-in-render": "Architecture",
2071
2607
  "react-doctor/no-nested-component-definition": "Correctness",
2072
2608
  "react-doctor/react-compiler-destructure-method": "Architecture",
2609
+ "react-doctor/no-legacy-class-lifecycles": "Correctness",
2610
+ "react-doctor/no-legacy-context-api": "Correctness",
2611
+ "react-doctor/no-default-props": "Architecture",
2612
+ "react-doctor/no-react-dom-deprecated-apis": "Architecture",
2073
2613
  "react-doctor/no-usememo-simple-expression": "Performance",
2074
2614
  "react-doctor/no-layout-property-animation": "Performance",
2075
2615
  "react-doctor/rerender-memo-with-default-value": "Performance",
@@ -2103,6 +2643,7 @@ const RULE_CATEGORY_MAP = {
2103
2643
  "react-doctor/rendering-conditional-render": "Correctness",
2104
2644
  "react-doctor/rendering-svg-precision": "Performance",
2105
2645
  "react-doctor/no-prevent-default": "Correctness",
2646
+ "react-doctor/no-uncontrolled-input": "Correctness",
2106
2647
  "react-doctor/no-document-start-view-transition": "Correctness",
2107
2648
  "react-doctor/no-flush-sync": "Performance",
2108
2649
  "react-doctor/nextjs-no-img-element": "Next.js",
@@ -2152,6 +2693,14 @@ const RULE_CATEGORY_MAP = {
2152
2693
  "react-doctor/no-disabled-zoom": "Accessibility",
2153
2694
  "react-doctor/no-outline-none": "Accessibility",
2154
2695
  "react-doctor/no-long-transition-duration": "Performance",
2696
+ "react-doctor/design-no-bold-heading": "Architecture",
2697
+ "react-doctor/design-no-redundant-padding-axes": "Architecture",
2698
+ "react-doctor/design-no-redundant-size-axes": "Architecture",
2699
+ "react-doctor/design-no-space-on-flex-children": "Architecture",
2700
+ "react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
2701
+ "react-doctor/design-no-three-period-ellipsis": "Architecture",
2702
+ "react-doctor/design-no-default-tailwind-palette": "Architecture",
2703
+ "react-doctor/design-no-vague-button-label": "Accessibility",
2155
2704
  "react-doctor/js-flatmap-filter": "Performance",
2156
2705
  "react-doctor/js-combine-iterations": "Performance",
2157
2706
  "react-doctor/js-tosorted-immutable": "Performance",
@@ -2209,10 +2758,18 @@ const RULE_CATEGORY_MAP = {
2209
2758
  const RULE_HELP_MAP = {
2210
2759
  "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} />`. See https://react.dev/learn/you-might-not-need-an-effect",
2211
2760
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
2761
+ "no-mirror-prop-effect": "Delete both the `useState` and the `useEffect` and read the prop directly during render. Mirroring a prop into local state forces a stale first render before the effect re-syncs",
2762
+ "no-mutable-in-deps": "Read mutable values (`location.pathname`, `ref.current`) inside the effect body instead of in the deps array, or subscribe with `useSyncExternalStore`. Mutations to these don't trigger re-renders, so listing them in deps doesn't make the effect react to changes",
2212
2763
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
2764
+ "no-effect-chain": "Compute as much as possible during render (e.g. `const isGameOver = round > 5`) and write all related state inside the event handler that originally fires the chain. Each effect link adds an extra render and makes the code rigid as requirements evolve",
2213
2765
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
2766
+ "no-event-trigger-state": "Delete the trigger state (`useState(null)` plus the `useEffect` that watches it) and call the side-effect (`post(...)` / `navigate(...)` / `track(...)`) directly inside the event handler that previously called the setter. State should not exist purely to schedule effect runs",
2214
2767
  "no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
2768
+ "no-direct-state-mutation": "Replace the mutation with a setter call that produces a new reference: `setItems([...items, newItem])`, `setItems(items.filter(x => x !== target))`, `setItems(items.toSorted(...))`. React only re-renders on a new reference, so in-place updates are silently dropped",
2769
+ "no-set-state-in-render": "Move the setter call into a `useEffect`, an event handler, or replace the state with a value computed during render. Calling a setter at render time triggers another render, which calls the setter again — an infinite loop",
2770
+ "prefer-use-effect-event": "Wrap the callback with `useEffectEvent(callback)` (React 19+) and call the resulting binding from inside the sub-handler. The Effect Event captures the latest props/state without being a reactive dep, so the effect doesn't re-subscribe on every parent render. See https://react.dev/reference/react/useEffectEvent",
2215
2771
  "prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
2772
+ "prefer-use-sync-external-store": "Replace the `useState(getSnapshot())` + `useEffect(() => store.subscribe(() => setSnapshot(getSnapshot())))` pair with `useSyncExternalStore(store.subscribe, getSnapshot)`. The hook handles tearing during concurrent renders and SSR snapshots; the manual subscribe pattern doesn't",
2216
2773
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
2217
2774
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
2218
2775
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
@@ -2221,7 +2778,11 @@ const RULE_HELP_MAP = {
2221
2778
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
2222
2779
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
2223
2780
  "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
2224
- "no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads.",
2781
+ "no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads. Only enabled on projects detected as React 19+.",
2782
+ "no-legacy-class-lifecycles": "Move side effects in `componentWillMount` to `componentDidMount`; replace `componentWillReceiveProps` with `componentDidUpdate` (compare prevProps) or the static `getDerivedStateFromProps` for pure state derivation; replace `componentWillUpdate` with `getSnapshotBeforeUpdate` paired with `componentDidUpdate`. The `UNSAFE_` prefix only silences the warning — React 19 removes both forms.",
2783
+ "no-legacy-context-api": "Replace `childContextTypes` + `getChildContext` with `const MyContext = createContext(...)` + `<MyContext.Provider value={...}>`; replace `contextTypes` with `static contextType = MyContext` (single context) or `useContext()` / `use()` from a function component. The provider and every consumer must migrate together — partial migrations leave consumers reading the wrong context.",
2784
+ "no-default-props": "React 19 removes `Component.defaultProps` for function components. Move the defaults into the destructured props parameter: `function Foo({ size = \"md\", variant = \"primary\" })` instead of `Foo.defaultProps = { size: \"md\", variant: \"primary\" }`.",
2785
+ "no-react-dom-deprecated-apis": "Switch the legacy `react-dom` root API (`render` / `hydrate` / `unmountComponentAtNode`) to `createRoot` / `hydrateRoot` / `root.unmount()` from `react-dom/client`. Replace `findDOMNode` with a ref. The whole `react-dom/test-utils` entry point is removed in React 19 — use `act` from `react` and `fireEvent` / `render` from `@testing-library/react`. Only enabled on projects detected as React 18+.",
2225
2786
  "no-render-prop-children": "Replace `renderXxx` props with compound subcomponents (e.g. `<Modal.Header>`) or `children` so the parent doesn't dictate every customization point",
2226
2787
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
2227
2788
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
@@ -2236,6 +2797,7 @@ const RULE_HELP_MAP = {
2236
2797
  "rerender-defer-reads-hook": "Read the URL state inside the handler (e.g. `new URL(window.location.href).searchParams`) so the component doesn't subscribe and re-render on every URL change",
2237
2798
  "rerender-derived-state-from-hook": "Use a threshold/media-query hook (e.g. `useMediaQuery(\"(max-width: 767px)\")`) — the component re-renders only when the threshold flips, not every pixel",
2238
2799
  "advanced-event-handler-refs": "Store the handler in a ref and have the listener read `handlerRef.current()` — the subscription stays put while the latest handler is always called",
2800
+ "effect-needs-cleanup": "Return a cleanup function that releases the subscription / timer: `return () => target.removeEventListener(name, handler)` for listeners, `return () => clearInterval(id)` / `clearTimeout(id)` for timers, or `return unsubscribe` if the subscribe call already returned one",
2239
2801
  "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
2240
2802
  "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
2241
2803
  "react-compiler-destructure-method": "Destructure the method up front: `const { push } = useRouter()` then call `push(...)` directly — clearer dependency graph and easier for React Compiler to memoize",
@@ -2282,9 +2844,18 @@ const RULE_HELP_MAP = {
2282
2844
  "no-disabled-zoom": "Remove `user-scalable=no` and `maximum-scale` from the viewport meta tag. If your layout breaks at 200% zoom, fix the layout — don't punish users with disabilities",
2283
2845
  "no-outline-none": "Use `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }` to show focus only for keyboard users while hiding it for mouse clicks",
2284
2846
  "no-long-transition-duration": "Keep UI transitions under 1s — 100-150ms for instant feedback, 200-300ms for state changes, 300-500ms for layout changes. Use longer durations only for page-load hero animations",
2847
+ "design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
2848
+ "design-no-redundant-padding-axes": "Collapse `px-N py-N` to `p-N` when both axes match. Keep them split only when one axis varies at a breakpoint (`py-2 md:py-3`)",
2849
+ "design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
2850
+ "design-no-space-on-flex-children": "Use `gap-*` on the flex/grid parent. `space-x-*` / `space-y-*` produce phantom gaps when a sibling is conditionally rendered, lose vertical spacing on wrapped lines, and don't mirror in RTL",
2851
+ "design-no-em-dash-in-jsx-text": "Replace em dashes in JSX text with commas, colons, semicolons, periods, or parentheses — em dashes read as model-output filler",
2852
+ "design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `&hellip;`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
2853
+ "design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
2854
+ "design-no-vague-button-label": "Name the action: \"Save changes\" instead of \"Continue\", \"Send invite\" instead of \"Submit\", \"Delete account\" instead of \"OK\". The label IS the button's accessible name",
2285
2855
  "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
2286
2856
  "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
2287
2857
  "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
2858
+ "no-uncontrolled-input": "Pass an explicit initial value to `useState` (e.g. `useState(\"\")` instead of `useState()`), add `onChange` (or `readOnly` to opt out) when you supply `value`, and drop `defaultValue` on controlled inputs — React ignores it",
2288
2859
  "nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
2289
2860
  "nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
2290
2861
  "nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
@@ -2367,6 +2938,7 @@ const RULE_HELP_MAP = {
2367
2938
  };
2368
2939
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
2369
2940
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
2941
+ const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
2370
2942
  const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2371
2943
  if (plugin === "react-hooks-js") return {
2372
2944
  message: REACT_COMPILER_MESSAGE,
@@ -2374,7 +2946,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2374
2946
  };
2375
2947
  return {
2376
2948
  message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
2377
- help: help || RULE_HELP_MAP[rule] || ""
2949
+ help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
2378
2950
  };
2379
2951
  };
2380
2952
  const parseRuleCode = (code) => {
@@ -2402,7 +2974,7 @@ const resolvePluginPath = () => {
2402
2974
  return pluginPath;
2403
2975
  };
2404
2976
  const resolveDiagnosticCategory = (plugin, rule) => {
2405
- return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
2977
+ return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
2406
2978
  };
2407
2979
  const SANITIZED_ENV = (() => {
2408
2980
  const sanitized = {};
@@ -2493,7 +3065,7 @@ const parseOxlintOutput = (stdout) => {
2493
3065
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
2494
3066
  }
2495
3067
  if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2496
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
3068
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2497
3069
  const { plugin, rule } = parseRuleCode(diagnostic.code);
2498
3070
  const primaryLabel = diagnostic.labels[0];
2499
3071
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -2523,8 +3095,8 @@ const validateRuleRegistration = () => {
2523
3095
  const missingCategory = [];
2524
3096
  for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2525
3097
  const ruleName = fullKey.replace(/^react-doctor\//, "");
2526
- if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2527
- if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
3098
+ if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
3099
+ if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
2528
3100
  }
2529
3101
  if (missingCategory.length > 0 || missingHelp.length > 0) {
2530
3102
  const detail = [missingCategory.length > 0 ? `Missing RULE_CATEGORY_MAP entries: ${missingCategory.join(", ")}` : null, missingHelp.length > 0 ? `Missing RULE_HELP_MAP entries: ${missingHelp.join(", ")}` : null].filter((entry) => entry !== null).join("; ");
@@ -2532,26 +3104,24 @@ const validateRuleRegistration = () => {
2532
3104
  }
2533
3105
  };
2534
3106
  const runOxlint = async (options) => {
2535
- const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
3107
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
2536
3108
  validateRuleRegistration();
2537
3109
  if (includePaths !== void 0 && includePaths.length === 0) return [];
2538
3110
  const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2539
3111
  const configPath = path.join(configDirectory, "oxlintrc.json");
3112
+ const pluginPath = resolvePluginPath();
3113
+ const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
2540
3114
  const config = createOxlintConfig({
2541
- pluginPath: resolvePluginPath(),
3115
+ pluginPath,
2542
3116
  framework,
2543
3117
  hasReactCompiler,
2544
3118
  hasTanStackQuery,
2545
- customRulesOnly
3119
+ customRulesOnly,
3120
+ reactMajorVersion,
3121
+ extendsPaths
2546
3122
  });
2547
3123
  const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
2548
3124
  try {
2549
- const fileHandle = fs.openSync(configPath, "wx", 384);
2550
- try {
2551
- fs.writeFileSync(fileHandle, JSON.stringify(config));
2552
- } finally {
2553
- fs.closeSync(fileHandle);
2554
- }
2555
3125
  const baseArgs = [
2556
3126
  resolveOxlintBinary(),
2557
3127
  "-c",
@@ -2570,12 +3140,41 @@ const runOxlint = async (options) => {
2570
3140
  baseArgs.push("--ignore-path", combinedIgnorePath);
2571
3141
  }
2572
3142
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2573
- const allDiagnostics = [];
2574
- for (const batch of fileBatches) {
2575
- const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
2576
- allDiagnostics.push(...parseOxlintOutput(stdout));
3143
+ const writeOxlintConfig = (configToWrite) => {
3144
+ fs.rmSync(configPath, { force: true });
3145
+ const fileHandle = fs.openSync(configPath, "wx", 384);
3146
+ try {
3147
+ fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
3148
+ } finally {
3149
+ fs.closeSync(fileHandle);
3150
+ }
3151
+ };
3152
+ const spawnLintBatches = async () => {
3153
+ const allDiagnostics = [];
3154
+ for (const batch of fileBatches) {
3155
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
3156
+ allDiagnostics.push(...parseOxlintOutput(stdout));
3157
+ }
3158
+ return allDiagnostics;
3159
+ };
3160
+ writeOxlintConfig(config);
3161
+ try {
3162
+ return await spawnLintBatches();
3163
+ } catch (error) {
3164
+ if (extendsPaths.length === 0) throw error;
3165
+ const reason = error instanceof Error ? error.message : String(error);
3166
+ process.stderr.write(`[react-doctor] could not adopt existing lint config (${reason.split("\n")[0]}); retrying without extends. Set "adoptExistingLintConfig": false to silence.\n`);
3167
+ writeOxlintConfig(createOxlintConfig({
3168
+ pluginPath,
3169
+ framework,
3170
+ hasReactCompiler,
3171
+ hasTanStackQuery,
3172
+ customRulesOnly,
3173
+ reactMajorVersion,
3174
+ extendsPaths: []
3175
+ }));
3176
+ return await spawnLintBatches();
2577
3177
  }
2578
- return allDiagnostics;
2579
3178
  } finally {
2580
3179
  restoreDisableDirectives();
2581
3180
  fs.rmSync(configDirectory, {
@@ -2591,22 +3190,29 @@ const SEVERITY_ORDER = {
2591
3190
  warning: 1
2592
3191
  };
2593
3192
  const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
2594
- const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
2595
- return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
3193
+ const sortByImportance = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
3194
+ const severityDelta = SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
3195
+ if (severityDelta !== 0) return severityDelta;
3196
+ return diagnosticsB.length - diagnosticsA.length;
2596
3197
  });
2597
3198
  const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
2598
- const buildFileLineMap = (diagnostics) => {
2599
- const fileLines = /* @__PURE__ */ new Map();
3199
+ const buildVerboseSiteMap = (diagnostics) => {
3200
+ const fileSites = /* @__PURE__ */ new Map();
2600
3201
  for (const diagnostic of diagnostics) {
2601
- const lines = fileLines.get(diagnostic.filePath) ?? [];
2602
- if (diagnostic.line > 0) lines.push(diagnostic.line);
2603
- fileLines.set(diagnostic.filePath, lines);
3202
+ const sites = fileSites.get(diagnostic.filePath) ?? [];
3203
+ if (diagnostic.line > 0) sites.push({
3204
+ line: diagnostic.line,
3205
+ suppressionHint: diagnostic.suppressionHint
3206
+ });
3207
+ fileSites.set(diagnostic.filePath, sites);
2604
3208
  }
2605
- return fileLines;
3209
+ return fileSites;
2606
3210
  };
2607
3211
  const printDiagnostics = (diagnostics, isVerbose) => {
2608
- const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2609
- for (const [, ruleDiagnostics] of sortedRuleGroups) {
3212
+ const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3213
+ const visibleRuleGroups = isVerbose ? sortedRuleGroups : sortedRuleGroups.slice(0, 3);
3214
+ const hiddenRuleGroups = isVerbose ? [] : sortedRuleGroups.slice(3);
3215
+ for (const [, ruleDiagnostics] of visibleRuleGroups) {
2610
3216
  const firstDiagnostic = ruleDiagnostics[0];
2611
3217
  const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
2612
3218
  const count = ruleDiagnostics.length;
@@ -2614,12 +3220,22 @@ const printDiagnostics = (diagnostics, isVerbose) => {
2614
3220
  logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
2615
3221
  if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
2616
3222
  if (isVerbose) {
2617
- const fileLines = buildFileLineMap(ruleDiagnostics);
2618
- for (const [filePath, lines] of fileLines) if (lines.length > 0) for (const line of lines) logger.dim(` ${filePath}:${line}`);
3223
+ const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3224
+ for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
3225
+ logger.dim(` ${filePath}:${site.line}`);
3226
+ if (site.suppressionHint) logger.dim(` ↳ ${site.suppressionHint}`);
3227
+ }
2619
3228
  else logger.dim(` ${filePath}`);
2620
3229
  }
2621
3230
  logger.break();
2622
3231
  }
3232
+ if (hiddenRuleGroups.length > 0) printHiddenDiagnosticsSummary(hiddenRuleGroups);
3233
+ };
3234
+ const printHiddenDiagnosticsSummary = (hiddenRuleGroups) => {
3235
+ const renderedParts = buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => colorizeBySeverity(part.text, part.severity));
3236
+ logger.log(` ${renderedParts.join(" ")}`);
3237
+ logger.dim(" Run `npx react-doctor@latest . --verbose` to get all details");
3238
+ logger.break();
2623
3239
  };
2624
3240
  const formatElapsedTime = (elapsedMilliseconds) => {
2625
3241
  if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
@@ -2627,7 +3243,6 @@ const formatElapsedTime = (elapsedMilliseconds) => {
2627
3243
  };
2628
3244
  const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2629
3245
  const firstDiagnostic = ruleDiagnostics[0];
2630
- const fileLines = buildFileLineMap(ruleDiagnostics);
2631
3246
  const sections = [
2632
3247
  `Rule: ${ruleKey}`,
2633
3248
  `Severity: ${firstDiagnostic.severity}`,
@@ -2638,14 +3253,18 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2638
3253
  ];
2639
3254
  if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
2640
3255
  sections.push("", "Files:");
2641
- for (const [filePath, lines] of fileLines) if (lines.length > 0) for (const line of lines) sections.push(` ${filePath}:${line}`);
3256
+ const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3257
+ for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
3258
+ sections.push(` ${filePath}:${site.line}`);
3259
+ if (site.suppressionHint) sections.push(` ${site.suppressionHint}`);
3260
+ }
2642
3261
  else sections.push(` ${filePath}`);
2643
3262
  return sections.join("\n") + "\n";
2644
3263
  };
2645
3264
  const writeDiagnosticsDirectory = (diagnostics) => {
2646
3265
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
2647
3266
  mkdirSync(outputDirectory, { recursive: true });
2648
- const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3267
+ const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2649
3268
  for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
2650
3269
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
2651
3270
  return outputDirectory;
@@ -2814,7 +3433,8 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
2814
3433
  includePaths: inputOptions.includePaths ?? [],
2815
3434
  customRulesOnly: userConfig?.customRulesOnly ?? false,
2816
3435
  share: userConfig?.share ?? true,
2817
- respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
3436
+ respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
3437
+ adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true
2818
3438
  });
2819
3439
  const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
2820
3440
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -2871,10 +3491,12 @@ const runScan = async (directory, options, userConfig, startTime) => {
2871
3491
  framework: projectInfo.framework,
2872
3492
  hasReactCompiler: projectInfo.hasReactCompiler,
2873
3493
  hasTanStackQuery: projectInfo.hasTanStackQuery,
3494
+ reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
2874
3495
  includePaths: lintIncludePaths,
2875
3496
  nodeBinaryPath: resolvedNodeBinaryPath,
2876
3497
  customRulesOnly: options.customRulesOnly,
2877
- respectInlineDisables: options.respectInlineDisables
3498
+ respectInlineDisables: options.respectInlineDisables,
3499
+ adoptExistingLintConfig: options.adoptExistingLintConfig
2878
3500
  });
2879
3501
  lintSpinner?.succeed("Running lint checks.");
2880
3502
  return lintDiagnostics;
@@ -2914,7 +3536,8 @@ const runScan = async (directory, options, userConfig, startTime) => {
2914
3536
  deadCodeDiagnostics,
2915
3537
  directory,
2916
3538
  isDiffMode,
2917
- userConfig
3539
+ userConfig,
3540
+ respectInlineDisables: options.respectInlineDisables
2918
3541
  });
2919
3542
  const elapsedMilliseconds = performance.now() - startTime;
2920
3543
  const skippedChecks = [];
@@ -3285,6 +3908,41 @@ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
3285
3908
  const encodeAnnotationProperty = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
3286
3909
  const encodeAnnotationMessage = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
3287
3910
  //#endregion
3911
+ //#region src/utils/find-owning-project.ts
3912
+ const findOwningProjectDirectory = (rootDirectory, filePath) => {
3913
+ const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath);
3914
+ const workspacePackages = listWorkspacePackages(rootDirectory);
3915
+ const candidates = workspacePackages.length > 0 ? workspacePackages : discoverReactSubprojects(rootDirectory);
3916
+ if (candidates.length === 0) return rootDirectory;
3917
+ let bestMatch = null;
3918
+ for (const candidate of candidates) {
3919
+ const candidateDirectory = path.resolve(candidate.directory);
3920
+ const relativeFromCandidate = path.relative(candidateDirectory, absoluteFile);
3921
+ if (relativeFromCandidate.startsWith("..") || path.isAbsolute(relativeFromCandidate)) continue;
3922
+ const depth = candidateDirectory.length;
3923
+ if (!bestMatch || depth > bestMatch.depth) bestMatch = {
3924
+ directory: candidate.directory,
3925
+ depth
3926
+ };
3927
+ }
3928
+ return bestMatch ? bestMatch.directory : rootDirectory;
3929
+ };
3930
+ //#endregion
3931
+ //#region src/utils/parse-file-line-argument.ts
3932
+ const parseFileLineArgument = (rawArgument) => {
3933
+ const lastColonIndex = rawArgument.lastIndexOf(":");
3934
+ if (lastColonIndex < 0) throw new Error(`Expected "<file>:<line>" (e.g. "src/foo.tsx:42"), got "${rawArgument}".`);
3935
+ const filePath = rawArgument.slice(0, lastColonIndex);
3936
+ const lineText = rawArgument.slice(lastColonIndex + 1);
3937
+ if (filePath.length === 0) throw new Error(`Missing file path in "${rawArgument}".`);
3938
+ const line = Number.parseInt(lineText, 10);
3939
+ if (!Number.isFinite(line) || line <= 0 || String(line) !== lineText.trim()) throw new Error(`Expected a positive line number in "${rawArgument}".`);
3940
+ return {
3941
+ filePath,
3942
+ line
3943
+ };
3944
+ };
3945
+ //#endregion
3288
3946
  //#region src/utils/select-projects.ts
3289
3947
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
3290
3948
  let packages = listWorkspacePackages(rootDirectory);
@@ -3333,7 +3991,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
3333
3991
  };
3334
3992
  //#endregion
3335
3993
  //#region src/cli.ts
3336
- const VERSION = "0.0.46";
3994
+ const VERSION = "0.1.0";
3337
3995
  const VALID_FAIL_ON_LEVELS = new Set([
3338
3996
  "error",
3339
3997
  "warning",
@@ -3462,6 +4120,46 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
3462
4120
  });
3463
4121
  return Boolean(shouldScanChangedOnly);
3464
4122
  };
4123
+ const colorizeRuleByDiagnostic = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
4124
+ const runExplain = async (fileLineArgument, context) => {
4125
+ const { filePath, line } = parseFileLineArgument(fileLineArgument);
4126
+ const targetDirectory = await resolveExplainTargetDirectory(filePath, context);
4127
+ const scanResult = await scan(targetDirectory, {
4128
+ ...context.scanOptions,
4129
+ silent: true,
4130
+ offline: true,
4131
+ configOverride: context.userConfig
4132
+ });
4133
+ const requestedRelativePath = toRelativePath(filePath, targetDirectory);
4134
+ const matchingDiagnostics = scanResult.diagnostics.filter((diagnostic) => diagnostic.line === line && toRelativePath(diagnostic.filePath, targetDirectory) === requestedRelativePath);
4135
+ if (matchingDiagnostics.length === 0) {
4136
+ logger.log(`No react-doctor diagnostics at ${filePath}:${line}.`);
4137
+ return;
4138
+ }
4139
+ for (const diagnostic of matchingDiagnostics) {
4140
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
4141
+ const severitySymbol = diagnostic.severity === "error" ? "✗" : "⚠";
4142
+ const colorizedRule = colorizeRuleByDiagnostic(ruleIdentifier, diagnostic.severity);
4143
+ const severityLabel = colorizeRuleByDiagnostic(diagnostic.severity, diagnostic.severity);
4144
+ logger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
4145
+ if (diagnostic.category) logger.dim(` Category: ${diagnostic.category}`);
4146
+ if (diagnostic.help) logger.dim(` ${diagnostic.help}`);
4147
+ if (diagnostic.suppressionHint) {
4148
+ logger.break();
4149
+ logger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
4150
+ } else logger.dim(" No nearby react-doctor-disable-next-line comment was detected — add one immediately above this line to suppress.");
4151
+ logger.break();
4152
+ }
4153
+ };
4154
+ const resolveExplainTargetDirectory = async (filePath, context) => {
4155
+ if (context.projectFlag) {
4156
+ const matchedDirectories = await selectProjects(context.resolvedDirectory, context.projectFlag, true);
4157
+ if (matchedDirectories.length === 0) return context.resolvedDirectory;
4158
+ if (matchedDirectories.length > 1) throw new Error(`--explain takes a single project; --project resolved to ${matchedDirectories.length} projects.`);
4159
+ return matchedDirectories[0];
4160
+ }
4161
+ return findOwningProjectDirectory(context.resolvedDirectory, filePath);
4162
+ };
3465
4163
  const validateModeFlags = (flags) => {
3466
4164
  const coercedDiff = coerceDiffValue(flags.diff);
3467
4165
  const exclusiveModes = [flags.staged ? "--staged" : null, coercedDiff !== void 0 && coercedDiff !== false ? "--diff" : null].filter((modeName) => modeName !== null);
@@ -3469,8 +4167,10 @@ const validateModeFlags = (flags) => {
3469
4167
  if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
3470
4168
  if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
3471
4169
  if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
4170
+ if (flags.explain !== void 0 && flags.why !== void 0) throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
4171
+ if ((flags.explain ?? flags.why) !== void 0 && (flags.json || flags.score || flags.annotations || flags.staged)) throw new Error("--explain cannot be combined with --json, --score, --annotations, or --staged.");
3472
4172
  };
3473
- 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("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "error").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").action(async (directory, flags) => {
4173
+ 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("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "error").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").addOption(new Option("--why <file:line>", "alias for --explain").hideHelp()).option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").action(async (directory, flags) => {
3474
4174
  const isScoreOnly = flags.score;
3475
4175
  const isJsonMode = flags.json;
3476
4176
  const isQuiet = isScoreOnly || isJsonMode;
@@ -3484,6 +4184,16 @@ const program = new Command().name("react-doctor").description("Diagnose React c
3484
4184
  try {
3485
4185
  validateModeFlags(flags);
3486
4186
  const userConfig = loadConfig(resolvedDirectory);
4187
+ const explainArgument = flags.explain ?? flags.why;
4188
+ if (explainArgument !== void 0) {
4189
+ await runExplain(explainArgument, {
4190
+ resolvedDirectory,
4191
+ userConfig,
4192
+ scanOptions: resolveCliScanOptions(flags, userConfig, program),
4193
+ projectFlag: flags.project
4194
+ });
4195
+ return;
4196
+ }
3487
4197
  if (!isQuiet) {
3488
4198
  logger.log(`react-doctor v${VERSION}`);
3489
4199
  logger.break();