@blumintinc/eslint-plugin-blumint 1.20.180 → 1.20.181

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.180',
226
+ version: '1.20.181',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -74,6 +74,70 @@ function isVoidishType(node) {
74
74
  return false;
75
75
  }
76
76
  }
77
+ /**
78
+ * Type names whose values are awaited rather than read. `PromiseLike` counts
79
+ * because thenability — not the `Promise` constructor — is what makes a value
80
+ * an awaited one, and a `PromiseLike` member is as unconvertible as a `Promise`
81
+ * one.
82
+ */
83
+ const THENABLE_TYPE_NAMES = new Set(['Promise', 'PromiseLike']);
84
+ /**
85
+ * `Promise` statics whose result is a promise whatever they are handed, so a
86
+ * `return Promise.all(...)` is a promise return with no annotation to read.
87
+ */
88
+ const PROMISE_STATIC_PRODUCERS = new Set([
89
+ 'resolve',
90
+ 'reject',
91
+ 'all',
92
+ 'allSettled',
93
+ 'race',
94
+ 'any',
95
+ ]);
96
+ /**
97
+ * Methods a promise answers, whose own result is another promise. `then` is the
98
+ * definition of thenable; `catch`/`finally` are sugar over it.
99
+ */
100
+ const THENABLE_CHAIN_METHODS = new Set(['then', 'catch', 'finally']);
101
+ /**
102
+ * The final segment of a type name, so a qualified spelling
103
+ * (`globalThis.Promise<T>`, `bluebird.Promise<T>`) is recognized as the thenable
104
+ * it names rather than dismissed for not being a bare `Identifier`.
105
+ */
106
+ function rightmostTypeName(typeName) {
107
+ if (typeName.type === utils_1.AST_NODE_TYPES.Identifier)
108
+ return typeName.name;
109
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
110
+ return typeName.right.name;
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * Whether a *written* type annotation denotes a thenable.
116
+ *
117
+ * This is deliberately syntactic and deliberately not exhaustive: the rule
118
+ * requests no parser services, so an alias that happens to resolve to a promise
119
+ * is out of reach. Failing to spot one costs a report the rule would otherwise
120
+ * have made, which is the safe direction — the unsafe one is prescribing (and
121
+ * on `private` members, applying) a getter rewrite that breaks every caller.
122
+ */
123
+ function isThenableTypeNode(node) {
124
+ if (!node)
125
+ return false;
126
+ switch (node.type) {
127
+ case utils_1.AST_NODE_TYPES.TSTypeReference: {
128
+ const name = rightmostTypeName(node.typeName);
129
+ return name !== null && THENABLE_TYPE_NAMES.has(name);
130
+ }
131
+ // A container that can hold a thenable still hands the caller one:
132
+ // `Promise<T> | undefined` is awaited at the call site exactly as `Promise<T>`
133
+ // is, and an intersection carries every constituent's contract.
134
+ case utils_1.AST_NODE_TYPES.TSUnionType:
135
+ case utils_1.AST_NODE_TYPES.TSIntersectionType:
136
+ return node.types.some(isThenableTypeNode);
137
+ default:
138
+ return false;
139
+ }
140
+ }
77
141
  function isFunctionLikeNode(value) {
78
142
  return (value.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
79
143
  value.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
@@ -617,6 +681,213 @@ exports.preferGetterOverParameterlessMethod = (0, createRule_1.createRule)({
617
681
  }
618
682
  return false;
619
683
  }
684
+ /**
685
+ * The expressions the method itself returns. A `return` inside a nested
686
+ * function is that callback's result, not the method's, so those are skipped
687
+ * exactly as every other body walk here skips them.
688
+ */
689
+ function collectReturnedExpressions(body) {
690
+ const returned = [];
691
+ const stack = [...body.body];
692
+ while (stack.length) {
693
+ const current = stack.pop();
694
+ if (isFunctionLikeNode(current)) {
695
+ continue;
696
+ }
697
+ if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
698
+ current.argument) {
699
+ returned.push(current.argument);
700
+ }
701
+ pushChildNodes(current, stack);
702
+ }
703
+ return returned;
704
+ }
705
+ /**
706
+ * Whether a function-valued node hands back a thenable: by its `async`
707
+ * keyword, by its own return annotation, or — failing both — by what its
708
+ * body demonstrably returns.
709
+ *
710
+ * `owner` fixes the class body that `this.<name>` resolves against. It stays
711
+ * the originally reported method through every recursion, because a sibling
712
+ * reached from that method's body lives in the same class body.
713
+ */
714
+ function functionYieldsThenable(owner, fn, seen) {
715
+ if (!fn)
716
+ return false;
717
+ if (fn.async)
718
+ return true;
719
+ const returnType = fn.returnType?.typeAnnotation;
720
+ if (returnType) {
721
+ return isThenableTypeNode(returnType);
722
+ }
723
+ const body = fn.body;
724
+ if (!body)
725
+ return false;
726
+ // A concise arrow body IS the returned expression.
727
+ if (body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
728
+ return isThenableExpression(owner, body, seen);
729
+ }
730
+ return collectReturnedExpressions(body).some((expression) => isThenableExpression(owner, expression, seen));
731
+ }
732
+ /** Recurses into a sibling's body once, never revisiting a function. */
733
+ function siblingFunctionYieldsThenable(owner, fn, seen) {
734
+ // `a() { return this.b(); } b() { return this.a(); }` is legal and would
735
+ // otherwise recur forever; visiting each function at most once also bounds
736
+ // the work by the size of the class body.
737
+ if (!fn || seen.has(fn))
738
+ return false;
739
+ seen.add(fn);
740
+ return functionYieldsThenable(owner, fn, seen);
741
+ }
742
+ /**
743
+ * Whether `this.<name>` resolves, within the enclosing class body, to a
744
+ * member that yields a thenable.
745
+ *
746
+ * A promise-returning method is often written with no annotation of its own
747
+ * (`readEpoch() { return this.evaluate(); }`), so the sibling's declaration
748
+ * is the only syntactic evidence available without a type checker. The
749
+ * sibling's BODY is consulted when it carries no annotation either, so the
750
+ * exemption survives a sibling transform that strips one — `--fix` under the
751
+ * recommended config runs `no-explicit-return-type` over the same file, and
752
+ * an exemption that only an annotation can carry does not survive it.
753
+ *
754
+ * `viaCall` distinguishes `this.evaluate()` from `this.evaluate`: reading a
755
+ * method without calling it yields the function object, which is not a
756
+ * thenable however the method is annotated, while reading a getter or a
757
+ * field is what produces its declared type.
758
+ */
759
+ function siblingYieldsThenable(owner, name, viaCall, seen) {
760
+ const classBody = owner.parent;
761
+ if (!classBody || classBody.type !== utils_1.AST_NODE_TYPES.ClassBody) {
762
+ return false;
763
+ }
764
+ return classBody.body.some((member) => {
765
+ if (member.type === utils_1.AST_NODE_TYPES.StaticBlock)
766
+ return false;
767
+ const memberName = memberNameOf(member.key, member.computed);
768
+ if (memberName !== name)
769
+ return false;
770
+ if (member.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
771
+ member.type === utils_1.AST_NODE_TYPES.TSAbstractMethodDefinition) {
772
+ const readsAsValue = member.kind === 'get' ? !viaCall : viaCall;
773
+ return (readsAsValue &&
774
+ siblingFunctionYieldsThenable(owner, member.value, seen));
775
+ }
776
+ if (member.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
777
+ member.type === utils_1.AST_NODE_TYPES.TSAbstractPropertyDefinition) {
778
+ const annotation = member.typeAnnotation?.typeAnnotation;
779
+ const value = member.value;
780
+ const isFunctionValued = !!value &&
781
+ (value.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
782
+ value.type === utils_1.AST_NODE_TYPES.FunctionExpression);
783
+ if (!viaCall) {
784
+ if (isThenableTypeNode(annotation))
785
+ return true;
786
+ // An un-annotated field initialized to a promise (`private pending =
787
+ // Promise.resolve(x)`) is thenable on its own evidence. A
788
+ // function-valued field is not: reading it yields the function.
789
+ return (!annotation &&
790
+ !isFunctionValued &&
791
+ !!value &&
792
+ isThenableExpression(owner, value, seen));
793
+ }
794
+ // A called field is function-valued, so its RETURN type is what
795
+ // reaches the caller.
796
+ if (annotation?.type === utils_1.AST_NODE_TYPES.TSFunctionType) {
797
+ return isThenableTypeNode(annotation.returnType?.typeAnnotation);
798
+ }
799
+ return (isFunctionValued &&
800
+ siblingFunctionYieldsThenable(owner, value, seen));
801
+ }
802
+ return false;
803
+ });
804
+ }
805
+ /** `Promise`, however it is qualified (`globalThis.Promise`, `bluebird.Promise`). */
806
+ function isPromiseNamespace(expression) {
807
+ if (expression.type === utils_1.AST_NODE_TYPES.Identifier) {
808
+ return expression.name === 'Promise';
809
+ }
810
+ if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression) {
811
+ return (memberNameOf(expression.property, expression.computed) === 'Promise');
812
+ }
813
+ return false;
814
+ }
815
+ function isThenableCall(owner, call, seen) {
816
+ // An optional call is a `ChainExpression` WRAPPING the call, so the
817
+ // callee here is always the plain member expression; the chain wrapper is
818
+ // unwrapped one level up.
819
+ const callee = call.callee;
820
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression)
821
+ return false;
822
+ const property = memberNameOf(callee.property, callee.computed);
823
+ if (property === null)
824
+ return false;
825
+ if (isPromiseNamespace(callee.object) &&
826
+ PROMISE_STATIC_PRODUCERS.has(property)) {
827
+ return true;
828
+ }
829
+ if (THENABLE_CHAIN_METHODS.has(property))
830
+ return true;
831
+ if (callee.object.type === utils_1.AST_NODE_TYPES.ThisExpression) {
832
+ return siblingYieldsThenable(owner, property, true, seen);
833
+ }
834
+ return false;
835
+ }
836
+ /**
837
+ * Whether a returned expression is demonstrably a thenable. Recursion is
838
+ * confined to combinators that pass a value straight through (assertions,
839
+ * `?:`, `&&`/`||`/`??`, optional chains), so the answer always rests on one
840
+ * of the concrete producers above rather than on a guess about a name.
841
+ */
842
+ function isThenableExpression(owner, expression, seen, depth = 0) {
843
+ if (depth > 4)
844
+ return false;
845
+ switch (expression.type) {
846
+ // `return await x` only parses inside an `async` method, so the method
847
+ // itself hands the caller a promise.
848
+ case utils_1.AST_NODE_TYPES.AwaitExpression:
849
+ return true;
850
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
851
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
852
+ return (isThenableTypeNode(expression.typeAnnotation) ||
853
+ isThenableExpression(owner, expression.expression, seen, depth + 1));
854
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
855
+ case utils_1.AST_NODE_TYPES.ChainExpression:
856
+ return isThenableExpression(owner, expression.expression, seen, depth + 1);
857
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
858
+ return (isThenableExpression(owner, expression.consequent, seen, depth + 1) ||
859
+ isThenableExpression(owner, expression.alternate, seen, depth + 1));
860
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
861
+ return (isThenableExpression(owner, expression.left, seen, depth + 1) ||
862
+ isThenableExpression(owner, expression.right, seen, depth + 1));
863
+ case utils_1.AST_NODE_TYPES.CallExpression:
864
+ return isThenableCall(owner, expression, seen);
865
+ case utils_1.AST_NODE_TYPES.MemberExpression:
866
+ // `return this.pending` where `pending: Promise<T>`.
867
+ return (expression.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
868
+ siblingYieldsThenable(owner, memberNameOf(expression.property, expression.computed) ?? '', false, seen));
869
+ default:
870
+ return false;
871
+ }
872
+ }
873
+ /**
874
+ * Whether the method hands the caller a thenable.
875
+ *
876
+ * TypeScript does not require the `async` keyword to return a promise, so
877
+ * keying on the keyword alone classified `fetchToken(): Promise<string>` as
878
+ * synchronous and asked for a getter (#2154). A getter is never a legal
879
+ * remedy here: it turns a call that starts work into a property read, so
880
+ * `session.epoch` spawns the work on what reads as a field access and any
881
+ * reflective call site (`(session as any).readEpoch()`) throws outright.
882
+ *
883
+ * An explicit return annotation is the method's whole contract, so a
884
+ * non-thenable one settles the question without reading the body — which is
885
+ * what keeps an annotated `(): string` method reportable even when its body
886
+ * mentions promises.
887
+ */
888
+ function returnsThenable(node) {
889
+ return functionYieldsThenable(node, node.value, new Set([node.value]));
890
+ }
620
891
  /**
621
892
  * Returns true when the method body contains a ThrowStatement that is
622
893
  * directly in the method's own scope (not inside a nested function/arrow).
@@ -882,7 +1153,11 @@ exports.preferGetterOverParameterlessMethod = (0, createRule_1.createRule)({
882
1153
  node.key.type !== utils_1.AST_NODE_TYPES.PrivateIdentifier) {
883
1154
  return;
884
1155
  }
885
- if (config.ignoreAsync && node.value.async)
1156
+ // `ignoreAsync` means "ignore asynchronous methods", not "ignore
1157
+ // methods bearing the async keyword": TypeScript does not require the
1158
+ // keyword to return a promise, so `fetchToken(): Promise<string>` is
1159
+ // asynchronous with no keyword written at all (#2154).
1160
+ if (config.ignoreAsync && returnsThenable(node))
886
1161
  return;
887
1162
  if (config.ignoreAbstract &&
888
1163
  node.type === utils_1.AST_NODE_TYPES.TSAbstractMethodDefinition) {
@@ -970,7 +1245,14 @@ exports.preferGetterOverParameterlessMethod = (0, createRule_1.createRule)({
970
1245
  const hasDuplicateSuggestedName = classBody?.type === utils_1.AST_NODE_TYPES.ClassBody
971
1246
  ? (suggestedNameCounts.get(classBody)?.get(scopeKey) ?? 0) > 1
972
1247
  : false;
973
- const isAsync = node.value.async;
1248
+ // A thenable-returning member has no legal getter form at all: the
1249
+ // rewrite converts a call that starts work into a property read, and
1250
+ // a reflective call site (`(session as any).readEpoch()`) throws
1251
+ // afterwards. The eligibility gate already withholds the report for
1252
+ // these, so this is a second, independent lock — a future change
1253
+ // there must not silently re-enable a rewrite that cannot compile or
1254
+ // run. It subsumes the former `async`-keyword-only withhold.
1255
+ const isThenableReturning = returnsThenable(node);
974
1256
  // A decorator cannot be applied to an ECMA private member under
975
1257
  // `experimentalDecorators` (TS1206), so a decorated `#foo()` has no
976
1258
  // legal getter form to convert to — the fix is impossible, not merely
@@ -1001,7 +1283,7 @@ exports.preferGetterOverParameterlessMethod = (0, createRule_1.createRule)({
1001
1283
  },
1002
1284
  fix: !isPrivate ||
1003
1285
  sideEffectReason ||
1004
- isAsync ||
1286
+ isThenableReturning ||
1005
1287
  !leftParen ||
1006
1288
  !rightParen ||
1007
1289
  hasCollision ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.180",
3
+ "version": "1.20.181",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "1.20.181",
4
+ "date": "2026-08-27T06:29:43.247Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-getter-over-parameterless-method",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2154
11
+ ],
12
+ "summary": "decide \"synchronous\" from the returned type, not the async keyword (closes #2154)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.180",
4
18
  "date": "2026-08-27T01:52:25.340Z",