react-doctor 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import fs, { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readdirS
3
3
  import os, { tmpdir } from "node:os";
4
4
  import path, { join } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
- import { Command, Option } from "commander";
6
+ import { Command } 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";
@@ -214,7 +214,7 @@ const finalize = (method, originalText, displayText) => {
214
214
  sharedInstance.stop();
215
215
  ora({
216
216
  text: displayText,
217
- indent: 2
217
+ indent: 0
218
218
  }).start()[method](displayText);
219
219
  const [remainingText] = pendingTexts;
220
220
  if (remainingText) sharedInstance.text = remainingText;
@@ -226,7 +226,7 @@ const spinner = (text) => ({ start() {
226
226
  pendingTexts.add(text);
227
227
  if (!sharedInstance) sharedInstance = ora({
228
228
  text,
229
- indent: 2
229
+ indent: 0
230
230
  }).start();
231
231
  else sharedInstance.text = text;
232
232
  const handle = {
@@ -304,26 +304,45 @@ const runInstallSkill = async (options = {}) => {
304
304
  }
305
305
  };
306
306
  //#endregion
307
- //#region src/utils/build-category-breakdown.ts
308
- const buildCategoryBreakdown = (diagnostics) => {
309
- const entriesByCategory = /* @__PURE__ */ new Map();
310
- for (const diagnostic of diagnostics) {
311
- const existingEntry = entriesByCategory.get(diagnostic.category) ?? {
312
- category: diagnostic.category,
313
- totalCount: 0,
314
- errorCount: 0,
315
- warningCount: 0
316
- };
317
- existingEntry.totalCount += 1;
318
- if (diagnostic.severity === "error") existingEntry.errorCount += 1;
319
- else existingEntry.warningCount += 1;
320
- entriesByCategory.set(diagnostic.category, existingEntry);
321
- }
322
- return [...entriesByCategory.values()].sort((entryA, entryB) => {
323
- if (entryA.errorCount !== entryB.errorCount) return entryB.errorCount - entryA.errorCount;
324
- if (entryA.totalCount !== entryB.totalCount) return entryB.totalCount - entryA.totalCount;
325
- return entryA.category.localeCompare(entryB.category);
326
- });
307
+ //#region src/errors.ts
308
+ var ReactDoctorError = class extends Error {
309
+ name = "ReactDoctorError";
310
+ constructor(message, options) {
311
+ super(message, options);
312
+ Object.setPrototypeOf(this, new.target.prototype);
313
+ }
314
+ };
315
+ var NoReactDependencyError = class extends ReactDoctorError {
316
+ name = "NoReactDependencyError";
317
+ directory;
318
+ constructor(directory, options) {
319
+ super(buildNoReactDependencyError(directory), options);
320
+ this.directory = directory;
321
+ }
322
+ };
323
+ var PackageJsonNotFoundError = class extends ReactDoctorError {
324
+ name = "PackageJsonNotFoundError";
325
+ directory;
326
+ constructor(directory, options) {
327
+ super(`No package.json found in ${directory}`, options);
328
+ this.directory = directory;
329
+ }
330
+ };
331
+ //#endregion
332
+ //#region src/utils/resolve-config-root-dir.ts
333
+ const resolveConfigRootDir = (config, configSourceDirectory) => {
334
+ if (!config || !configSourceDirectory) return null;
335
+ const rawRootDir = config.rootDir;
336
+ if (typeof rawRootDir !== "string") return null;
337
+ const trimmedRootDir = rawRootDir.trim();
338
+ if (trimmedRootDir.length === 0) return null;
339
+ const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
340
+ if (resolvedRootDir === configSourceDirectory) return null;
341
+ if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
342
+ logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
343
+ return null;
344
+ }
345
+ return resolvedRootDir;
327
346
  };
328
347
  //#endregion
329
348
  //#region src/utils/build-hidden-diagnostics-summary.ts
@@ -824,7 +843,7 @@ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
824
843
  };
825
844
  //#endregion
826
845
  //#region src/utils/find-stacked-disable-comments.ts
827
- const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
846
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
828
847
  const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
829
848
  const collected = [];
830
849
  let isStillInChain = true;
@@ -846,13 +865,21 @@ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
846
865
  };
847
866
  //#endregion
848
867
  //#region src/utils/is-rule-listed-in-comment.ts
868
+ const stripDescriptionTail = (ruleList) => {
869
+ const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
870
+ if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
871
+ return ruleList.slice(0, descriptionMatch.index);
872
+ };
849
873
  const isRuleListedInComment = (ruleList, ruleId) => {
850
- if (!ruleList?.trim()) return true;
851
- return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
874
+ const trimmed = ruleList?.trim();
875
+ if (!trimmed) return true;
876
+ const ruleSection = stripDescriptionTail(trimmed).trim();
877
+ if (!ruleSection) return true;
878
+ return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
852
879
  };
853
880
  //#endregion
854
881
  //#region src/utils/evaluate-suppression.ts
855
- const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
882
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
856
883
  const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
857
884
  const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
858
885
  const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
@@ -1273,9 +1300,9 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
1273
1300
  for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
1274
1301
  return null;
1275
1302
  };
1276
- const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
1303
+ const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
1277
1304
  const rawVersion = collectAllDependencies(packageJson)[packageName];
1278
- const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
1305
+ const catalogName = explicitCatalogReference ?? (rawVersion ? extractCatalogName(rawVersion) : null);
1279
1306
  if (isPlainObject(packageJson.catalog)) {
1280
1307
  const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
1281
1308
  if (version) return version;
@@ -1292,9 +1319,22 @@ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
1292
1319
  }
1293
1320
  }
1294
1321
  const workspaces = packageJson.workspaces;
1295
- if (workspaces && !Array.isArray(workspaces) && isPlainObject(workspaces.catalog)) {
1296
- const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
1297
- if (version) return version;
1322
+ if (workspaces && !Array.isArray(workspaces)) {
1323
+ if (isPlainObject(workspaces.catalog)) {
1324
+ const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
1325
+ if (version) return version;
1326
+ }
1327
+ if (isPlainObject(workspaces.catalogs)) {
1328
+ const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
1329
+ if (namedCatalog && isPlainObject(namedCatalog)) {
1330
+ const version = resolveVersionFromCatalog(namedCatalog, packageName);
1331
+ if (version) return version;
1332
+ }
1333
+ for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
1334
+ const version = resolveVersionFromCatalog(catalogEntries, packageName);
1335
+ if (version) return version;
1336
+ }
1337
+ }
1298
1338
  }
1299
1339
  if (rootDirectory) {
1300
1340
  const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
@@ -1381,7 +1421,8 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
1381
1421
  };
1382
1422
  const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
1383
1423
  const rootInfo = extractDependencyInfo(rootPackageJson);
1384
- const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot);
1424
+ const leafPackageJsonPath = path.join(directory, "package.json");
1425
+ const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, isFile(leafPackageJsonPath) ? extractCatalogName(collectAllDependencies(readPackageJson(leafPackageJsonPath)).react ?? "") ?? null : null);
1385
1426
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
1386
1427
  return {
1387
1428
  reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
@@ -1509,15 +1550,16 @@ const discoverProject = (directory) => {
1509
1550
  const cached = cachedProjectInfos.get(directory);
1510
1551
  if (cached !== void 0) return cached;
1511
1552
  const packageJsonPath = path.join(directory, "package.json");
1512
- if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
1553
+ if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
1513
1554
  const packageJson = readPackageJson(packageJsonPath);
1514
1555
  let { reactVersion, framework } = extractDependencyInfo(packageJson);
1515
- if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory);
1556
+ const leafCatalogReference = extractCatalogName(collectAllDependencies(packageJson).react ?? "") ?? null;
1557
+ if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory, leafCatalogReference);
1516
1558
  if (!reactVersion) {
1517
1559
  const monorepoRoot = findMonorepoRoot(directory);
1518
1560
  if (monorepoRoot) {
1519
1561
  const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
1520
- if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
1562
+ if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot, leafCatalogReference);
1521
1563
  }
1522
1564
  }
1523
1565
  if (!reactVersion || framework === "unknown") {
@@ -1591,6 +1633,7 @@ const BOOLEAN_FIELD_NAMES = [
1591
1633
  "respectInlineDisables",
1592
1634
  "adoptExistingLintConfig"
1593
1635
  ];
1636
+ const STRING_FIELD_NAMES = ["rootDir"];
1594
1637
  const warnConfigField = (message) => {
1595
1638
  process.stderr.write(`[react-doctor] ${message}\n`);
1596
1639
  };
@@ -1606,6 +1649,10 @@ const coerceMaybeBooleanString = (fieldName, value) => {
1606
1649
  }
1607
1650
  warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1608
1651
  };
1652
+ const validateString = (fieldName, value) => {
1653
+ if (typeof value === "string") return value;
1654
+ warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
1655
+ };
1609
1656
  const validateConfigTypes = (config) => {
1610
1657
  const validated = { ...config };
1611
1658
  for (const fieldName of BOOLEAN_FIELD_NAMES) {
@@ -1615,6 +1662,13 @@ const validateConfigTypes = (config) => {
1615
1662
  if (coerced === void 0) delete validated[fieldName];
1616
1663
  else validated[fieldName] = coerced;
1617
1664
  }
1665
+ for (const fieldName of STRING_FIELD_NAMES) {
1666
+ const original = config[fieldName];
1667
+ if (original === void 0) continue;
1668
+ const validatedString = validateString(fieldName, original);
1669
+ if (validatedString === void 0) delete validated[fieldName];
1670
+ else validated[fieldName] = validatedString;
1671
+ }
1618
1672
  return validated;
1619
1673
  };
1620
1674
  //#endregion
@@ -1626,7 +1680,10 @@ const loadConfigFromDirectory = (directory) => {
1626
1680
  if (isFile(configFilePath)) try {
1627
1681
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
1628
1682
  const parsed = JSON.parse(fileContent);
1629
- if (isPlainObject(parsed)) return validateConfigTypes(parsed);
1683
+ if (isPlainObject(parsed)) return {
1684
+ config: validateConfigTypes(parsed),
1685
+ sourceDirectory: directory
1686
+ };
1630
1687
  logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1631
1688
  } catch (error) {
1632
1689
  logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
@@ -1637,7 +1694,10 @@ const loadConfigFromDirectory = (directory) => {
1637
1694
  const packageJson = JSON.parse(fileContent);
1638
1695
  if (isPlainObject(packageJson)) {
1639
1696
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
1640
- if (isPlainObject(embeddedConfig)) return validateConfigTypes(embeddedConfig);
1697
+ if (isPlainObject(embeddedConfig)) return {
1698
+ config: validateConfigTypes(embeddedConfig),
1699
+ sourceDirectory: directory
1700
+ };
1641
1701
  }
1642
1702
  } catch {
1643
1703
  return null;
@@ -1646,7 +1706,7 @@ const loadConfigFromDirectory = (directory) => {
1646
1706
  };
1647
1707
  const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1648
1708
  const cachedConfigs = /* @__PURE__ */ new Map();
1649
- const loadConfig = (rootDirectory) => {
1709
+ const loadConfigWithSource = (rootDirectory) => {
1650
1710
  const cached = cachedConfigs.get(rootDirectory);
1651
1711
  if (cached !== void 0) return cached;
1652
1712
  const localConfig = loadConfigFromDirectory(rootDirectory);
@@ -1675,6 +1735,32 @@ const loadConfig = (rootDirectory) => {
1675
1735
  return null;
1676
1736
  };
1677
1737
  //#endregion
1738
+ //#region src/utils/wrap-indented-text.ts
1739
+ const wrapLine = (lineText, contentWidth) => {
1740
+ if (lineText.length <= contentWidth) return [lineText];
1741
+ const wrappedLines = [];
1742
+ let remainingText = lineText.trim();
1743
+ while (remainingText.length > contentWidth) {
1744
+ const candidateText = remainingText.slice(0, contentWidth);
1745
+ const breakIndex = candidateText.lastIndexOf(" ");
1746
+ if (breakIndex <= 0) {
1747
+ wrappedLines.push(candidateText);
1748
+ remainingText = remainingText.slice(contentWidth).trimStart();
1749
+ continue;
1750
+ }
1751
+ wrappedLines.push(remainingText.slice(0, breakIndex));
1752
+ remainingText = remainingText.slice(breakIndex + 1).trimStart();
1753
+ }
1754
+ if (remainingText.length > 0) wrappedLines.push(remainingText);
1755
+ return wrappedLines;
1756
+ };
1757
+ const wrapIndentedText = (text, linePrefix, width) => {
1758
+ const contentWidth = width - linePrefix.length;
1759
+ if (contentWidth <= 0) return indentOnly(text, linePrefix);
1760
+ return text.split("\n").flatMap((lineText) => wrapLine(lineText, contentWidth)).map((lineText) => `${linePrefix}${lineText}`).join("\n");
1761
+ };
1762
+ const indentOnly = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
1763
+ //#endregion
1678
1764
  //#region src/utils/resolve-compatible-node.ts
1679
1765
  const parseNodeVersion = (versionString) => {
1680
1766
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
@@ -2256,6 +2342,16 @@ const TANSTACK_START_RULES = {
2256
2342
  "react-doctor/tanstack-start-redirect-in-try-catch": "warn",
2257
2343
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
2258
2344
  };
2345
+ const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
2346
+ "effect/no-derived-state": "warn",
2347
+ "effect/no-chain-state-updates": "warn",
2348
+ "effect/no-event-handler": "warn",
2349
+ "effect/no-adjust-state-on-prop-change": "warn",
2350
+ "effect/no-reset-all-state-on-prop-change": "warn",
2351
+ "effect/no-pass-live-state-to-parent": "warn",
2352
+ "effect/no-pass-data-to-parent": "warn",
2353
+ "effect/no-initialize-state": "warn"
2354
+ };
2259
2355
  const REACT_COMPILER_RULES = {
2260
2356
  "react-hooks-js/set-state-in-render": "error",
2261
2357
  "react-hooks-js/immutability": "error",
@@ -2300,6 +2396,23 @@ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
2300
2396
  availableRuleNames: readPluginRuleNames(pluginSpecifier)
2301
2397
  };
2302
2398
  };
2399
+ const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
2400
+ const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
2401
+ if (customRulesOnly) return null;
2402
+ let pluginSpecifier;
2403
+ try {
2404
+ pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
2405
+ } catch {
2406
+ return null;
2407
+ }
2408
+ return {
2409
+ entry: {
2410
+ name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
2411
+ specifier: pluginSpecifier
2412
+ },
2413
+ availableRuleNames: readPluginRuleNames(pluginSpecifier)
2414
+ };
2415
+ };
2303
2416
  const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
2304
2417
  if (availableRuleNames.size === 0) return rules;
2305
2418
  const ruleKeyPrefix = `${pluginNamespace}/`;
@@ -2510,6 +2623,11 @@ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
2510
2623
  const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
2511
2624
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
2512
2625
  const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
2626
+ const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
2627
+ const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
2628
+ const jsPlugins = [];
2629
+ if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
2630
+ if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
2513
2631
  return {
2514
2632
  ...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
2515
2633
  categories: {
@@ -2522,11 +2640,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
2522
2640
  nursery: "off"
2523
2641
  },
2524
2642
  plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
2525
- jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin.entry, pluginPath] : [pluginPath],
2643
+ jsPlugins: [...jsPlugins, pluginPath],
2526
2644
  rules: {
2527
2645
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
2528
2646
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
2529
2647
  ...reactCompilerRules,
2648
+ ...youMightNotNeedEffectRules,
2530
2649
  ...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
2531
2650
  ...framework === "nextjs" ? NEXTJS_RULES : {},
2532
2651
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
@@ -2640,6 +2759,7 @@ const PLUGIN_CATEGORY_MAP = {
2640
2759
  "react-doctor": "Other",
2641
2760
  "jsx-a11y": "Accessibility",
2642
2761
  knip: "Dead Code",
2762
+ effect: "State & Effects",
2643
2763
  eslint: "Correctness",
2644
2764
  oxc: "Correctness",
2645
2765
  typescript: "Correctness",
@@ -3154,6 +3274,7 @@ const parseOxlintOutput = (stdout) => {
3154
3274
  severity: diagnostic.severity,
3155
3275
  message: cleaned.message,
3156
3276
  help: cleaned.help,
3277
+ url: diagnostic.url,
3157
3278
  line: primaryLabel?.span.line ?? 0,
3158
3279
  column: primaryLabel?.span.column ?? 0,
3159
3280
  category: resolveDiagnosticCategory(plugin, rule)
@@ -3285,6 +3406,11 @@ const buildVerboseSiteMap = (diagnostics) => {
3285
3406
  return fileSites;
3286
3407
  };
3287
3408
  const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
3409
+ const formatIssueCount = (count) => `${count} ${count === 1 ? "issue" : "issues"}`;
3410
+ const toRuleTitle = (ruleName) => {
3411
+ const readableRuleName = ruleName.replace(/^(no|prefer|require|use)-/, "").replace(/^(nextjs|tanstack-start)-/, "").replaceAll("-", " ");
3412
+ return (readableRuleName.charAt(0).toUpperCase() + readableRuleName.slice(1)).replace(/\b(css|html|url|svg|jsx|api|ua)\b/gi, (match) => match.toUpperCase());
3413
+ };
3288
3414
  const computeRuleNameColumnWidth = (ruleKeys) => {
3289
3415
  const longestRuleNameLength = ruleKeys.reduce((longest, ruleKey) => Math.max(longest, ruleKey.length), 0);
3290
3416
  return Math.max(36, longestRuleNameLength);
@@ -3296,6 +3422,9 @@ const padRuleNameToColumn = (ruleName, columnWidth) => {
3296
3422
  const grayLine = (text) => {
3297
3423
  logger.log(highlighter.gray(text));
3298
3424
  };
3425
+ const grayWrappedLine = (text, linePrefix) => {
3426
+ grayLine(wrapIndentedText(text, linePrefix, 88));
3427
+ };
3299
3428
  const printCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
3300
3429
  const firstDiagnostic = ruleDiagnostics[0];
3301
3430
  const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
@@ -3304,13 +3433,38 @@ const printCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth
3304
3433
  const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
3305
3434
  logger.log(` ${icon} ${ruleNameRendering}${trailingBadge}`);
3306
3435
  };
3307
- const printDetailedRuleGroup = (ruleKey, ruleDiagnostics, rootDirectory, ruleNameColumnWidth) => {
3308
- printCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3436
+ const getWorstSeverity = (diagnostics) => diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "error" : "warning";
3437
+ const buildCategoryDiagnosticGroups = (diagnostics) => {
3438
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
3439
+ return {
3440
+ category,
3441
+ diagnostics: categoryDiagnostics,
3442
+ ruleGroups: sortByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()])
3443
+ };
3444
+ }).toSorted((categoryGroupA, categoryGroupB) => {
3445
+ const severityDelta = SEVERITY_ORDER[getWorstSeverity(categoryGroupA.diagnostics)] - SEVERITY_ORDER[getWorstSeverity(categoryGroupB.diagnostics)];
3446
+ if (severityDelta !== 0) return severityDelta;
3447
+ if (categoryGroupA.diagnostics.length !== categoryGroupB.diagnostics.length) return categoryGroupB.diagnostics.length - categoryGroupA.diagnostics.length;
3448
+ return categoryGroupA.category.localeCompare(categoryGroupB.category);
3449
+ });
3450
+ };
3451
+ const printDefaultRuleGroup = (ruleKey, ruleDiagnostics, rootDirectory) => {
3309
3452
  const firstDiagnostic = ruleDiagnostics[0];
3310
- grayLine(indentMultilineText(firstDiagnostic.message, " "));
3311
- if (firstDiagnostic.help) grayLine(indentMultilineText(`→ ${firstDiagnostic.help}`, " "));
3453
+ const ruleTitle = toRuleTitle(firstDiagnostic.rule);
3454
+ const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "" : "⚠", firstDiagnostic.severity);
3455
+ const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
3456
+ const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
3457
+ logger.log(` ${icon} ${ruleTitle}${trailingBadge}`);
3458
+ grayWrappedLine(firstDiagnostic.message, " ");
3459
+ if (firstDiagnostic.help) grayWrappedLine(firstDiagnostic.help, " ");
3460
+ if (firstDiagnostic.url) grayLine(` ${firstDiagnostic.url}`);
3312
3461
  const firstLocation = ruleDiagnostics.find((diagnostic) => diagnostic.line > 0);
3313
- if (firstLocation) grayLine(` ${toRelativePath(firstLocation.filePath, rootDirectory)}:${firstLocation.line}`);
3462
+ if (firstLocation) grayLine(` ${toRelativePath(firstLocation.filePath, rootDirectory)}:${firstLocation.line}`);
3463
+ };
3464
+ const printDefaultCategoryGroup = (categoryGroup, visibleRuleGroups, rootDirectory) => {
3465
+ const issueCount = formatIssueCount(categoryGroup.diagnostics.length);
3466
+ logger.log(`${highlighter.bold(categoryGroup.category)} ${highlighter.dim(issueCount)}`);
3467
+ for (const [ruleKey, ruleDiagnostics] of visibleRuleGroups) printDefaultRuleGroup(ruleKey, ruleDiagnostics, rootDirectory);
3314
3468
  logger.break();
3315
3469
  };
3316
3470
  const printVerboseRuleGroup = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
@@ -3326,22 +3480,36 @@ const printVerboseRuleGroup = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) =>
3326
3480
  else grayLine(` ${filePath}`);
3327
3481
  logger.break();
3328
3482
  };
3483
+ const printDefaultDiagnostics = (diagnostics, rootDirectory) => {
3484
+ const categoryGroups = buildCategoryDiagnosticGroups(diagnostics);
3485
+ const hiddenRuleGroups = [];
3486
+ const visibleCategoryGroups = categoryGroups.slice(0, 5);
3487
+ const hiddenCategoryGroups = categoryGroups.slice(5);
3488
+ for (const categoryGroup of visibleCategoryGroups) {
3489
+ const visibleRuleGroups = categoryGroup.ruleGroups.slice(0, 3);
3490
+ const remainingRuleGroups = categoryGroup.ruleGroups.slice(3);
3491
+ printDefaultCategoryGroup(categoryGroup, visibleRuleGroups, rootDirectory);
3492
+ hiddenRuleGroups.push(...remainingRuleGroups);
3493
+ }
3494
+ hiddenRuleGroups.push(...hiddenCategoryGroups.flatMap((categoryGroup) => categoryGroup.ruleGroups));
3495
+ if (hiddenRuleGroups.length > 0) printHiddenDiagnosticsSummary(hiddenRuleGroups);
3496
+ };
3329
3497
  const printDiagnostics = (diagnostics, isVerbose, rootDirectory) => {
3330
- const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3331
- const visibleRuleGroups = isVerbose ? sortedRuleGroups : sortedRuleGroups.slice(0, 5);
3332
- const hiddenRuleGroups = isVerbose ? [] : sortedRuleGroups.slice(5);
3498
+ if (!isVerbose) {
3499
+ printDefaultDiagnostics(diagnostics, rootDirectory);
3500
+ return;
3501
+ }
3502
+ const visibleRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3333
3503
  const ruleNameColumnWidth = computeRuleNameColumnWidth(visibleRuleGroups.map(([ruleKey]) => ruleKey));
3334
3504
  visibleRuleGroups.forEach(([ruleKey, ruleDiagnostics]) => {
3335
- if (isVerbose) {
3336
- printVerboseRuleGroup(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3337
- return;
3338
- }
3339
- printDetailedRuleGroup(ruleKey, ruleDiagnostics, rootDirectory, ruleNameColumnWidth);
3505
+ printVerboseRuleGroup(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3340
3506
  });
3341
- if (hiddenRuleGroups.length > 0) printHiddenDiagnosticsSummary(hiddenRuleGroups);
3342
3507
  };
3343
3508
  const printHiddenDiagnosticsSummary = (hiddenRuleGroups) => {
3344
- const renderedParts = buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => colorizeBySeverity(part.text, part.severity));
3509
+ const renderedParts = buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => {
3510
+ const [icon, ...labelParts] = part.text.split(" ");
3511
+ return `${colorizeBySeverity(icon, part.severity)} ${highlighter.dim(labelParts.join(" "))}`;
3512
+ });
3345
3513
  logger.log(` ${renderedParts.join(" ")}`);
3346
3514
  grayLine(" Run `npx react-doctor@latest . --verbose` to get all details");
3347
3515
  logger.break();
@@ -3361,6 +3529,7 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
3361
3529
  firstDiagnostic.message
3362
3530
  ];
3363
3531
  if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
3532
+ if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
3364
3533
  sections.push("", "Files:");
3365
3534
  const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3366
3535
  for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
@@ -3432,32 +3601,6 @@ const printNoScoreHeader = (noScoreMessage) => {
3432
3601
  logger.log(` ${highlighter.gray(noScoreMessage)}`);
3433
3602
  logger.break();
3434
3603
  };
3435
- const buildCategoryBar = (count, maximumCount, useErrorColor) => {
3436
- if (maximumCount === 0) return highlighter.dim("░".repeat(16));
3437
- const filledCount = Math.max(1, Math.round(count / maximumCount * 16));
3438
- const cappedFilledCount = Math.min(filledCount, 16);
3439
- const emptyCount = 16 - cappedFilledCount;
3440
- const filledSegment = "█".repeat(cappedFilledCount);
3441
- const emptySegment = "░".repeat(emptyCount);
3442
- return `${useErrorColor ? highlighter.error(filledSegment) : highlighter.warn(filledSegment)}${highlighter.dim(emptySegment)}`;
3443
- };
3444
- const padCategoryLabel = (categoryLabel) => {
3445
- if (categoryLabel.length >= 18) return categoryLabel;
3446
- return categoryLabel + " ".repeat(18 - categoryLabel.length);
3447
- };
3448
- const printCategoryBreakdown = (entries) => {
3449
- if (entries.length === 0) return;
3450
- const maximumCount = Math.max(...entries.map((entry) => entry.totalCount));
3451
- logger.dim(" By category");
3452
- for (const entry of entries) {
3453
- const paddedLabel = padCategoryLabel(entry.category);
3454
- const categoryBar = buildCategoryBar(entry.totalCount, maximumCount, entry.errorCount > 0);
3455
- const totalCountDisplay = String(entry.totalCount);
3456
- const errorBadge = entry.errorCount > 0 ? ` ${highlighter.error(`${entry.errorCount}×`)}` : "";
3457
- logger.log(` ${paddedLabel}${categoryBar} ${totalCountDisplay}${errorBadge}`);
3458
- }
3459
- logger.break();
3460
- };
3461
3604
  const buildShareUrl = (diagnostics, scoreResult, projectName) => {
3462
3605
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
3463
3606
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
@@ -3483,7 +3626,6 @@ const printCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMillis
3483
3626
  logger.log(` ${issueCountColor(issueCountText)} ${highlighter.dim(`${fileCountText} ${elapsedTimeText}`)}`);
3484
3627
  };
3485
3628
  const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
3486
- printCategoryBreakdown(buildCategoryBreakdown(diagnostics));
3487
3629
  if (scoreResult) printScoreHeader(scoreResult);
3488
3630
  else printNoScoreHeader(noScoreMessage);
3489
3631
  printCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds);
@@ -3565,7 +3707,15 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
3565
3707
  };
3566
3708
  const scan = async (directory, inputOptions = {}) => {
3567
3709
  const startTime = performance.now();
3568
- const userConfig = inputOptions.configOverride !== void 0 ? inputOptions.configOverride : loadConfig(directory);
3710
+ let scanDirectory = directory;
3711
+ let userConfig;
3712
+ if (inputOptions.configOverride !== void 0) userConfig = inputOptions.configOverride;
3713
+ else {
3714
+ const loadedConfig = loadConfigWithSource(directory);
3715
+ const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
3716
+ if (redirectedDirectory) scanDirectory = redirectedDirectory;
3717
+ userConfig = loadedConfig?.config ?? null;
3718
+ }
3569
3719
  const options = mergeScanOptions(inputOptions, userConfig);
3570
3720
  const wasLoggerSilent = isLoggerSilent();
3571
3721
  const wasSpinnerSilent = isSpinnerSilent();
@@ -3574,7 +3724,7 @@ const scan = async (directory, inputOptions = {}) => {
3574
3724
  setSpinnerSilent(true);
3575
3725
  }
3576
3726
  try {
3577
- return await runScan(directory, options, userConfig, startTime);
3727
+ return await runScan(scanDirectory, options, userConfig, startTime);
3578
3728
  } finally {
3579
3729
  if (options.silent) {
3580
3730
  setLoggerSilent(wasLoggerSilent);
@@ -3586,7 +3736,7 @@ const runScan = async (directory, options, userConfig, startTime) => {
3586
3736
  const projectInfo = discoverProject(directory);
3587
3737
  const { includePaths } = options;
3588
3738
  const isDiffMode = includePaths.length > 0;
3589
- if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(directory));
3739
+ if (!projectInfo.reactVersion) throw new NoReactDependencyError(directory);
3590
3740
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
3591
3741
  const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
3592
3742
  if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
@@ -4102,7 +4252,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
4102
4252
  };
4103
4253
  //#endregion
4104
4254
  //#region src/cli.ts
4105
- const VERSION = "0.1.3";
4255
+ const VERSION = "0.1.5";
4106
4256
  const VALID_FAIL_ON_LEVELS = new Set([
4107
4257
  "error",
4108
4258
  "warning",
@@ -4281,20 +4431,28 @@ const validateModeFlags = (flags) => {
4281
4431
  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.");
4282
4432
  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.");
4283
4433
  };
4284
- 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) => {
4434
+ 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").option("--why <file:line>", "alias for --explain").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) => {
4285
4435
  const isScoreOnly = flags.score;
4286
4436
  const isJsonMode = flags.json;
4287
4437
  const isQuiet = isScoreOnly || isJsonMode;
4288
- const resolvedDirectory = path.resolve(directory);
4438
+ const requestedDirectory = path.resolve(directory);
4289
4439
  const jsonStartTime = performance.now();
4290
4440
  isJsonModeActive = isJsonMode;
4291
4441
  isCompactJsonOutput = Boolean(flags.jsonCompact);
4292
- resolvedDirectoryForCancel = resolvedDirectory;
4442
+ resolvedDirectoryForCancel = requestedDirectory;
4293
4443
  cancelStartTime = jsonStartTime;
4294
4444
  if (isJsonMode) setLoggerSilent(true);
4295
4445
  try {
4296
4446
  validateModeFlags(flags);
4297
- const userConfig = loadConfig(resolvedDirectory);
4447
+ const loadedConfig = loadConfigWithSource(requestedDirectory);
4448
+ const userConfig = loadedConfig?.config ?? null;
4449
+ const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
4450
+ const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
4451
+ resolvedDirectoryForCancel = resolvedDirectory;
4452
+ if (redirectedDirectory && !isQuiet) {
4453
+ logger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
4454
+ logger.break();
4455
+ }
4298
4456
  const explainArgument = flags.explain ?? flags.why;
4299
4457
  if (explainArgument !== void 0) {
4300
4458
  await runExplain(explainArgument, {
@@ -4368,7 +4526,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
4368
4526
  totalElapsedMilliseconds: performance.now() - jsonStartTime
4369
4527
  }));
4370
4528
  if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
4371
- if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
4529
+ if (!isScoreOnly && shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
4372
4530
  } finally {
4373
4531
  cleanupSnapshot?.();
4374
4532
  }
@@ -4412,7 +4570,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
4412
4570
  }
4413
4571
  const scanResult = await scan(projectDirectory, {
4414
4572
  ...scanOptions,
4415
- includePaths
4573
+ includePaths,
4574
+ configOverride: userConfig
4416
4575
  });
4417
4576
  allDiagnostics.push(...scanResult.diagnostics);
4418
4577
  completedScans.push({
@@ -4431,13 +4590,13 @@ const program = new Command().name("react-doctor").description("Diagnose React c
4431
4590
  totalElapsedMilliseconds: performance.now() - jsonStartTime
4432
4591
  }));
4433
4592
  if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
4434
- if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
4593
+ if (!isScoreOnly && shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
4435
4594
  } catch (error) {
4436
4595
  try {
4437
4596
  if (isJsonMode) {
4438
4597
  writeJsonReport(buildJsonReportError({
4439
4598
  version: VERSION,
4440
- directory: resolvedDirectory,
4599
+ directory: resolvedDirectoryForCancel ?? requestedDirectory,
4441
4600
  error,
4442
4601
  elapsedMilliseconds: performance.now() - jsonStartTime,
4443
4602
  mode: currentReportMode
@@ -6946,7 +6946,7 @@ const ALL_RULES_AT_RECOMMENDED_SEVERITY = {
6946
6946
  const eslintPlugin = {
6947
6947
  meta: {
6948
6948
  name: PLUGIN_NAMESPACE,
6949
- version: "0.1.3"
6949
+ version: "0.1.5"
6950
6950
  },
6951
6951
  rules: eslintShapedRules,
6952
6952
  configs: {