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