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