@blumintinc/eslint-plugin-blumint 1.20.82 → 1.20.83

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/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.82',
226
+ version: '1.20.83',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -38,15 +38,27 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
38
38
  }
39
39
  // Get the expected type name from the parent folder
40
40
  const folderName = path_1.default.basename(path_1.default.dirname(filename));
41
- let hasExpectedType = false;
42
- let typeHasIdentifiable = false;
41
+ // The folder-matching alias only satisfies the rule if it is reachable by
42
+ // consumers. An alias can be exported inline (`export type X = ...`) or
43
+ // separately (`export { X }` / `export type { X }`), and the separate form
44
+ // can appear either before or after the declaration, so exportedness can't
45
+ // be decided inside the TSTypeAliasDeclaration visitor alone — it's
46
+ // resolved once the whole module has been scanned, in Program:exit.
47
+ let matchingAliasNode = null;
48
+ let matchingAliasInlineExported = false;
49
+ let matchingAliasHasIdentifiable = false;
50
+ const locallyExportedNames = new Set();
43
51
  return {
44
52
  Program() {
45
53
  // Reset flags for each file
46
- hasExpectedType = false;
47
- typeHasIdentifiable = false;
54
+ matchingAliasNode = null;
55
+ matchingAliasInlineExported = false;
56
+ matchingAliasHasIdentifiable = false;
57
+ locallyExportedNames.clear();
48
58
  },
49
59
  'Program:exit'(node) {
60
+ const hasExpectedType = matchingAliasNode !== null &&
61
+ (matchingAliasInlineExported || locallyExportedNames.has(folderName));
50
62
  if (!hasExpectedType) {
51
63
  context.report({
52
64
  node,
@@ -57,7 +69,7 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
57
69
  },
58
70
  });
59
71
  }
60
- else if (!typeHasIdentifiable) {
72
+ else if (!matchingAliasHasIdentifiable) {
61
73
  context.report({
62
74
  node,
63
75
  messageId: 'notExtendingIdentifiable',
@@ -67,9 +79,22 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
67
79
  });
68
80
  }
69
81
  },
82
+ ExportNamedDeclaration(node) {
83
+ // A re-export (`export { X } from './elsewhere'`) exports a binding
84
+ // from another module, not the local alias declared in this file, so
85
+ // it must never satisfy the gate.
86
+ if (node.source != null) {
87
+ return;
88
+ }
89
+ for (const specifier of node.specifiers) {
90
+ locallyExportedNames.add(specifier.local.name);
91
+ }
92
+ },
70
93
  TSTypeAliasDeclaration(node) {
71
94
  if (node.id.name === folderName) {
72
- hasExpectedType = true;
95
+ matchingAliasNode = node;
96
+ matchingAliasInlineExported =
97
+ node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
73
98
  const findTypeAliasAnnotation = (typeName) => {
74
99
  let scope = context.getScope();
75
100
  while (scope) {
@@ -209,7 +234,7 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
209
234
  }
210
235
  return false;
211
236
  };
212
- typeHasIdentifiable = checkType(node.typeAnnotation);
237
+ matchingAliasHasIdentifiable = checkType(node.typeAnnotation);
213
238
  }
214
239
  },
215
240
  };
@@ -95,6 +95,79 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
95
95
  return {};
96
96
  }
97
97
  }
98
+ const MATH_FOLDABLE_METHODS = new Set(['max', 'min']);
99
+ /**
100
+ * Resolves a `Math.max`/`Math.min` call over numeric literals to the number
101
+ * it always produces. Calls whose value depends on runtime state (spread
102
+ * arguments, identifiers, computed member access, nested calls) stay
103
+ * unresolved so callers never treat them as constants.
104
+ */
105
+ function evaluateMathMinMaxCall(node) {
106
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression ||
107
+ node.callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
108
+ node.callee.computed ||
109
+ node.callee.object.type !== utils_1.AST_NODE_TYPES.Identifier ||
110
+ node.callee.object.name !== 'Math' ||
111
+ node.callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
112
+ !MATH_FOLDABLE_METHODS.has(node.callee.property.name) ||
113
+ node.arguments.length < 2) {
114
+ return undefined;
115
+ }
116
+ const numbers = [];
117
+ for (const argument of node.arguments) {
118
+ if (argument.type !== utils_1.AST_NODE_TYPES.Literal ||
119
+ typeof argument.value !== 'number') {
120
+ return undefined;
121
+ }
122
+ numbers.push(argument.value);
123
+ }
124
+ return node.callee.property.name === 'max'
125
+ ? Math.max(...numbers)
126
+ : Math.min(...numbers);
127
+ }
128
+ /**
129
+ * Resolves a comparison operand to the number it always evaluates to.
130
+ * Returns undefined when the operand depends on runtime values.
131
+ */
132
+ function resolveNumericOperand(node) {
133
+ if (node.type === utils_1.AST_NODE_TYPES.Literal &&
134
+ typeof node.value === 'number') {
135
+ return node.value;
136
+ }
137
+ return evaluateMathMinMaxCall(node);
138
+ }
139
+ function compareNumericValues(leftValue, operator, rightValue) {
140
+ switch (operator) {
141
+ case '>':
142
+ return leftValue > rightValue
143
+ ? { isTruthy: true }
144
+ : { isFalsy: true };
145
+ case '>=':
146
+ return leftValue >= rightValue
147
+ ? { isTruthy: true }
148
+ : { isFalsy: true };
149
+ case '<':
150
+ return leftValue < rightValue
151
+ ? { isTruthy: true }
152
+ : { isFalsy: true };
153
+ case '<=':
154
+ return leftValue <= rightValue
155
+ ? { isTruthy: true }
156
+ : { isFalsy: true };
157
+ case '==':
158
+ case '===':
159
+ return leftValue === rightValue
160
+ ? { isTruthy: true }
161
+ : { isFalsy: true };
162
+ case '!=':
163
+ case '!==':
164
+ return leftValue !== rightValue
165
+ ? { isTruthy: true }
166
+ : { isFalsy: true };
167
+ default:
168
+ return {};
169
+ }
170
+ }
98
171
  /**
99
172
  * Checks if a binary expression with literals is always truthy or falsy
100
173
  */
@@ -114,10 +187,17 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
114
187
  }
115
188
  }
116
189
  }
117
- // Only handle cases where both sides are literals
190
+ // A non-literal operand can still be a compile-time constant, so resolve
191
+ // it before abandoning the comparison. Bailing out unconditionally here
192
+ // hid every folded-call comparison (Math.max(1, 2) === 0) from the rule.
118
193
  if (node.left.type !== utils_1.AST_NODE_TYPES.Literal ||
119
194
  node.right.type !== utils_1.AST_NODE_TYPES.Literal) {
120
- return {};
195
+ const resolvedLeft = resolveNumericOperand(node.left);
196
+ const resolvedRight = resolveNumericOperand(node.right);
197
+ if (resolvedLeft === undefined || resolvedRight === undefined) {
198
+ return {};
199
+ }
200
+ return compareNumericValues(resolvedLeft, node.operator, resolvedRight);
121
201
  }
122
202
  const leftValue = node.left.value;
123
203
  const rightValue = node.right.value;
@@ -130,33 +210,9 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
130
210
  }
131
211
  // Check numeric comparisons
132
212
  if (typeof leftValue === 'number' && typeof rightValue === 'number') {
133
- switch (node.operator) {
134
- case '>':
135
- return leftValue > rightValue
136
- ? { isTruthy: true }
137
- : { isFalsy: true };
138
- case '>=':
139
- return leftValue >= rightValue
140
- ? { isTruthy: true }
141
- : { isFalsy: true };
142
- case '<':
143
- return leftValue < rightValue
144
- ? { isTruthy: true }
145
- : { isFalsy: true };
146
- case '<=':
147
- return leftValue <= rightValue
148
- ? { isTruthy: true }
149
- : { isFalsy: true };
150
- case '==':
151
- case '===':
152
- return leftValue === rightValue
153
- ? { isTruthy: true }
154
- : { isFalsy: true };
155
- case '!=':
156
- case '!==':
157
- return leftValue !== rightValue
158
- ? { isTruthy: true }
159
- : { isFalsy: true };
213
+ const numericResult = compareNumericValues(leftValue, node.operator, rightValue);
214
+ if (numericResult.isTruthy || numericResult.isFalsy) {
215
+ return numericResult;
160
216
  }
161
217
  }
162
218
  // Check string comparisons
@@ -624,64 +680,12 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
624
680
  ? { isTruthy: true }
625
681
  : { isFalsy: true };
626
682
  }
627
- // Handle Math.max/min
628
- if (node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
629
- node.callee.object.name === 'Math' &&
630
- node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
631
- (node.callee.property.name === 'max' ||
632
- node.callee.property.name === 'min') &&
633
- node.arguments.length >= 2 &&
634
- node.arguments.every((arg) => arg.type === utils_1.AST_NODE_TYPES.Literal &&
635
- typeof arg.value === 'number')) {
636
- const numbers = node.arguments.map((arg) => arg.value);
637
- const result = node.callee.property.name === 'max'
638
- ? Math.max(...numbers)
639
- : Math.min(...numbers);
640
- // If this is part of a comparison, evaluate it
641
- if (node.parent &&
642
- node.parent.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
643
- ['===', '!==', '==', '!=', '>', '<', '>=', '<='].includes(node.parent.operator) &&
644
- ((node.parent.left === node &&
645
- node.parent.right.type === utils_1.AST_NODE_TYPES.Literal &&
646
- typeof node.parent.right.value === 'number') ||
647
- (node.parent.right === node &&
648
- node.parent.left.type === utils_1.AST_NODE_TYPES.Literal &&
649
- typeof node.parent.left.value === 'number'))) {
650
- const comparison = node.parent;
651
- const literalNode = comparison.left === node
652
- ? comparison.right
653
- : comparison.left;
654
- const compareValue = literalNode.value;
655
- switch (comparison.operator) {
656
- case '===':
657
- case '==':
658
- return result === compareValue
659
- ? { isTruthy: true }
660
- : { isFalsy: true };
661
- case '!==':
662
- case '!=':
663
- return result !== compareValue
664
- ? { isTruthy: true }
665
- : { isFalsy: true };
666
- case '>':
667
- return result > compareValue
668
- ? { isTruthy: true }
669
- : { isFalsy: true };
670
- case '<':
671
- return result < compareValue
672
- ? { isTruthy: true }
673
- : { isFalsy: true };
674
- case '>=':
675
- return result >= compareValue
676
- ? { isTruthy: true }
677
- : { isFalsy: true };
678
- case '<=':
679
- return result <= compareValue
680
- ? { isTruthy: true }
681
- : { isFalsy: true };
682
- }
683
- }
684
- return result !== 0 ? { isTruthy: true } : { isFalsy: true };
683
+ // Handle Math.max/min used directly as the condition. Comparisons that
684
+ // contain such a call are folded by checkBinaryExpression instead,
685
+ // since evaluation of a comparison starts at the BinaryExpression.
686
+ const mathValue = evaluateMathMinMaxCall(node);
687
+ if (mathValue !== undefined) {
688
+ return mathValue !== 0 ? { isTruthy: true } : { isFalsy: true };
685
689
  }
686
690
  // Handle Object.keys().length
687
691
  if (node.callee.property.name === 'length' &&
@@ -1038,67 +1042,6 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
1038
1042
  return result ? { isTruthy: true } : { isFalsy: true };
1039
1043
  }
1040
1044
  }
1041
- // Handle Math.max/min
1042
- if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
1043
- node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
1044
- node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
1045
- node.callee.object.name === 'Math' &&
1046
- node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
1047
- (node.callee.property.name === 'max' ||
1048
- node.callee.property.name === 'min') &&
1049
- node.arguments.length >= 2 &&
1050
- node.arguments.every((arg) => arg.type === utils_1.AST_NODE_TYPES.Literal &&
1051
- typeof arg.value === 'number')) {
1052
- const numbers = node.arguments.map((arg) => arg.value);
1053
- const result = node.callee.property.name === 'max'
1054
- ? Math.max(...numbers)
1055
- : Math.min(...numbers);
1056
- // If this is part of a comparison, evaluate it
1057
- if (node.parent &&
1058
- node.parent.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
1059
- ['===', '!==', '==', '!=', '>', '<', '>=', '<='].includes(node.parent.operator) &&
1060
- ((node.parent.left === node &&
1061
- node.parent.right.type === utils_1.AST_NODE_TYPES.Literal &&
1062
- typeof node.parent.right.value === 'number') ||
1063
- (node.parent.right === node &&
1064
- node.parent.left.type === utils_1.AST_NODE_TYPES.Literal &&
1065
- typeof node.parent.left.value === 'number'))) {
1066
- const comparison = node.parent;
1067
- const literalNode = comparison.left === node
1068
- ? comparison.right
1069
- : comparison.left;
1070
- const compareValue = literalNode.value;
1071
- switch (comparison.operator) {
1072
- case '===':
1073
- case '==':
1074
- return result === compareValue
1075
- ? { isTruthy: true }
1076
- : { isFalsy: true };
1077
- case '!==':
1078
- case '!=':
1079
- return result !== compareValue
1080
- ? { isTruthy: true }
1081
- : { isFalsy: true };
1082
- case '>':
1083
- return result > compareValue
1084
- ? { isTruthy: true }
1085
- : { isFalsy: true };
1086
- case '<':
1087
- return result < compareValue
1088
- ? { isTruthy: true }
1089
- : { isFalsy: true };
1090
- case '>=':
1091
- return result >= compareValue
1092
- ? { isTruthy: true }
1093
- : { isFalsy: true };
1094
- case '<=':
1095
- return result <= compareValue
1096
- ? { isTruthy: true }
1097
- : { isFalsy: true };
1098
- }
1099
- }
1100
- return result !== 0 ? { isTruthy: true } : { isFalsy: true };
1101
- }
1102
1045
  // Handle nullish coalescing
1103
1046
  if (node.type === utils_1.AST_NODE_TYPES.LogicalExpression) {
1104
1047
  const logicalNode = node;
@@ -39,11 +39,22 @@ exports.noConditionalLiteralsInJsx = (0, createRule_1.createRule)({
39
39
  });
40
40
  // If we were evaluating
41
41
  // <div>{property} {conditional && 'string'}</div>
42
- // Then {property} would be one of the siblingExpressionNodes
43
- const siblingExpressionNodes = parentChildren.filter((n) => n.type === 'JSXExpressionContainer' &&
44
- 'expression' in n &&
45
- (n.expression.type === 'Identifier' ||
46
- n.expression.type === 'MemberExpression'));
42
+ // Then {property} would be one of the siblingExpressionNodes.
43
+ //
44
+ // Any expression container beside the conditional can render text, and
45
+ // it fragments the text node exactly as adjacent JSX text does, so the
46
+ // shape of the sibling's expression is not a useful discriminator.
47
+ // Whether an arbitrary expression renders text rather than an element
48
+ // (or nothing at all) is not decidable syntactically, so the broad
49
+ // reading of "other text or expressions" wins. The one container that
50
+ // provably renders nothing is a comment ({/* ... */}), whose expression
51
+ // is a JSXEmptyExpression.
52
+ const siblingExpressionNodes = parentChildren.filter((n) =>
53
+ // The container under evaluation is not its own sibling: a sole
54
+ // conditional literal fragments nothing.
55
+ n !== node &&
56
+ n.type === utils_1.TSESTree.AST_NODE_TYPES.JSXExpressionContainer &&
57
+ n.expression.type !== utils_1.TSESTree.AST_NODE_TYPES.JSXEmptyExpression);
47
58
  const hasSiblingContent = siblingTextNodes.concat(siblingExpressionNodes).length > 0;
48
59
  if (!hasSiblingContent) {
49
60
  return;
@@ -10,19 +10,63 @@ const DEFAULT_FUNCTION_PATTERNS = [
10
10
  'func',
11
11
  'on[A-Z].*',
12
12
  ];
13
+ /**
14
+ * Walks the top-level statements of a Program to build a name -> type-node
15
+ * map of every `type X = ...` alias declared in the file (including ones
16
+ * behind `export`). Populated up front from `Program.body` rather than from
17
+ * a `TSTypeAliasDeclaration` visitor, because ESLint visits in document
18
+ * order: an alias declared AFTER the `useState` call it types would not yet
19
+ * be known if we relied on visitor order alone.
20
+ */
21
+ function collectTypeAliases(programNode) {
22
+ const aliases = new Map();
23
+ for (const statement of programNode.body) {
24
+ const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
25
+ statement.declaration
26
+ ? statement.declaration
27
+ : statement;
28
+ if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
29
+ aliases.set(declaration.id.name, declaration.typeAnnotation);
30
+ }
31
+ }
32
+ return aliases;
33
+ }
13
34
  /**
14
35
  * Returns true if the TSTypeAnnotation node (from useState's type parameter)
15
36
  * represents a function type — either directly or as part of a union with
16
37
  * null/undefined. We check purely syntactically; no type-checker required.
38
+ *
39
+ * A `TSTypeReference` (e.g. `ToClose` in `useState<ToClose>`) is resolved
40
+ * against same-file type aliases and recursed into. Cross-file aliases
41
+ * (imported types) are syntactically unreachable and are left unreported by
42
+ * this signal — the name-pattern and scope-binding signals still apply.
43
+ * `visitedAliases` guards against infinite recursion on a self-referential
44
+ * or mutually-recursive alias chain (`type A = B; type B = A;`).
17
45
  */
18
- function isFunctionTypeAnnotation(typeNode) {
46
+ function isFunctionTypeAnnotation(typeNode, aliases, visitedAliases = new Set()) {
19
47
  switch (typeNode.type) {
20
48
  case utils_1.AST_NODE_TYPES.TSFunctionType:
21
49
  case utils_1.AST_NODE_TYPES.TSConstructorType:
22
50
  return true;
23
51
  case utils_1.AST_NODE_TYPES.TSUnionType:
24
52
  // Union like `(() => void) | null` — any member being a function type suffices
25
- return typeNode.types.some(isFunctionTypeAnnotation);
53
+ return typeNode.types.some((member) => isFunctionTypeAnnotation(member, aliases, visitedAliases));
54
+ case utils_1.AST_NODE_TYPES.TSTypeReference: {
55
+ const { typeName } = typeNode;
56
+ // Qualified names (`Foo.Bar`) aren't resolvable without a type checker
57
+ if (typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
58
+ return false;
59
+ }
60
+ if (visitedAliases.has(typeName.name)) {
61
+ return false;
62
+ }
63
+ const aliasTarget = aliases.get(typeName.name);
64
+ if (!aliasTarget) {
65
+ return false;
66
+ }
67
+ visitedAliases.add(typeName.name);
68
+ return isFunctionTypeAnnotation(aliasTarget, aliases, visitedAliases);
69
+ }
26
70
  default:
27
71
  return false;
28
72
  }
@@ -31,12 +75,12 @@ function isFunctionTypeAnnotation(typeNode) {
31
75
  * Checks whether a useState call expression has a type parameter that
32
76
  * includes a function type, e.g. useState<(() => void) | null>(null).
33
77
  */
34
- function useStateHasFunctionTypeParam(callNode) {
78
+ function useStateHasFunctionTypeParam(callNode, aliases) {
35
79
  const typeParams = callNode.typeParameters;
36
80
  if (!typeParams || typeParams.params.length === 0) {
37
81
  return false;
38
82
  }
39
- return isFunctionTypeAnnotation(typeParams.params[0]);
83
+ return isFunctionTypeAnnotation(typeParams.params[0], aliases);
40
84
  }
41
85
  /**
42
86
  * Returns true when the AST node is a safe value to pass to a setter — i.e.,
@@ -182,7 +226,14 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
182
226
  * useState array-destructuring declarations.
183
227
  */
184
228
  const setterFunctionTyped = new Map();
229
+ // Populated by the Program visitor, which runs before any descendant
230
+ // visitor, so declaration order of `type` aliases relative to the
231
+ // `useState` call that references them never matters.
232
+ let typeAliases = new Map();
185
233
  return {
234
+ Program(node) {
235
+ typeAliases = collectTypeAliases(node);
236
+ },
186
237
  VariableDeclarator(node) {
187
238
  // Look for `const [state, setter] = useState<T>(...)` or
188
239
  // `const [state, setter] = React.useState<T>(...)`.
@@ -210,7 +261,7 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
210
261
  return;
211
262
  }
212
263
  const setterName = setterElement.name;
213
- const hasFunctionType = useStateHasFunctionTypeParam(callNode);
264
+ const hasFunctionType = useStateHasFunctionTypeParam(callNode, typeAliases);
214
265
  setterFunctionTyped.set(setterName, hasFunctionType);
215
266
  },
216
267
  CallExpression(node) {
@@ -22,48 +22,54 @@ const describeChild = (child) => {
22
22
  return 'child node';
23
23
  }
24
24
  };
25
+ /**
26
+ * A `JSXText` child that is pure whitespace AND spans a line break is
27
+ * formatting padding — the newline + indentation prettier inserts around a
28
+ * single-line child in the multi-line fragment form — and renders no output.
29
+ * A whitespace-only child WITHOUT a newline (e.g. `<> <Foo /> </>`) renders
30
+ * an actual space between siblings, so it still counts as meaningful.
31
+ */
32
+ const isFormattingWhitespace = (child) => child.type === 'JSXText' &&
33
+ /^\s*$/.test(child.value) &&
34
+ child.value.includes('\n');
25
35
  exports.noUselessFragment = (0, createRule_1.createRule)({
26
36
  name: 'no-useless-fragment',
27
37
  create(context) {
28
38
  return {
29
39
  JSXFragment(node) {
30
- if (node.children.length === 1) {
31
- const [child] = node.children;
32
- /**
33
- * A fragment whose only child is an expression container — e.g.
34
- * `<>{portal}</>` is NOT useless. Unwrapping it to a bare
35
- * `{portal}` is invalid in statement/return position, and wrapping a
36
- * single ReactNode expression in a fragment is the idiomatic way to
37
- * render it. (Mirrors the upstream rule's `allowExpressions`.)
38
- */
39
- if (child.type === 'JSXExpressionContainer') {
40
- return;
41
- }
42
- context.report({
43
- node,
44
- messageId: 'noUselessFragment',
45
- data: {
46
- childKind: describeChild(child),
47
- },
48
- fix(fixer) {
49
- const sourceCode = context.sourceCode;
50
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
51
- const openingFragment = sourceCode.getFirstToken(node);
52
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
53
- const closingFragment = sourceCode.getLastToken(node);
54
- return [
55
- fixer.removeRange([
56
- openingFragment.range[0],
57
- openingFragment.range[0] + 2,
58
- ]),
59
- fixer.removeRange([
60
- closingFragment.range[0] - 3,
61
- closingFragment.range[0],
62
- ]),
63
- ];
64
- },
65
- });
40
+ const meaningfulChildren = node.children.filter((child) => !isFormattingWhitespace(child));
41
+ if (meaningfulChildren.length !== 1) {
42
+ return;
43
+ }
44
+ const [child] = meaningfulChildren;
45
+ /**
46
+ * A fragment whose only child is an expression container — e.g.
47
+ * `<>{portal}</>` — is NOT useless. Unwrapping it to a bare
48
+ * `{portal}` is invalid in statement/return position, and wrapping a
49
+ * single ReactNode expression in a fragment is the idiomatic way to
50
+ * render it. (Mirrors the upstream rule's `allowExpressions`.)
51
+ */
52
+ if (child.type === 'JSXExpressionContainer') {
53
+ return;
66
54
  }
55
+ /**
56
+ * Unwrapping is only sound when the child is itself standalone JSX.
57
+ * A text child (`<>hello</>`) would become a bare identifier
58
+ * reference, and a spread child (`<>{...items}</>`) is not a valid
59
+ * expression on its own — both are report-only so the developer
60
+ * chooses how to restructure the surrounding code.
61
+ */
62
+ const isFixable = child.type === 'JSXElement' || child.type === 'JSXFragment';
63
+ context.report({
64
+ node,
65
+ messageId: 'noUselessFragment',
66
+ data: {
67
+ childKind: describeChild(child),
68
+ },
69
+ fix: isFixable
70
+ ? (fixer) => fixer.replaceText(node, context.sourceCode.getText(child))
71
+ : null,
72
+ });
67
73
  },
68
74
  };
69
75
  },
@@ -50,6 +50,23 @@ const FUNCTION_TYPES = new Set([
50
50
  utils_1.AST_NODE_TYPES.FunctionExpression,
51
51
  utils_1.AST_NODE_TYPES.FunctionDeclaration,
52
52
  ]);
53
+ /**
54
+ * Sentinel standing in for the receiver of a `this`-rooted member chain, which
55
+ * has no identifier to key on. `this` is a reserved word, so no binding can
56
+ * carry this name and shadow the sentinel.
57
+ */
58
+ const THIS_ROOT = 'this';
59
+ /**
60
+ * Node types that bind their own `this`, so a `this` inside one is a different
61
+ * receiver than the enclosing scope's. Arrow functions are deliberately absent:
62
+ * they close over the lexical `this`, exactly as a closure closes over an
63
+ * identifier binding.
64
+ */
65
+ const THIS_REBINDING_TYPES = new Set([
66
+ utils_1.AST_NODE_TYPES.FunctionExpression,
67
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
68
+ utils_1.AST_NODE_TYPES.ClassBody,
69
+ ]);
53
70
  /**
54
71
  * Function/constructor/conditional type notation must be parenthesized to
55
72
  * appear as a `|` union member, or the emitted annotation does not parse
@@ -362,12 +379,22 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
362
379
  }
363
380
  return cur.type === utils_1.AST_NODE_TYPES.Identifier;
364
381
  }
365
- /** Root identifier of a member chain (`a.b.c` -> `a`). */
366
- function rootIdentifierName(node) {
382
+ /**
383
+ * Root of a member chain (`a.b.c` -> `a`, `this.a.b` -> `THIS_ROOT`).
384
+ *
385
+ * A `this`-rooted chain names its receiver with a keyword rather than a
386
+ * binding, so it needs a sentinel to participate in root-keyed analysis at
387
+ * all. `'this'` cannot collide with a real root: `this` is a reserved word,
388
+ * so no identifier can ever carry that name.
389
+ */
390
+ function chainRootName(node) {
367
391
  let cur = node;
368
392
  while (cur.type === utils_1.AST_NODE_TYPES.MemberExpression) {
369
393
  cur = cur.object;
370
394
  }
395
+ if (cur.type === utils_1.AST_NODE_TYPES.ThisExpression) {
396
+ return THIS_ROOT;
397
+ }
371
398
  return cur.type === utils_1.AST_NODE_TYPES.Identifier ? cur.name : null;
372
399
  }
373
400
  function isInlineLiteralKey(node) {
@@ -485,6 +512,48 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
485
512
  visit(node);
486
513
  return found;
487
514
  }
515
+ /**
516
+ * Whether the subtree reads the receiver of the enclosing scope — the `this`
517
+ * counterpart of `referencesIdentifier`. Nested `function`/class bodies are
518
+ * skipped because their `this` is a different receiver, so a `this` inside
519
+ * one is no more a read of the narrowed object than an identifier of the
520
+ * same name declared in an inner scope would be.
521
+ */
522
+ function referencesThis(node) {
523
+ let found = false;
524
+ const visit = (n) => {
525
+ if (found || !n || typeof n !== 'object') {
526
+ return;
527
+ }
528
+ const anyNode = n;
529
+ if (typeof anyNode.type !== 'string') {
530
+ return;
531
+ }
532
+ if (anyNode.type === utils_1.AST_NODE_TYPES.ThisExpression) {
533
+ found = true;
534
+ return;
535
+ }
536
+ if (THIS_REBINDING_TYPES.has(anyNode.type)) {
537
+ return;
538
+ }
539
+ for (const key of Object.keys(anyNode)) {
540
+ if (key === 'parent') {
541
+ continue;
542
+ }
543
+ const val = anyNode[key];
544
+ if (Array.isArray(val)) {
545
+ for (const child of val) {
546
+ visit(child);
547
+ }
548
+ }
549
+ else {
550
+ visit(val);
551
+ }
552
+ }
553
+ };
554
+ visit(node);
555
+ return found;
556
+ }
488
557
  // ---- Name derivation ----------------------------------------------------
489
558
  function toUpperSnake(name) {
490
559
  return name
@@ -799,16 +868,25 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
799
868
  * Narrowing exemption (Edge Case 1): when the discriminant is `obj.tag`, a
800
869
  * flat Record cannot express variant narrowing. If any KEPT branch value
801
870
  * references the base object beyond the tag access itself, do not fire.
871
+ *
872
+ * `this.obj.tag` narrows identically — the receiver is reached through a
873
+ * keyword instead of a binding, which changes nothing about what the Record
874
+ * would lose (hoisting `this.obj.data` out of the switch drops the narrowing
875
+ * and the emitted code no longer typechecks), so a `this`-rooted chain is
876
+ * matched against `this` reads in the kept branches.
802
877
  */
803
878
  function isNarrowingExempt(discriminant, keptValues) {
804
879
  if (discriminant.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
805
880
  return false;
806
881
  }
807
- const root = rootIdentifierName(discriminant);
882
+ const root = chainRootName(discriminant);
808
883
  if (!root) {
809
884
  return false;
810
885
  }
811
- return keptValues.some((value) => referencesIdentifier(value, root));
886
+ const readsRoot = root === THIS_ROOT
887
+ ? referencesThis
888
+ : (value) => referencesIdentifier(value, root);
889
+ return keptValues.some(readsRoot);
812
890
  }
813
891
  // ---- Switch form --------------------------------------------------------
814
892
  function handleSwitch(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.82",
3
+ "version": "1.20.83",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,58 @@
1
1
  [
2
+ {
3
+ "version": "1.20.83",
4
+ "date": "2026-08-03T02:05:36.672Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-identifiable-firestore-type",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1635
11
+ ],
12
+ "summary": "require the folder-matching type to be exported (closes #1635)"
13
+ },
14
+ {
15
+ "name": "no-always-true-false-conditions",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1625
19
+ ],
20
+ "summary": "fold Math.max/min operands inside comparisons (closes #1625)"
21
+ },
22
+ {
23
+ "name": "no-conditional-literals-in-jsx",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1627
27
+ ],
28
+ "summary": "count every non-comment expression container as adjacent content (closes #1627)"
29
+ },
30
+ {
31
+ "name": "no-direct-function-state",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1636
35
+ ],
36
+ "summary": "see function types behind a same-file alias (closes #1636)"
37
+ },
38
+ {
39
+ "name": "no-useless-fragment",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1634
43
+ ],
44
+ "summary": "stop the fixer corrupting source and count meaningful children (closes #1634)"
45
+ },
46
+ {
47
+ "name": "prefer-map-over-conditional-dispatch",
48
+ "changeType": "fix",
49
+ "issues": [
50
+ 1626
51
+ ],
52
+ "summary": "extend the narrowing exemption to this-rooted discriminants (closes #1626)"
53
+ }
54
+ ]
55
+ },
2
56
  {
3
57
  "version": "1.20.82",
4
58
  "date": "2026-08-02T21:42:12.697Z",