react-doctor 0.1.2 → 0.1.4

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 CHANGED
@@ -160,21 +160,26 @@ When a suppression isn't working, `--explain <file:line>` reports what the scann
160
160
 
161
161
  ### Config keys
162
162
 
163
- | Key | Type | Default |
164
- | ------------------------- | -------------------------------- | -------- |
165
- | `ignore.rules` | `string[]` | `[]` |
166
- | `ignore.files` | `string[]` | `[]` |
167
- | `ignore.overrides` | `{ files, rules? }[]` | `[]` |
168
- | `lint` | `boolean` | `true` |
169
- | `deadCode` | `boolean` | `true` |
170
- | `verbose` | `boolean` | `false` |
171
- | `diff` | `boolean \| string` | |
172
- | `failOn` | `"error" \| "warning" \| "none"` | `"none"` |
173
- | `customRulesOnly` | `boolean` | `false` |
174
- | `share` | `boolean` | `true` |
175
- | `textComponents` | `string[]` | `[]` |
176
- | `respectInlineDisables` | `boolean` | `true` |
177
- | `adoptExistingLintConfig` | `boolean` | `true` |
163
+ | Key | Type | Default |
164
+ | -------------------------- | -------------------------------- | -------- |
165
+ | `ignore.rules` | `string[]` | `[]` |
166
+ | `ignore.files` | `string[]` | `[]` |
167
+ | `ignore.overrides` | `{ files, rules? }[]` | `[]` |
168
+ | `lint` | `boolean` | `true` |
169
+ | `deadCode` | `boolean` | `true` |
170
+ | `verbose` | `boolean` | `false` |
171
+ | `diff` | `boolean \| string` | |
172
+ | `failOn` | `"error" \| "warning" \| "none"` | `"none"` |
173
+ | `customRulesOnly` | `boolean` | `false` |
174
+ | `share` | `boolean` | `true` |
175
+ | `textComponents` | `string[]` | `[]` |
176
+ | `rawTextWrapperComponents` | `string[]` | `[]` |
177
+ | `respectInlineDisables` | `boolean` | `true` |
178
+ | `adoptExistingLintConfig` | `boolean` | `true` |
179
+
180
+ `textComponents` is the broad escape hatch for `rn-no-raw-text` — list components that themselves behave like React Native's `<Text>` (custom `Typography`, `NativeTabs.Trigger.Label`, etc.) and the rule will treat them as text containers regardless of what their children look like.
181
+
182
+ `rawTextWrapperComponents` is the narrower option for components that are not text elements but safely route string-only children through an internal `<Text>` (e.g. `heroui-native`'s `Button`, which stringifies its children and renders them through a `ButtonLabel`). Listed wrappers suppress `rn-no-raw-text` only when their children are entirely stringifiable. A wrapper with mixed children — e.g. `<Button>Save<Icon /></Button>` — still reports because the wrapper can't safely route raw text alongside a sibling JSX element.
178
183
 
179
184
  ## Node.js API
180
185
 
package/dist/cli.js CHANGED
@@ -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,28 +304,6 @@ 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
- });
327
- };
328
- //#endregion
329
307
  //#region src/utils/build-hidden-diagnostics-summary.ts
330
308
  const buildHiddenDiagnosticsSummary = (hiddenDiagnostics) => {
331
309
  const errorCount = hiddenDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
@@ -912,6 +890,8 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
912
890
  //#endregion
913
891
  //#region src/utils/filter-diagnostics.ts
914
892
  const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
893
+ const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
894
+ const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
915
895
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
916
896
  const normalizedFile = filePath.replace(/\\/g, "/");
917
897
  if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
@@ -937,21 +917,93 @@ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
937
917
  }
938
918
  return false;
939
919
  };
920
+ const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
921
+ for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
922
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
923
+ if (!match) continue;
924
+ const fullName = match[1];
925
+ return {
926
+ fullName,
927
+ leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
928
+ lineIndex
929
+ };
930
+ }
931
+ return null;
932
+ };
933
+ const resolveJsxRange = (lines, opener) => {
934
+ const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
935
+ let closerLineIndex = -1;
936
+ let closerColumn = -1;
937
+ for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
938
+ const match = closingPattern.exec(lines[lineIndex]);
939
+ if (!match) continue;
940
+ closerLineIndex = lineIndex;
941
+ closerColumn = match.index;
942
+ break;
943
+ }
944
+ if (closerLineIndex < 0) return null;
945
+ const openerLine = lines[opener.lineIndex];
946
+ const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
947
+ if (tagStartIndex < 0) return null;
948
+ const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
949
+ let bodyText;
950
+ if (opener.lineIndex === closerLineIndex) {
951
+ if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
952
+ bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
953
+ } else {
954
+ const segments = [];
955
+ if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
956
+ for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
957
+ segments.push(lines[closerLineIndex].slice(0, closerColumn));
958
+ bodyText = segments.join("\n");
959
+ }
960
+ return {
961
+ closerLineIndex,
962
+ closerColumn,
963
+ bodyText
964
+ };
965
+ };
966
+ const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
967
+ const diagnosticLineIndex = diagnosticLine - 1;
968
+ const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
969
+ let upperBoundLineIndex = diagnosticLineIndex;
970
+ while (upperBoundLineIndex >= 0) {
971
+ const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
972
+ if (!opener) return false;
973
+ const range = resolveJsxRange(lines, opener);
974
+ if (range === null) {
975
+ upperBoundLineIndex = opener.lineIndex - 1;
976
+ continue;
977
+ }
978
+ if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
979
+ upperBoundLineIndex = opener.lineIndex - 1;
980
+ continue;
981
+ }
982
+ if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
983
+ return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
984
+ }
985
+ return false;
986
+ };
940
987
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
941
988
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
942
989
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
943
990
  const compiledOverrides = compileIgnoreOverrides(config);
944
991
  const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
945
992
  const hasTextComponents = textComponentNames.size > 0;
993
+ const rawTextWrapperComponentNames = new Set(Array.isArray(config.rawTextWrapperComponents) ? config.rawTextWrapperComponents.filter((name) => typeof name === "string") : []);
994
+ const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
946
995
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
947
996
  return diagnostics.filter((diagnostic) => {
948
997
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
949
998
  if (ignoredRules.has(ruleIdentifier)) return false;
950
999
  if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
951
1000
  if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
952
- if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
1001
+ if ((hasTextComponents || hasRawTextWrappers) && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
953
1002
  const lines = getFileLines(diagnostic.filePath);
954
- if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1003
+ if (lines) {
1004
+ if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1005
+ if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return false;
1006
+ }
955
1007
  }
956
1008
  return true;
957
1009
  });
@@ -1601,6 +1653,32 @@ const loadConfig = (rootDirectory) => {
1601
1653
  return null;
1602
1654
  };
1603
1655
  //#endregion
1656
+ //#region src/utils/wrap-indented-text.ts
1657
+ const wrapLine = (lineText, contentWidth) => {
1658
+ if (lineText.length <= contentWidth) return [lineText];
1659
+ const wrappedLines = [];
1660
+ let remainingText = lineText.trim();
1661
+ while (remainingText.length > contentWidth) {
1662
+ const candidateText = remainingText.slice(0, contentWidth);
1663
+ const breakIndex = candidateText.lastIndexOf(" ");
1664
+ if (breakIndex <= 0) {
1665
+ wrappedLines.push(candidateText);
1666
+ remainingText = remainingText.slice(contentWidth).trimStart();
1667
+ continue;
1668
+ }
1669
+ wrappedLines.push(remainingText.slice(0, breakIndex));
1670
+ remainingText = remainingText.slice(breakIndex + 1).trimStart();
1671
+ }
1672
+ if (remainingText.length > 0) wrappedLines.push(remainingText);
1673
+ return wrappedLines;
1674
+ };
1675
+ const wrapIndentedText = (text, linePrefix, width) => {
1676
+ const contentWidth = width - linePrefix.length;
1677
+ if (contentWidth <= 0) return indentOnly(text, linePrefix);
1678
+ return text.split("\n").flatMap((lineText) => wrapLine(lineText, contentWidth)).map((lineText) => `${linePrefix}${lineText}`).join("\n");
1679
+ };
1680
+ const indentOnly = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
1681
+ //#endregion
1604
1682
  //#region src/utils/resolve-compatible-node.ts
1605
1683
  const parseNodeVersion = (versionString) => {
1606
1684
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
@@ -2183,22 +2261,22 @@ const TANSTACK_START_RULES = {
2183
2261
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
2184
2262
  };
2185
2263
  const REACT_COMPILER_RULES = {
2186
- "react-hooks-js/set-state-in-render": "warn",
2187
- "react-hooks-js/immutability": "warn",
2188
- "react-hooks-js/refs": "warn",
2189
- "react-hooks-js/purity": "warn",
2190
- "react-hooks-js/hooks": "warn",
2191
- "react-hooks-js/set-state-in-effect": "warn",
2192
- "react-hooks-js/globals": "warn",
2193
- "react-hooks-js/error-boundaries": "warn",
2194
- "react-hooks-js/preserve-manual-memoization": "warn",
2195
- "react-hooks-js/unsupported-syntax": "warn",
2196
- "react-hooks-js/component-hook-factories": "warn",
2197
- "react-hooks-js/static-components": "warn",
2198
- "react-hooks-js/use-memo": "warn",
2199
- "react-hooks-js/void-use-memo": "warn",
2200
- "react-hooks-js/incompatible-library": "warn",
2201
- "react-hooks-js/todo": "warn"
2264
+ "react-hooks-js/set-state-in-render": "error",
2265
+ "react-hooks-js/immutability": "error",
2266
+ "react-hooks-js/refs": "error",
2267
+ "react-hooks-js/purity": "error",
2268
+ "react-hooks-js/hooks": "error",
2269
+ "react-hooks-js/set-state-in-effect": "error",
2270
+ "react-hooks-js/globals": "error",
2271
+ "react-hooks-js/error-boundaries": "error",
2272
+ "react-hooks-js/preserve-manual-memoization": "error",
2273
+ "react-hooks-js/unsupported-syntax": "error",
2274
+ "react-hooks-js/component-hook-factories": "error",
2275
+ "react-hooks-js/static-components": "error",
2276
+ "react-hooks-js/use-memo": "error",
2277
+ "react-hooks-js/void-use-memo": "error",
2278
+ "react-hooks-js/incompatible-library": "error",
2279
+ "react-hooks-js/todo": "error"
2202
2280
  };
2203
2281
  const readPluginRuleNames = (pluginSpecifier) => {
2204
2282
  try {
@@ -3080,6 +3158,7 @@ const parseOxlintOutput = (stdout) => {
3080
3158
  severity: diagnostic.severity,
3081
3159
  message: cleaned.message,
3082
3160
  help: cleaned.help,
3161
+ url: diagnostic.url,
3083
3162
  line: primaryLabel?.span.line ?? 0,
3084
3163
  column: primaryLabel?.span.column ?? 0,
3085
3164
  category: resolveDiagnosticCategory(plugin, rule)
@@ -3211,6 +3290,11 @@ const buildVerboseSiteMap = (diagnostics) => {
3211
3290
  return fileSites;
3212
3291
  };
3213
3292
  const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
3293
+ const formatIssueCount = (count) => `${count} ${count === 1 ? "issue" : "issues"}`;
3294
+ const toRuleTitle = (ruleName) => {
3295
+ const readableRuleName = ruleName.replace(/^(no|prefer|require|use)-/, "").replace(/^(nextjs|tanstack-start)-/, "").replaceAll("-", " ");
3296
+ return (readableRuleName.charAt(0).toUpperCase() + readableRuleName.slice(1)).replace(/\b(css|html|url|svg|jsx|api|ua)\b/gi, (match) => match.toUpperCase());
3297
+ };
3214
3298
  const computeRuleNameColumnWidth = (ruleKeys) => {
3215
3299
  const longestRuleNameLength = ruleKeys.reduce((longest, ruleKey) => Math.max(longest, ruleKey.length), 0);
3216
3300
  return Math.max(36, longestRuleNameLength);
@@ -3222,6 +3306,9 @@ const padRuleNameToColumn = (ruleName, columnWidth) => {
3222
3306
  const grayLine = (text) => {
3223
3307
  logger.log(highlighter.gray(text));
3224
3308
  };
3309
+ const grayWrappedLine = (text, linePrefix) => {
3310
+ grayLine(wrapIndentedText(text, linePrefix, 88));
3311
+ };
3225
3312
  const printCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
3226
3313
  const firstDiagnostic = ruleDiagnostics[0];
3227
3314
  const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
@@ -3230,13 +3317,38 @@ const printCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth
3230
3317
  const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
3231
3318
  logger.log(` ${icon} ${ruleNameRendering}${trailingBadge}`);
3232
3319
  };
3233
- const printDetailedRuleGroup = (ruleKey, ruleDiagnostics, rootDirectory, ruleNameColumnWidth) => {
3234
- printCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3320
+ const getWorstSeverity = (diagnostics) => diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "error" : "warning";
3321
+ const buildCategoryDiagnosticGroups = (diagnostics) => {
3322
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
3323
+ return {
3324
+ category,
3325
+ diagnostics: categoryDiagnostics,
3326
+ ruleGroups: sortByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()])
3327
+ };
3328
+ }).toSorted((categoryGroupA, categoryGroupB) => {
3329
+ const severityDelta = SEVERITY_ORDER[getWorstSeverity(categoryGroupA.diagnostics)] - SEVERITY_ORDER[getWorstSeverity(categoryGroupB.diagnostics)];
3330
+ if (severityDelta !== 0) return severityDelta;
3331
+ if (categoryGroupA.diagnostics.length !== categoryGroupB.diagnostics.length) return categoryGroupB.diagnostics.length - categoryGroupA.diagnostics.length;
3332
+ return categoryGroupA.category.localeCompare(categoryGroupB.category);
3333
+ });
3334
+ };
3335
+ const printDefaultRuleGroup = (ruleKey, ruleDiagnostics, rootDirectory) => {
3235
3336
  const firstDiagnostic = ruleDiagnostics[0];
3236
- grayLine(indentMultilineText(firstDiagnostic.message, " "));
3237
- if (firstDiagnostic.help) grayLine(indentMultilineText(`→ ${firstDiagnostic.help}`, " "));
3337
+ const ruleTitle = toRuleTitle(firstDiagnostic.rule);
3338
+ const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "" : "⚠", firstDiagnostic.severity);
3339
+ const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
3340
+ const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
3341
+ logger.log(` ${icon} ${ruleTitle}${trailingBadge}`);
3342
+ grayWrappedLine(firstDiagnostic.message, " ");
3343
+ if (firstDiagnostic.help) grayWrappedLine(firstDiagnostic.help, " ");
3344
+ if (firstDiagnostic.url) grayLine(` ${firstDiagnostic.url}`);
3238
3345
  const firstLocation = ruleDiagnostics.find((diagnostic) => diagnostic.line > 0);
3239
- if (firstLocation) grayLine(` ${toRelativePath(firstLocation.filePath, rootDirectory)}:${firstLocation.line}`);
3346
+ if (firstLocation) grayLine(` ${toRelativePath(firstLocation.filePath, rootDirectory)}:${firstLocation.line}`);
3347
+ };
3348
+ const printDefaultCategoryGroup = (categoryGroup, visibleRuleGroups, rootDirectory) => {
3349
+ const issueCount = formatIssueCount(categoryGroup.diagnostics.length);
3350
+ logger.log(`${highlighter.bold(categoryGroup.category)} ${highlighter.dim(issueCount)}`);
3351
+ for (const [ruleKey, ruleDiagnostics] of visibleRuleGroups) printDefaultRuleGroup(ruleKey, ruleDiagnostics, rootDirectory);
3240
3352
  logger.break();
3241
3353
  };
3242
3354
  const printVerboseRuleGroup = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
@@ -3252,22 +3364,36 @@ const printVerboseRuleGroup = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) =>
3252
3364
  else grayLine(` ${filePath}`);
3253
3365
  logger.break();
3254
3366
  };
3367
+ const printDefaultDiagnostics = (diagnostics, rootDirectory) => {
3368
+ const categoryGroups = buildCategoryDiagnosticGroups(diagnostics);
3369
+ const hiddenRuleGroups = [];
3370
+ const visibleCategoryGroups = categoryGroups.slice(0, 5);
3371
+ const hiddenCategoryGroups = categoryGroups.slice(5);
3372
+ for (const categoryGroup of visibleCategoryGroups) {
3373
+ const visibleRuleGroups = categoryGroup.ruleGroups.slice(0, 3);
3374
+ const remainingRuleGroups = categoryGroup.ruleGroups.slice(3);
3375
+ printDefaultCategoryGroup(categoryGroup, visibleRuleGroups, rootDirectory);
3376
+ hiddenRuleGroups.push(...remainingRuleGroups);
3377
+ }
3378
+ hiddenRuleGroups.push(...hiddenCategoryGroups.flatMap((categoryGroup) => categoryGroup.ruleGroups));
3379
+ if (hiddenRuleGroups.length > 0) printHiddenDiagnosticsSummary(hiddenRuleGroups);
3380
+ };
3255
3381
  const printDiagnostics = (diagnostics, isVerbose, rootDirectory) => {
3256
- const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3257
- const visibleRuleGroups = isVerbose ? sortedRuleGroups : sortedRuleGroups.slice(0, 5);
3258
- const hiddenRuleGroups = isVerbose ? [] : sortedRuleGroups.slice(5);
3382
+ if (!isVerbose) {
3383
+ printDefaultDiagnostics(diagnostics, rootDirectory);
3384
+ return;
3385
+ }
3386
+ const visibleRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3259
3387
  const ruleNameColumnWidth = computeRuleNameColumnWidth(visibleRuleGroups.map(([ruleKey]) => ruleKey));
3260
3388
  visibleRuleGroups.forEach(([ruleKey, ruleDiagnostics]) => {
3261
- if (isVerbose) {
3262
- printVerboseRuleGroup(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3263
- return;
3264
- }
3265
- printDetailedRuleGroup(ruleKey, ruleDiagnostics, rootDirectory, ruleNameColumnWidth);
3389
+ printVerboseRuleGroup(ruleKey, ruleDiagnostics, ruleNameColumnWidth);
3266
3390
  });
3267
- if (hiddenRuleGroups.length > 0) printHiddenDiagnosticsSummary(hiddenRuleGroups);
3268
3391
  };
3269
3392
  const printHiddenDiagnosticsSummary = (hiddenRuleGroups) => {
3270
- const renderedParts = buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => colorizeBySeverity(part.text, part.severity));
3393
+ const renderedParts = buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => {
3394
+ const [icon, ...labelParts] = part.text.split(" ");
3395
+ return `${colorizeBySeverity(icon, part.severity)} ${highlighter.dim(labelParts.join(" "))}`;
3396
+ });
3271
3397
  logger.log(` ${renderedParts.join(" ")}`);
3272
3398
  grayLine(" Run `npx react-doctor@latest . --verbose` to get all details");
3273
3399
  logger.break();
@@ -3287,6 +3413,7 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
3287
3413
  firstDiagnostic.message
3288
3414
  ];
3289
3415
  if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
3416
+ if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
3290
3417
  sections.push("", "Files:");
3291
3418
  const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3292
3419
  for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
@@ -3358,32 +3485,6 @@ const printNoScoreHeader = (noScoreMessage) => {
3358
3485
  logger.log(` ${highlighter.gray(noScoreMessage)}`);
3359
3486
  logger.break();
3360
3487
  };
3361
- const buildCategoryBar = (count, maximumCount, useErrorColor) => {
3362
- if (maximumCount === 0) return highlighter.dim("░".repeat(16));
3363
- const filledCount = Math.max(1, Math.round(count / maximumCount * 16));
3364
- const cappedFilledCount = Math.min(filledCount, 16);
3365
- const emptyCount = 16 - cappedFilledCount;
3366
- const filledSegment = "█".repeat(cappedFilledCount);
3367
- const emptySegment = "░".repeat(emptyCount);
3368
- return `${useErrorColor ? highlighter.error(filledSegment) : highlighter.warn(filledSegment)}${highlighter.dim(emptySegment)}`;
3369
- };
3370
- const padCategoryLabel = (categoryLabel) => {
3371
- if (categoryLabel.length >= 18) return categoryLabel;
3372
- return categoryLabel + " ".repeat(18 - categoryLabel.length);
3373
- };
3374
- const printCategoryBreakdown = (entries) => {
3375
- if (entries.length === 0) return;
3376
- const maximumCount = Math.max(...entries.map((entry) => entry.totalCount));
3377
- logger.dim(" By category");
3378
- for (const entry of entries) {
3379
- const paddedLabel = padCategoryLabel(entry.category);
3380
- const categoryBar = buildCategoryBar(entry.totalCount, maximumCount, entry.errorCount > 0);
3381
- const totalCountDisplay = String(entry.totalCount);
3382
- const errorBadge = entry.errorCount > 0 ? ` ${highlighter.error(`${entry.errorCount}×`)}` : "";
3383
- logger.log(` ${paddedLabel}${categoryBar} ${totalCountDisplay}${errorBadge}`);
3384
- }
3385
- logger.break();
3386
- };
3387
3488
  const buildShareUrl = (diagnostics, scoreResult, projectName) => {
3388
3489
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
3389
3490
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
@@ -3409,7 +3510,6 @@ const printCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMillis
3409
3510
  logger.log(` ${issueCountColor(issueCountText)} ${highlighter.dim(`${fileCountText} ${elapsedTimeText}`)}`);
3410
3511
  };
3411
3512
  const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
3412
- printCategoryBreakdown(buildCategoryBreakdown(diagnostics));
3413
3513
  if (scoreResult) printScoreHeader(scoreResult);
3414
3514
  else printNoScoreHeader(noScoreMessage);
3415
3515
  printCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds);
@@ -4028,7 +4128,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
4028
4128
  };
4029
4129
  //#endregion
4030
4130
  //#region src/cli.ts
4031
- const VERSION = "0.1.2";
4131
+ const VERSION = "0.1.4";
4032
4132
  const VALID_FAIL_ON_LEVELS = new Set([
4033
4133
  "error",
4034
4134
  "warning",
@@ -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.2"
6949
+ version: "0.1.4"
6950
6950
  },
6951
6951
  rules: eslintShapedRules,
6952
6952
  configs: {
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ interface Diagnostic {
18
18
  severity: "error" | "warning";
19
19
  message: string;
20
20
  help: string;
21
+ url?: string;
21
22
  line: number;
22
23
  column: number;
23
24
  category: string;
@@ -76,6 +77,23 @@ interface ReactDoctorConfig {
76
77
  customRulesOnly?: boolean;
77
78
  share?: boolean;
78
79
  textComponents?: string[];
80
+ /**
81
+ * Names of components that safely route string-only children through a
82
+ * React Native `<Text>` internally (e.g. `heroui-native`'s `Button`,
83
+ * which stringifies its children and renders them through a
84
+ * `ButtonLabel` → `Text`). For listed components, `rn-no-raw-text`
85
+ * is suppressed ONLY when the wrapper's children are entirely
86
+ * stringifiable (no nested JSX elements). A wrapper with mixed
87
+ * children — e.g. `<Button>Save<Icon /></Button>` — still reports,
88
+ * because the wrapper can't safely route raw text alongside a
89
+ * sibling JSX element.
90
+ *
91
+ * Use this instead of `textComponents` when the component is not
92
+ * itself a text element but is known to wrap its string children
93
+ * in one. `textComponents` is the broader escape hatch and
94
+ * suppresses regardless of sibling content.
95
+ */
96
+ rawTextWrapperComponents?: string[];
79
97
  /**
80
98
  * Whether to respect inline `// eslint-disable*`, `// oxlint-disable*`,
81
99
  * and `// react-doctor-disable*` comments in source files. Default: `true`.
package/dist/index.js CHANGED
@@ -1349,6 +1349,8 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
1349
1349
  //#endregion
1350
1350
  //#region src/utils/filter-diagnostics.ts
1351
1351
  const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
1352
+ const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
1353
+ const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1352
1354
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
1353
1355
  const normalizedFile = filePath.replace(/\\/g, "/");
1354
1356
  if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
@@ -1374,21 +1376,93 @@ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
1374
1376
  }
1375
1377
  return false;
1376
1378
  };
1379
+ const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
1380
+ for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
1381
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
1382
+ if (!match) continue;
1383
+ const fullName = match[1];
1384
+ return {
1385
+ fullName,
1386
+ leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
1387
+ lineIndex
1388
+ };
1389
+ }
1390
+ return null;
1391
+ };
1392
+ const resolveJsxRange = (lines, opener) => {
1393
+ const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
1394
+ let closerLineIndex = -1;
1395
+ let closerColumn = -1;
1396
+ for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
1397
+ const match = closingPattern.exec(lines[lineIndex]);
1398
+ if (!match) continue;
1399
+ closerLineIndex = lineIndex;
1400
+ closerColumn = match.index;
1401
+ break;
1402
+ }
1403
+ if (closerLineIndex < 0) return null;
1404
+ const openerLine = lines[opener.lineIndex];
1405
+ const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
1406
+ if (tagStartIndex < 0) return null;
1407
+ const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
1408
+ let bodyText;
1409
+ if (opener.lineIndex === closerLineIndex) {
1410
+ if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
1411
+ bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
1412
+ } else {
1413
+ const segments = [];
1414
+ if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
1415
+ for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
1416
+ segments.push(lines[closerLineIndex].slice(0, closerColumn));
1417
+ bodyText = segments.join("\n");
1418
+ }
1419
+ return {
1420
+ closerLineIndex,
1421
+ closerColumn,
1422
+ bodyText
1423
+ };
1424
+ };
1425
+ const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
1426
+ const diagnosticLineIndex = diagnosticLine - 1;
1427
+ const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
1428
+ let upperBoundLineIndex = diagnosticLineIndex;
1429
+ while (upperBoundLineIndex >= 0) {
1430
+ const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
1431
+ if (!opener) return false;
1432
+ const range = resolveJsxRange(lines, opener);
1433
+ if (range === null) {
1434
+ upperBoundLineIndex = opener.lineIndex - 1;
1435
+ continue;
1436
+ }
1437
+ if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
1438
+ upperBoundLineIndex = opener.lineIndex - 1;
1439
+ continue;
1440
+ }
1441
+ if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
1442
+ return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
1443
+ }
1444
+ return false;
1445
+ };
1377
1446
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
1378
1447
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
1379
1448
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
1380
1449
  const compiledOverrides = compileIgnoreOverrides(config);
1381
1450
  const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
1382
1451
  const hasTextComponents = textComponentNames.size > 0;
1452
+ const rawTextWrapperComponentNames = new Set(Array.isArray(config.rawTextWrapperComponents) ? config.rawTextWrapperComponents.filter((name) => typeof name === "string") : []);
1453
+ const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
1383
1454
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
1384
1455
  return diagnostics.filter((diagnostic) => {
1385
1456
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1386
1457
  if (ignoredRules.has(ruleIdentifier)) return false;
1387
1458
  if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
1388
1459
  if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
1389
- if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
1460
+ if ((hasTextComponents || hasRawTextWrappers) && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
1390
1461
  const lines = getFileLines(diagnostic.filePath);
1391
- if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1462
+ if (lines) {
1463
+ if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1464
+ if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return false;
1465
+ }
1392
1466
  }
1393
1467
  return true;
1394
1468
  });
@@ -1850,22 +1924,22 @@ const TANSTACK_START_RULES = {
1850
1924
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
1851
1925
  };
1852
1926
  const REACT_COMPILER_RULES = {
1853
- "react-hooks-js/set-state-in-render": "warn",
1854
- "react-hooks-js/immutability": "warn",
1855
- "react-hooks-js/refs": "warn",
1856
- "react-hooks-js/purity": "warn",
1857
- "react-hooks-js/hooks": "warn",
1858
- "react-hooks-js/set-state-in-effect": "warn",
1859
- "react-hooks-js/globals": "warn",
1860
- "react-hooks-js/error-boundaries": "warn",
1861
- "react-hooks-js/preserve-manual-memoization": "warn",
1862
- "react-hooks-js/unsupported-syntax": "warn",
1863
- "react-hooks-js/component-hook-factories": "warn",
1864
- "react-hooks-js/static-components": "warn",
1865
- "react-hooks-js/use-memo": "warn",
1866
- "react-hooks-js/void-use-memo": "warn",
1867
- "react-hooks-js/incompatible-library": "warn",
1868
- "react-hooks-js/todo": "warn"
1927
+ "react-hooks-js/set-state-in-render": "error",
1928
+ "react-hooks-js/immutability": "error",
1929
+ "react-hooks-js/refs": "error",
1930
+ "react-hooks-js/purity": "error",
1931
+ "react-hooks-js/hooks": "error",
1932
+ "react-hooks-js/set-state-in-effect": "error",
1933
+ "react-hooks-js/globals": "error",
1934
+ "react-hooks-js/error-boundaries": "error",
1935
+ "react-hooks-js/preserve-manual-memoization": "error",
1936
+ "react-hooks-js/unsupported-syntax": "error",
1937
+ "react-hooks-js/component-hook-factories": "error",
1938
+ "react-hooks-js/static-components": "error",
1939
+ "react-hooks-js/use-memo": "error",
1940
+ "react-hooks-js/void-use-memo": "error",
1941
+ "react-hooks-js/incompatible-library": "error",
1942
+ "react-hooks-js/todo": "error"
1869
1943
  };
1870
1944
  const readPluginRuleNames = (pluginSpecifier) => {
1871
1945
  try {
@@ -2747,6 +2821,7 @@ const parseOxlintOutput = (stdout) => {
2747
2821
  severity: diagnostic.severity,
2748
2822
  message: cleaned.message,
2749
2823
  help: cleaned.help,
2824
+ url: diagnostic.url,
2750
2825
  line: primaryLabel?.span.line ?? 0,
2751
2826
  column: primaryLabel?.span.column ?? 0,
2752
2827
  category: resolveDiagnosticCategory(plugin, rule)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",