react-doctor 0.1.2 → 0.1.3

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
@@ -912,6 +912,8 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
912
912
  //#endregion
913
913
  //#region src/utils/filter-diagnostics.ts
914
914
  const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
915
+ const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
916
+ const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
915
917
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
916
918
  const normalizedFile = filePath.replace(/\\/g, "/");
917
919
  if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
@@ -937,21 +939,93 @@ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
937
939
  }
938
940
  return false;
939
941
  };
942
+ const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
943
+ for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
944
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
945
+ if (!match) continue;
946
+ const fullName = match[1];
947
+ return {
948
+ fullName,
949
+ leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
950
+ lineIndex
951
+ };
952
+ }
953
+ return null;
954
+ };
955
+ const resolveJsxRange = (lines, opener) => {
956
+ const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
957
+ let closerLineIndex = -1;
958
+ let closerColumn = -1;
959
+ for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
960
+ const match = closingPattern.exec(lines[lineIndex]);
961
+ if (!match) continue;
962
+ closerLineIndex = lineIndex;
963
+ closerColumn = match.index;
964
+ break;
965
+ }
966
+ if (closerLineIndex < 0) return null;
967
+ const openerLine = lines[opener.lineIndex];
968
+ const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
969
+ if (tagStartIndex < 0) return null;
970
+ const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
971
+ let bodyText;
972
+ if (opener.lineIndex === closerLineIndex) {
973
+ if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
974
+ bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
975
+ } else {
976
+ const segments = [];
977
+ if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
978
+ for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
979
+ segments.push(lines[closerLineIndex].slice(0, closerColumn));
980
+ bodyText = segments.join("\n");
981
+ }
982
+ return {
983
+ closerLineIndex,
984
+ closerColumn,
985
+ bodyText
986
+ };
987
+ };
988
+ const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
989
+ const diagnosticLineIndex = diagnosticLine - 1;
990
+ const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
991
+ let upperBoundLineIndex = diagnosticLineIndex;
992
+ while (upperBoundLineIndex >= 0) {
993
+ const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
994
+ if (!opener) return false;
995
+ const range = resolveJsxRange(lines, opener);
996
+ if (range === null) {
997
+ upperBoundLineIndex = opener.lineIndex - 1;
998
+ continue;
999
+ }
1000
+ if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
1001
+ upperBoundLineIndex = opener.lineIndex - 1;
1002
+ continue;
1003
+ }
1004
+ if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
1005
+ return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
1006
+ }
1007
+ return false;
1008
+ };
940
1009
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
941
1010
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
942
1011
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
943
1012
  const compiledOverrides = compileIgnoreOverrides(config);
944
1013
  const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
945
1014
  const hasTextComponents = textComponentNames.size > 0;
1015
+ const rawTextWrapperComponentNames = new Set(Array.isArray(config.rawTextWrapperComponents) ? config.rawTextWrapperComponents.filter((name) => typeof name === "string") : []);
1016
+ const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
946
1017
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
947
1018
  return diagnostics.filter((diagnostic) => {
948
1019
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
949
1020
  if (ignoredRules.has(ruleIdentifier)) return false;
950
1021
  if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
951
1022
  if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
952
- if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
1023
+ if ((hasTextComponents || hasRawTextWrappers) && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
953
1024
  const lines = getFileLines(diagnostic.filePath);
954
- if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1025
+ if (lines) {
1026
+ if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1027
+ if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return false;
1028
+ }
955
1029
  }
956
1030
  return true;
957
1031
  });
@@ -2183,22 +2257,22 @@ const TANSTACK_START_RULES = {
2183
2257
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
2184
2258
  };
2185
2259
  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"
2260
+ "react-hooks-js/set-state-in-render": "error",
2261
+ "react-hooks-js/immutability": "error",
2262
+ "react-hooks-js/refs": "error",
2263
+ "react-hooks-js/purity": "error",
2264
+ "react-hooks-js/hooks": "error",
2265
+ "react-hooks-js/set-state-in-effect": "error",
2266
+ "react-hooks-js/globals": "error",
2267
+ "react-hooks-js/error-boundaries": "error",
2268
+ "react-hooks-js/preserve-manual-memoization": "error",
2269
+ "react-hooks-js/unsupported-syntax": "error",
2270
+ "react-hooks-js/component-hook-factories": "error",
2271
+ "react-hooks-js/static-components": "error",
2272
+ "react-hooks-js/use-memo": "error",
2273
+ "react-hooks-js/void-use-memo": "error",
2274
+ "react-hooks-js/incompatible-library": "error",
2275
+ "react-hooks-js/todo": "error"
2202
2276
  };
2203
2277
  const readPluginRuleNames = (pluginSpecifier) => {
2204
2278
  try {
@@ -4028,7 +4102,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
4028
4102
  };
4029
4103
  //#endregion
4030
4104
  //#region src/cli.ts
4031
- const VERSION = "0.1.2";
4105
+ const VERSION = "0.1.3";
4032
4106
  const VALID_FAIL_ON_LEVELS = new Set([
4033
4107
  "error",
4034
4108
  "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.3"
6950
6950
  },
6951
6951
  rules: eslintShapedRules,
6952
6952
  configs: {
package/dist/index.d.ts CHANGED
@@ -76,6 +76,23 @@ interface ReactDoctorConfig {
76
76
  customRulesOnly?: boolean;
77
77
  share?: boolean;
78
78
  textComponents?: string[];
79
+ /**
80
+ * Names of components that safely route string-only children through a
81
+ * React Native `<Text>` internally (e.g. `heroui-native`'s `Button`,
82
+ * which stringifies its children and renders them through a
83
+ * `ButtonLabel` → `Text`). For listed components, `rn-no-raw-text`
84
+ * is suppressed ONLY when the wrapper's children are entirely
85
+ * stringifiable (no nested JSX elements). A wrapper with mixed
86
+ * children — e.g. `<Button>Save<Icon /></Button>` — still reports,
87
+ * because the wrapper can't safely route raw text alongside a
88
+ * sibling JSX element.
89
+ *
90
+ * Use this instead of `textComponents` when the component is not
91
+ * itself a text element but is known to wrap its string children
92
+ * in one. `textComponents` is the broader escape hatch and
93
+ * suppresses regardless of sibling content.
94
+ */
95
+ rawTextWrapperComponents?: string[];
79
96
  /**
80
97
  * Whether to respect inline `// eslint-disable*`, `// oxlint-disable*`,
81
98
  * 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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",