react-doctor 0.0.47 → 0.1.1

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([
@@ -88,7 +89,9 @@ const highlighter = {
88
89
  warn: pc.yellow,
89
90
  info: pc.cyan,
90
91
  success: pc.green,
91
- dim: pc.dim
92
+ dim: pc.dim,
93
+ gray: pc.gray,
94
+ bold: pc.bold
92
95
  };
93
96
  //#endregion
94
97
  //#region src/utils/logger.ts
@@ -295,7 +298,47 @@ const runInstallSkill = async (options = {}) => {
295
298
  }
296
299
  };
297
300
  //#endregion
298
- //#region src/core/calculate-score-locally.ts
301
+ //#region src/utils/build-category-breakdown.ts
302
+ const buildCategoryBreakdown = (diagnostics) => {
303
+ const entriesByCategory = /* @__PURE__ */ new Map();
304
+ for (const diagnostic of diagnostics) {
305
+ const existingEntry = entriesByCategory.get(diagnostic.category) ?? {
306
+ category: diagnostic.category,
307
+ totalCount: 0,
308
+ errorCount: 0,
309
+ warningCount: 0
310
+ };
311
+ existingEntry.totalCount += 1;
312
+ if (diagnostic.severity === "error") existingEntry.errorCount += 1;
313
+ else existingEntry.warningCount += 1;
314
+ entriesByCategory.set(diagnostic.category, existingEntry);
315
+ }
316
+ return [...entriesByCategory.values()].sort((entryA, entryB) => {
317
+ if (entryA.errorCount !== entryB.errorCount) return entryB.errorCount - entryA.errorCount;
318
+ if (entryA.totalCount !== entryB.totalCount) return entryB.totalCount - entryA.totalCount;
319
+ return entryA.category.localeCompare(entryB.category);
320
+ });
321
+ };
322
+ //#endregion
323
+ //#region src/utils/build-hidden-diagnostics-summary.ts
324
+ const buildHiddenDiagnosticsSummary = (hiddenDiagnostics) => {
325
+ const errorCount = hiddenDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
326
+ const warningCount = hiddenDiagnostics.length - errorCount;
327
+ const parts = [];
328
+ if (errorCount > 0) parts.push({
329
+ severity: "error",
330
+ count: errorCount,
331
+ text: `✗ ${errorCount} more error${errorCount === 1 ? "" : "s"}`
332
+ });
333
+ if (warningCount > 0) parts.push({
334
+ severity: "warning",
335
+ count: warningCount,
336
+ text: `⚠ ${warningCount} more warning${warningCount === 1 ? "" : "s"}`
337
+ });
338
+ return parts;
339
+ };
340
+ //#endregion
341
+ //#region src/utils/calculate-score-locally.ts
299
342
  const getScoreLabel = (score) => {
300
343
  if (score >= 75) return "Great";
301
344
  if (score >= 50) return "Needs work";
@@ -327,7 +370,7 @@ const calculateScoreLocally = (diagnostics) => {
327
370
  };
328
371
  };
329
372
  //#endregion
330
- //#region src/core/try-score-from-api.ts
373
+ //#region src/utils/try-score-from-api.ts
331
374
  const parseScoreResult = (value) => {
332
375
  if (typeof value !== "object" || value === null) return null;
333
376
  if (!("score" in value) || !("label" in value)) return null;
@@ -370,10 +413,6 @@ const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
370
413
  }
371
414
  };
372
415
  //#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
416
  //#region src/utils/proxy-fetch.ts
378
417
  const getGlobalProcess = () => {
379
418
  const candidate = globalThis.process;
@@ -402,8 +441,8 @@ const proxyFetch = async (url, init) => {
402
441
  return fetch(url, fetchInit);
403
442
  };
404
443
  //#endregion
405
- //#region src/utils/calculate-score-node.ts
406
- const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
444
+ //#region src/utils/calculate-score.ts
445
+ const calculateScore = async (diagnostics) => await tryScoreFromApi(diagnostics, proxyFetch) ?? calculateScoreLocally(diagnostics);
407
446
  //#endregion
408
447
  //#region src/utils/colorize-by-score.ts
409
448
  const colorizeByScore = (text, score) => {
@@ -413,6 +452,85 @@ const colorizeByScore = (text, score) => {
413
452
  };
414
453
  //#endregion
415
454
  //#region src/plugin/constants.ts
455
+ const FETCH_CALLEE_NAMES = new Set([
456
+ "fetch",
457
+ "ky",
458
+ "got",
459
+ "wretch",
460
+ "ofetch"
461
+ ]);
462
+ const FETCH_MEMBER_OBJECTS = new Set([
463
+ "axios",
464
+ "ky",
465
+ "got",
466
+ "ofetch",
467
+ "wretch",
468
+ "request"
469
+ ]);
470
+ const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
471
+ "setTimeout",
472
+ "setInterval",
473
+ "requestAnimationFrame",
474
+ "requestIdleCallback",
475
+ "queueMicrotask"
476
+ ]);
477
+ const SUBSCRIPTION_METHOD_NAMES = new Set([
478
+ "subscribe",
479
+ "addEventListener",
480
+ "addListener",
481
+ "on",
482
+ "watch",
483
+ "listen",
484
+ "sub"
485
+ ]);
486
+ new Set([
487
+ ...new Set([
488
+ "unsubscribe",
489
+ "removeEventListener",
490
+ "removeListener",
491
+ "off",
492
+ "unwatch",
493
+ "unlisten",
494
+ "unsub"
495
+ ]),
496
+ "cleanup",
497
+ "dispose",
498
+ "destroy",
499
+ "teardown"
500
+ ]);
501
+ new Set([
502
+ ...SUBSCRIPTION_METHOD_NAMES,
503
+ "connect",
504
+ "disconnect",
505
+ "open",
506
+ "close",
507
+ "fetch",
508
+ "post",
509
+ "put",
510
+ "patch"
511
+ ]);
512
+ new Set([
513
+ ...FETCH_MEMBER_OBJECTS,
514
+ "api",
515
+ "client",
516
+ "http",
517
+ "fetcher"
518
+ ]);
519
+ new Set([...FETCH_CALLEE_NAMES, ...TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES]);
520
+ new Set([
521
+ ...FETCH_CALLEE_NAMES,
522
+ "post",
523
+ "put",
524
+ "patch",
525
+ "navigate",
526
+ "navigateTo",
527
+ "showNotification",
528
+ "toast",
529
+ "alert",
530
+ "confirm",
531
+ "logVisit",
532
+ "captureEvent"
533
+ ]);
416
534
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
417
535
  //#endregion
418
536
  //#region src/utils/is-file.ts
@@ -515,6 +633,13 @@ const createNodeReadFileLinesSync = (rootDirectory) => {
515
633
  };
516
634
  };
517
635
  //#endregion
636
+ //#region src/utils/is-plain-object.ts
637
+ const isPlainObject = (value) => {
638
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
639
+ const prototype = Object.getPrototypeOf(value);
640
+ return prototype === null || prototype === Object.prototype;
641
+ };
642
+ //#endregion
518
643
  //#region src/utils/match-glob-pattern.ts
519
644
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
520
645
  const compileGlobPattern = (pattern) => {
@@ -542,13 +667,232 @@ const compileGlobPattern = (pattern) => {
542
667
  return new RegExp(regexSource);
543
668
  };
544
669
  //#endregion
545
- //#region src/utils/is-ignored-file.ts
670
+ //#region src/utils/to-relative-path.ts
546
671
  const toRelativePath = (filePath, rootDirectory) => {
547
672
  const normalizedFilePath = filePath.replace(/\\/g, "/");
548
673
  const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
549
674
  if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
550
675
  return normalizedFilePath.replace(/^\.\//, "");
551
676
  };
677
+ //#endregion
678
+ //#region src/utils/apply-ignore-overrides.ts
679
+ const warnConfigField$1 = (message) => {
680
+ process.stderr.write(`[react-doctor] ${message}\n`);
681
+ };
682
+ const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
683
+ const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
684
+ const validateOverrideEntry = (entry, index) => {
685
+ if (!isPlainObject(entry)) {
686
+ warnConfigField$1(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
687
+ return null;
688
+ }
689
+ if (!isStringArray(entry.files)) {
690
+ warnConfigField$1(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
691
+ return null;
692
+ }
693
+ if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
694
+ 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).`);
695
+ return { files: entry.files };
696
+ }
697
+ return entry.rules === void 0 ? { files: entry.files } : {
698
+ files: entry.files,
699
+ rules: entry.rules
700
+ };
701
+ };
702
+ const compileIgnoreOverrides = (userConfig) => {
703
+ const overrides = userConfig?.ignore?.overrides;
704
+ if (overrides === void 0) return [];
705
+ if (!Array.isArray(overrides)) {
706
+ warnConfigField$1(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
707
+ return [];
708
+ }
709
+ return overrides.flatMap((entry, index) => {
710
+ const validated = validateOverrideEntry(entry, index);
711
+ if (!validated) return [];
712
+ const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
713
+ if (filePatterns.length === 0) return [];
714
+ return [{
715
+ filePatterns,
716
+ ruleIds: new Set(collectStringList(validated.rules))
717
+ }];
718
+ });
719
+ };
720
+ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
721
+ if (overrides.length === 0) return false;
722
+ const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
723
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
724
+ return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
725
+ };
726
+ //#endregion
727
+ //#region src/utils/find-jsx-opener-span.ts
728
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
729
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
730
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
731
+ let stringDelimiter = null;
732
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
733
+ const character = line[charIndex];
734
+ if (stringDelimiter !== null) {
735
+ if (character === "\\") {
736
+ charIndex++;
737
+ continue;
738
+ }
739
+ if (character === stringDelimiter) stringDelimiter = null;
740
+ continue;
741
+ }
742
+ if (character === "\"" || character === "'" || character === "`") {
743
+ stringDelimiter = character;
744
+ continue;
745
+ }
746
+ if (character === "/" && line[charIndex + 1] === "/") return true;
747
+ }
748
+ return false;
749
+ };
750
+ const findOpenerTagOnLine = (line) => {
751
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
752
+ if (match.index === void 0) continue;
753
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
754
+ }
755
+ return null;
756
+ };
757
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
758
+ const openerLine = lines[openerLineIndex];
759
+ if (openerLine === void 0) return null;
760
+ const opener = findOpenerTagOnLine(openerLine);
761
+ if (!opener) return null;
762
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
763
+ let braceDepth = 0;
764
+ let innerAngleDepth = 0;
765
+ let stringDelimiter = null;
766
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
767
+ const currentLine = lines[lineIndex];
768
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
769
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
770
+ const character = currentLine[charIndex];
771
+ if (stringDelimiter !== null) {
772
+ if (character === "\\") {
773
+ charIndex++;
774
+ continue;
775
+ }
776
+ if (character === stringDelimiter) stringDelimiter = null;
777
+ continue;
778
+ }
779
+ if (character === "\"" || character === "'" || character === "`") {
780
+ stringDelimiter = character;
781
+ continue;
782
+ }
783
+ if (character === "{") {
784
+ braceDepth++;
785
+ continue;
786
+ }
787
+ if (character === "}") {
788
+ braceDepth--;
789
+ continue;
790
+ }
791
+ if (braceDepth !== 0) continue;
792
+ if (character === "<") {
793
+ const followCharacter = currentLine[charIndex + 1];
794
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
795
+ continue;
796
+ }
797
+ if (character !== ">") continue;
798
+ const previousCharacter = currentLine[charIndex - 1];
799
+ const nextCharacter = currentLine[charIndex + 1];
800
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
801
+ if (innerAngleDepth > 0) {
802
+ innerAngleDepth--;
803
+ continue;
804
+ }
805
+ return lineIndex;
806
+ }
807
+ }
808
+ return null;
809
+ };
810
+ //#endregion
811
+ //#region src/utils/find-enclosing-jsx-opener.ts
812
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
813
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
814
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
815
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
816
+ }
817
+ return null;
818
+ };
819
+ //#endregion
820
+ //#region src/utils/find-stacked-disable-comments.ts
821
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
822
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
823
+ const collected = [];
824
+ let isStillInChain = true;
825
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
826
+ const candidateLine = lines[candidateIndex];
827
+ if (candidateLine === void 0) break;
828
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
829
+ if (match) {
830
+ collected.push({
831
+ commentLineIndex: candidateIndex,
832
+ ruleList: match[1],
833
+ isInChain: isStillInChain
834
+ });
835
+ continue;
836
+ }
837
+ isStillInChain = false;
838
+ }
839
+ return collected;
840
+ };
841
+ //#endregion
842
+ //#region src/utils/is-rule-listed-in-comment.ts
843
+ const isRuleListedInComment = (ruleList, ruleId) => {
844
+ if (!ruleList?.trim()) return true;
845
+ return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
846
+ };
847
+ //#endregion
848
+ //#region src/utils/evaluate-suppression.ts
849
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
850
+ const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
851
+ const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
852
+ const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
853
+ const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
854
+ const buildAdjacentMismatchHint = (comment, ruleId) => {
855
+ const ruleListText = comment.ruleList?.trim() ?? "";
856
+ 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}`;
857
+ };
858
+ const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
859
+ const commentLineNumber = comment.commentLineIndex + 1;
860
+ const diagnosticLineNumber = diagnosticLineIndex + 1;
861
+ 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.`;
862
+ };
863
+ const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
864
+ for (const comments of commentsByAnchor) {
865
+ const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
866
+ if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
867
+ const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
868
+ if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
869
+ }
870
+ return null;
871
+ };
872
+ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
873
+ const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
874
+ if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
875
+ isSuppressed: true,
876
+ nearMissHint: null
877
+ };
878
+ const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
879
+ if (hasChainSuppressor(directComments, ruleId)) return {
880
+ isSuppressed: true,
881
+ nearMissHint: null
882
+ };
883
+ const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
884
+ const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
885
+ if (hasChainSuppressor(openerComments, ruleId)) return {
886
+ isSuppressed: true,
887
+ nearMissHint: null
888
+ };
889
+ return {
890
+ isSuppressed: false,
891
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
892
+ };
893
+ };
894
+ //#endregion
895
+ //#region src/utils/is-ignored-file.ts
552
896
  const compileIgnoredFilePatterns = (userConfig) => {
553
897
  const files = userConfig?.ignore?.files;
554
898
  if (!Array.isArray(files)) return [];
@@ -561,14 +905,12 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
561
905
  };
562
906
  //#endregion
563
907
  //#region src/utils/filter-diagnostics.ts
908
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
564
909
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
565
910
  const normalizedFile = filePath.replace(/\\/g, "/");
566
911
  if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
567
912
  return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
568
913
  };
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
914
  const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
573
915
  const cache = /* @__PURE__ */ new Map();
574
916
  return (filePath) => {
@@ -589,13 +931,10 @@ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
589
931
  }
590
932
  return false;
591
933
  };
592
- const isRuleSuppressed = (commentRules, ruleId) => {
593
- if (!commentRules?.trim()) return true;
594
- return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
595
- };
596
934
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
597
935
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
598
936
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
937
+ const compiledOverrides = compileIgnoreOverrides(config);
599
938
  const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
600
939
  const hasTextComponents = textComponentNames.size > 0;
601
940
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
@@ -603,6 +942,7 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLi
603
942
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
604
943
  if (ignoredRules.has(ruleIdentifier)) return false;
605
944
  if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
945
+ if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
606
946
  if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
607
947
  const lines = getFileLines(diagnostic.filePath);
608
948
  if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
@@ -612,41 +952,36 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLi
612
952
  };
613
953
  const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
614
954
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
615
- return diagnostics.filter((diagnostic) => {
616
- if (diagnostic.line <= 0) return true;
955
+ return diagnostics.flatMap((diagnostic) => {
956
+ if (diagnostic.line <= 0) return [diagnostic];
617
957
  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;
958
+ if (!lines) return [diagnostic];
959
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
960
+ const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
961
+ if (evaluation.isSuppressed) return [];
962
+ return evaluation.nearMissHint ? [{
963
+ ...diagnostic,
964
+ suppressionHint: evaluation.nearMissHint
965
+ }] : [diagnostic];
633
966
  });
634
967
  };
635
968
  //#endregion
636
969
  //#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);
970
+ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
971
+ const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
972
+ if (options.respectInlineDisables === false) return filtered;
973
+ return filterInlineSuppressions(filtered, directory, readFileLinesSync);
639
974
  };
640
975
  //#endregion
641
976
  //#region src/utils/combine-diagnostics.ts
642
977
  const combineDiagnostics = (input) => {
643
- const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true } = input;
978
+ const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true, respectInlineDisables } = input;
644
979
  const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
645
980
  return mergeAndFilterDiagnostics([
646
981
  ...lintDiagnostics,
647
982
  ...deadCodeDiagnostics,
648
983
  ...extraDiagnostics
649
- ], directory, userConfig, readFileLinesSync);
984
+ ], directory, userConfig, readFileLinesSync, { respectInlineDisables });
650
985
  };
651
986
  //#endregion
652
987
  //#region src/utils/jsx-include-paths.ts
@@ -670,13 +1005,6 @@ const findMonorepoRoot = (startDirectory) => {
670
1005
  return null;
671
1006
  };
672
1007
  //#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
1008
  //#region src/utils/discover-project.ts
681
1009
  const REACT_COMPILER_PACKAGES = new Set([
682
1010
  "babel-plugin-react-compiler",
@@ -1074,7 +1402,7 @@ const hasCompilerInConfigFile = (filePath) => {
1074
1402
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
1075
1403
  };
1076
1404
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
1077
- const isProjectBoundary$1 = (directory) => {
1405
+ const isProjectBoundary$2 = (directory) => {
1078
1406
  if (fs.existsSync(path.join(directory, ".git"))) return true;
1079
1407
  return isMonorepoRoot(directory);
1080
1408
  };
@@ -1084,14 +1412,14 @@ const detectReactCompiler = (directory, packageJson) => {
1084
1412
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
1085
1413
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
1086
1414
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
1087
- if (isProjectBoundary$1(directory)) return false;
1415
+ if (isProjectBoundary$2(directory)) return false;
1088
1416
  let ancestorDirectory = path.dirname(directory);
1089
1417
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1090
1418
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
1091
1419
  if (isFile(ancestorPackagePath)) {
1092
1420
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
1093
1421
  }
1094
- if (isProjectBoundary$1(ancestorDirectory)) return false;
1422
+ if (isProjectBoundary$2(ancestorDirectory)) return false;
1095
1423
  ancestorDirectory = path.dirname(ancestorDirectory);
1096
1424
  }
1097
1425
  return false;
@@ -1158,32 +1486,6 @@ const formatErrorMessage = (error) => error instanceof Error ? error.message ||
1158
1486
  const formatErrorChain = (rootError) => collectErrorChain(rootError).map(formatErrorMessage).join(" → ");
1159
1487
  const getErrorChainMessages = (rootError) => collectErrorChain(rootError).map(formatErrorMessage);
1160
1488
  //#endregion
1161
- //#region src/utils/framed-box.ts
1162
- const createFramedLine = (plainText, renderedText = plainText) => ({
1163
- plainText,
1164
- renderedText
1165
- });
1166
- const renderFramedBoxString = (framedLines) => {
1167
- if (framedLines.length === 0) return "";
1168
- const borderColorizer = highlighter.dim;
1169
- const outerIndent = " ".repeat(2);
1170
- const horizontalPadding = " ".repeat(1);
1171
- const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
1172
- const borderLine = "─".repeat(maximumLineLength + 2);
1173
- const lines = [];
1174
- lines.push(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
1175
- for (const framedLine of framedLines) {
1176
- const trailingSpaces = " ".repeat(maximumLineLength - framedLine.plainText.length);
1177
- lines.push(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer("│")}`);
1178
- }
1179
- lines.push(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
1180
- return lines.join("\n");
1181
- };
1182
- const printFramedBox = (framedLines) => {
1183
- const rendered = renderFramedBoxString(framedLines);
1184
- if (rendered) logger.log(rendered);
1185
- };
1186
- //#endregion
1187
1489
  //#region src/utils/group-by.ts
1188
1490
  const groupBy = (items, keyFn) => {
1189
1491
  const groups = /* @__PURE__ */ new Map();
@@ -1206,7 +1508,8 @@ const BOOLEAN_FIELD_NAMES = [
1206
1508
  "verbose",
1207
1509
  "customRulesOnly",
1208
1510
  "share",
1209
- "respectInlineDisables"
1511
+ "respectInlineDisables",
1512
+ "adoptExistingLintConfig"
1210
1513
  ];
1211
1514
  const warnConfigField = (message) => {
1212
1515
  process.stderr.write(`[react-doctor] ${message}\n`);
@@ -1261,7 +1564,7 @@ const loadConfigFromDirectory = (directory) => {
1261
1564
  }
1262
1565
  return null;
1263
1566
  };
1264
- const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1567
+ const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1265
1568
  const cachedConfigs = /* @__PURE__ */ new Map();
1266
1569
  const loadConfig = (rootDirectory) => {
1267
1570
  const cached = cachedConfigs.get(rootDirectory);
@@ -1271,7 +1574,7 @@ const loadConfig = (rootDirectory) => {
1271
1574
  cachedConfigs.set(rootDirectory, localConfig);
1272
1575
  return localConfig;
1273
1576
  }
1274
- if (isProjectBoundary(rootDirectory)) {
1577
+ if (isProjectBoundary$1(rootDirectory)) {
1275
1578
  cachedConfigs.set(rootDirectory, null);
1276
1579
  return null;
1277
1580
  }
@@ -1282,7 +1585,7 @@ const loadConfig = (rootDirectory) => {
1282
1585
  cachedConfigs.set(rootDirectory, ancestorConfig);
1283
1586
  return ancestorConfig;
1284
1587
  }
1285
- if (isProjectBoundary(ancestorDirectory)) {
1588
+ if (isProjectBoundary$1(ancestorDirectory)) {
1286
1589
  cachedConfigs.set(rootDirectory, null);
1287
1590
  return null;
1288
1591
  }
@@ -1441,36 +1744,57 @@ const extractFailedPluginName = (error) => {
1441
1744
  //#region src/utils/has-knip-config.ts
1442
1745
  const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1443
1746
  //#endregion
1747
+ //#region src/utils/sanitize-knip-config-patterns.ts
1748
+ const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
1749
+ const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
1750
+ const sanitizeKnipConfigPatterns = (parsedConfig) => {
1751
+ for (const [key, value] of Object.entries(parsedConfig)) {
1752
+ if (typeof value === "string") {
1753
+ if (!isMeaningfulPattern(value)) delete parsedConfig[key];
1754
+ continue;
1755
+ }
1756
+ if (Array.isArray(value)) {
1757
+ if (value.length === 0) continue;
1758
+ const sanitized = sanitizeStringArray(value);
1759
+ if (sanitized.length === value.length) continue;
1760
+ if (sanitized.length === 0) delete parsedConfig[key];
1761
+ else parsedConfig[key] = sanitized;
1762
+ continue;
1763
+ }
1764
+ if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
1765
+ }
1766
+ };
1767
+ //#endregion
1444
1768
  //#region src/utils/run-knip.ts
1445
- const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1446
- files: {
1769
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
1770
+ ["files", {
1447
1771
  category: "Dead Code",
1448
1772
  message: "Unused file",
1449
1773
  severity: "warning"
1450
- },
1451
- exports: {
1774
+ }],
1775
+ ["exports", {
1452
1776
  category: "Dead Code",
1453
1777
  message: "Unused export",
1454
1778
  severity: "warning"
1455
- },
1456
- types: {
1779
+ }],
1780
+ ["types", {
1457
1781
  category: "Dead Code",
1458
1782
  message: "Unused type",
1459
1783
  severity: "warning"
1460
- },
1461
- duplicates: {
1784
+ }],
1785
+ ["duplicates", {
1462
1786
  category: "Dead Code",
1463
1787
  message: "Duplicate export",
1464
1788
  severity: "warning"
1465
- }
1466
- };
1789
+ }]
1790
+ ]);
1467
1791
  const FALLBACK_KNIP_DESCRIPTOR = {
1468
1792
  category: "Dead Code",
1469
1793
  message: "Issue",
1470
1794
  severity: "warning"
1471
1795
  };
1472
1796
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1473
- const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1797
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
1474
1798
  const diagnostics = [];
1475
1799
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1476
1800
  filePath: path.relative(rootDirectory, issue.filePath),
@@ -1508,7 +1832,7 @@ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1508
1832
  const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1509
1833
  const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1510
1834
  const failedPlugin = extractFailedPluginName(error);
1511
- if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
1835
+ if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
1512
1836
  disabledPlugins.add(failedPlugin);
1513
1837
  parsedConfig[failedPlugin] = false;
1514
1838
  return true;
@@ -1522,6 +1846,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1522
1846
  ...tsConfigFile ? { tsConfigFile } : {}
1523
1847
  }));
1524
1848
  const parsedConfig = options.parsedConfig;
1849
+ sanitizeKnipConfigPatterns(parsedConfig);
1525
1850
  const disabledPlugins = /* @__PURE__ */ new Set();
1526
1851
  let lastKnipError;
1527
1852
  for (let attempt = 0; attempt < 6; attempt++) try {
@@ -1553,7 +1878,7 @@ const runKnip = async (rootDirectory) => {
1553
1878
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1554
1879
  const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1555
1880
  const diagnostics = [];
1556
- const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1881
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
1557
1882
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1558
1883
  filePath: path.relative(rootDirectory, unusedFilePath),
1559
1884
  plugin: "knip",
@@ -1573,6 +1898,18 @@ const runKnip = async (rootDirectory) => {
1573
1898
  return diagnostics;
1574
1899
  };
1575
1900
  //#endregion
1901
+ //#region src/utils/parse-react-major.ts
1902
+ const parseReactMajor = (reactVersion) => {
1903
+ if (typeof reactVersion !== "string") return null;
1904
+ const trimmed = reactVersion.trim();
1905
+ if (trimmed.length === 0) return null;
1906
+ const match = trimmed.match(/(\d+)/);
1907
+ if (!match) return null;
1908
+ const major = Number.parseInt(match[1], 10);
1909
+ if (!Number.isFinite(major) || major <= 0) return null;
1910
+ return major;
1911
+ };
1912
+ //#endregion
1576
1913
  //#region src/utils/batch-include-paths.ts
1577
1914
  const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1578
1915
  const batchIncludePaths = (baseArgs, includePaths) => {
@@ -1596,6 +1933,80 @@ const batchIncludePaths = (baseArgs, includePaths) => {
1596
1933
  return batches;
1597
1934
  };
1598
1935
  //#endregion
1936
+ //#region src/utils/can-oxlint-extend-config.ts
1937
+ const EXTENDS_LOCAL_PATH_PREFIXES = [
1938
+ "./",
1939
+ "../",
1940
+ "/"
1941
+ ];
1942
+ const isLocalPathExtend = (entry) => {
1943
+ for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
1944
+ return false;
1945
+ };
1946
+ const stripJsoncComments = (raw) => {
1947
+ let result = "";
1948
+ let cursor = 0;
1949
+ let inString = false;
1950
+ let stringQuote = "";
1951
+ while (cursor < raw.length) {
1952
+ const character = raw[cursor];
1953
+ const nextCharacter = raw[cursor + 1];
1954
+ if (inString) {
1955
+ result += character;
1956
+ if (character === "\\" && cursor + 1 < raw.length) {
1957
+ result += nextCharacter;
1958
+ cursor += 2;
1959
+ continue;
1960
+ }
1961
+ if (character === stringQuote) inString = false;
1962
+ cursor += 1;
1963
+ continue;
1964
+ }
1965
+ if (character === "\"" || character === "'") {
1966
+ inString = true;
1967
+ stringQuote = character;
1968
+ result += character;
1969
+ cursor += 1;
1970
+ continue;
1971
+ }
1972
+ if (character === "/" && nextCharacter === "/") {
1973
+ const lineEndIndex = raw.indexOf("\n", cursor);
1974
+ cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
1975
+ continue;
1976
+ }
1977
+ if (character === "/" && nextCharacter === "*") {
1978
+ const blockEndIndex = raw.indexOf("*/", cursor + 2);
1979
+ cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
1980
+ continue;
1981
+ }
1982
+ result += character;
1983
+ cursor += 1;
1984
+ }
1985
+ return result;
1986
+ };
1987
+ const parseJsonOrJsonc = (raw) => {
1988
+ try {
1989
+ return JSON.parse(raw);
1990
+ } catch {
1991
+ return JSON.parse(stripJsoncComments(raw));
1992
+ }
1993
+ };
1994
+ const canOxlintExtendConfig = (configPath) => {
1995
+ if (!configPath.endsWith(".eslintrc.json")) return true;
1996
+ let parsed;
1997
+ try {
1998
+ parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
1999
+ } catch {
2000
+ return true;
2001
+ }
2002
+ if (!isPlainObject(parsed)) return true;
2003
+ const extendsValue = parsed.extends;
2004
+ if (extendsValue === void 0 || extendsValue === null) return true;
2005
+ const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
2006
+ if (extendsEntries.length === 0) return true;
2007
+ return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
2008
+ };
2009
+ //#endregion
1599
2010
  //#region src/utils/parse-gitattributes-linguist.ts
1600
2011
  const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
1601
2012
  const FALSY_VALUES = new Set([
@@ -1680,6 +2091,29 @@ const collectIgnorePatterns = (rootDirectory) => {
1680
2091
  return patterns;
1681
2092
  };
1682
2093
  //#endregion
2094
+ //#region src/utils/detect-user-lint-config.ts
2095
+ const findFirstLintConfigInDirectory = (directory) => {
2096
+ for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
2097
+ const candidatePath = path.join(directory, filename);
2098
+ if (isFile(candidatePath)) return candidatePath;
2099
+ }
2100
+ return null;
2101
+ };
2102
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
2103
+ const detectUserLintConfigPaths = (rootDirectory) => {
2104
+ const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
2105
+ if (directLintConfig) return [directLintConfig];
2106
+ if (isProjectBoundary(rootDirectory)) return [];
2107
+ let ancestorDirectory = path.dirname(rootDirectory);
2108
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
2109
+ const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
2110
+ if (ancestorLintConfig) return [ancestorLintConfig];
2111
+ if (isProjectBoundary(ancestorDirectory)) return [];
2112
+ ancestorDirectory = path.dirname(ancestorDirectory);
2113
+ }
2114
+ return [];
2115
+ };
2116
+ //#endregion
1683
2117
  //#region src/oxlint-config.ts
1684
2118
  const esmRequire$1 = createRequire(import.meta.url);
1685
2119
  const NEXTJS_RULES = {
@@ -1841,18 +2275,27 @@ const BUILTIN_A11Y_RULES = {
1841
2275
  const GLOBAL_REACT_DOCTOR_RULES = {
1842
2276
  "react-doctor/no-derived-state-effect": "warn",
1843
2277
  "react-doctor/no-fetch-in-effect": "warn",
2278
+ "react-doctor/no-mirror-prop-effect": "warn",
2279
+ "react-doctor/no-mutable-in-deps": "error",
1844
2280
  "react-doctor/no-cascading-set-state": "warn",
2281
+ "react-doctor/no-effect-chain": "warn",
1845
2282
  "react-doctor/no-effect-event-handler": "warn",
1846
2283
  "react-doctor/no-effect-event-in-deps": "error",
2284
+ "react-doctor/no-event-trigger-state": "warn",
1847
2285
  "react-doctor/no-prop-callback-in-effect": "warn",
1848
2286
  "react-doctor/no-derived-useState": "warn",
2287
+ "react-doctor/no-direct-state-mutation": "warn",
2288
+ "react-doctor/no-set-state-in-render": "warn",
2289
+ "react-doctor/prefer-use-effect-event": "warn",
1849
2290
  "react-doctor/prefer-useReducer": "warn",
2291
+ "react-doctor/prefer-use-sync-external-store": "warn",
1850
2292
  "react-doctor/rerender-lazy-state-init": "warn",
1851
2293
  "react-doctor/rerender-functional-setstate": "warn",
1852
2294
  "react-doctor/rerender-dependencies": "error",
1853
2295
  "react-doctor/rerender-state-only-in-handlers": "warn",
1854
2296
  "react-doctor/rerender-defer-reads-hook": "warn",
1855
2297
  "react-doctor/advanced-event-handler-refs": "warn",
2298
+ "react-doctor/effect-needs-cleanup": "error",
1856
2299
  "react-doctor/no-giant-component": "warn",
1857
2300
  "react-doctor/no-render-in-render": "warn",
1858
2301
  "react-doctor/no-many-boolean-props": "warn",
@@ -1860,6 +2303,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1860
2303
  "react-doctor/no-render-prop-children": "warn",
1861
2304
  "react-doctor/no-nested-component-definition": "error",
1862
2305
  "react-doctor/react-compiler-destructure-method": "warn",
2306
+ "react-doctor/no-legacy-class-lifecycles": "error",
2307
+ "react-doctor/no-legacy-context-api": "error",
2308
+ "react-doctor/no-default-props": "warn",
2309
+ "react-doctor/no-react-dom-deprecated-apis": "warn",
1863
2310
  "react-doctor/no-usememo-simple-expression": "warn",
1864
2311
  "react-doctor/no-layout-property-animation": "error",
1865
2312
  "react-doctor/rerender-memo-with-default-value": "warn",
@@ -1908,6 +2355,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1908
2355
  "react-doctor/rendering-conditional-render": "warn",
1909
2356
  "react-doctor/rendering-svg-precision": "warn",
1910
2357
  "react-doctor/no-prevent-default": "warn",
2358
+ "react-doctor/no-uncontrolled-input": "warn",
1911
2359
  "react-doctor/no-document-start-view-transition": "warn",
1912
2360
  "react-doctor/no-flush-sync": "warn",
1913
2361
  "react-doctor/server-auth-actions": "error",
@@ -1935,6 +2383,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1935
2383
  "react-doctor/no-disabled-zoom": "error",
1936
2384
  "react-doctor/no-outline-none": "warn",
1937
2385
  "react-doctor/no-long-transition-duration": "warn",
2386
+ "react-doctor/design-no-bold-heading": "warn",
2387
+ "react-doctor/design-no-redundant-padding-axes": "warn",
2388
+ "react-doctor/design-no-redundant-size-axes": "warn",
2389
+ "react-doctor/design-no-space-on-flex-children": "warn",
2390
+ "react-doctor/design-no-em-dash-in-jsx-text": "warn",
2391
+ "react-doctor/design-no-three-period-ellipsis": "warn",
2392
+ "react-doctor/design-no-default-tailwind-palette": "warn",
2393
+ "react-doctor/design-no-vague-button-label": "warn",
1938
2394
  "react-doctor/async-parallel": "warn"
1939
2395
  };
1940
2396
  const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
@@ -1944,10 +2400,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1944
2400
  ...Object.keys(TANSTACK_START_RULES),
1945
2401
  ...Object.keys(TANSTACK_QUERY_RULES)
1946
2402
  ]);
1947
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => {
2403
+ const VERSION_GATED_RULE_IDS = new Map([
2404
+ ["react-doctor/no-react19-deprecated-apis", {
2405
+ minMajor: 19,
2406
+ mode: "deprecation-warning"
2407
+ }],
2408
+ ["react-doctor/no-default-props", {
2409
+ minMajor: 19,
2410
+ mode: "deprecation-warning"
2411
+ }],
2412
+ ["react-doctor/no-react-dom-deprecated-apis", {
2413
+ minMajor: 18,
2414
+ mode: "deprecation-warning"
2415
+ }],
2416
+ ["react-doctor/prefer-use-effect-event", {
2417
+ minMajor: 19,
2418
+ mode: "prefer-newer-api"
2419
+ }]
2420
+ ]);
2421
+ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
2422
+ return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
2423
+ const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
2424
+ if (gate === void 0) return true;
2425
+ if (gate.mode === "deprecation-warning") return true;
2426
+ if (reactMajorVersion === null) return true;
2427
+ return reactMajorVersion >= gate.minMajor;
2428
+ }));
2429
+ };
2430
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
1948
2431
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
1949
2432
  const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
1950
2433
  return {
2434
+ ...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
1951
2435
  categories: {
1952
2436
  correctness: "off",
1953
2437
  suspicious: "off",
@@ -1963,7 +2447,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
1963
2447
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1964
2448
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1965
2449
  ...reactCompilerRules,
1966
- ...GLOBAL_REACT_DOCTOR_RULES,
2450
+ ...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
1967
2451
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1968
2452
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1969
2453
  ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
@@ -2075,23 +2559,43 @@ const PLUGIN_CATEGORY_MAP = {
2075
2559
  "react-hooks-js": "React Compiler",
2076
2560
  "react-doctor": "Other",
2077
2561
  "jsx-a11y": "Accessibility",
2078
- knip: "Dead Code"
2562
+ knip: "Dead Code",
2563
+ eslint: "Correctness",
2564
+ oxc: "Correctness",
2565
+ typescript: "Correctness",
2566
+ unicorn: "Correctness",
2567
+ import: "Bundle Size",
2568
+ promise: "Correctness",
2569
+ n: "Correctness",
2570
+ node: "Correctness",
2571
+ vitest: "Correctness",
2572
+ jest: "Correctness",
2573
+ nextjs: "Next.js"
2079
2574
  };
2080
2575
  const RULE_CATEGORY_MAP = {
2081
2576
  "react-doctor/no-derived-state-effect": "State & Effects",
2082
2577
  "react-doctor/no-fetch-in-effect": "State & Effects",
2578
+ "react-doctor/no-mirror-prop-effect": "State & Effects",
2579
+ "react-doctor/no-mutable-in-deps": "State & Effects",
2083
2580
  "react-doctor/no-cascading-set-state": "State & Effects",
2581
+ "react-doctor/no-effect-chain": "State & Effects",
2084
2582
  "react-doctor/no-effect-event-handler": "State & Effects",
2085
2583
  "react-doctor/no-effect-event-in-deps": "State & Effects",
2584
+ "react-doctor/no-event-trigger-state": "State & Effects",
2086
2585
  "react-doctor/no-prop-callback-in-effect": "State & Effects",
2087
2586
  "react-doctor/no-derived-useState": "State & Effects",
2587
+ "react-doctor/no-direct-state-mutation": "State & Effects",
2588
+ "react-doctor/no-set-state-in-render": "State & Effects",
2589
+ "react-doctor/prefer-use-effect-event": "State & Effects",
2088
2590
  "react-doctor/prefer-useReducer": "State & Effects",
2591
+ "react-doctor/prefer-use-sync-external-store": "State & Effects",
2089
2592
  "react-doctor/rerender-lazy-state-init": "Performance",
2090
2593
  "react-doctor/rerender-functional-setstate": "Performance",
2091
2594
  "react-doctor/rerender-dependencies": "State & Effects",
2092
2595
  "react-doctor/rerender-state-only-in-handlers": "Performance",
2093
2596
  "react-doctor/rerender-defer-reads-hook": "Performance",
2094
2597
  "react-doctor/advanced-event-handler-refs": "Performance",
2598
+ "react-doctor/effect-needs-cleanup": "State & Effects",
2095
2599
  "react-doctor/no-generic-handler-names": "Architecture",
2096
2600
  "react-doctor/no-giant-component": "Architecture",
2097
2601
  "react-doctor/no-many-boolean-props": "Architecture",
@@ -2100,6 +2604,10 @@ const RULE_CATEGORY_MAP = {
2100
2604
  "react-doctor/no-render-in-render": "Architecture",
2101
2605
  "react-doctor/no-nested-component-definition": "Correctness",
2102
2606
  "react-doctor/react-compiler-destructure-method": "Architecture",
2607
+ "react-doctor/no-legacy-class-lifecycles": "Correctness",
2608
+ "react-doctor/no-legacy-context-api": "Correctness",
2609
+ "react-doctor/no-default-props": "Architecture",
2610
+ "react-doctor/no-react-dom-deprecated-apis": "Architecture",
2103
2611
  "react-doctor/no-usememo-simple-expression": "Performance",
2104
2612
  "react-doctor/no-layout-property-animation": "Performance",
2105
2613
  "react-doctor/rerender-memo-with-default-value": "Performance",
@@ -2133,6 +2641,7 @@ const RULE_CATEGORY_MAP = {
2133
2641
  "react-doctor/rendering-conditional-render": "Correctness",
2134
2642
  "react-doctor/rendering-svg-precision": "Performance",
2135
2643
  "react-doctor/no-prevent-default": "Correctness",
2644
+ "react-doctor/no-uncontrolled-input": "Correctness",
2136
2645
  "react-doctor/no-document-start-view-transition": "Correctness",
2137
2646
  "react-doctor/no-flush-sync": "Performance",
2138
2647
  "react-doctor/nextjs-no-img-element": "Next.js",
@@ -2182,6 +2691,14 @@ const RULE_CATEGORY_MAP = {
2182
2691
  "react-doctor/no-disabled-zoom": "Accessibility",
2183
2692
  "react-doctor/no-outline-none": "Accessibility",
2184
2693
  "react-doctor/no-long-transition-duration": "Performance",
2694
+ "react-doctor/design-no-bold-heading": "Architecture",
2695
+ "react-doctor/design-no-redundant-padding-axes": "Architecture",
2696
+ "react-doctor/design-no-redundant-size-axes": "Architecture",
2697
+ "react-doctor/design-no-space-on-flex-children": "Architecture",
2698
+ "react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
2699
+ "react-doctor/design-no-three-period-ellipsis": "Architecture",
2700
+ "react-doctor/design-no-default-tailwind-palette": "Architecture",
2701
+ "react-doctor/design-no-vague-button-label": "Accessibility",
2185
2702
  "react-doctor/js-flatmap-filter": "Performance",
2186
2703
  "react-doctor/js-combine-iterations": "Performance",
2187
2704
  "react-doctor/js-tosorted-immutable": "Performance",
@@ -2239,10 +2756,18 @@ const RULE_CATEGORY_MAP = {
2239
2756
  const RULE_HELP_MAP = {
2240
2757
  "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",
2241
2758
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
2759
+ "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",
2760
+ "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",
2242
2761
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
2762
+ "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",
2243
2763
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
2764
+ "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",
2244
2765
  "no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
2766
+ "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",
2767
+ "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",
2768
+ "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",
2245
2769
  "prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
2770
+ "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",
2246
2771
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
2247
2772
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
2248
2773
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
@@ -2251,7 +2776,11 @@ const RULE_HELP_MAP = {
2251
2776
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
2252
2777
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
2253
2778
  "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
2254
- "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.",
2779
+ "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+.",
2780
+ "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.",
2781
+ "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.",
2782
+ "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\" }`.",
2783
+ "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+.",
2255
2784
  "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",
2256
2785
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
2257
2786
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
@@ -2266,6 +2795,7 @@ const RULE_HELP_MAP = {
2266
2795
  "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",
2267
2796
  "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",
2268
2797
  "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",
2798
+ "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",
2269
2799
  "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
2270
2800
  "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
2271
2801
  "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",
@@ -2312,9 +2842,18 @@ const RULE_HELP_MAP = {
2312
2842
  "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",
2313
2843
  "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",
2314
2844
  "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",
2845
+ "design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
2846
+ "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`)",
2847
+ "design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
2848
+ "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",
2849
+ "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",
2850
+ "design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `&hellip;`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
2851
+ "design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
2852
+ "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",
2315
2853
  "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
2316
2854
  "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
2317
2855
  "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
2856
+ "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",
2318
2857
  "nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
2319
2858
  "nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
2320
2859
  "nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
@@ -2397,6 +2936,7 @@ const RULE_HELP_MAP = {
2397
2936
  };
2398
2937
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
2399
2938
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
2939
+ const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
2400
2940
  const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2401
2941
  if (plugin === "react-hooks-js") return {
2402
2942
  message: REACT_COMPILER_MESSAGE,
@@ -2404,7 +2944,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2404
2944
  };
2405
2945
  return {
2406
2946
  message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
2407
- help: help || RULE_HELP_MAP[rule] || ""
2947
+ help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
2408
2948
  };
2409
2949
  };
2410
2950
  const parseRuleCode = (code) => {
@@ -2432,7 +2972,7 @@ const resolvePluginPath = () => {
2432
2972
  return pluginPath;
2433
2973
  };
2434
2974
  const resolveDiagnosticCategory = (plugin, rule) => {
2435
- return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
2975
+ return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
2436
2976
  };
2437
2977
  const SANITIZED_ENV = (() => {
2438
2978
  const sanitized = {};
@@ -2523,7 +3063,7 @@ const parseOxlintOutput = (stdout) => {
2523
3063
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
2524
3064
  }
2525
3065
  if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2526
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
3066
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2527
3067
  const { plugin, rule } = parseRuleCode(diagnostic.code);
2528
3068
  const primaryLabel = diagnostic.labels[0];
2529
3069
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -2553,8 +3093,8 @@ const validateRuleRegistration = () => {
2553
3093
  const missingCategory = [];
2554
3094
  for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2555
3095
  const ruleName = fullKey.replace(/^react-doctor\//, "");
2556
- if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2557
- if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
3096
+ if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
3097
+ if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
2558
3098
  }
2559
3099
  if (missingCategory.length > 0 || missingHelp.length > 0) {
2560
3100
  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("; ");
@@ -2562,26 +3102,24 @@ const validateRuleRegistration = () => {
2562
3102
  }
2563
3103
  };
2564
3104
  const runOxlint = async (options) => {
2565
- const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
3105
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
2566
3106
  validateRuleRegistration();
2567
3107
  if (includePaths !== void 0 && includePaths.length === 0) return [];
2568
3108
  const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2569
3109
  const configPath = path.join(configDirectory, "oxlintrc.json");
3110
+ const pluginPath = resolvePluginPath();
3111
+ const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
2570
3112
  const config = createOxlintConfig({
2571
- pluginPath: resolvePluginPath(),
3113
+ pluginPath,
2572
3114
  framework,
2573
3115
  hasReactCompiler,
2574
3116
  hasTanStackQuery,
2575
- customRulesOnly
3117
+ customRulesOnly,
3118
+ reactMajorVersion,
3119
+ extendsPaths
2576
3120
  });
2577
3121
  const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
2578
3122
  try {
2579
- const fileHandle = fs.openSync(configPath, "wx", 384);
2580
- try {
2581
- fs.writeFileSync(fileHandle, JSON.stringify(config));
2582
- } finally {
2583
- fs.closeSync(fileHandle);
2584
- }
2585
3123
  const baseArgs = [
2586
3124
  resolveOxlintBinary(),
2587
3125
  "-c",
@@ -2600,12 +3138,39 @@ const runOxlint = async (options) => {
2600
3138
  baseArgs.push("--ignore-path", combinedIgnorePath);
2601
3139
  }
2602
3140
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2603
- const allDiagnostics = [];
2604
- for (const batch of fileBatches) {
2605
- const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
2606
- allDiagnostics.push(...parseOxlintOutput(stdout));
3141
+ const writeOxlintConfig = (configToWrite) => {
3142
+ fs.rmSync(configPath, { force: true });
3143
+ const fileHandle = fs.openSync(configPath, "wx", 384);
3144
+ try {
3145
+ fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
3146
+ } finally {
3147
+ fs.closeSync(fileHandle);
3148
+ }
3149
+ };
3150
+ const spawnLintBatches = async () => {
3151
+ const allDiagnostics = [];
3152
+ for (const batch of fileBatches) {
3153
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
3154
+ allDiagnostics.push(...parseOxlintOutput(stdout));
3155
+ }
3156
+ return allDiagnostics;
3157
+ };
3158
+ writeOxlintConfig(config);
3159
+ try {
3160
+ return await spawnLintBatches();
3161
+ } catch (error) {
3162
+ if (extendsPaths.length === 0) throw error;
3163
+ writeOxlintConfig(createOxlintConfig({
3164
+ pluginPath,
3165
+ framework,
3166
+ hasReactCompiler,
3167
+ hasTanStackQuery,
3168
+ customRulesOnly,
3169
+ reactMajorVersion,
3170
+ extendsPaths: []
3171
+ }));
3172
+ return await spawnLintBatches();
2607
3173
  }
2608
- return allDiagnostics;
2609
3174
  } finally {
2610
3175
  restoreDisableDirectives();
2611
3176
  fs.rmSync(configDirectory, {
@@ -2621,35 +3186,90 @@ const SEVERITY_ORDER = {
2621
3186
  warning: 1
2622
3187
  };
2623
3188
  const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
2624
- const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
2625
- return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
3189
+ const sortByImportance = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
3190
+ const severityDelta = SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
3191
+ if (severityDelta !== 0) return severityDelta;
3192
+ return diagnosticsB.length - diagnosticsA.length;
2626
3193
  });
2627
3194
  const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
2628
- const buildFileLineMap = (diagnostics) => {
2629
- const fileLines = /* @__PURE__ */ new Map();
3195
+ const buildVerboseSiteMap = (diagnostics) => {
3196
+ const fileSites = /* @__PURE__ */ new Map();
2630
3197
  for (const diagnostic of diagnostics) {
2631
- const lines = fileLines.get(diagnostic.filePath) ?? [];
2632
- if (diagnostic.line > 0) lines.push(diagnostic.line);
2633
- fileLines.set(diagnostic.filePath, lines);
2634
- }
2635
- return fileLines;
2636
- };
2637
- const printDiagnostics = (diagnostics, isVerbose) => {
2638
- const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2639
- for (const [, ruleDiagnostics] of sortedRuleGroups) {
2640
- const firstDiagnostic = ruleDiagnostics[0];
2641
- const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
2642
- const count = ruleDiagnostics.length;
2643
- const countLabel = count > 1 ? colorizeBySeverity(` (${count})`, firstDiagnostic.severity) : "";
2644
- logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
2645
- if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
3198
+ const sites = fileSites.get(diagnostic.filePath) ?? [];
3199
+ if (diagnostic.line > 0) sites.push({
3200
+ line: diagnostic.line,
3201
+ suppressionHint: diagnostic.suppressionHint
3202
+ });
3203
+ fileSites.set(diagnostic.filePath, sites);
3204
+ }
3205
+ return fileSites;
3206
+ };
3207
+ const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
3208
+ const computeRuleNameColumnWidth = (ruleKeys) => {
3209
+ const longestRuleNameLength = ruleKeys.reduce((longest, ruleKey) => Math.max(longest, ruleKey.length), 0);
3210
+ return Math.max(36, longestRuleNameLength);
3211
+ };
3212
+ const padRuleNameToColumn = (ruleName, columnWidth) => {
3213
+ if (ruleName.length >= columnWidth) return ruleName;
3214
+ return ruleName + " ".repeat(columnWidth - ruleName.length);
3215
+ };
3216
+ const grayLine = (text) => {
3217
+ logger.log(highlighter.gray(text));
3218
+ };
3219
+ const printCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
3220
+ const firstDiagnostic = ruleDiagnostics[0];
3221
+ const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
3222
+ const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
3223
+ const ruleNameRendering = siteCountBadge.length > 0 ? colorizeBySeverity(padRuleNameToColumn(ruleKey, ruleNameColumnWidth), firstDiagnostic.severity) : colorizeBySeverity(ruleKey, firstDiagnostic.severity);
3224
+ const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
3225
+ logger.log(` ${icon} ${ruleNameRendering}${trailingBadge}`);
3226
+ };
3227
+ const printDetailedRuleGroup = (ruleKey, ruleDiagnostics, rootDirectory, ruleNameColumnWidth) => {
3228
+ printCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3229
+ const firstDiagnostic = ruleDiagnostics[0];
3230
+ grayLine(indentMultilineText(firstDiagnostic.message, " "));
3231
+ if (firstDiagnostic.help) grayLine(indentMultilineText(`→ ${firstDiagnostic.help}`, " "));
3232
+ const firstLocation = ruleDiagnostics.find((diagnostic) => diagnostic.line > 0);
3233
+ if (firstLocation) grayLine(` ${toRelativePath(firstLocation.filePath, rootDirectory)}:${firstLocation.line}`);
3234
+ logger.break();
3235
+ };
3236
+ const printVerboseRuleGroup = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
3237
+ printCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3238
+ const firstDiagnostic = ruleDiagnostics[0];
3239
+ grayLine(indentMultilineText(firstDiagnostic.message, " "));
3240
+ if (firstDiagnostic.help) grayLine(indentMultilineText(`→ ${firstDiagnostic.help}`, " "));
3241
+ const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3242
+ for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
3243
+ grayLine(` ${filePath}:${site.line}`);
3244
+ if (site.suppressionHint) grayLine(` ↳ ${site.suppressionHint}`);
3245
+ }
3246
+ else grayLine(` ${filePath}`);
3247
+ logger.break();
3248
+ };
3249
+ const printDiagnostics = (diagnostics, isVerbose, rootDirectory) => {
3250
+ const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3251
+ const visibleRuleGroups = isVerbose ? sortedRuleGroups : sortedRuleGroups.slice(0, 5);
3252
+ const hiddenRuleGroups = isVerbose ? [] : sortedRuleGroups.slice(5);
3253
+ const ruleNameColumnWidth = computeRuleNameColumnWidth(visibleRuleGroups.map(([ruleKey]) => ruleKey));
3254
+ visibleRuleGroups.forEach(([ruleKey, ruleDiagnostics], visibleIndex) => {
2646
3255
  if (isVerbose) {
2647
- const fileLines = buildFileLineMap(ruleDiagnostics);
2648
- for (const [filePath, lines] of fileLines) if (lines.length > 0) for (const line of lines) logger.dim(` ${filePath}:${line}`);
2649
- else logger.dim(` ${filePath}`);
3256
+ printVerboseRuleGroup(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3257
+ return;
2650
3258
  }
2651
- logger.break();
2652
- }
3259
+ if (visibleIndex < 1) {
3260
+ printDetailedRuleGroup(ruleKey, ruleDiagnostics, rootDirectory, ruleNameColumnWidth);
3261
+ return;
3262
+ }
3263
+ printCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3264
+ });
3265
+ if (visibleRuleGroups.length > 1 && !isVerbose) logger.break();
3266
+ if (hiddenRuleGroups.length > 0) printHiddenDiagnosticsSummary(hiddenRuleGroups);
3267
+ };
3268
+ const printHiddenDiagnosticsSummary = (hiddenRuleGroups) => {
3269
+ const renderedParts = buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => colorizeBySeverity(part.text, part.severity));
3270
+ logger.log(` ${renderedParts.join(" ")}`);
3271
+ grayLine(" Run `npx react-doctor@latest . --verbose` to get all details");
3272
+ logger.break();
2653
3273
  };
2654
3274
  const formatElapsedTime = (elapsedMilliseconds) => {
2655
3275
  if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
@@ -2657,7 +3277,6 @@ const formatElapsedTime = (elapsedMilliseconds) => {
2657
3277
  };
2658
3278
  const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2659
3279
  const firstDiagnostic = ruleDiagnostics[0];
2660
- const fileLines = buildFileLineMap(ruleDiagnostics);
2661
3280
  const sections = [
2662
3281
  `Rule: ${ruleKey}`,
2663
3282
  `Severity: ${firstDiagnostic.severity}`,
@@ -2668,14 +3287,18 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2668
3287
  ];
2669
3288
  if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
2670
3289
  sections.push("", "Files:");
2671
- for (const [filePath, lines] of fileLines) if (lines.length > 0) for (const line of lines) sections.push(` ${filePath}:${line}`);
3290
+ const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3291
+ for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
3292
+ sections.push(` ${filePath}:${site.line}`);
3293
+ if (site.suppressionHint) sections.push(` ${site.suppressionHint}`);
3294
+ }
2672
3295
  else sections.push(` ${filePath}`);
2673
3296
  return sections.join("\n") + "\n";
2674
3297
  };
2675
3298
  const writeDiagnosticsDirectory = (diagnostics) => {
2676
3299
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
2677
3300
  mkdirSync(outputDirectory, { recursive: true });
2678
- const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3301
+ const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2679
3302
  for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
2680
3303
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
2681
3304
  return outputDirectory;
@@ -2688,37 +3311,76 @@ const buildScoreBarSegments = (score) => {
2688
3311
  emptySegment: "░".repeat(emptyCount)
2689
3312
  };
2690
3313
  };
2691
- const buildPlainScoreBar = (score) => {
2692
- const { filledSegment, emptySegment } = buildScoreBarSegments(score);
2693
- return `${filledSegment}${emptySegment}`;
2694
- };
2695
3314
  const buildScoreBar = (score) => {
2696
3315
  const { filledSegment, emptySegment } = buildScoreBarSegments(score);
2697
3316
  return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
2698
3317
  };
2699
- const printScoreGauge = (score, label) => {
2700
- const scoreDisplay = colorizeByScore(`${score}`, score);
2701
- const labelDisplay = colorizeByScore(label, score);
2702
- logger.log(` ${scoreDisplay} / 100 ${labelDisplay}`);
2703
- logger.break();
2704
- logger.log(` ${buildScoreBar(score)}`);
2705
- logger.break();
2706
- };
2707
3318
  const getDoctorFace = (score) => {
2708
3319
  if (score >= 75) return ["◠ ◠", " ▽ "];
2709
3320
  if (score >= 50) return ["• •", " ─ "];
2710
3321
  return ["x x", " ▽ "];
2711
3322
  };
2712
- const printBranding = (score) => {
2713
- if (score !== void 0) {
2714
- const [eyes, mouth] = getDoctorFace(score);
2715
- const colorize = (text) => colorizeByScore(text, score);
2716
- logger.log(colorize(" ┌─────┐"));
2717
- logger.log(colorize(` │ ${eyes} │`));
2718
- logger.log(colorize(` │ ${mouth} │`));
2719
- logger.log(colorize(" └─────┘"));
3323
+ const BRANDING_LINE = `React Doctor ${highlighter.dim("(www.react.doctor)")}`;
3324
+ const buildFaceRenderedLines = (score) => {
3325
+ const [eyes, mouth] = getDoctorFace(score);
3326
+ const colorize = (text) => colorizeByScore(text, score);
3327
+ return [
3328
+ "┌─────┐",
3329
+ `│ ${eyes} │`,
3330
+ `│ ${mouth} │`,
3331
+ "└─────┘"
3332
+ ].map(colorize);
3333
+ };
3334
+ const printScoreHeader = (scoreResult) => {
3335
+ const renderedFaceLines = buildFaceRenderedLines(scoreResult.score);
3336
+ const scoreNumber = colorizeByScore(`${scoreResult.score}`, scoreResult.score);
3337
+ const scoreLabel = colorizeByScore(scoreResult.label, scoreResult.score);
3338
+ const rightColumnLines = [
3339
+ `${scoreNumber} ${highlighter.dim(`/ 100`)} ${scoreLabel}`,
3340
+ buildScoreBar(scoreResult.score),
3341
+ BRANDING_LINE,
3342
+ ""
3343
+ ];
3344
+ for (let lineIndex = 0; lineIndex < renderedFaceLines.length; lineIndex += 1) {
3345
+ const rightColumnContent = rightColumnLines[lineIndex] ?? "";
3346
+ const separator = rightColumnContent.length > 0 ? " " : "";
3347
+ logger.log(` ${renderedFaceLines[lineIndex]}${separator}${rightColumnContent}`);
3348
+ }
3349
+ logger.break();
3350
+ };
3351
+ const printBrandingOnlyHeader = () => {
3352
+ logger.log(` ${BRANDING_LINE}`);
3353
+ logger.break();
3354
+ };
3355
+ const printNoScoreHeader = (noScoreMessage) => {
3356
+ logger.log(` ${BRANDING_LINE}`);
3357
+ logger.log(` ${highlighter.gray(noScoreMessage)}`);
3358
+ logger.break();
3359
+ };
3360
+ const buildCategoryBar = (count, maximumCount, useErrorColor) => {
3361
+ if (maximumCount === 0) return highlighter.dim("░".repeat(16));
3362
+ const filledCount = Math.max(1, Math.round(count / maximumCount * 16));
3363
+ const cappedFilledCount = Math.min(filledCount, 16);
3364
+ const emptyCount = 16 - cappedFilledCount;
3365
+ const filledSegment = "█".repeat(cappedFilledCount);
3366
+ const emptySegment = "░".repeat(emptyCount);
3367
+ return `${useErrorColor ? highlighter.error(filledSegment) : highlighter.warn(filledSegment)}${highlighter.dim(emptySegment)}`;
3368
+ };
3369
+ const padCategoryLabel = (categoryLabel) => {
3370
+ if (categoryLabel.length >= 18) return categoryLabel;
3371
+ return categoryLabel + " ".repeat(18 - categoryLabel.length);
3372
+ };
3373
+ const printCategoryBreakdown = (entries) => {
3374
+ if (entries.length === 0) return;
3375
+ const maximumCount = Math.max(...entries.map((entry) => entry.totalCount));
3376
+ logger.dim(" By category");
3377
+ for (const entry of entries) {
3378
+ const paddedLabel = padCategoryLabel(entry.category);
3379
+ const categoryBar = buildCategoryBar(entry.totalCount, maximumCount, entry.errorCount > 0);
3380
+ const totalCountDisplay = String(entry.totalCount);
3381
+ const errorBadge = entry.errorCount > 0 ? ` ${highlighter.error(`${entry.errorCount}×`)}` : "";
3382
+ logger.log(` ${paddedLabel}${categoryBar} ${totalCountDisplay}${errorBadge}`);
2720
3383
  }
2721
- logger.log(` React Doctor ${highlighter.dim("(www.react.doctor)")}`);
2722
3384
  logger.break();
2723
3385
  };
2724
3386
  const buildShareUrl = (diagnostics, scoreResult, projectName) => {
@@ -2733,67 +3395,31 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
2733
3395
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
2734
3396
  return `${SHARE_BASE_URL}?${params.toString()}`;
2735
3397
  };
2736
- const buildBrandingLines = (scoreResult, noScoreMessage) => {
2737
- const lines = [];
2738
- if (scoreResult) {
2739
- const [eyes, mouth] = getDoctorFace(scoreResult.score);
2740
- const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
2741
- lines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
2742
- lines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
2743
- lines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
2744
- lines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
2745
- lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
2746
- lines.push(createFramedLine(""));
2747
- const scoreLinePlainText = `${scoreResult.score} / 100 ${scoreResult.label}`;
2748
- const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / 100 ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
2749
- lines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
2750
- lines.push(createFramedLine(""));
2751
- lines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
2752
- lines.push(createFramedLine(""));
2753
- } else {
2754
- lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
2755
- lines.push(createFramedLine(""));
2756
- lines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage)));
2757
- lines.push(createFramedLine(""));
2758
- }
2759
- return lines;
2760
- };
2761
- const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMilliseconds) => {
3398
+ const printCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMilliseconds) => {
2762
3399
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
2763
3400
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
2764
3401
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
2765
- const elapsed = formatElapsedTime(elapsedMilliseconds);
2766
- const plainParts = [];
2767
- const renderedParts = [];
2768
- if (errorCount > 0) {
2769
- const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
2770
- plainParts.push(errorText);
2771
- renderedParts.push(highlighter.error(errorText));
2772
- }
2773
- if (warningCount > 0) {
2774
- const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
2775
- plainParts.push(warningText);
2776
- renderedParts.push(highlighter.warn(warningText));
2777
- }
3402
+ const totalIssueCount = diagnostics.length;
3403
+ const elapsedTimeLabel = formatElapsedTime(elapsedMilliseconds);
3404
+ const issueCountColor = errorCount > 0 ? highlighter.error : warningCount > 0 ? highlighter.warn : highlighter.dim;
3405
+ const issueCountText = `${totalIssueCount} ${totalIssueCount === 1 ? "issue" : "issues"}`;
2778
3406
  const fileCountText = totalSourceFileCount > 0 ? `across ${affectedFileCount}/${totalSourceFileCount} files` : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
2779
- const elapsedTimeText = `in ${elapsed}`;
2780
- plainParts.push(fileCountText, elapsedTimeText);
2781
- renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
2782
- return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
3407
+ const elapsedTimeText = `in ${elapsedTimeLabel}`;
3408
+ logger.log(` ${issueCountColor(issueCountText)} ${highlighter.dim(`${fileCountText} ${elapsedTimeText}`)}`);
2783
3409
  };
2784
3410
  const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
2785
- printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
3411
+ printCategoryBreakdown(buildCategoryBreakdown(diagnostics));
3412
+ if (scoreResult) printScoreHeader(scoreResult);
3413
+ else printNoScoreHeader(noScoreMessage);
3414
+ printCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds);
2786
3415
  try {
2787
3416
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
2788
- logger.break();
2789
- logger.dim(` Full diagnostics written to ${diagnosticsDirectory}`);
2790
- } catch {
2791
- logger.break();
2792
- }
3417
+ logger.log(highlighter.gray(` Full diagnostics written to ${diagnosticsDirectory}`));
3418
+ } catch {}
2793
3419
  if (!isOffline) {
2794
- const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
2795
3420
  logger.break();
2796
- logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
3421
+ const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
3422
+ logger.log(` ${highlighter.bold("→ Share your results:")} ${highlighter.info(shareUrl)}`);
2797
3423
  }
2798
3424
  };
2799
3425
  const resolveOxlintNode = async (isLintEnabled, isQuiet) => {
@@ -2844,7 +3470,8 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
2844
3470
  includePaths: inputOptions.includePaths ?? [],
2845
3471
  customRulesOnly: userConfig?.customRulesOnly ?? false,
2846
3472
  share: userConfig?.share ?? true,
2847
- respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
3473
+ respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
3474
+ adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true
2848
3475
  });
2849
3476
  const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
2850
3477
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -2901,10 +3528,12 @@ const runScan = async (directory, options, userConfig, startTime) => {
2901
3528
  framework: projectInfo.framework,
2902
3529
  hasReactCompiler: projectInfo.hasReactCompiler,
2903
3530
  hasTanStackQuery: projectInfo.hasTanStackQuery,
3531
+ reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
2904
3532
  includePaths: lintIncludePaths,
2905
3533
  nodeBinaryPath: resolvedNodeBinaryPath,
2906
3534
  customRulesOnly: options.customRulesOnly,
2907
- respectInlineDisables: options.respectInlineDisables
3535
+ respectInlineDisables: options.respectInlineDisables,
3536
+ adoptExistingLintConfig: options.adoptExistingLintConfig
2908
3537
  });
2909
3538
  lintSpinner?.succeed("Running lint checks.");
2910
3539
  return lintDiagnostics;
@@ -2944,7 +3573,8 @@ const runScan = async (directory, options, userConfig, startTime) => {
2944
3573
  deadCodeDiagnostics,
2945
3574
  directory,
2946
3575
  isDiffMode,
2947
- userConfig
3576
+ userConfig,
3577
+ respectInlineDisables: options.respectInlineDisables
2948
3578
  });
2949
3579
  const elapsedMilliseconds = performance.now() - startTime;
2950
3580
  const skippedChecks = [];
@@ -2972,15 +3602,13 @@ const runScan = async (directory, options, userConfig, startTime) => {
2972
3602
  } else logger.success("No issues found!");
2973
3603
  logger.break();
2974
3604
  if (hasSkippedChecks) {
2975
- printBranding();
2976
- logger.dim(" Score not shown — some checks could not complete.");
2977
- } else if (scoreResult) {
2978
- printBranding(scoreResult.score);
2979
- printScoreGauge(scoreResult.score, scoreResult.label);
2980
- } else logger.dim(` ${noScoreMessage}`);
3605
+ printBrandingOnlyHeader();
3606
+ logger.log(highlighter.gray(" Score not shown — some checks could not complete."));
3607
+ } else if (scoreResult) printScoreHeader(scoreResult);
3608
+ else printNoScoreHeader(noScoreMessage);
2981
3609
  return buildResult();
2982
3610
  }
2983
- printDiagnostics(diagnostics, options.verbose);
3611
+ printDiagnostics(diagnostics, options.verbose, directory);
2984
3612
  const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
2985
3613
  const shouldShowShareLink = !options.offline && options.share;
2986
3614
  printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, !shouldShowShareLink);
@@ -3315,6 +3943,41 @@ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
3315
3943
  const encodeAnnotationProperty = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
3316
3944
  const encodeAnnotationMessage = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
3317
3945
  //#endregion
3946
+ //#region src/utils/find-owning-project.ts
3947
+ const findOwningProjectDirectory = (rootDirectory, filePath) => {
3948
+ const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath);
3949
+ const workspacePackages = listWorkspacePackages(rootDirectory);
3950
+ const candidates = workspacePackages.length > 0 ? workspacePackages : discoverReactSubprojects(rootDirectory);
3951
+ if (candidates.length === 0) return rootDirectory;
3952
+ let bestMatch = null;
3953
+ for (const candidate of candidates) {
3954
+ const candidateDirectory = path.resolve(candidate.directory);
3955
+ const relativeFromCandidate = path.relative(candidateDirectory, absoluteFile);
3956
+ if (relativeFromCandidate.startsWith("..") || path.isAbsolute(relativeFromCandidate)) continue;
3957
+ const depth = candidateDirectory.length;
3958
+ if (!bestMatch || depth > bestMatch.depth) bestMatch = {
3959
+ directory: candidate.directory,
3960
+ depth
3961
+ };
3962
+ }
3963
+ return bestMatch ? bestMatch.directory : rootDirectory;
3964
+ };
3965
+ //#endregion
3966
+ //#region src/utils/parse-file-line-argument.ts
3967
+ const parseFileLineArgument = (rawArgument) => {
3968
+ const lastColonIndex = rawArgument.lastIndexOf(":");
3969
+ if (lastColonIndex < 0) throw new Error(`Expected "<file>:<line>" (e.g. "src/foo.tsx:42"), got "${rawArgument}".`);
3970
+ const filePath = rawArgument.slice(0, lastColonIndex);
3971
+ const lineText = rawArgument.slice(lastColonIndex + 1);
3972
+ if (filePath.length === 0) throw new Error(`Missing file path in "${rawArgument}".`);
3973
+ const line = Number.parseInt(lineText, 10);
3974
+ if (!Number.isFinite(line) || line <= 0 || String(line) !== lineText.trim()) throw new Error(`Expected a positive line number in "${rawArgument}".`);
3975
+ return {
3976
+ filePath,
3977
+ line
3978
+ };
3979
+ };
3980
+ //#endregion
3318
3981
  //#region src/utils/select-projects.ts
3319
3982
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
3320
3983
  let packages = listWorkspacePackages(rootDirectory);
@@ -3363,7 +4026,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
3363
4026
  };
3364
4027
  //#endregion
3365
4028
  //#region src/cli.ts
3366
- const VERSION = "0.0.47";
4029
+ const VERSION = "0.1.1";
3367
4030
  const VALID_FAIL_ON_LEVELS = new Set([
3368
4031
  "error",
3369
4032
  "warning",
@@ -3492,6 +4155,46 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
3492
4155
  });
3493
4156
  return Boolean(shouldScanChangedOnly);
3494
4157
  };
4158
+ const colorizeRuleByDiagnostic = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
4159
+ const runExplain = async (fileLineArgument, context) => {
4160
+ const { filePath, line } = parseFileLineArgument(fileLineArgument);
4161
+ const targetDirectory = await resolveExplainTargetDirectory(filePath, context);
4162
+ const scanResult = await scan(targetDirectory, {
4163
+ ...context.scanOptions,
4164
+ silent: true,
4165
+ offline: true,
4166
+ configOverride: context.userConfig
4167
+ });
4168
+ const requestedRelativePath = toRelativePath(filePath, targetDirectory);
4169
+ const matchingDiagnostics = scanResult.diagnostics.filter((diagnostic) => diagnostic.line === line && toRelativePath(diagnostic.filePath, targetDirectory) === requestedRelativePath);
4170
+ if (matchingDiagnostics.length === 0) {
4171
+ logger.log(`No react-doctor diagnostics at ${filePath}:${line}.`);
4172
+ return;
4173
+ }
4174
+ for (const diagnostic of matchingDiagnostics) {
4175
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
4176
+ const severitySymbol = diagnostic.severity === "error" ? "✗" : "⚠";
4177
+ const colorizedRule = colorizeRuleByDiagnostic(ruleIdentifier, diagnostic.severity);
4178
+ const severityLabel = colorizeRuleByDiagnostic(diagnostic.severity, diagnostic.severity);
4179
+ logger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
4180
+ if (diagnostic.category) logger.dim(` Category: ${diagnostic.category}`);
4181
+ if (diagnostic.help) logger.dim(` ${diagnostic.help}`);
4182
+ if (diagnostic.suppressionHint) {
4183
+ logger.break();
4184
+ logger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
4185
+ } else logger.dim(" No nearby react-doctor-disable-next-line comment was detected — add one immediately above this line to suppress.");
4186
+ logger.break();
4187
+ }
4188
+ };
4189
+ const resolveExplainTargetDirectory = async (filePath, context) => {
4190
+ if (context.projectFlag) {
4191
+ const matchedDirectories = await selectProjects(context.resolvedDirectory, context.projectFlag, true);
4192
+ if (matchedDirectories.length === 0) return context.resolvedDirectory;
4193
+ if (matchedDirectories.length > 1) throw new Error(`--explain takes a single project; --project resolved to ${matchedDirectories.length} projects.`);
4194
+ return matchedDirectories[0];
4195
+ }
4196
+ return findOwningProjectDirectory(context.resolvedDirectory, filePath);
4197
+ };
3495
4198
  const validateModeFlags = (flags) => {
3496
4199
  const coercedDiff = coerceDiffValue(flags.diff);
3497
4200
  const exclusiveModes = [flags.staged ? "--staged" : null, coercedDiff !== void 0 && coercedDiff !== false ? "--diff" : null].filter((modeName) => modeName !== null);
@@ -3499,8 +4202,10 @@ const validateModeFlags = (flags) => {
3499
4202
  if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
3500
4203
  if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
3501
4204
  if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
4205
+ 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.");
4206
+ 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.");
3502
4207
  };
3503
- 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) => {
4208
+ 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) => {
3504
4209
  const isScoreOnly = flags.score;
3505
4210
  const isJsonMode = flags.json;
3506
4211
  const isQuiet = isScoreOnly || isJsonMode;
@@ -3514,6 +4219,16 @@ const program = new Command().name("react-doctor").description("Diagnose React c
3514
4219
  try {
3515
4220
  validateModeFlags(flags);
3516
4221
  const userConfig = loadConfig(resolvedDirectory);
4222
+ const explainArgument = flags.explain ?? flags.why;
4223
+ if (explainArgument !== void 0) {
4224
+ await runExplain(explainArgument, {
4225
+ resolvedDirectory,
4226
+ userConfig,
4227
+ scanOptions: resolveCliScanOptions(flags, userConfig, program),
4228
+ projectFlag: flags.project
4229
+ });
4230
+ return;
4231
+ }
3517
4232
  if (!isQuiet) {
3518
4233
  logger.log(`react-doctor v${VERSION}`);
3519
4234
  logger.break();