@blumintinc/eslint-plugin-blumint 1.20.172 → 1.20.173

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.
Files changed (39) hide show
  1. package/lib/index.js +1 -1
  2. package/lib/rules/enforce-assert-safe-object-key.js +333 -10
  3. package/lib/rules/enforce-empty-object-check.js +266 -48
  4. package/lib/rules/enforce-firestore-rules-get-access.js +159 -7
  5. package/lib/rules/enforce-memoize-async.js +30 -0
  6. package/lib/rules/enforce-memoize-getters.js +30 -0
  7. package/lib/rules/enforce-microdiff.js +103 -2
  8. package/lib/rules/enforce-mui-rounded-icons.js +121 -3
  9. package/lib/rules/enforce-querykey-ts.js +256 -5
  10. package/lib/rules/global-const-style.js +145 -20
  11. package/lib/rules/jsdoc-above-field.d.ts +2 -1
  12. package/lib/rules/jsdoc-above-field.js +180 -5
  13. package/lib/rules/memo-compare-deeply-complex-props.js +124 -23
  14. package/lib/rules/no-array-length-in-deps.js +102 -9
  15. package/lib/rules/no-entire-object-hook-deps.js +24 -2
  16. package/lib/rules/no-explicit-return-type.js +367 -8
  17. package/lib/rules/no-firestore-jest-mock.js +74 -1
  18. package/lib/rules/no-redundant-annotation-assertion.js +164 -1
  19. package/lib/rules/no-redundant-param-types.js +374 -1
  20. package/lib/rules/no-unnecessary-destructuring.js +26 -2
  21. package/lib/rules/no-useless-fragment.d.ts +2 -1
  22. package/lib/rules/no-useless-fragment.js +81 -1
  23. package/lib/rules/no-usememo-for-pass-by-value.js +45 -3
  24. package/lib/rules/prefer-clone-deep.js +286 -28
  25. package/lib/rules/prefer-map-over-conditional-dispatch.d.ts +35 -0
  26. package/lib/rules/prefer-map-over-conditional-dispatch.js +293 -26
  27. package/lib/rules/prefer-nullish-coalescing-boolean-props.js +291 -81
  28. package/lib/rules/prefer-use-deep-compare-memo.js +164 -63
  29. package/lib/rules/require-hooks-default-params.d.ts +2 -1
  30. package/lib/rules/require-hooks-default-params.js +258 -2
  31. package/lib/rules/require-image-optimized.js +243 -19
  32. package/lib/rules/require-memo.js +113 -18
  33. package/lib/rules/require-memoize-jsx-returners.js +30 -0
  34. package/lib/rules/use-latest-callback.js +164 -3
  35. package/lib/utils/harvestRuleTesterCases.js +2 -2
  36. package/lib/utils/tempFixtureDir.d.ts +36 -0
  37. package/lib/utils/tempFixtureDir.js +95 -0
  38. package/package.json +1 -1
  39. package/release-manifest.json +240 -0
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.172',
226
+ version: '1.20.173',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -652,8 +652,14 @@ function foldKeyDomains(domains) {
652
652
  * the key and its bracket splits that set in half. Claiming the access instead
653
653
  * leaves a competing rewrite of it either wholly discarded (and re-made against
654
654
  * the fixed text on a later pass) or wholly applied, both of which parse.
655
+ *
656
+ * `keyEnd` is where the written key ends once the parentheses around it are
657
+ * counted in, which is what the bracket closing a computed property is looked
658
+ * for after: in `{ [(id)]: value }` the token following the key itself is the
659
+ * `)`, and reading that as "no bracket here" would shrink the span back onto the
660
+ * key and leave the property's own brackets outside it.
655
661
  */
656
- function accessSpan(sourceCode, written) {
662
+ function accessSpan(sourceCode, written, keyEnd = written) {
657
663
  const { parent } = written;
658
664
  if (!parent) {
659
665
  return null;
@@ -669,7 +675,7 @@ function accessSpan(sourceCode, written) {
669
675
  // A computed property's range runs past its key to the end of its value,
670
676
  // which the wrap has no business claiming; the bracket that closes the key
671
677
  // is where this access ends.
672
- const closing = sourceCode.getTokenAfter(written);
678
+ const closing = sourceCode.getTokenAfter(keyEnd);
673
679
  return closing?.value === ']'
674
680
  ? [parent.range[0], closing.range[1]]
675
681
  : [written.range[0], written.range[1]];
@@ -681,6 +687,119 @@ function accessSpan(sourceCode, written) {
681
687
  }
682
688
  return null;
683
689
  }
690
+ /**
691
+ * The grouping parentheses written around a key, innermost first.
692
+ *
693
+ * A pair is claimed only when both halves face the key at once, which is what
694
+ * keeps a parenthesis belonging to some enclosing construct out of the run: the
695
+ * `(` of `if (key in obj)` and of `read(key in obj)` is answered by the `in`
696
+ * keyword rather than by a `)`, so the walk stops before it.
697
+ *
698
+ * Anything other than whitespace between a parenthesis and what it encloses ends
699
+ * the walk too. That text is a comment, and dropping the parenthesis would move
700
+ * the comment out of the group the author wrote it inside — where a line comment
701
+ * decides what the lines after it may hold.
702
+ */
703
+ function groupingParensAround(sourceCode, node) {
704
+ const pairs = [];
705
+ const { text } = sourceCode;
706
+ let innerStart = node.range[0];
707
+ let innerEnd = node.range[1];
708
+ let open = sourceCode.getTokenBefore(node);
709
+ let close = sourceCode.getTokenAfter(node);
710
+ while (open?.value === '(' && close?.value === ')') {
711
+ if (text.slice(open.range[1], innerStart).trim() !== '' ||
712
+ text.slice(innerEnd, close.range[0]).trim() !== '') {
713
+ break;
714
+ }
715
+ pairs.push({ open, close });
716
+ innerStart = open.range[0];
717
+ innerEnd = close.range[1];
718
+ open = sourceCode.getTokenBefore(open);
719
+ close = sourceCode.getTokenAfter(close);
720
+ }
721
+ return pairs;
722
+ }
723
+ /**
724
+ * Whether the parentheses the source wrote around a key can be dropped.
725
+ *
726
+ * The emission is a call, which is the tightest-binding expression there is, and
727
+ * every position that yields an access span takes one bare: between the brackets
728
+ * of a lookup or of a computed property, and as the left operand of `in`. So the
729
+ * pair is redundant the moment the key is wrapped, and prettier deletes it
730
+ * again — which is the whole defect (#2108).
731
+ *
732
+ * The one thing the parenthesis is doing is separating the key from the token
733
+ * before it. Dropping it where that token ends in an identifier character would
734
+ * fuse the two into one word, so the pair stays there.
735
+ */
736
+ function dropsGroupingParens(text, span, outermost) {
737
+ const prefix = text.slice(span[0], outermost.open.range[0]);
738
+ const preceding = prefix === '' ? text.slice(Math.max(0, span[0] - 1), span[0]) : prefix;
739
+ return !/[\p{ID_Continue}$]$/u.test(preceding);
740
+ }
741
+ /**
742
+ * Prettier's default print width, which is what this repo and agora both format
743
+ * with. Wrapping a key widens the line it sits on, and a line the formatter
744
+ * would break is a line the formatter DOES break: agora runs prettier and
745
+ * `eslint --fix` over the same tree, so an emission past this width churns the
746
+ * file on every pass (#2108).
747
+ */
748
+ const PRINT_WIDTH = 80;
749
+ /** Prettier's default `tabWidth`, the step it indents a broken body by. */
750
+ const INDENT_STEP = ' ';
751
+ /**
752
+ * A `;` closing the rewritten line, optionally followed by same-line
753
+ * comments. Prettier leaves such comments where they are — after the `;` on
754
+ * the closing line — when it opens a lookup, so the emission may carry them
755
+ * (measured against prettier 2.8.8). A comment after a `,` is re-hosted
756
+ * BEFORE the comma instead, a relocation the emitter declines to model.
757
+ */
758
+ const SEMICOLON_WITH_TRAILING_COMMENTS = /^;(?:\s*\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:\s*\/\/.*)?$/;
759
+ /**
760
+ * Bodies prettier keeps on the arrow's own line instead of breaking after `=>`.
761
+ * Each of these opens a block of its own that absorbs the overflow, so a break
762
+ * inserted ahead of one is a layout prettier does not print.
763
+ */
764
+ const HUGGED_ARROW_BODY_TYPES = new Set([
765
+ utils_1.AST_NODE_TYPES.ArrayExpression,
766
+ utils_1.AST_NODE_TYPES.ObjectExpression,
767
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
768
+ utils_1.AST_NODE_TYPES.TemplateLiteral,
769
+ utils_1.AST_NODE_TYPES.TaggedTemplateExpression,
770
+ utils_1.AST_NODE_TYPES.SequenceExpression,
771
+ utils_1.AST_NODE_TYPES.JSXElement,
772
+ utils_1.AST_NODE_TYPES.JSXFragment,
773
+ ]);
774
+ /**
775
+ * Positions where a concise arrow body is laid out by the construct around it
776
+ * rather than by the arrow's own group: an argument list hugs its last argument
777
+ * and breaks the list first, and an arrow chain is printed as one unit. The
778
+ * accepted positions are the ones measured against prettier 2.8.8 — a
779
+ * declarator's initializer, a property or class-property value, an assignment's
780
+ * right-hand side, and a returned or thrown value.
781
+ */
782
+ const BREAKABLE_ARROW_OWNER_TYPES = new Set([
783
+ utils_1.AST_NODE_TYPES.VariableDeclarator,
784
+ utils_1.AST_NODE_TYPES.Property,
785
+ utils_1.AST_NODE_TYPES.PropertyDefinition,
786
+ utils_1.AST_NODE_TYPES.AssignmentExpression,
787
+ utils_1.AST_NODE_TYPES.ReturnStatement,
788
+ ]);
789
+ /**
790
+ * The arrow function whose concise body holds the key, or null when the key
791
+ * sits somewhere the arrow's `=>` does not govern — in its parameters, in a
792
+ * block body, or in no arrow at all.
793
+ */
794
+ function enclosingConciseArrow(node) {
795
+ let child = node;
796
+ let parent = node.parent;
797
+ while (parent && parent.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
798
+ child = parent;
799
+ parent = parent.parent;
800
+ }
801
+ return parent && parent.body === child ? parent : null;
802
+ }
684
803
  /**
685
804
  * An enum is a compiler-checked finite set. Members with literal initializers
686
805
  * enumerate their runtime key strings (which is what lets the forbidden-name
@@ -899,17 +1018,213 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
899
1018
  * span (discarded whole, and re-made against the fixed text on the next
900
1019
  * pass) or wholly outside it. Both parse. The re-emitted head and tail are
901
1020
  * copied verbatim from the source, so the text this fix produces is
902
- * character-for-character what replacing the key alone produced.
1021
+ * character-for-character what replacing the key alone produced — bar the
1022
+ * grouping parentheses `dropsGroupingParens` settles.
903
1023
  */
904
- const wrapKey = (fixer, node, argText) => {
905
- const replacement = `assertSafe(${argText})`;
906
- const span = accessSpan(context.sourceCode, node);
1024
+ const planWrap = (node, argText) => {
1025
+ const { sourceCode } = context;
1026
+ const replacement = `${ASSERT_SAFE_NAME}(${argText})`;
1027
+ const parens = groupingParensAround(sourceCode, node);
1028
+ const outermost = parens[parens.length - 1];
1029
+ const span = accessSpan(sourceCode, node, outermost?.close ?? node);
907
1030
  if (!span) {
908
- return fixer.replaceText(node, replacement);
1031
+ return { range: [node.range[0], node.range[1]], text: replacement };
909
1032
  }
910
1033
  const [start, end] = span;
911
- const { text } = context.sourceCode;
912
- return fixer.replaceTextRange([start, end], `${text.slice(start, node.range[0])}${replacement}${text.slice(node.range[1], end)}`);
1034
+ const { text } = sourceCode;
1035
+ const edits = [
1036
+ { range: [node.range[0], node.range[1]], text: replacement },
1037
+ ];
1038
+ if (outermost && dropsGroupingParens(text, span, outermost)) {
1039
+ for (const pair of parens) {
1040
+ edits.push({ range: [pair.open.range[0], pair.open.range[1]], text: '' }, { range: [pair.close.range[0], pair.close.range[1]], text: '' });
1041
+ }
1042
+ edits.sort((left, right) => left.range[0] - right.range[0]);
1043
+ }
1044
+ let emitted = '';
1045
+ let cursor = start;
1046
+ for (const edit of edits) {
1047
+ emitted += `${text.slice(cursor, edit.range[0])}${edit.text}`;
1048
+ cursor = edit.range[1];
1049
+ }
1050
+ return {
1051
+ range: [start, end],
1052
+ text: `${emitted}${text.slice(cursor, end)}`,
1053
+ };
1054
+ };
1055
+ /**
1056
+ * The line break prettier prints after `=>` once the wrap has widened the
1057
+ * arrow body past the print width, or null where this emitter cannot say
1058
+ * what prettier would print.
1059
+ *
1060
+ * Prettier lays a concise arrow body out as `group(indent([line, body]))`,
1061
+ * so the break after `=>` is the FIRST one it reaches for: a body that fits
1062
+ * on the indented line is laid out exactly this way, and one that does not
1063
+ * is broken further inside — which `planMemberBreak` prints for the one
1064
+ * shape it has measured, and which this emitter otherwise declines rather
1065
+ * than guesses at. Every other decline below is deliberate for the same
1066
+ * reason — the one-line emission it falls back to is what shipped before
1067
+ * the width was measured at all, so a decline costs formatting and never
1068
+ * meaning.
1069
+ */
1070
+ const planArrowBreak = (node, wrap) => {
1071
+ const { sourceCode } = context;
1072
+ const { line } = node.loc.start;
1073
+ const original = sourceCode.lines[line - 1] ?? '';
1074
+ const delta = wrap.text.length - (wrap.range[1] - wrap.range[0]);
1075
+ if (original.length + delta <= PRINT_WIDTH) {
1076
+ return null;
1077
+ }
1078
+ // A span that already crosses lines carries the author's own breaks, so
1079
+ // the single line measured above is not the whole of what moves.
1080
+ if (wrap.text.includes('\n') ||
1081
+ sourceCode.getLocFromIndex(wrap.range[0]).line !== line ||
1082
+ sourceCode.getLocFromIndex(wrap.range[1]).line !== line) {
1083
+ return null;
1084
+ }
1085
+ const arrow = enclosingConciseArrow(node);
1086
+ const body = arrow?.body;
1087
+ if (!arrow ||
1088
+ !body ||
1089
+ body.type === utils_1.AST_NODE_TYPES.BlockStatement ||
1090
+ HUGGED_ARROW_BODY_TYPES.has(body.type) ||
1091
+ !arrow.parent ||
1092
+ !BREAKABLE_ARROW_OWNER_TYPES.has(arrow.parent.type)) {
1093
+ return null;
1094
+ }
1095
+ const arrowToken = sourceCode.getTokenBefore(body);
1096
+ if (arrowToken?.value !== '=>' ||
1097
+ arrowToken.loc.end.line !== line ||
1098
+ body.loc.start.line !== line ||
1099
+ body.loc.end.line !== line ||
1100
+ sourceCode.text.slice(arrowToken.range[1], body.range[0]).trim() !== '') {
1101
+ return null;
1102
+ }
1103
+ // Prettier indents the body one step in from the line the arrow's own
1104
+ // group opens on, which is the line its parameter list starts.
1105
+ const anchor = sourceCode.lines[arrow.loc.start.line - 1] ?? '';
1106
+ const bodyIndent = `${/^[\t ]*/.exec(anchor)?.[0] ?? ''}${INDENT_STEP}`;
1107
+ const head = original.slice(0, arrowToken.loc.end.column).trimEnd();
1108
+ const tail = original.slice(body.loc.start.column);
1109
+ if (head.length > PRINT_WIDTH ||
1110
+ bodyIndent.length + tail.length + delta > PRINT_WIDTH) {
1111
+ return null;
1112
+ }
1113
+ return {
1114
+ range: [arrowToken.range[1], body.range[0]],
1115
+ text: `\n${bodyIndent}`,
1116
+ };
1117
+ };
1118
+ /**
1119
+ * The wrap re-emitted in the shape prettier prints a computed lookup in
1120
+ * once the wrap has widened the line past the print width and the break
1121
+ * after `=>` is already taken, or null where this emitter cannot say what
1122
+ * prettier would print.
1123
+ *
1124
+ * Prettier lays a computed lookup out as `object[` + indent(softline +
1125
+ * key) + softline + `]`, so a body that no longer fits on its own line is
1126
+ * opened at the bracket with the key one step in; the call is then
1127
+ * measured on that line and, where it does not fit there either, opened
1128
+ * at its own parenthesis with the argument one step further in and a
1129
+ * trailing comma. Both steps are measured against prettier 2.8.8 at
1130
+ * agora's options, whose `trailingComma: "all"` is what puts the comma
1131
+ * after the argument (#2134).
1132
+ *
1133
+ * The emission is limited to the shape measured: a single-line lookup
1134
+ * that starts its line as the whole body of an arrow whose `=>` closes
1135
+ * an earlier line, followed by the `;` or `,` that ends the statement or
1136
+ * the element — a `;` optionally carrying same-line comments. Anything
1137
+ * else — a key the author
1138
+ * parenthesized or commented, a lookup broken across lines, a key so long
1139
+ * that even its own line overflows — falls back to the one-line emission,
1140
+ * which is what shipped before the width was measured at all.
1141
+ */
1142
+ const planMemberBreak = (node, wrap, argText) => {
1143
+ const { sourceCode } = context;
1144
+ const { text } = sourceCode;
1145
+ const { line } = node.loc.start;
1146
+ const original = sourceCode.lines[line - 1] ?? '';
1147
+ const delta = wrap.text.length - (wrap.range[1] - wrap.range[0]);
1148
+ if (original.length + delta <= PRINT_WIDTH) {
1149
+ return null;
1150
+ }
1151
+ const member = node.parent;
1152
+ if (!member ||
1153
+ member.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
1154
+ !member.computed ||
1155
+ member.property !== node ||
1156
+ wrap.range[0] !== member.range[0] ||
1157
+ wrap.range[1] !== member.range[1] ||
1158
+ wrap.text.includes('\n') ||
1159
+ member.loc.start.line !== line ||
1160
+ member.loc.end.line !== line ||
1161
+ groupingParensAround(sourceCode, node).length > 0 ||
1162
+ sourceCode.getCommentsInside(member).length > 0) {
1163
+ return null;
1164
+ }
1165
+ const arrow = enclosingConciseArrow(node);
1166
+ const body = arrow?.body;
1167
+ // An optional chain wraps the lookup in a ChainExpression of the same
1168
+ // extent, so the body is the lookup either way.
1169
+ const bodyIsLookup = body === member ||
1170
+ (body?.type === utils_1.AST_NODE_TYPES.ChainExpression &&
1171
+ body.expression === member);
1172
+ const arrowToken = sourceCode.getTokenBefore(member);
1173
+ if (!arrow ||
1174
+ !bodyIsLookup ||
1175
+ arrowToken?.value !== '=>' ||
1176
+ arrowToken.loc.end.line >= line) {
1177
+ return null;
1178
+ }
1179
+ // A comment on a line of its own between `=>` and the body stays on
1180
+ // that line and leaves the body's layout untouched, so it is carried;
1181
+ // one that shares the arrow's line is text prettier relocates into the
1182
+ // parameter list, which this emitter does not model.
1183
+ const gapComments = sourceCode.getTokensBetween(arrowToken, member, {
1184
+ includeComments: true,
1185
+ });
1186
+ if (gapComments.some((comment) => comment.loc.start.line <= arrowToken.loc.end.line)) {
1187
+ return null;
1188
+ }
1189
+ const openBracket = sourceCode.getTokenBefore(node);
1190
+ const closeBracket = sourceCode.getLastToken(member);
1191
+ if (openBracket?.value !== '[' ||
1192
+ closeBracket?.value !== ']' ||
1193
+ text.slice(openBracket.range[1], node.range[0]) !== '' ||
1194
+ text.slice(node.range[1], closeBracket.range[0]) !== '') {
1195
+ return null;
1196
+ }
1197
+ const indent = original.slice(0, member.loc.start.column);
1198
+ const tail = original.slice(member.loc.end.column);
1199
+ const isBreakableTail = tail === ';' ||
1200
+ tail === ',' ||
1201
+ SEMICOLON_WITH_TRAILING_COMMENTS.test(tail);
1202
+ if (indent.trim() !== '' || !isBreakableTail) {
1203
+ return null;
1204
+ }
1205
+ // Everything through the bracket — `m[`, `s.map[`, `m?.[`, `m[a][` —
1206
+ // stays on the body's line; prettier opens only the outermost lookup.
1207
+ const head = text.slice(member.range[0], openBracket.range[1]);
1208
+ if (indent.length + head.length > PRINT_WIDTH) {
1209
+ return null;
1210
+ }
1211
+ const keyIndent = `${indent}${INDENT_STEP}`;
1212
+ const argIndent = `${keyIndent}${INDENT_STEP}`;
1213
+ const call = `${ASSERT_SAFE_NAME}(${argText})`;
1214
+ let key;
1215
+ if (keyIndent.length + call.length <= PRINT_WIDTH) {
1216
+ key = `${keyIndent}${call}`;
1217
+ }
1218
+ else if (argIndent.length + argText.length + 1 <= PRINT_WIDTH) {
1219
+ key = `${keyIndent}${ASSERT_SAFE_NAME}(\n${argIndent}${argText},\n${keyIndent})`;
1220
+ }
1221
+ else {
1222
+ return null;
1223
+ }
1224
+ return {
1225
+ range: [member.range[0], member.range[1]],
1226
+ text: `${head}\n${key}\n${indent}]`,
1227
+ };
913
1228
  };
914
1229
  /**
915
1230
  * Helper function to create fixes for a node
@@ -921,7 +1236,15 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
921
1236
  fixes.push(addAssertSafeImport(fixer));
922
1237
  importClaimed = true;
923
1238
  }
924
- fixes.push(wrapKey(fixer, node, argText));
1239
+ const wrap = planWrap(node, argText);
1240
+ const arrowBreak = planArrowBreak(node, wrap);
1241
+ if (arrowBreak) {
1242
+ fixes.push(fixer.replaceTextRange(arrowBreak.range, arrowBreak.text));
1243
+ }
1244
+ const access = arrowBreak
1245
+ ? wrap
1246
+ : planMemberBreak(node, wrap, argText) ?? wrap;
1247
+ fixes.push(fixer.replaceTextRange(access.range, access.text));
925
1248
  return fixes;
926
1249
  };
927
1250
  // The report is emitted even when suppressed: ESLint discards it, and