react-doctor 0.0.47 → 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 +788 -108
- 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 +907 -397
- 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 = {
|
|
@@ -1841,18 +2277,27 @@ const BUILTIN_A11Y_RULES = {
|
|
|
1841
2277
|
const GLOBAL_REACT_DOCTOR_RULES = {
|
|
1842
2278
|
"react-doctor/no-derived-state-effect": "warn",
|
|
1843
2279
|
"react-doctor/no-fetch-in-effect": "warn",
|
|
2280
|
+
"react-doctor/no-mirror-prop-effect": "warn",
|
|
2281
|
+
"react-doctor/no-mutable-in-deps": "error",
|
|
1844
2282
|
"react-doctor/no-cascading-set-state": "warn",
|
|
2283
|
+
"react-doctor/no-effect-chain": "warn",
|
|
1845
2284
|
"react-doctor/no-effect-event-handler": "warn",
|
|
1846
2285
|
"react-doctor/no-effect-event-in-deps": "error",
|
|
2286
|
+
"react-doctor/no-event-trigger-state": "warn",
|
|
1847
2287
|
"react-doctor/no-prop-callback-in-effect": "warn",
|
|
1848
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",
|
|
1849
2292
|
"react-doctor/prefer-useReducer": "warn",
|
|
2293
|
+
"react-doctor/prefer-use-sync-external-store": "warn",
|
|
1850
2294
|
"react-doctor/rerender-lazy-state-init": "warn",
|
|
1851
2295
|
"react-doctor/rerender-functional-setstate": "warn",
|
|
1852
2296
|
"react-doctor/rerender-dependencies": "error",
|
|
1853
2297
|
"react-doctor/rerender-state-only-in-handlers": "warn",
|
|
1854
2298
|
"react-doctor/rerender-defer-reads-hook": "warn",
|
|
1855
2299
|
"react-doctor/advanced-event-handler-refs": "warn",
|
|
2300
|
+
"react-doctor/effect-needs-cleanup": "error",
|
|
1856
2301
|
"react-doctor/no-giant-component": "warn",
|
|
1857
2302
|
"react-doctor/no-render-in-render": "warn",
|
|
1858
2303
|
"react-doctor/no-many-boolean-props": "warn",
|
|
@@ -1860,6 +2305,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1860
2305
|
"react-doctor/no-render-prop-children": "warn",
|
|
1861
2306
|
"react-doctor/no-nested-component-definition": "error",
|
|
1862
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",
|
|
1863
2312
|
"react-doctor/no-usememo-simple-expression": "warn",
|
|
1864
2313
|
"react-doctor/no-layout-property-animation": "error",
|
|
1865
2314
|
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
@@ -1908,6 +2357,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1908
2357
|
"react-doctor/rendering-conditional-render": "warn",
|
|
1909
2358
|
"react-doctor/rendering-svg-precision": "warn",
|
|
1910
2359
|
"react-doctor/no-prevent-default": "warn",
|
|
2360
|
+
"react-doctor/no-uncontrolled-input": "warn",
|
|
1911
2361
|
"react-doctor/no-document-start-view-transition": "warn",
|
|
1912
2362
|
"react-doctor/no-flush-sync": "warn",
|
|
1913
2363
|
"react-doctor/server-auth-actions": "error",
|
|
@@ -1935,6 +2385,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1935
2385
|
"react-doctor/no-disabled-zoom": "error",
|
|
1936
2386
|
"react-doctor/no-outline-none": "warn",
|
|
1937
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",
|
|
1938
2396
|
"react-doctor/async-parallel": "warn"
|
|
1939
2397
|
};
|
|
1940
2398
|
const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
@@ -1944,10 +2402,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
|
1944
2402
|
...Object.keys(TANSTACK_START_RULES),
|
|
1945
2403
|
...Object.keys(TANSTACK_QUERY_RULES)
|
|
1946
2404
|
]);
|
|
1947
|
-
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 = [] }) => {
|
|
1948
2433
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
1949
2434
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
1950
2435
|
return {
|
|
2436
|
+
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
1951
2437
|
categories: {
|
|
1952
2438
|
correctness: "off",
|
|
1953
2439
|
suspicious: "off",
|
|
@@ -1963,7 +2449,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
1963
2449
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
1964
2450
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
1965
2451
|
...reactCompilerRules,
|
|
1966
|
-
...GLOBAL_REACT_DOCTOR_RULES,
|
|
2452
|
+
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
1967
2453
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
1968
2454
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
1969
2455
|
...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
|
|
@@ -2075,23 +2561,43 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
2075
2561
|
"react-hooks-js": "React Compiler",
|
|
2076
2562
|
"react-doctor": "Other",
|
|
2077
2563
|
"jsx-a11y": "Accessibility",
|
|
2078
|
-
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"
|
|
2079
2576
|
};
|
|
2080
2577
|
const RULE_CATEGORY_MAP = {
|
|
2081
2578
|
"react-doctor/no-derived-state-effect": "State & Effects",
|
|
2082
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",
|
|
2083
2582
|
"react-doctor/no-cascading-set-state": "State & Effects",
|
|
2583
|
+
"react-doctor/no-effect-chain": "State & Effects",
|
|
2084
2584
|
"react-doctor/no-effect-event-handler": "State & Effects",
|
|
2085
2585
|
"react-doctor/no-effect-event-in-deps": "State & Effects",
|
|
2586
|
+
"react-doctor/no-event-trigger-state": "State & Effects",
|
|
2086
2587
|
"react-doctor/no-prop-callback-in-effect": "State & Effects",
|
|
2087
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",
|
|
2088
2592
|
"react-doctor/prefer-useReducer": "State & Effects",
|
|
2593
|
+
"react-doctor/prefer-use-sync-external-store": "State & Effects",
|
|
2089
2594
|
"react-doctor/rerender-lazy-state-init": "Performance",
|
|
2090
2595
|
"react-doctor/rerender-functional-setstate": "Performance",
|
|
2091
2596
|
"react-doctor/rerender-dependencies": "State & Effects",
|
|
2092
2597
|
"react-doctor/rerender-state-only-in-handlers": "Performance",
|
|
2093
2598
|
"react-doctor/rerender-defer-reads-hook": "Performance",
|
|
2094
2599
|
"react-doctor/advanced-event-handler-refs": "Performance",
|
|
2600
|
+
"react-doctor/effect-needs-cleanup": "State & Effects",
|
|
2095
2601
|
"react-doctor/no-generic-handler-names": "Architecture",
|
|
2096
2602
|
"react-doctor/no-giant-component": "Architecture",
|
|
2097
2603
|
"react-doctor/no-many-boolean-props": "Architecture",
|
|
@@ -2100,6 +2606,10 @@ const RULE_CATEGORY_MAP = {
|
|
|
2100
2606
|
"react-doctor/no-render-in-render": "Architecture",
|
|
2101
2607
|
"react-doctor/no-nested-component-definition": "Correctness",
|
|
2102
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",
|
|
2103
2613
|
"react-doctor/no-usememo-simple-expression": "Performance",
|
|
2104
2614
|
"react-doctor/no-layout-property-animation": "Performance",
|
|
2105
2615
|
"react-doctor/rerender-memo-with-default-value": "Performance",
|
|
@@ -2133,6 +2643,7 @@ const RULE_CATEGORY_MAP = {
|
|
|
2133
2643
|
"react-doctor/rendering-conditional-render": "Correctness",
|
|
2134
2644
|
"react-doctor/rendering-svg-precision": "Performance",
|
|
2135
2645
|
"react-doctor/no-prevent-default": "Correctness",
|
|
2646
|
+
"react-doctor/no-uncontrolled-input": "Correctness",
|
|
2136
2647
|
"react-doctor/no-document-start-view-transition": "Correctness",
|
|
2137
2648
|
"react-doctor/no-flush-sync": "Performance",
|
|
2138
2649
|
"react-doctor/nextjs-no-img-element": "Next.js",
|
|
@@ -2182,6 +2693,14 @@ const RULE_CATEGORY_MAP = {
|
|
|
2182
2693
|
"react-doctor/no-disabled-zoom": "Accessibility",
|
|
2183
2694
|
"react-doctor/no-outline-none": "Accessibility",
|
|
2184
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",
|
|
2185
2704
|
"react-doctor/js-flatmap-filter": "Performance",
|
|
2186
2705
|
"react-doctor/js-combine-iterations": "Performance",
|
|
2187
2706
|
"react-doctor/js-tosorted-immutable": "Performance",
|
|
@@ -2239,10 +2758,18 @@ const RULE_CATEGORY_MAP = {
|
|
|
2239
2758
|
const RULE_HELP_MAP = {
|
|
2240
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",
|
|
2241
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",
|
|
2242
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",
|
|
2243
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",
|
|
2244
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",
|
|
2245
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",
|
|
2246
2773
|
"rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
|
|
2247
2774
|
"rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
|
|
2248
2775
|
"rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
|
|
@@ -2251,7 +2778,11 @@ const RULE_HELP_MAP = {
|
|
|
2251
2778
|
"no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
|
|
2252
2779
|
"no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
|
|
2253
2780
|
"no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
|
|
2254
|
-
"no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads.",
|
|
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+.",
|
|
2255
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",
|
|
2256
2787
|
"no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
|
|
2257
2788
|
"no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
|
|
@@ -2266,6 +2797,7 @@ const RULE_HELP_MAP = {
|
|
|
2266
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",
|
|
2267
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",
|
|
2268
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",
|
|
2269
2801
|
"async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
|
|
2270
2802
|
"async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
|
|
2271
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",
|
|
@@ -2312,9 +2844,18 @@ const RULE_HELP_MAP = {
|
|
|
2312
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",
|
|
2313
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",
|
|
2314
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",
|
|
2315
2855
|
"no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
|
|
2316
2856
|
"rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
|
|
2317
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",
|
|
2318
2859
|
"nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
2319
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",
|
|
2320
2861
|
"nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
|
|
@@ -2397,6 +2938,7 @@ const RULE_HELP_MAP = {
|
|
|
2397
2938
|
};
|
|
2398
2939
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2399
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;
|
|
2400
2942
|
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2401
2943
|
if (plugin === "react-hooks-js") return {
|
|
2402
2944
|
message: REACT_COMPILER_MESSAGE,
|
|
@@ -2404,7 +2946,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
|
2404
2946
|
};
|
|
2405
2947
|
return {
|
|
2406
2948
|
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2407
|
-
help: help || RULE_HELP_MAP
|
|
2949
|
+
help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
|
|
2408
2950
|
};
|
|
2409
2951
|
};
|
|
2410
2952
|
const parseRuleCode = (code) => {
|
|
@@ -2432,7 +2974,7 @@ const resolvePluginPath = () => {
|
|
|
2432
2974
|
return pluginPath;
|
|
2433
2975
|
};
|
|
2434
2976
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
2435
|
-
return RULE_CATEGORY_MAP
|
|
2977
|
+
return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
|
|
2436
2978
|
};
|
|
2437
2979
|
const SANITIZED_ENV = (() => {
|
|
2438
2980
|
const sanitized = {};
|
|
@@ -2523,7 +3065,7 @@ const parseOxlintOutput = (stdout) => {
|
|
|
2523
3065
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
|
|
2524
3066
|
}
|
|
2525
3067
|
if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
|
|
2526
|
-
return parsed.diagnostics.filter((diagnostic) => diagnostic.code &&
|
|
3068
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
2527
3069
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2528
3070
|
const primaryLabel = diagnostic.labels[0];
|
|
2529
3071
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -2553,8 +3095,8 @@ const validateRuleRegistration = () => {
|
|
|
2553
3095
|
const missingCategory = [];
|
|
2554
3096
|
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2555
3097
|
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2556
|
-
if (!(fullKey
|
|
2557
|
-
if (!(ruleName
|
|
3098
|
+
if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
|
|
3099
|
+
if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
|
|
2558
3100
|
}
|
|
2559
3101
|
if (missingCategory.length > 0 || missingHelp.length > 0) {
|
|
2560
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("; ");
|
|
@@ -2562,26 +3104,24 @@ const validateRuleRegistration = () => {
|
|
|
2562
3104
|
}
|
|
2563
3105
|
};
|
|
2564
3106
|
const runOxlint = async (options) => {
|
|
2565
|
-
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;
|
|
2566
3108
|
validateRuleRegistration();
|
|
2567
3109
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2568
3110
|
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
2569
3111
|
const configPath = path.join(configDirectory, "oxlintrc.json");
|
|
3112
|
+
const pluginPath = resolvePluginPath();
|
|
3113
|
+
const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
|
|
2570
3114
|
const config = createOxlintConfig({
|
|
2571
|
-
pluginPath
|
|
3115
|
+
pluginPath,
|
|
2572
3116
|
framework,
|
|
2573
3117
|
hasReactCompiler,
|
|
2574
3118
|
hasTanStackQuery,
|
|
2575
|
-
customRulesOnly
|
|
3119
|
+
customRulesOnly,
|
|
3120
|
+
reactMajorVersion,
|
|
3121
|
+
extendsPaths
|
|
2576
3122
|
});
|
|
2577
3123
|
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2578
3124
|
try {
|
|
2579
|
-
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
2580
|
-
try {
|
|
2581
|
-
fs.writeFileSync(fileHandle, JSON.stringify(config));
|
|
2582
|
-
} finally {
|
|
2583
|
-
fs.closeSync(fileHandle);
|
|
2584
|
-
}
|
|
2585
3125
|
const baseArgs = [
|
|
2586
3126
|
resolveOxlintBinary(),
|
|
2587
3127
|
"-c",
|
|
@@ -2600,12 +3140,41 @@ const runOxlint = async (options) => {
|
|
|
2600
3140
|
baseArgs.push("--ignore-path", combinedIgnorePath);
|
|
2601
3141
|
}
|
|
2602
3142
|
const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
|
|
2603
|
-
const
|
|
2604
|
-
|
|
2605
|
-
const
|
|
2606
|
-
|
|
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();
|
|
2607
3177
|
}
|
|
2608
|
-
return allDiagnostics;
|
|
2609
3178
|
} finally {
|
|
2610
3179
|
restoreDisableDirectives();
|
|
2611
3180
|
fs.rmSync(configDirectory, {
|
|
@@ -2621,22 +3190,29 @@ const SEVERITY_ORDER = {
|
|
|
2621
3190
|
warning: 1
|
|
2622
3191
|
};
|
|
2623
3192
|
const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
|
|
2624
|
-
const
|
|
2625
|
-
|
|
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;
|
|
2626
3197
|
});
|
|
2627
3198
|
const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
|
|
2628
|
-
const
|
|
2629
|
-
const
|
|
3199
|
+
const buildVerboseSiteMap = (diagnostics) => {
|
|
3200
|
+
const fileSites = /* @__PURE__ */ new Map();
|
|
2630
3201
|
for (const diagnostic of diagnostics) {
|
|
2631
|
-
const
|
|
2632
|
-
if (diagnostic.line > 0)
|
|
2633
|
-
|
|
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);
|
|
2634
3208
|
}
|
|
2635
|
-
return
|
|
3209
|
+
return fileSites;
|
|
2636
3210
|
};
|
|
2637
3211
|
const printDiagnostics = (diagnostics, isVerbose) => {
|
|
2638
|
-
const sortedRuleGroups =
|
|
2639
|
-
|
|
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) {
|
|
2640
3216
|
const firstDiagnostic = ruleDiagnostics[0];
|
|
2641
3217
|
const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
|
|
2642
3218
|
const count = ruleDiagnostics.length;
|
|
@@ -2644,12 +3220,22 @@ const printDiagnostics = (diagnostics, isVerbose) => {
|
|
|
2644
3220
|
logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
|
|
2645
3221
|
if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
|
|
2646
3222
|
if (isVerbose) {
|
|
2647
|
-
const
|
|
2648
|
-
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
|
+
}
|
|
2649
3228
|
else logger.dim(` ${filePath}`);
|
|
2650
3229
|
}
|
|
2651
3230
|
logger.break();
|
|
2652
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();
|
|
2653
3239
|
};
|
|
2654
3240
|
const formatElapsedTime = (elapsedMilliseconds) => {
|
|
2655
3241
|
if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
|
|
@@ -2657,7 +3243,6 @@ const formatElapsedTime = (elapsedMilliseconds) => {
|
|
|
2657
3243
|
};
|
|
2658
3244
|
const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
|
|
2659
3245
|
const firstDiagnostic = ruleDiagnostics[0];
|
|
2660
|
-
const fileLines = buildFileLineMap(ruleDiagnostics);
|
|
2661
3246
|
const sections = [
|
|
2662
3247
|
`Rule: ${ruleKey}`,
|
|
2663
3248
|
`Severity: ${firstDiagnostic.severity}`,
|
|
@@ -2668,14 +3253,18 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
|
|
|
2668
3253
|
];
|
|
2669
3254
|
if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
|
|
2670
3255
|
sections.push("", "Files:");
|
|
2671
|
-
|
|
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
|
+
}
|
|
2672
3261
|
else sections.push(` ${filePath}`);
|
|
2673
3262
|
return sections.join("\n") + "\n";
|
|
2674
3263
|
};
|
|
2675
3264
|
const writeDiagnosticsDirectory = (diagnostics) => {
|
|
2676
3265
|
const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
|
|
2677
3266
|
mkdirSync(outputDirectory, { recursive: true });
|
|
2678
|
-
const sortedRuleGroups =
|
|
3267
|
+
const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
|
|
2679
3268
|
for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
2680
3269
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
|
|
2681
3270
|
return outputDirectory;
|
|
@@ -2844,7 +3433,8 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
|
|
|
2844
3433
|
includePaths: inputOptions.includePaths ?? [],
|
|
2845
3434
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
2846
3435
|
share: userConfig?.share ?? true,
|
|
2847
|
-
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
|
|
3436
|
+
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
3437
|
+
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true
|
|
2848
3438
|
});
|
|
2849
3439
|
const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
|
|
2850
3440
|
const frameworkLabel = formatFrameworkName(projectInfo.framework);
|
|
@@ -2901,10 +3491,12 @@ const runScan = async (directory, options, userConfig, startTime) => {
|
|
|
2901
3491
|
framework: projectInfo.framework,
|
|
2902
3492
|
hasReactCompiler: projectInfo.hasReactCompiler,
|
|
2903
3493
|
hasTanStackQuery: projectInfo.hasTanStackQuery,
|
|
3494
|
+
reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
|
|
2904
3495
|
includePaths: lintIncludePaths,
|
|
2905
3496
|
nodeBinaryPath: resolvedNodeBinaryPath,
|
|
2906
3497
|
customRulesOnly: options.customRulesOnly,
|
|
2907
|
-
respectInlineDisables: options.respectInlineDisables
|
|
3498
|
+
respectInlineDisables: options.respectInlineDisables,
|
|
3499
|
+
adoptExistingLintConfig: options.adoptExistingLintConfig
|
|
2908
3500
|
});
|
|
2909
3501
|
lintSpinner?.succeed("Running lint checks.");
|
|
2910
3502
|
return lintDiagnostics;
|
|
@@ -2944,7 +3536,8 @@ const runScan = async (directory, options, userConfig, startTime) => {
|
|
|
2944
3536
|
deadCodeDiagnostics,
|
|
2945
3537
|
directory,
|
|
2946
3538
|
isDiffMode,
|
|
2947
|
-
userConfig
|
|
3539
|
+
userConfig,
|
|
3540
|
+
respectInlineDisables: options.respectInlineDisables
|
|
2948
3541
|
});
|
|
2949
3542
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
2950
3543
|
const skippedChecks = [];
|
|
@@ -3315,6 +3908,41 @@ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
|
3315
3908
|
const encodeAnnotationProperty = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
3316
3909
|
const encodeAnnotationMessage = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
3317
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
|
|
3318
3946
|
//#region src/utils/select-projects.ts
|
|
3319
3947
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
3320
3948
|
let packages = listWorkspacePackages(rootDirectory);
|
|
@@ -3363,7 +3991,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
|
|
|
3363
3991
|
};
|
|
3364
3992
|
//#endregion
|
|
3365
3993
|
//#region src/cli.ts
|
|
3366
|
-
const VERSION = "0.0
|
|
3994
|
+
const VERSION = "0.1.0";
|
|
3367
3995
|
const VALID_FAIL_ON_LEVELS = new Set([
|
|
3368
3996
|
"error",
|
|
3369
3997
|
"warning",
|
|
@@ -3492,6 +4120,46 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
|
|
|
3492
4120
|
});
|
|
3493
4121
|
return Boolean(shouldScanChangedOnly);
|
|
3494
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
|
+
};
|
|
3495
4163
|
const validateModeFlags = (flags) => {
|
|
3496
4164
|
const coercedDiff = coerceDiffValue(flags.diff);
|
|
3497
4165
|
const exclusiveModes = [flags.staged ? "--staged" : null, coercedDiff !== void 0 && coercedDiff !== false ? "--diff" : null].filter((modeName) => modeName !== null);
|
|
@@ -3499,8 +4167,10 @@ const validateModeFlags = (flags) => {
|
|
|
3499
4167
|
if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
|
|
3500
4168
|
if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
|
|
3501
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.");
|
|
3502
4172
|
};
|
|
3503
|
-
const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details
|
|
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) => {
|
|
3504
4174
|
const isScoreOnly = flags.score;
|
|
3505
4175
|
const isJsonMode = flags.json;
|
|
3506
4176
|
const isQuiet = isScoreOnly || isJsonMode;
|
|
@@ -3514,6 +4184,16 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
3514
4184
|
try {
|
|
3515
4185
|
validateModeFlags(flags);
|
|
3516
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
|
+
}
|
|
3517
4197
|
if (!isQuiet) {
|
|
3518
4198
|
logger.log(`react-doctor v${VERSION}`);
|
|
3519
4199
|
logger.break();
|