@blumintinc/eslint-plugin-blumint 1.20.63 → 1.20.65

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.63',
226
+ version: '1.20.65',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -565,6 +565,131 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
565
565
  }
566
566
  return false;
567
567
  }
568
+ /**
569
+ * `@firebase/rules-unit-testing` hands back the compat (v8) Firestore, whose
570
+ * `.doc()`, `.collection()` and `.collectionGroup()` declare zero type
571
+ * parameters. Asking for a schema generic there produces
572
+ * `TS2558: Expected 0 type arguments, but got 1`, so every remediation this
573
+ * rule suggests is uncompilable on that surface and the receiver must be
574
+ * exempt.
575
+ */
576
+ const RULES_UNIT_TESTING_MODULE = '@firebase/rules-unit-testing';
577
+ let rulesUnitTestingLocals;
578
+ /**
579
+ * Scanned lazily rather than from an `ImportDeclaration` visitor so that
580
+ * traversal order can never decide whether the exemption applies. Type-only
581
+ * specifiers count because a callback parameter annotated `RulesTestContext`
582
+ * is the other way a compat handle reaches a `.doc()` receiver.
583
+ */
584
+ function getRulesUnitTestingLocals() {
585
+ if (rulesUnitTestingLocals) {
586
+ return rulesUnitTestingLocals;
587
+ }
588
+ const locals = new Set();
589
+ for (const statement of context.sourceCode.ast.body) {
590
+ if (statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
591
+ statement.source.value === RULES_UNIT_TESTING_MODULE) {
592
+ for (const specifier of statement.specifiers) {
593
+ locals.add(specifier.local.name);
594
+ }
595
+ }
596
+ }
597
+ rulesUnitTestingLocals = locals;
598
+ return locals;
599
+ }
600
+ /**
601
+ * A qualified annotation such as `rut.RulesTestContext` is rooted at the
602
+ * namespace binding, which is the name the import declaration provides.
603
+ */
604
+ function rootTypeNameOf(typeNode) {
605
+ if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
606
+ return undefined;
607
+ }
608
+ let entity = typeNode.typeName;
609
+ while (entity.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
610
+ entity = entity.left;
611
+ }
612
+ return entity.type === utils_1.AST_NODE_TYPES.Identifier
613
+ ? entity.name
614
+ : undefined;
615
+ }
616
+ function isRulesUnitTestingType(typeNode) {
617
+ if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType ||
618
+ typeNode.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
619
+ return typeNode.types.some(isRulesUnitTestingType);
620
+ }
621
+ const rootName = rootTypeNameOf(typeNode);
622
+ return !!rootName && getRulesUnitTestingLocals().has(rootName);
623
+ }
624
+ /**
625
+ * Walks an expression toward its syntactic root and reports whether that
626
+ * root is a value supplied by `@firebase/rules-unit-testing`.
627
+ *
628
+ * Only `const` bindings are followed, mirroring `isTypedCollectionBinding`:
629
+ * a `let`/`var` receiver can be reassigned to an Admin SDK handle, where the
630
+ * generic is both supportable and valuable, so exempting it would silently
631
+ * drop enforcement.
632
+ */
633
+ function tracesToRulesUnitTesting(node, visited = new Set()) {
634
+ if (!node || getRulesUnitTestingLocals().size === 0) {
635
+ return false;
636
+ }
637
+ // Guards against a self-referential declaration such as `const a = a.b;`.
638
+ if (visited.has(node)) {
639
+ return false;
640
+ }
641
+ visited.add(node);
642
+ switch (node.type) {
643
+ case utils_1.AST_NODE_TYPES.AwaitExpression:
644
+ return tracesToRulesUnitTesting(node.argument, visited);
645
+ case utils_1.AST_NODE_TYPES.CallExpression:
646
+ return tracesToRulesUnitTesting(node.callee, visited);
647
+ case utils_1.AST_NODE_TYPES.MemberExpression:
648
+ return tracesToRulesUnitTesting(node.object, visited);
649
+ case utils_1.AST_NODE_TYPES.ChainExpression:
650
+ return tracesToRulesUnitTesting(node.expression, visited);
651
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
652
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
653
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
654
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
655
+ return tracesToRulesUnitTesting(node.expression, visited);
656
+ case utils_1.AST_NODE_TYPES.Identifier:
657
+ return identifierTracesToRulesUnitTesting(node, visited);
658
+ default:
659
+ return false;
660
+ }
661
+ }
662
+ function identifierTracesToRulesUnitTesting(node, visited) {
663
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
664
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
665
+ if (!variable || variable.defs.length !== 1) {
666
+ return false;
667
+ }
668
+ const def = variable.defs[0];
669
+ if (def.type === 'ImportBinding') {
670
+ return (def.parent?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
671
+ def.parent.source.value === RULES_UNIT_TESTING_MODULE);
672
+ }
673
+ // Covers the `withSecurityRulesDisabled(async (ctx: RulesTestContext) =>
674
+ // ...)` callback, where the compat handle never appears as an initializer.
675
+ if (def.type === 'Parameter') {
676
+ const annotation = def.name.typeAnnotation;
677
+ return (!!annotation && isRulesUnitTestingType(annotation.typeAnnotation));
678
+ }
679
+ if (def.type !== 'Variable' ||
680
+ def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
681
+ def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
682
+ def.parent.kind !== 'const') {
683
+ return false;
684
+ }
685
+ const declarator = def.node;
686
+ if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
687
+ declarator.id.typeAnnotation &&
688
+ isRulesUnitTestingType(declarator.id.typeAnnotation.typeAnnotation)) {
689
+ return true;
690
+ }
691
+ return tracesToRulesUnitTesting(declarator.init, visited);
692
+ }
568
693
  return {
569
694
  TSTypeReference(node) {
570
695
  if (node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -601,6 +726,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
601
726
  if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
602
727
  node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
603
728
  node.callee.property.name === 'doc') {
729
+ if (tracesToRulesUnitTesting(node.callee.object)) {
730
+ return;
731
+ }
604
732
  const typeAnnotation = node.typeParameters;
605
733
  const isOnTypedCollection = isTypedCollectionReference(node.callee.object);
606
734
  // If this is a .doc() call on a typed CollectionReference,
@@ -655,6 +783,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
655
783
  node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
656
784
  node.callee.property.name === 'collection' &&
657
785
  !isPartOfMethodChain(node)) {
786
+ if (tracesToRulesUnitTesting(node.callee.object)) {
787
+ return;
788
+ }
658
789
  const typeAnnotation = node.typeParameters;
659
790
  if (!typeAnnotation) {
660
791
  context.report({
@@ -675,6 +806,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
675
806
  else if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
676
807
  node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
677
808
  node.callee.property.name === 'collectionGroup') {
809
+ if (tracesToRulesUnitTesting(node.callee.object)) {
810
+ return;
811
+ }
678
812
  const typeAnnotation = node.typeParameters;
679
813
  if (!typeAnnotation) {
680
814
  context.report({
@@ -27,6 +27,7 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
27
27
  // Regular expression to match standalone "id" surrounded by whitespace or punctuation
28
28
  // This ensures we only match "id" as a word, not as part of another word
29
29
  const idRegex = /(^|\s|[.,;:!?'"()\[\]{}])id(\s|$|[.,;:!?'"()\[\]{}])/g;
30
+ const sourceCode = context.getSourceCode();
30
31
  // DOM / Testing-Library APIs whose first argument is an attribute NAME
31
32
  // (code), not user-facing text. A literal like 'id' passed here is a DOM
32
33
  // attribute name; flagging or rewriting it to 'ID' breaks the call.
@@ -145,6 +146,54 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
145
146
  }
146
147
  return false;
147
148
  }
149
+ /**
150
+ * Escape a string so it can sit inside a literal delimited by `delimiter`.
151
+ *
152
+ * JSON escaping already covers backslashes, control characters and every
153
+ * other sequence a JavaScript string literal needs, but it is hardcoded to
154
+ * double quotes. For a single-quoted literal the escaping is simply
155
+ * inverted: the double quotes go bare and the apostrophes carry the
156
+ * backslash.
157
+ */
158
+ function escapeForDelimiter(text, delimiter) {
159
+ const jsonBody = JSON.stringify(text).slice(1, -1);
160
+ if (delimiter === '"') {
161
+ return jsonBody;
162
+ }
163
+ // Every `"` in a JSON body is escaped and every `\` belongs to an escape
164
+ // sequence, so unescaping the quotes first cannot corrupt a `\\` pair.
165
+ return jsonBody.replace(/\\"/g, '"').replace(/'/g, "\\'");
166
+ }
167
+ /**
168
+ * Rebuild a string literal around the corrected text, keeping the quote
169
+ * character the author chose. Rewriting the delimiter is a formatting
170
+ * regression on every fixed file, so the fix has to reuse it.
171
+ */
172
+ function fixStringLiteral(node, fixedText) {
173
+ const raw = sourceCode.getText(node);
174
+ const delimiter = raw[0];
175
+ if (delimiter !== "'" && delimiter !== '"') {
176
+ return null;
177
+ }
178
+ const rawBody = raw.slice(1, -1);
179
+ const isJsxAttributeValue = node.parent && node.parent.type === utils_1.AST_NODE_TYPES.JSXAttribute;
180
+ // Substituting inside the raw source reproduces the file byte for byte,
181
+ // so take that path whenever the source between the quotes already is the
182
+ // parsed value. It is also the only safe path for a JSX attribute value,
183
+ // which the parser does not escape-process: there a backslash is a
184
+ // literal character (rebuilding would double it) and an entity such as
185
+ // " decodes to a delimiter that cannot be written back escaped.
186
+ if (rawBody === node.value || isJsxAttributeValue) {
187
+ idRegex.lastIndex = 0;
188
+ const fixedBody = rawBody.replace(idRegex, (_match, prefix, suffix) => `${prefix}ID${suffix}`);
189
+ // An entity-bearing JSX value can carry the match only in its decoded
190
+ // form; report it without a fix rather than emit a no-op replacement.
191
+ return fixedBody === rawBody
192
+ ? null
193
+ : `${delimiter}${fixedBody}${delimiter}`;
194
+ }
195
+ return `${delimiter}${escapeForDelimiter(fixedText, delimiter)}${delimiter}`;
196
+ }
148
197
  /**
149
198
  * Check if a string contains "id" as a standalone word and report if found
150
199
  */
@@ -177,16 +226,21 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
177
226
  node,
178
227
  messageId: 'enforceIdCapitalization',
179
228
  fix: (fixer) => {
180
- if (node.type === utils_1.AST_NODE_TYPES.Literal) {
181
- return fixer.replaceText(node, JSON.stringify(fixedText));
182
- }
183
- else if (node.type === utils_1.AST_NODE_TYPES.JSXText) {
229
+ // JSX text carries no delimiters, so its own text is the content.
230
+ if (node.type === utils_1.AST_NODE_TYPES.JSXText) {
184
231
  return fixer.replaceText(node, fixedText);
185
232
  }
186
- else if (node.type === utils_1.AST_NODE_TYPES.TemplateElement) {
187
- return fixer.replaceText(node, fixedText);
233
+ if (node.type === utils_1.AST_NODE_TYPES.Literal) {
234
+ const replacement = fixStringLiteral(node, fixedText);
235
+ return replacement === null
236
+ ? null
237
+ : fixer.replaceText(node, replacement);
188
238
  }
189
- return fixer.replaceText(node, JSON.stringify(fixedText));
239
+ // Any other node kind (a TemplateElement, say) is a fragment of a
240
+ // larger construct whose delimiters and `${}` expressions live
241
+ // outside this node; rebuilding it from the parsed value would
242
+ // destroy them, so report without a fix.
243
+ return null;
190
244
  },
191
245
  });
192
246
  }
@@ -75,6 +75,57 @@ function declaresVoidResult(returnType) {
75
75
  return (typeArguments?.length === 1 &&
76
76
  typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
77
77
  }
78
+ /**
79
+ * The type annotation a parameter declares, reached through the wrapper shapes
80
+ * a parameter can take. A default value (`cb = noop`) holds the annotated
81
+ * binding in `left`, while a rest element carries the annotation on itself and
82
+ * keeps only the binding name in `argument`.
83
+ */
84
+ function parameterAnnotation(param) {
85
+ if (param.type === utils_1.AST_NODE_TYPES.Identifier) {
86
+ return param.typeAnnotation?.typeAnnotation;
87
+ }
88
+ if (param.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
89
+ return param.left.typeAnnotation?.typeAnnotation;
90
+ }
91
+ if (param.type === utils_1.AST_NODE_TYPES.RestElement) {
92
+ return (param.typeAnnotation?.typeAnnotation ??
93
+ (param.argument.type === utils_1.AST_NODE_TYPES.Identifier
94
+ ? param.argument.typeAnnotation?.typeAnnotation
95
+ : undefined));
96
+ }
97
+ return undefined;
98
+ }
99
+ /**
100
+ * Whether the method's sole parameter is annotated as a function.
101
+ *
102
+ * `@Memoize()` keys its cache on the argument value, compared against stored
103
+ * entries by deep equality. A function argument satisfies that comparison in
104
+ * exactly two ways and both defeat the decorator: a stable reference (a
105
+ * module-level function, a bound method, `this.handler`) matches the first
106
+ * entry, so every later call replays the first result and the body never runs
107
+ * again; a fresh arrow per call — the common shape — matches nothing, so every
108
+ * lookup misses while the map accumulates one dead closure per call and each
109
+ * later call pays a longer scan. Neither is an optimisation, and the fixer
110
+ * would apply it unattended, so such a method is skipped.
111
+ *
112
+ * The `params.length > 1` gate already encodes that the decorator keys on a
113
+ * single argument; this asks the remaining question, whether that argument is
114
+ * keyable at all.
115
+ *
116
+ * Detection is syntactic, mirroring the return-type exemption: only an
117
+ * annotation written as a function type declares the intent to honour. A
118
+ * callback reached through a type alias (`onUrl: UrlPresenter`) or hidden in a
119
+ * union (`cb: string | (() => void)`) is not categorically a function without a
120
+ * type checker this rule deliberately does not require, and an unannotated
121
+ * parameter declares nothing at all, so all of those keep reporting.
122
+ */
123
+ function declaresFunctionParameter(params) {
124
+ if (params.length !== 1) {
125
+ return false;
126
+ }
127
+ return parameterAnnotation(params[0])?.type === utils_1.AST_NODE_TYPES.TSFunctionType;
128
+ }
78
129
  /**
79
130
  * Matches a memoize decorator in supported syntaxes:
80
131
  * - @Alias()
@@ -181,6 +232,11 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
181
232
  if (node.value.params.length > 1) {
182
233
  return;
183
234
  }
235
+ // A callback argument cannot serve as a cache key, so the decorator
236
+ // either replays a stale result or never hits while leaking entries.
237
+ if (declaresFunctionParameter(node.value.params)) {
238
+ return;
239
+ }
184
240
  // A method declared to produce no value has no result to cache, so the
185
241
  // decorator's benefit is unobtainable while its cost is real: the
186
242
  // fixer would convert a repeatable side effect into a
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceMemoizeGetters = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
+ const visitor_keys_1 = require("@typescript-eslint/visitor-keys");
5
6
  const createRule_1 = require("../utils/createRule");
6
7
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
8
  const disableDirectives_1 = require("../utils/disableDirectives");
@@ -68,6 +69,238 @@ function isMemoizeDecorator(decorator, alias) {
68
69
  }
69
70
  return false;
70
71
  }
72
+ /**
73
+ * Node modules whose exports observe or drive the world outside the process.
74
+ * A getter that reaches one of them yields a fresh observation per access, and
75
+ * `@Memoize()` would pin the first observation for the life of the instance —
76
+ * a behaviour change, not an optimization.
77
+ */
78
+ const IO_MODULES = new Set([
79
+ 'child_process',
80
+ 'node:child_process',
81
+ 'fs',
82
+ 'node:fs',
83
+ 'fs/promises',
84
+ 'node:fs/promises',
85
+ 'net',
86
+ 'node:net',
87
+ 'http',
88
+ 'node:http',
89
+ 'https',
90
+ 'node:https',
91
+ 'dns',
92
+ 'node:dns',
93
+ ]);
94
+ /**
95
+ * Builtins whose result differs between two otherwise identical accesses, so
96
+ * the cached first value stops tracking whatever the getter samples.
97
+ */
98
+ const NON_DETERMINISTIC_CALLS = new Set([
99
+ 'Date.now',
100
+ 'Math.random',
101
+ 'performance.now',
102
+ 'crypto.randomUUID',
103
+ 'crypto.getRandomValues',
104
+ 'process.hrtime',
105
+ 'process.hrtime.bigint',
106
+ ]);
107
+ /**
108
+ * The dotted path of a chain of plain identifiers (`process.hrtime.bigint`), or
109
+ * `null` once any link is computed or is not an identifier. Keying on the
110
+ * spelled path keeps the analysis syntactic, which is all this rule has: it
111
+ * runs without `parserOptions.project`, so no type information is available.
112
+ */
113
+ function staticMemberPath(node) {
114
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
115
+ return node.name;
116
+ }
117
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
118
+ !node.computed &&
119
+ node.property.type === utils_1.AST_NODE_TYPES.Identifier) {
120
+ const objectPath = staticMemberPath(node.object);
121
+ return objectPath === null ? null : `${objectPath}.${node.property.name}`;
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * The member name a key spells, covering the forms `this.<name>` can reach:
127
+ * plain identifiers, string-literal keys, and private names.
128
+ */
129
+ function keyName(key) {
130
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
131
+ return key.name;
132
+ }
133
+ if (key.type === utils_1.AST_NODE_TYPES.PrivateIdentifier) {
134
+ return `#${key.name}`;
135
+ }
136
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
137
+ return key.value;
138
+ }
139
+ return null;
140
+ }
141
+ /** The member name of a `this.<name>` access, or `null` for anything else. */
142
+ function thisMemberName(node) {
143
+ if (node.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
144
+ node.object.type !== utils_1.AST_NODE_TYPES.ThisExpression) {
145
+ return null;
146
+ }
147
+ return keyName(node.property);
148
+ }
149
+ function forEachDescendant(root, visit) {
150
+ const stack = [root];
151
+ while (stack.length > 0) {
152
+ const current = stack.pop();
153
+ if (!current) {
154
+ break;
155
+ }
156
+ visit(current);
157
+ const keys = visitor_keys_1.visitorKeys[current.type] ?? [];
158
+ for (const key of keys) {
159
+ const value = current[key];
160
+ if (Array.isArray(value)) {
161
+ for (const element of value) {
162
+ if (ASTHelpers_1.ASTHelpers.isNode(element)) {
163
+ stack.push(element);
164
+ }
165
+ }
166
+ }
167
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
168
+ stack.push(value);
169
+ }
170
+ }
171
+ }
172
+ }
173
+ /**
174
+ * Local names bound by an import of a Node I/O module, covering named, default
175
+ * and namespace specifiers alike: a call through any of them — `execFileSync()`
176
+ * or `fs.readFileSync()` — reaches the same I/O.
177
+ */
178
+ function collectIoBindings(program) {
179
+ const bindings = new Set();
180
+ for (const statement of program.body) {
181
+ if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
182
+ statement.importKind === 'type' ||
183
+ !IO_MODULES.has(String(statement.source.value))) {
184
+ continue;
185
+ }
186
+ for (const specifier of statement.specifiers) {
187
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
188
+ specifier.importKind === 'type') {
189
+ continue;
190
+ }
191
+ bindings.add(specifier.local.name);
192
+ }
193
+ }
194
+ return bindings;
195
+ }
196
+ /**
197
+ * Whether a single node is one of the seed impurities: a call reaching a Node
198
+ * I/O binding, a non-deterministic builtin, or a read of `process.env`.
199
+ */
200
+ function isImpureSeed(node, ioBindings) {
201
+ // `new Date()` samples the wall clock; `new Date(value)` is a pure conversion.
202
+ if (node.type === utils_1.AST_NODE_TYPES.NewExpression) {
203
+ return (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
204
+ node.callee.name === 'Date' &&
205
+ node.arguments.length === 0);
206
+ }
207
+ // Every `process.env.X` / `process.env['X']` read contains this subexpression.
208
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
209
+ return staticMemberPath(node) === 'process.env';
210
+ }
211
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
212
+ return false;
213
+ }
214
+ const calleePath = staticMemberPath(node.callee);
215
+ if (calleePath === null) {
216
+ return false;
217
+ }
218
+ return (NON_DETERMINISTIC_CALLS.has(calleePath) ||
219
+ ioBindings.has(calleePath.split('.')[0]));
220
+ }
221
+ /**
222
+ * The function a class element carries and the name `this.<name>` reaches it
223
+ * by, for the element kinds a getter can delegate to: methods, accessors, and
224
+ * fields holding a function. Anything else (a static block, a plain field) is
225
+ * not a delegation target.
226
+ */
227
+ function describeMember(element) {
228
+ if (element.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
229
+ element.type === utils_1.AST_NODE_TYPES.TSAbstractMethodDefinition) {
230
+ return { fn: element.value, name: keyName(element.key) };
231
+ }
232
+ if (element.type === utils_1.AST_NODE_TYPES.PropertyDefinition &&
233
+ (element.value?.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
234
+ element.value?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
235
+ return { fn: element.value, name: keyName(element.key) };
236
+ }
237
+ return undefined;
238
+ }
239
+ /**
240
+ * The class elements whose value depends on live external state, as a
241
+ * class-local fixpoint: seed the members that reach I/O or a non-deterministic
242
+ * builtin directly, then keep marking any member that touches an already-impure
243
+ * `this.<member>` until nothing changes.
244
+ *
245
+ * The iteration — rather than a single direct-callee check — is what covers the
246
+ * production shape that motivates the exemption: a getter delegating to a
247
+ * sibling private method that itself delegates further before reaching the I/O
248
+ * call.
249
+ */
250
+ function analyzeClassImpurity(classBody, ioBindings) {
251
+ const analyses = [];
252
+ const byName = new Map();
253
+ for (const element of classBody.body) {
254
+ const member = describeMember(element);
255
+ if (!member) {
256
+ continue;
257
+ }
258
+ const analysis = {
259
+ element,
260
+ dependencies: new Set(),
261
+ impure: false,
262
+ };
263
+ forEachDescendant(member.fn, (node) => {
264
+ if (isImpureSeed(node, ioBindings)) {
265
+ analysis.impure = true;
266
+ return;
267
+ }
268
+ // A bare read of `this.<member>` counts alongside a call: reading an
269
+ // impure getter is exactly the propagation path this analysis exists for.
270
+ const dependency = thisMemberName(node);
271
+ if (dependency !== null) {
272
+ analysis.dependencies.add(dependency);
273
+ }
274
+ });
275
+ analyses.push(analysis);
276
+ if (member.name !== null) {
277
+ const siblings = byName.get(member.name);
278
+ if (siblings) {
279
+ siblings.push(analysis);
280
+ }
281
+ else {
282
+ byName.set(member.name, [analysis]);
283
+ }
284
+ }
285
+ }
286
+ let changed = true;
287
+ while (changed) {
288
+ changed = false;
289
+ for (const analysis of analyses) {
290
+ if (analysis.impure) {
291
+ continue;
292
+ }
293
+ const reachesImpure = [...analysis.dependencies].some((dependency) => (byName.get(dependency) ?? []).some((target) => target.impure));
294
+ if (reachesImpure) {
295
+ analysis.impure = true;
296
+ changed = true;
297
+ }
298
+ }
299
+ }
300
+ return new Set(analyses
301
+ .filter((analysis) => analysis.impure)
302
+ .map(({ element }) => element));
303
+ }
71
304
  exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
72
305
  name: 'enforce-memoize-getters',
73
306
  meta: {
@@ -150,6 +383,24 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
150
383
  }
151
384
  return memoizeImportCache;
152
385
  };
386
+ let ioBindingCache = null;
387
+ const ioBindings = () => {
388
+ if (!ioBindingCache) {
389
+ ioBindingCache = collectIoBindings(sourceCode.ast);
390
+ }
391
+ return ioBindingCache;
392
+ };
393
+ // One fixpoint per class serves every getter it declares.
394
+ const impurityCache = new Map();
395
+ const impureMembersOf = (classBody) => {
396
+ const cached = impurityCache.get(classBody);
397
+ if (cached) {
398
+ return cached;
399
+ }
400
+ const analyzed = analyzeClassImpurity(classBody, ioBindings());
401
+ impurityCache.set(classBody, analyzed);
402
+ return analyzed;
403
+ };
153
404
  return {
154
405
  MethodDefinition(node) {
155
406
  // Target: instance private getters
@@ -161,6 +412,14 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
161
412
  // enforce only "private" accessibility (undefined => public)
162
413
  if (node.accessibility !== 'private')
163
414
  return;
415
+ // A getter whose value is a fresh read of live external state is not a
416
+ // lazy factory: memoizing it pins the first observation forever, so the
417
+ // report is dropped along with its unattended `--fix` edit.
418
+ const classBody = node.parent;
419
+ if (classBody?.type === utils_1.AST_NODE_TYPES.ClassBody &&
420
+ impureMembersOf(classBody).has(node)) {
421
+ return;
422
+ }
164
423
  const { hasMemoizeImport, memoizeAlias, memoizeNamespace, hasNamedImport, } = memoizeImports();
165
424
  const decoratorAliases = memoizeAlias === MEMOIZE_NAME
166
425
  ? [MEMOIZE_NAME]
@@ -390,6 +390,79 @@ function getModuleScopeValueBindings(program) {
390
390
  }
391
391
  return names;
392
392
  }
393
+ /** The leading whitespace of the line the offset sits on. */
394
+ function indentationAt(sourceCode, offset) {
395
+ const text = sourceCode.getText();
396
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
397
+ const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
398
+ return match ? match[0] : '';
399
+ }
400
+ /**
401
+ * Ranges whose interior line breaks carry string data rather than formatting.
402
+ * A multi-line template literal (or a string spliced together with line
403
+ * continuations) evaluates to the whitespace written inside it, so shifting
404
+ * those lines would silently change the value the code produces.
405
+ */
406
+ function stringDataRangesOf(sourceCode, node) {
407
+ return sourceCode
408
+ .getTokens(node)
409
+ .filter((token) => (token.type === utils_1.AST_TOKEN_TYPES.Template ||
410
+ token.type === utils_1.AST_TOKEN_TYPES.String) &&
411
+ token.loc.start.line !== token.loc.end.line)
412
+ .map((token) => token.range);
413
+ }
414
+ /**
415
+ * The callback's text re-indented for the module scope it is hoisted into.
416
+ *
417
+ * Module scope sits at least one nesting level shallower than the component
418
+ * body the callback is lifted out of, so splicing the text verbatim leaves
419
+ * every interior line — and the closing brace — indented for a level the
420
+ * declaration no longer occupies (issue #1560). Each line therefore moves by
421
+ * the difference between the callback's original indentation and the
422
+ * indentation of the statement the declaration is inserted before. The
423
+ * callback's own line is the reference rather than the declaration's, so a
424
+ * callback broken onto its own argument line sheds that extra level too.
425
+ */
426
+ function dedentedCallbackText(sourceCode, callback, hoistTarget) {
427
+ const text = sourceCode.getText(callback);
428
+ const targetIndent = indentationAt(sourceCode, hoistTarget.range[0]);
429
+ const callbackIndent = indentationAt(sourceCode, callback.range[0]);
430
+ if (targetIndent === callbackIndent) {
431
+ return text;
432
+ }
433
+ const shiftLine = (() => {
434
+ if (callbackIndent.startsWith(targetIndent)) {
435
+ const removed = callbackIndent.slice(targetIndent.length);
436
+ return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
437
+ }
438
+ if (targetIndent.startsWith(callbackIndent)) {
439
+ const added = targetIndent.slice(callbackIndent.length);
440
+ return (line) => `${added}${line}`;
441
+ }
442
+ // Indent characters that disagree give no delta that can be applied
443
+ // without corrupting the layout, so the text is left as the author wrote it.
444
+ return null;
445
+ })();
446
+ if (!shiftLine) {
447
+ return text;
448
+ }
449
+ const stringData = stringDataRangesOf(sourceCode, callback);
450
+ const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
451
+ let offset = callback.range[0];
452
+ return text
453
+ .split('\n')
454
+ .map((line, index) => {
455
+ const lineStart = offset;
456
+ offset += line.length + 1;
457
+ // The first line is spliced in after the hoisted declaration's `=`, so it
458
+ // has no indentation of its own left to adjust.
459
+ if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
460
+ return line;
461
+ }
462
+ return shiftLine(line);
463
+ })
464
+ .join('\n');
465
+ }
393
466
  function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCache) {
394
467
  if (!callExpression.parent ||
395
468
  callExpression.parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
@@ -442,7 +515,7 @@ function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCac
442
515
  const identifierText = sourceCode
443
516
  .getText()
444
517
  .slice(declarator.id.range[0], idRangeEnd);
445
- const functionText = sourceCode.getText(callback);
518
+ const functionText = dedentedCallbackText(sourceCode, callback, hoistTarget);
446
519
  const hoisted = `const ${identifierText} = ${functionText};\n`;
447
520
  const fileText = sourceCode.getText();
448
521
  let removeStart = varDecl.range[0];
@@ -8,6 +8,7 @@ type Options = [
8
8
  allowAbstractMethodSignatures?: boolean;
9
9
  allowDtsFiles?: boolean;
10
10
  allowFirestoreFunctionFiles?: boolean;
11
+ allowVoidReturnTypes?: boolean;
11
12
  }
12
13
  ];
13
14
  export declare const noExplicitReturnType: TSESLint.RuleModule<MessageIds, Options>;
@@ -10,6 +10,7 @@ const defaultOptions = {
10
10
  allowAbstractMethodSignatures: true,
11
11
  allowDtsFiles: true,
12
12
  allowFirestoreFunctionFiles: true,
13
+ allowVoidReturnTypes: true,
13
14
  };
14
15
  function getNameFromIdentifierOrLiteral(key) {
15
16
  if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
@@ -576,6 +577,45 @@ function isReadonlyWideningReturnType(returnType) {
576
577
  }
577
578
  return false;
578
579
  }
580
+ /**
581
+ * Returns true when the annotation declares that the function produces no
582
+ * value: a bare `void`, or `Promise<void>` with exactly one type argument.
583
+ *
584
+ * The rule's case against an explicit return type is that it restates what the
585
+ * implementation already returns and "can drift from what the implementation
586
+ * actually returns, hiding bugs behind a stale type". That case does not reach
587
+ * `void`/`Promise<void>`. Such an annotation is not a restatement of a result,
588
+ * it is a declaration that there is no result, and TypeScript enforces it:
589
+ * adding `return <expr>` to a function annotated `Promise<void>` is a compile
590
+ * error. It cannot drift into a lie, so removing it destroys information rather
591
+ * than removing redundancy.
592
+ *
593
+ * That information is load-bearing for other rules: `enforce-memoize-async`
594
+ * reads exactly this declaration of intent and skips a method declared
595
+ * `Promise<void>`, because caching a call that yields nothing turns a repeatable
596
+ * side effect into a once-per-instance one. Stripping the annotation happens
597
+ * unattended — `eslint --fix` re-lints until the output settles, so the strip
598
+ * and the memoization that follows it land in the same run.
599
+ *
600
+ * The shape match stays deliberately exact, mirroring the check
601
+ * `enforce-memoize-async` performs. A union (`Promise<void | string>`), a nested
602
+ * wrapper (`Promise<Awaited<void>>`) or any other arity can resolve to a value,
603
+ * so those annotations remain redundant restatements and stay reportable.
604
+ */
605
+ function declaresVoidResult(returnType) {
606
+ const annotation = returnType.typeAnnotation;
607
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSVoidKeyword) {
608
+ return true;
609
+ }
610
+ if (annotation.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
611
+ annotation.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
612
+ annotation.typeName.name !== 'Promise') {
613
+ return false;
614
+ }
615
+ const typeArguments = annotation.typeParameters?.params;
616
+ return (typeArguments?.length === 1 &&
617
+ typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
618
+ }
579
619
  exports.noExplicitReturnType = (0, createRule_1.createRule)({
580
620
  name: 'no-explicit-return-type',
581
621
  meta: {
@@ -597,6 +637,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
597
637
  allowAbstractMethodSignatures: { type: 'boolean' },
598
638
  allowDtsFiles: { type: 'boolean' },
599
639
  allowFirestoreFunctionFiles: { type: 'boolean' },
640
+ allowVoidReturnTypes: { type: 'boolean' },
600
641
  },
601
642
  additionalProperties: false,
602
643
  },
@@ -649,6 +690,18 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
649
690
  filename.endsWith('.f.ts'))) {
650
691
  return {};
651
692
  }
693
+ /**
694
+ * Applied at the implementation sites this rule's fixer can rewrite —
695
+ * functions, arrows and class methods. Signature-only declarations
696
+ * (interface methods, abstract methods, `declare function`) are outside
697
+ * the exemption: they have no body to infer from, so their annotation is
698
+ * mandatory rather than redundant, they are reported only when the
699
+ * matching `allow*` option is turned off, and no fixer ever strips them.
700
+ */
701
+ function isAllowedVoidReturnType(returnType) {
702
+ return (Boolean(mergedOptions.allowVoidReturnTypes) &&
703
+ declaresVoidResult(returnType));
704
+ }
652
705
  function fixReturnType(fixer, node) {
653
706
  // Some nodes expose returnType directly while others nest it under value.
654
707
  const returnType = 'returnType' in node
@@ -667,6 +720,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
667
720
  return;
668
721
  if (isTypeGuardFunction(node) ||
669
722
  isReadonlyWideningReturnType(returnType) ||
723
+ isAllowedVoidReturnType(returnType) ||
670
724
  (mergedOptions.allowRecursiveFunctions &&
671
725
  isRecursiveFunction(node)) ||
672
726
  isReturnTypeRequiredByRecursion(node)) {
@@ -693,6 +747,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
693
747
  }
694
748
  if (isTypeGuardFunction(node) ||
695
749
  isReadonlyWideningReturnType(returnType) ||
750
+ isAllowedVoidReturnType(returnType) ||
696
751
  (mergedOptions.allowRecursiveFunctions &&
697
752
  isRecursiveFunction(node)) ||
698
753
  isReturnTypeRequiredByRecursion(node)) {
@@ -711,6 +766,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
711
766
  return;
712
767
  if (isTypeGuardFunction(node) ||
713
768
  isReadonlyWideningReturnType(returnType) ||
769
+ isAllowedVoidReturnType(returnType) ||
714
770
  isReturnTypeRequiredByRecursion(node)) {
715
771
  return;
716
772
  }
@@ -744,6 +800,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
744
800
  return;
745
801
  if (isTypeGuardFunction(node.value) ||
746
802
  isReadonlyWideningReturnType(returnType) ||
803
+ isAllowedVoidReturnType(returnType) ||
747
804
  (mergedOptions.allowAbstractMethodSignatures &&
748
805
  isInterfaceOrAbstractMethodSignature(node)) ||
749
806
  isReturnTypeRequiredByRecursion(node)) {
@@ -44,6 +44,81 @@ const findLatestCallbackImport = (program) => {
44
44
  }
45
45
  return null;
46
46
  };
47
+ /** The leading whitespace of the line the offset sits on. */
48
+ const indentationAt = (sourceCode, offset) => {
49
+ const text = sourceCode.getText();
50
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
51
+ const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
52
+ return match ? match[0] : '';
53
+ };
54
+ /**
55
+ * Ranges whose interior line breaks carry string data rather than formatting.
56
+ * A multi-line template literal (or a string spliced together with line
57
+ * continuations) evaluates to the whitespace written inside it, so shifting
58
+ * those lines would silently change the value the code produces — the same
59
+ * carve-out `parallelize-async-operations` makes for spliced-in arguments.
60
+ */
61
+ const stringDataRangesOf = (sourceCode, node) => sourceCode
62
+ .getTokens(node)
63
+ .filter((token) => (token.type === utils_1.AST_TOKEN_TYPES.Template ||
64
+ token.type === utils_1.AST_TOKEN_TYPES.String) &&
65
+ token.loc.start.line !== token.loc.end.line)
66
+ .map((token) => token.range);
67
+ /**
68
+ * The callback's text re-indented for the line the rewritten call puts it on.
69
+ *
70
+ * Dropping the dependency array lets the call collapse onto one line, and when
71
+ * the original spelled the callback on a line of its own that collapse removes
72
+ * exactly one nesting level. Emitting the callback verbatim would leave every
73
+ * interior line indented for the level it no longer occupies (issue #1559), so
74
+ * each line moves by the difference between the callback's original indentation
75
+ * and the indentation of the line the call starts on. Preserving the original
76
+ * multi-line layout instead is not an option: a lone function argument is
77
+ * hugged onto the call line by the formatter, so the broken-out form would be
78
+ * reformatted away on the next write.
79
+ */
80
+ const reindentedCallbackText = (sourceCode, call, callback) => {
81
+ const text = sourceCode.getText(callback);
82
+ const callIndent = indentationAt(sourceCode, call.range[0]);
83
+ const callbackIndent = indentationAt(sourceCode, callback.range[0]);
84
+ // A callback already on the call's line loses no nesting level, so its body
85
+ // must be reproduced byte for byte.
86
+ if (callIndent === callbackIndent) {
87
+ return text;
88
+ }
89
+ const shiftLine = (() => {
90
+ if (callbackIndent.startsWith(callIndent)) {
91
+ const removed = callbackIndent.slice(callIndent.length);
92
+ return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
93
+ }
94
+ if (callIndent.startsWith(callbackIndent)) {
95
+ const added = callIndent.slice(callbackIndent.length);
96
+ return (line) => `${added}${line}`;
97
+ }
98
+ // Indent characters that disagree give no delta that can be applied
99
+ // without corrupting the layout, so the text is left as the author wrote it.
100
+ return null;
101
+ })();
102
+ if (!shiftLine) {
103
+ return text;
104
+ }
105
+ const stringData = stringDataRangesOf(sourceCode, callback);
106
+ const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
107
+ let offset = callback.range[0];
108
+ return text
109
+ .split('\n')
110
+ .map((line, index) => {
111
+ const lineStart = offset;
112
+ offset += line.length + 1;
113
+ // The first line is spliced in after the call's open paren, so it has no
114
+ // indentation of its own left to adjust.
115
+ if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
116
+ return line;
117
+ }
118
+ return shiftLine(line);
119
+ })
120
+ .join('\n');
121
+ };
47
122
  exports.useLatestCallback = (0, createRule_1.createRule)({
48
123
  name: 'use-latest-callback',
49
124
  meta: {
@@ -406,7 +481,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
406
481
  return fixes;
407
482
  };
408
483
  const conversionFix = (fixer, conversion) => {
409
- const callbackText = sourceCode.getText(conversion.node.arguments[0]);
484
+ const callbackText = reindentedCallbackText(sourceCode, conversion.node, conversion.node.arguments[0]);
410
485
  const typeParams = conversion.node.typeParameters
411
486
  ? sourceCode.getText(conversion.node.typeParameters)
412
487
  : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.63",
3
+ "version": "1.20.65",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,72 @@
1
1
  [
2
+ {
3
+ "version": "1.20.65",
4
+ "date": "2026-08-01T13:35:40.762Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-firestore-doc-ref-generic",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1564
11
+ ],
12
+ "summary": "exempt compat Firestore receivers from @firebase/rules-unit-testing (closes #1564)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.64",
18
+ "date": "2026-08-01T12:02:23.073Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-id-capitalization",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1558
25
+ ],
26
+ "summary": "preserve the original quote delimiter when rewriting a literal (closes #1558)"
27
+ },
28
+ {
29
+ "name": "enforce-memoize-async",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1563
33
+ ],
34
+ "summary": "skip methods whose sole parameter is a callback (closes #1563)"
35
+ },
36
+ {
37
+ "name": "enforce-memoize-getters",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1561
41
+ ],
42
+ "summary": "exempt getters that read live external state (closes #1561)"
43
+ },
44
+ {
45
+ "name": "no-empty-dependency-use-callbacks",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1560
49
+ ],
50
+ "summary": "dedent the callback when hoisting it to module scope (closes #1560)"
51
+ },
52
+ {
53
+ "name": "no-explicit-return-type",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1562
57
+ ],
58
+ "summary": "keep void and Promise<void> annotations under allowVoidReturnTypes (closes #1562)"
59
+ },
60
+ {
61
+ "name": "use-latest-callback",
62
+ "changeType": "fix",
63
+ "issues": [
64
+ 1559
65
+ ],
66
+ "summary": "re-indent the callback body when collapsing a multi-line useCallback (closes #1559)"
67
+ }
68
+ ]
69
+ },
2
70
  {
3
71
  "version": "1.20.63",
4
72
  "date": "2026-08-01T09:37:04.617Z",