@blumintinc/eslint-plugin-blumint 1.20.152 → 1.20.153

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.152',
226
+ version: '1.20.153',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -69,6 +69,111 @@ function memberNameOf(key) {
69
69
  }
70
70
  return undefined;
71
71
  }
72
+ const EQUALITY_OPERATORS = new Set([
73
+ '===',
74
+ '!==',
75
+ '==',
76
+ '!=',
77
+ ]);
78
+ /**
79
+ * The text of a literal string, written either way round: `'string'` and
80
+ * `` `string` `` assert the same thing about the operand beside them.
81
+ */
82
+ function stringLiteralValueOf(node) {
83
+ if (node.type === utils_1.AST_NODE_TYPES.Literal && typeof node.value === 'string') {
84
+ return node.value;
85
+ }
86
+ if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
87
+ node.expressions.length === 0 &&
88
+ node.quasis.length === 1) {
89
+ return node.quasis[0].value.cooked;
90
+ }
91
+ return undefined;
92
+ }
93
+ /**
94
+ * The operand an equality comparison holds opposite `operand`, so operand
95
+ * order carries no meaning: `typeof x === 'string'` and
96
+ * `'string' === typeof x` are the same assertion.
97
+ */
98
+ function comparedAgainst(comparison, operand) {
99
+ if (!EQUALITY_OPERATORS.has(comparison.operator))
100
+ return undefined;
101
+ if (comparison.left === operand)
102
+ return comparison.right;
103
+ if (comparison.right === operand)
104
+ return comparison.left;
105
+ return undefined;
106
+ }
107
+ /**
108
+ * The outermost node standing for the same value, so a contradiction written
109
+ * around `verdict!` or `verdict as string` is a contradiction about
110
+ * `verdict`.
111
+ */
112
+ function passthroughValueOf(node) {
113
+ let current = node;
114
+ while (current.parent) {
115
+ const { parent } = current;
116
+ const wraps = (parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
117
+ parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
118
+ parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
119
+ parent.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
120
+ parent.type === utils_1.AST_NODE_TYPES.ChainExpression) &&
121
+ parent.expression ===
122
+ current;
123
+ if (!wraps)
124
+ break;
125
+ current = parent;
126
+ }
127
+ return current;
128
+ }
129
+ /** `Error`, `TypeError` and any `…Error` class take a string message. */
130
+ function isErrorConstructor(callee) {
131
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
132
+ return callee.name.endsWith('Error');
133
+ }
134
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
135
+ const member = memberNameOf(callee.property);
136
+ return !!member && member.name.endsWith('Error');
137
+ }
138
+ return false;
139
+ }
140
+ /**
141
+ * Whether this reference uses the value in a way a boolean could not be
142
+ * used, which disproves a booleanness read off a name.
143
+ */
144
+ function referenceContradictsBoolean(reference) {
145
+ const value = passthroughValueOf(reference);
146
+ const { parent } = value;
147
+ if (!parent)
148
+ return false;
149
+ // `typeof verdict === 'string'`. A tag of `'boolean'` AFFIRMS the boolean
150
+ // reading whichever equality operator carries it — `!== 'boolean'` is how
151
+ // a boolean guard is spelled — so only some other tag contradicts.
152
+ if (parent.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
153
+ parent.operator === 'typeof' &&
154
+ parent.argument === value) {
155
+ const comparison = parent.parent;
156
+ if (comparison?.type !== utils_1.AST_NODE_TYPES.BinaryExpression)
157
+ return false;
158
+ const other = comparedAgainst(comparison, parent);
159
+ const tag = other ? stringLiteralValueOf(other) : undefined;
160
+ return tag !== undefined && tag !== 'boolean';
161
+ }
162
+ // `verdict === 'occupied'` — a value compared with a string is not a
163
+ // boolean, since no boolean is ever equal to one.
164
+ if (parent.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
165
+ const other = comparedAgainst(parent, value);
166
+ return !!other && stringLiteralValueOf(other) !== undefined;
167
+ }
168
+ // `throw new Error(verdict)` — the message parameter is a string, so the
169
+ // binding carries the failure reason rather than a verdict flag.
170
+ if (parent.type === utils_1.AST_NODE_TYPES.NewExpression &&
171
+ parent.arguments[0] === value &&
172
+ isErrorConstructor(parent.callee)) {
173
+ return true;
174
+ }
175
+ return false;
176
+ }
72
177
  exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
73
178
  name: 'enforce-boolean-naming-prefixes',
74
179
  meta: {
@@ -914,6 +1019,66 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
914
1019
  }
915
1020
  return calleeReturnEvaluation(calleeName) !== 'nonBoolean';
916
1021
  }
1022
+ /**
1023
+ * Whether the only evidence that a binding holds a boolean is a NAME.
1024
+ *
1025
+ * `calleeReturnEvaluation` answers "does this callee demonstrably return a
1026
+ * non-boolean?"; its 'indeterminate' verdict is the case where the callee's
1027
+ * body is out of reach (an import, a parameter, a value read off a builder
1028
+ * chain) and the callee's `is`/`has`/`can` prefix is all that is left. A
1029
+ * boolean-sounding property (`state.isValid`) is the same kind of evidence.
1030
+ *
1031
+ * Everything else — an explicit `: boolean` annotation, a boolean literal, a
1032
+ * comparison or negation, a `Boolean()` coercion, a resolvable declaration
1033
+ * whose return classifies as boolean — is evidence about the VALUE, which no
1034
+ * use site is allowed to outrank.
1035
+ */
1036
+ function booleanEvidenceIsNameOnly(declarator) {
1037
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
1038
+ hasBooleanTypeAnnotation(declarator.id) ||
1039
+ !declarator.init) {
1040
+ return false;
1041
+ }
1042
+ const restsOnName = (expression) => {
1043
+ const value = unwrapChainExpression(expression);
1044
+ // A property name is the whole of the evidence in
1045
+ // `isLikelyBooleanByMemberExpression`, the only path that reads one.
1046
+ if (value.type === utils_1.AST_NODE_TYPES.MemberExpression) {
1047
+ return true;
1048
+ }
1049
+ if (value.type === utils_1.AST_NODE_TYPES.CallExpression &&
1050
+ value.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
1051
+ return (!isGlobalBooleanCall(value) &&
1052
+ calleeReturnEvaluation(value.callee.name) === 'indeterminate');
1053
+ }
1054
+ // `isFoo(x) || fallback` reaches booleanness through its left operand.
1055
+ if (value.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
1056
+ value.operator === '||') {
1057
+ return restsOnName(value.left);
1058
+ }
1059
+ return false;
1060
+ };
1061
+ return restsOnName(declarator.init);
1062
+ }
1063
+ /**
1064
+ * Whether any use of the binding contradicts booleanness.
1065
+ *
1066
+ * Validator families built on `ValidatorPipeline` return `true | string` —
1067
+ * `true` for a pass, the failure message for a fail — while
1068
+ * `enforce-is-prefix-validators` requires the validator itself to be
1069
+ * `is`-prefixed. Inferring the result's booleanness from that mandated
1070
+ * prefix makes the two rules unsatisfiable together, so a use site that
1071
+ * reads the value as a string settles it against the name.
1072
+ *
1073
+ * References come from the scope manager, never from matching the name as
1074
+ * text: a contradiction must belong to THIS binding, not to a shadowing
1075
+ * inner one, a sibling scope's binding, or an unrelated same-named value.
1076
+ */
1077
+ function useSiteContradictsBoolean(declarator) {
1078
+ return context
1079
+ .getDeclaredVariables(declarator)
1080
+ .some((variable) => variable.references.some((reference) => referenceContradictsBoolean(reference.identifier)));
1081
+ }
917
1082
  /**
918
1083
  * Check if a variable is used in a while loop condition and is likely a DOM element or tree node
919
1084
  * This helps identify variables like 'parent', 'element', 'node', etc. that are used
@@ -1233,6 +1398,14 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
1233
1398
  utils_1.AST_NODE_TYPES.TSBooleanKeyword) {
1234
1399
  isBooleanVar = true;
1235
1400
  }
1401
+ // A booleanness read off a name loses to a use site that treats the value
1402
+ // as something else, which is what keeps this rule satisfiable alongside
1403
+ // `enforce-is-prefix-validators` for `true | string` validator verdicts.
1404
+ if (isBooleanVar &&
1405
+ booleanEvidenceIsNameOnly(node) &&
1406
+ useSiteContradictsBoolean(node)) {
1407
+ return;
1408
+ }
1236
1409
  if (isBooleanVar && !hasApprovedPrefix(variableName)) {
1237
1410
  context.report({
1238
1411
  node: node.id,
@@ -207,25 +207,41 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
207
207
  return undefined;
208
208
  }
209
209
  /**
210
- * `as const` turns an array literal into a readonly *tuple*, which TS4104
211
- * refuses to assign to a mutable array or tuple. Where the annotation says
212
- * the value must be mutable, appending `as const` breaks the build, and no
213
- * edit at the literal can satisfy the rule — honouring it would mean
214
- * rewriting the signature, a call the author has to make. So the rule stays
215
- * silent rather than reporting something the developer cannot act on
216
- * (#1526).
210
+ * `as const` turns an array literal into a fixed-length readonly *tuple*,
211
+ * strictly narrower than the mutable array the literal otherwise gets. Two
212
+ * separate breakages follow from that narrowing, and neither is visible at
213
+ * the literal:
214
+ *
215
+ * - Where the enclosing signature declares a mutable array or tuple, TS4104
216
+ * refuses the assignment, so appending `as const` breaks the build. No
217
+ * edit at the literal satisfies the rule — honouring it means rewriting
218
+ * the signature, a call the author has to make (#1526).
219
+ * - Where the signature is inferred, the frozen arity becomes part of the
220
+ * return type and every caller inherits it: `.length` narrows to a literal
221
+ * number (TS2367 against any other length), `.includes` narrows its
222
+ * parameter to the element union — `never` for `[]` — (TS2345), and the
223
+ * value stops satisfying a mutable `T[]` parameter. The break lands in a
224
+ * different function than the one edited, and the callers are beyond what
225
+ * the rule can see (#2015).
226
+ *
227
+ * So an array literal is left alone unless the enclosing signature states a
228
+ * type that accepts a readonly tuple. An annotation the rule cannot resolve
229
+ * still counts as accepting, per `acceptsReadonlyArray`: the annotation, not
230
+ * the literal, is what callers read, so the arity never escapes.
217
231
  *
218
232
  * Object literals are unaffected: `readonly` property modifiers do not
219
233
  * enter assignability, so `{ a: 1 } as const` still satisfies a mutable
220
- * `{ a: number }`.
234
+ * `{ a: number }`, and freezing one fixes no arity.
221
235
  */
222
- function conflictsWithDeclaredType(literal, ancestors) {
236
+ function freezingArrayIsUnsafe(literal, ancestors) {
223
237
  if (!isArrayLiteral(literal)) {
224
238
  return false;
225
239
  }
226
240
  const enclosingFunction = enclosingFunctionOf(ancestors);
227
- if (!enclosingFunction) {
228
- return false;
241
+ // With no declared return type in view, the inferred tuple is what the
242
+ // callers get.
243
+ if (!enclosingFunction || !declaredReturnTypeOf(enclosingFunction)) {
244
+ return true;
229
245
  }
230
246
  const returnedValueType = returnedValueTypeOf(enclosingFunction);
231
247
  return !!returnedValueType && !acceptsReadonlyArray(returnedValueType);
@@ -285,10 +301,10 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
285
301
  if (isInsideReactHook(ancestors) && isArrayLiteral(literal)) {
286
302
  return;
287
303
  }
288
- // Skip arrays the enclosing signature declares mutable: `as const`
289
- // cannot compile there and the developer cannot act on the report
290
- // (#1526)
291
- if (conflictsWithDeclaredType(literal, ancestors)) {
304
+ // Skip arrays whose enclosing signature does not accept the readonly
305
+ // tuple `as const` produces declared mutable (#1526) or inferred, in
306
+ // which case the frozen arity reaches every caller (#2015)
307
+ if (freezingArrayIsUnsafe(literal, ancestors)) {
292
308
  return;
293
309
  }
294
310
  // Report the issue and provide a fix
@@ -108,6 +108,139 @@ const isComponentFactoryCall = (node) => {
108
108
  // the same terms as `const Row = (props) => {...}` (Issue #1681).
109
109
  const isFunctionValue = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
110
110
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
111
+ // `as const` does more than pin literal types: it makes the value deeply
112
+ // `readonly`. A binding that is written through after its declaration therefore
113
+ // cannot carry the assertion at all — appending it turns compiling code into
114
+ // `TS2339: Property 'push' does not exist on type 'readonly []'` for an array
115
+ // and `TS2540: Cannot assign to 'a' because it is a read-only property` for an
116
+ // object (Issue #2013). These are the built-in methods that mutate their
117
+ // receiver rather than returning a fresh value, so a call to one of them is a
118
+ // write even though no assignment target names the binding.
119
+ const MUTATING_METHOD_NAMES = new Set([
120
+ 'push',
121
+ 'pop',
122
+ 'shift',
123
+ 'unshift',
124
+ 'splice',
125
+ 'sort',
126
+ 'reverse',
127
+ 'fill',
128
+ 'copyWithin',
129
+ ]);
130
+ /**
131
+ * Climbs out of the wrappers that denote the same value as `node` — type
132
+ * wrappers (`(X as any).push()`, `X!.push()`) and the `ChainExpression` an
133
+ * optional access hangs on the outside of the whole chain (`delete X?.a`). The
134
+ * role a node plays in its statement is decided by the outermost such wrapper,
135
+ * so a classifier that reads `node.parent` directly answers for the wrapper
136
+ * instead of the access.
137
+ */
138
+ const outermostValueOf = (node) => {
139
+ let current = node;
140
+ for (;;) {
141
+ const parent = current.parent;
142
+ if (parent &&
143
+ ((isValueWrapper(parent) && parent.expression === current) ||
144
+ (parent.type === utils_1.AST_NODE_TYPES.ChainExpression &&
145
+ parent.expression === current))) {
146
+ current = parent;
147
+ continue;
148
+ }
149
+ return current;
150
+ }
151
+ };
152
+ /**
153
+ * The outermost property-access path rooted at `identifier`: `X` in `X.a.b`
154
+ * yields the `X.a.b` member expression. Returns `null` when the identifier is
155
+ * not the base of any access, which is every reference that merely reads the
156
+ * binding as a value — `other.push(X)` passes it as an ARGUMENT, so the
157
+ * mutation happens to `other`, not to `X`.
158
+ *
159
+ * The climb stops at the first parent that is not a member access on the
160
+ * current node, so `X.map(f).push(1)` yields `X.map`: the mutated receiver
161
+ * there is the array `map` returned, not `X`.
162
+ */
163
+ const accessPathOf = (identifier) => {
164
+ let current = outermostValueOf(identifier);
165
+ let path = null;
166
+ for (;;) {
167
+ const parent = current.parent;
168
+ if (!parent ||
169
+ parent.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
170
+ parent.object !== current) {
171
+ return path;
172
+ }
173
+ path = parent;
174
+ current = outermostValueOf(parent);
175
+ }
176
+ };
177
+ /** The property name an access reads, for `X.push` and `X['push']` alike. */
178
+ const accessedPropertyName = (path) => {
179
+ if (!path.computed && path.property.type === utils_1.AST_NODE_TYPES.Identifier) {
180
+ return path.property.name;
181
+ }
182
+ if (path.computed &&
183
+ path.property.type === utils_1.AST_NODE_TYPES.Literal &&
184
+ typeof path.property.value === 'string') {
185
+ return path.property.value;
186
+ }
187
+ return null;
188
+ };
189
+ const isMutatingMethodCall = (path) => {
190
+ const propertyName = accessedPropertyName(path);
191
+ if (propertyName === null || !MUTATING_METHOD_NAMES.has(propertyName)) {
192
+ return false;
193
+ }
194
+ const callee = outermostValueOf(path);
195
+ return (callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
196
+ callee.parent.callee === callee);
197
+ };
198
+ /**
199
+ * Whether `node` sits in a position that writes to it: the left of an
200
+ * assignment (plain or compound), the operand of `++`/`--` or `delete`, the
201
+ * loop variable of `for…in`/`for…of`, or a slot in a destructuring assignment
202
+ * target (`[X.a] = […]`, `({ p: X.a } = …)`).
203
+ */
204
+ const isWriteTarget = (node) => {
205
+ const value = outermostValueOf(node);
206
+ const parent = value.parent;
207
+ if (!parent) {
208
+ return false;
209
+ }
210
+ switch (parent.type) {
211
+ case utils_1.AST_NODE_TYPES.AssignmentExpression:
212
+ return parent.left === value;
213
+ case utils_1.AST_NODE_TYPES.UpdateExpression:
214
+ return parent.argument === value;
215
+ case utils_1.AST_NODE_TYPES.UnaryExpression:
216
+ return parent.operator === 'delete' && parent.argument === value;
217
+ case utils_1.AST_NODE_TYPES.ForInStatement:
218
+ case utils_1.AST_NODE_TYPES.ForOfStatement:
219
+ return parent.left === value;
220
+ // Destructuring targets nest, so the answer belongs to the pattern's own
221
+ // position. The same node types appear in ObjectExpression/ArrayExpression
222
+ // VALUES, where the recursion reaches a non-assignment parent and stops.
223
+ case utils_1.AST_NODE_TYPES.ArrayPattern:
224
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
225
+ case utils_1.AST_NODE_TYPES.Property:
226
+ case utils_1.AST_NODE_TYPES.RestElement:
227
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
228
+ return isWriteTarget(parent);
229
+ default:
230
+ return false;
231
+ }
232
+ };
233
+ /**
234
+ * Whether the binding is written through anywhere in the file. Answered from
235
+ * the scope manager's reference list rather than a textual search for the
236
+ * name, so a same-named binding in another scope (`const arr` shadowed inside a
237
+ * callback) contributes nothing, and a same-named method on an unrelated
238
+ * receiver (`other.push(1)`) is never even visited.
239
+ */
240
+ const isBindingMutated = (variable) => variable.references.some((reference) => {
241
+ const path = accessPathOf(reference.identifier);
242
+ return path !== null && (isMutatingMethodCall(path) || isWriteTarget(path));
243
+ });
111
244
  /**
112
245
  * Walks the scope chain upward from `scope` (inclusive) and reports whether
113
246
  * `targetName` is bound anywhere between `scope` and `stopScope` (inclusive).
@@ -366,9 +499,22 @@ exports.default = (0, createRule_1.createRule)({
366
499
  (target.value === null || typeof target.value === 'boolean')) {
367
500
  return false;
368
501
  }
369
- return (target.type === utils_1.AST_NODE_TYPES.Literal ||
370
- target.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
371
- target.type === utils_1.AST_NODE_TYPES.ObjectExpression);
502
+ if (target.type !== utils_1.AST_NODE_TYPES.Literal &&
503
+ target.type !== utils_1.AST_NODE_TYPES.ArrayExpression &&
504
+ target.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
505
+ return false;
506
+ }
507
+ // A binding that is mutated later can never take the assertion:
508
+ // `as const` types the value `readonly`, so the appended text
509
+ // turns working code into TS2339/TS2540 (Issue #2013). The
510
+ // report is withheld rather than merely the fix, on the same
511
+ // terms as the `null`/boolean carve-out above — a violation no
512
+ // legal edit can clear is not a violation. The rename is a
513
+ // separate concern and still applies.
514
+ const declaredVariable = context
515
+ .getDeclaredVariables(declaration)
516
+ .find((variable) => variable.name === name);
517
+ return !declaredVariable || !isBindingMutated(declaredVariable);
372
518
  };
373
519
  if (shouldHaveAsConst(init)) {
374
520
  context.report({
@@ -713,6 +713,104 @@ function declaresVoidResult(returnType) {
713
713
  return (typeArguments?.length === 1 &&
714
714
  typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
715
715
  }
716
+ // TypeScript's built-in decorator signatures. A factory annotated with one of
717
+ // these is the one shape where the annotation is WIDER than what inference
718
+ // produces rather than a restatement of it: `MethodDecorator` accepts three
719
+ // parameters, the returned closure typically declares none, and a decoration
720
+ // site requires the declared arity. Stripping the annotation therefore turns
721
+ // every `@Factory()` use into TS1329 (#2014).
722
+ const DECORATOR_TYPE_NAMES = new Set([
723
+ 'ClassDecorator',
724
+ 'MethodDecorator',
725
+ 'ParameterDecorator',
726
+ 'PropertyDecorator',
727
+ ]);
728
+ /**
729
+ * The identifier a type name resolves to. A qualified name (`ts.MethodDecorator`)
730
+ * denotes the type its right-most segment names, so that segment is what decides
731
+ * — a substring test over the printed annotation would equally match
732
+ * `MyMethodDecoratorConfig`, which is an unrelated user type.
733
+ */
734
+ function rightmostTypeName(typeName) {
735
+ if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
736
+ return typeName.name;
737
+ }
738
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
739
+ return rightmostTypeName(typeName.right);
740
+ }
741
+ return undefined;
742
+ }
743
+ function namesDecoratorType(annotation) {
744
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
745
+ const name = rightmostTypeName(annotation.typeName);
746
+ return name !== undefined && DECORATOR_TYPE_NAMES.has(name);
747
+ }
748
+ // A factory usable in more than one position (`ClassDecorator &
749
+ // MethodDecorator`) still owes every decoration site the declared shape.
750
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSUnionType ||
751
+ annotation.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
752
+ return annotation.types.some(namesDecoratorType);
753
+ }
754
+ return false;
755
+ }
756
+ /**
757
+ * The identifier a CALLED decorator invokes: `Log` for `@Log()` and `@Log()()`.
758
+ *
759
+ * Only a called decorator identifies a factory, and only a factory's return type
760
+ * is what the decoration site consumes. A bare `@Log` names the decorator
761
+ * itself, whose annotation restates the value it returns exactly as inference
762
+ * would — so it stays reportable rather than being silenced by proximity to a
763
+ * decorator.
764
+ *
765
+ * An owner-qualified decorator (`@registry.log()`) names a property rather than
766
+ * a binding, and matching it by property name alone would silence the rule on
767
+ * every unrelated method of the same name, so it yields nothing.
768
+ */
769
+ function decoratorFactoryIdentifier(expression) {
770
+ if (expression.type !== utils_1.AST_NODE_TYPES.CallExpression)
771
+ return undefined;
772
+ const callee = expression.callee;
773
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
774
+ return callee;
775
+ }
776
+ return decoratorFactoryIdentifier(callee);
777
+ }
778
+ /**
779
+ * The declarations invoked by a decorator in this file.
780
+ *
781
+ * This catches the factory whose annotation is a user-defined decorator type
782
+ * (`type Cached = (t: object, k: string, d: PropertyDescriptor) => void`), which
783
+ * no name test can recognise. Each identifier is resolved through the scope
784
+ * manager rather than compared by name, so a same-named binding elsewhere in the
785
+ * file cannot silence the rule on a function no decorator actually reaches.
786
+ */
787
+ function decoratorReferencedDeclarations(source, visitorKeys) {
788
+ const heads = new Set();
789
+ const stack = [source.ast];
790
+ while (stack.length > 0) {
791
+ const current = stack.pop();
792
+ if (current.type === utils_1.AST_NODE_TYPES.Decorator) {
793
+ const head = decoratorFactoryIdentifier(current.expression);
794
+ if (head) {
795
+ heads.add(head);
796
+ }
797
+ }
798
+ pushChildren(current, visitorKeys, stack);
799
+ }
800
+ const declarations = new Set();
801
+ if (heads.size === 0)
802
+ return declarations;
803
+ for (const scope of source.scopeManager?.scopes ?? []) {
804
+ for (const reference of scope.references) {
805
+ if (!heads.has(reference.identifier))
806
+ continue;
807
+ for (const definition of reference.resolved?.defs ?? []) {
808
+ declarations.add(definition.node);
809
+ }
810
+ }
811
+ }
812
+ return declarations;
813
+ }
716
814
  function containsRange(outer, inner) {
717
815
  return inner[0] >= outer[0] && inner[1] <= outer[1];
718
816
  }
@@ -967,6 +1065,40 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
967
1065
  // Edges are resolved lazily, and only for functions a direct
968
1066
  // self-reference has already failed to explain.
969
1067
  const participatesInReturnCycle = createReturnCycleResolver(visitorKeys);
1068
+ // Decorators are visited after the functions they name — a class body is
1069
+ // walked long after the top-level factory it decorates with — so the
1070
+ // answer is computed from the whole tree rather than accumulated during
1071
+ // the walk, and memoised because most files hold no decorator at all.
1072
+ let decoratedDeclarations;
1073
+ const declarationsNamedByDecorators = () => {
1074
+ decoratedDeclarations ??= decoratorReferencedDeclarations(sourceCode, visitorKeys);
1075
+ return decoratedDeclarations;
1076
+ };
1077
+ /**
1078
+ * True when the annotation is what makes the function usable in a
1079
+ * decorator position. TypeScript infers the concrete closure the factory
1080
+ * returns — `() => void` for `return () => {};` — which declares fewer
1081
+ * parameters than a decoration site passes, so removing the annotation
1082
+ * turns every `@Factory()` use into TS1329 (#2014).
1083
+ *
1084
+ * The question is answered syntactically. A `RuleTester` fixture carries
1085
+ * no `parserOptions.project`, so a type-based answer would be untestable
1086
+ * and would silently no-op wherever consumers lint without a program.
1087
+ */
1088
+ function isDecoratorFactory(node, returnType) {
1089
+ if (namesDecoratorType(returnType.typeAnnotation))
1090
+ return true;
1091
+ const declarations = declarationsNamedByDecorators();
1092
+ if (declarations.size === 0)
1093
+ return false;
1094
+ if (declarations.has(node))
1095
+ return true;
1096
+ // `const Log = (): Cached => ...` is bound by its declarator, which is
1097
+ // what a decorator's identifier resolves to.
1098
+ const parent = node.parent;
1099
+ return (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
1100
+ declarations.has(parent));
1101
+ }
970
1102
  /**
971
1103
  * True when TypeScript cannot infer the return type because the function
972
1104
  * is referenced from within its own return expression (TS7023). Removing
@@ -1100,6 +1232,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1100
1232
  if (isTypeGuardFunction(node) ||
1101
1233
  isReadonlyWideningReturnType(returnType) ||
1102
1234
  isAllowedVoidReturnType(returnType) ||
1235
+ isDecoratorFactory(node, returnType) ||
1103
1236
  (mergedOptions.allowRecursiveFunctions &&
1104
1237
  isRecursiveFunction(node)) ||
1105
1238
  isReturnTypeRequiredByRecursion(node)) {
@@ -1117,6 +1250,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1117
1250
  if (isTypeGuardFunction(node) ||
1118
1251
  isReadonlyWideningReturnType(returnType) ||
1119
1252
  isAllowedVoidReturnType(returnType) ||
1253
+ isDecoratorFactory(node, returnType) ||
1120
1254
  (mergedOptions.allowRecursiveFunctions &&
1121
1255
  isRecursiveFunction(node)) ||
1122
1256
  isReturnTypeRequiredByRecursion(node)) {
@@ -1131,6 +1265,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1131
1265
  if (isTypeGuardFunction(node) ||
1132
1266
  isReadonlyWideningReturnType(returnType) ||
1133
1267
  isAllowedVoidReturnType(returnType) ||
1268
+ isDecoratorFactory(node, returnType) ||
1134
1269
  isReturnTypeRequiredByRecursion(node)) {
1135
1270
  return;
1136
1271
  }
@@ -1156,6 +1291,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1156
1291
  if (isTypeGuardFunction(node.value) ||
1157
1292
  isReadonlyWideningReturnType(returnType) ||
1158
1293
  isAllowedVoidReturnType(returnType) ||
1294
+ isDecoratorFactory(node, returnType) ||
1159
1295
  (mergedOptions.allowAbstractMethodSignatures &&
1160
1296
  isInterfaceOrAbstractMethodSignature(node)) ||
1161
1297
  isReturnTypeRequiredByRecursion(node)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.152",
3
+ "version": "1.20.153",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "1.20.153",
4
+ "date": "2026-08-14T20:21:23.691Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-boolean-naming-prefixes",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2016
11
+ ],
12
+ "summary": "decline when the use site contradicts the callee's name (closes #2016)"
13
+ },
14
+ {
15
+ "name": "enforce-object-literal-as-const",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2015
19
+ ],
20
+ "summary": "keep an unannotated returned array unfrozen (closes #2015)"
21
+ },
22
+ {
23
+ "name": "global-const-style",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 2013
27
+ ],
28
+ "summary": "decline the as const when the binding is mutated later (closes #2013)"
29
+ },
30
+ {
31
+ "name": "no-explicit-return-type",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 2014
35
+ ],
36
+ "summary": "keep a decorator factory's annotation (closes #2014)"
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "1.20.152",
4
42
  "date": "2026-08-14T10:28:08.765Z",