@blumintinc/eslint-plugin-blumint 1.20.157 → 1.20.159

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.157',
226
+ version: '1.20.159',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -204,7 +204,15 @@ exports.enforceFExtensionForEntryPoints = (0, createRule_1.createRule)({
204
204
  const filePath = context.filename ??
205
205
  context.getFilename();
206
206
  const fileName = path_1.default.basename(filePath);
207
- const entryPoints = new Set(options.entryPoints?.length ? options.entryPoints : DEFAULT_ENTRY_POINTS);
207
+ // Configured names EXTEND the defaults rather than replacing them. A
208
+ // default only matches when it is actually imported from firebase-functions
209
+ // or the internal wrappers, so carrying an unused one costs nothing, while
210
+ // dropping one silently stops enforcing the convention for that wrapper —
211
+ // which is what registering a single custom trigger used to do.
212
+ const entryPoints = new Set([
213
+ ...DEFAULT_ENTRY_POINTS,
214
+ ...(options.entryPoints ?? []),
215
+ ]);
208
216
  // Only apply to files under functions/src/
209
217
  const normalizedPath = filePath.replace(/\\/g, '/');
210
218
  if (!normalizedPath.includes('/functions/src/') &&
@@ -1,5 +1,6 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type Options = [{
2
3
  max?: number;
3
4
  }];
4
- export declare const noExcessiveParentChain: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"excessiveParentChain", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
5
+ export declare const noExcessiveParentChain: TSESLint.RuleModule<"excessiveParentChain", Options, TSESLint.RuleListener>;
5
6
  export {};
@@ -3,6 +3,59 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noExcessiveParentChain = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const importRemoval_1 = require("../utils/importRemoval");
7
+ const patternBindingRemoval_1 = require("../utils/patternBindingRemoval");
8
+ /**
9
+ * How many times the cleanup below re-asks which bindings its own deletions have
10
+ * stranded. Each round deletes at least one declaration, so a handler would have
11
+ * to chain more re-destructures than this to run out — and running out declines
12
+ * rather than shipping half a cleanup.
13
+ */
14
+ const MAX_CLEANUP_ROUNDS = 8;
15
+ function rangesOverlap(left, right) {
16
+ return left[0] < right[1] && right[0] < left[1];
17
+ }
18
+ /**
19
+ * Whether an initializer is a plain read, so that deleting the declarator
20
+ * deletes no work the file depends on. A call, an `await` or a computed access
21
+ * could be doing something the surviving code still needs; a property read off
22
+ * an identifier is the same risk the destructuring arm already accepts, since
23
+ * `const { data } = event` throws on a nullish `event` exactly as `event.data`
24
+ * does.
25
+ */
26
+ function isPureRead(node) {
27
+ switch (node.type) {
28
+ case utils_1.AST_NODE_TYPES.Identifier:
29
+ case utils_1.AST_NODE_TYPES.ThisExpression:
30
+ return true;
31
+ case utils_1.AST_NODE_TYPES.ChainExpression:
32
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
33
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
34
+ return isPureRead(node.expression);
35
+ case utils_1.AST_NODE_TYPES.MemberExpression:
36
+ return !node.computed && isPureRead(node.object);
37
+ default:
38
+ return false;
39
+ }
40
+ }
41
+ /** The declarator a binding comes from, when exactly one declares it. */
42
+ function declaratorOf(variable) {
43
+ if (variable.defs.length !== 1)
44
+ return null;
45
+ const [definition] = variable.defs;
46
+ if (definition.type !== utils_1.TSESLint.Scope.DefinitionType.Variable)
47
+ return null;
48
+ return definition.node;
49
+ }
50
+ /**
51
+ * A parameter is part of its function's signature, so a rewrite of a read inside
52
+ * the body has no license to change it. Left in place rather than declined:
53
+ * an unused parameter is legal and is what `args: 'none'` already permits.
54
+ */
55
+ function isParameterBinding(variable) {
56
+ return (variable.defs.length > 0 &&
57
+ variable.defs.every((definition) => definition.type === utils_1.TSESLint.Scope.DefinitionType.Parameter));
58
+ }
6
59
  // Maximum number of consecutive .parent calls allowed before warning
7
60
  const DEFAULT_MAX_PARENT_CHAIN_LENGTH = 2;
8
61
  // Handler types that this rule applies to
@@ -40,6 +93,7 @@ exports.noExcessiveParentChain = (0, createRule_1.createRule)({
40
93
  defaultOptions: [{}],
41
94
  create(context) {
42
95
  const maxParentChainLength = context.options[0]?.max ?? DEFAULT_MAX_PARENT_CHAIN_LENGTH;
96
+ const sourceCode = context.getSourceCode();
43
97
  // Track variables that contain event data
44
98
  const eventDataVariables = new Map();
45
99
  const eventIdentifiers = new Set();
@@ -84,6 +138,189 @@ exports.noExcessiveParentChain = (0, createRule_1.createRule)({
84
138
  }
85
139
  return outermost;
86
140
  };
141
+ /**
142
+ * The binding a name denotes at the point the suggestion writes it, resolved
143
+ * through the scope chain rather than matched by text: a shadowing local
144
+ * carrying the same spelling as the event binding is a different binding, and
145
+ * mistaking one for the other is how a fixer came to rewrite an import the
146
+ * reported call never resolved to (#1903).
147
+ */
148
+ const resolveInScope = (scope, name) => {
149
+ for (let current = scope; current; current = current.upper) {
150
+ const variable = current.variables.find((candidate) => candidate.name === name);
151
+ if (variable)
152
+ return variable;
153
+ }
154
+ return null;
155
+ };
156
+ /**
157
+ * The coarse second opinion the destructuring planner takes, for the bindings
158
+ * it does not own. Scope analysis and a plain name scan can disagree, and a
159
+ * removal that turns out to be wrong deletes working code. The scan is
160
+ * windowed to the scope that declares the binding, since an occurrence of a
161
+ * function-local name outside that function belongs to another binding.
162
+ */
163
+ const nameSurvivesRemoval = (variable, ranges) => {
164
+ const scope = variable.scope.block.range;
165
+ const elsewhere = [
166
+ [0, scope[0]],
167
+ [scope[1], sourceCode.text.length],
168
+ ];
169
+ return (0, importRemoval_1.nameOccursOutside)(sourceCode, variable.name, [...ranges, ...elsewhere], variable.identifiers.map((identifier) => identifier.range));
170
+ };
171
+ /**
172
+ * The ranges that unbind `variables` declared by a plain `const x = ...`, or
173
+ * `null` when any of them cannot be unbound safely.
174
+ *
175
+ * The destructuring counterpart lives in `planPatternBindingRemoval`; this
176
+ * arm exists because a chain roots just as often at an intermediate read
177
+ * (`const afterRef = change.after`) as at a destructured property.
178
+ */
179
+ const planIdentifierBindingRemoval = (variables, removed) => {
180
+ const byDeclaration = new Map();
181
+ for (const variable of variables) {
182
+ const declarator = declaratorOf(variable);
183
+ if (!declarator || declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
184
+ return null;
185
+ }
186
+ const declaration = declarator.parent;
187
+ if (!declaration ||
188
+ declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration) {
189
+ return null;
190
+ }
191
+ // `const` is what makes "nothing reads it" the whole story: a `let` can
192
+ // be assigned from anywhere its scope reaches, and an assignment is not
193
+ // a read.
194
+ if (declaration.kind !== 'const' || declaration.declare)
195
+ return null;
196
+ // The ranges below span separators, so a comment among the declarators
197
+ // would be swallowed or stranded depending on where it sits.
198
+ if (sourceCode.getCommentsInside(declaration).length > 0)
199
+ return null;
200
+ if (!declarator.init || !isPureRead(declarator.init))
201
+ return null;
202
+ const group = byDeclaration.get(declaration);
203
+ if (group) {
204
+ group.add(declarator);
205
+ }
206
+ else {
207
+ byDeclaration.set(declaration, new Set([declarator]));
208
+ }
209
+ }
210
+ const ranges = [];
211
+ for (const [declaration, dropped] of byDeclaration) {
212
+ const siblings = declaration.declarations;
213
+ if (siblings.some((sibling) => !dropped.has(sibling))) {
214
+ const runs = (0, importRemoval_1.removalRuns)(siblings.map((sibling) => ({
215
+ range: sibling.range,
216
+ removed: dropped.has(sibling),
217
+ })));
218
+ if (!runs)
219
+ return null;
220
+ ranges.push(...runs);
221
+ continue;
222
+ }
223
+ if (!(0, patternBindingRemoval_1.isFreestandingStatement)(declaration))
224
+ return null;
225
+ const range = (0, importRemoval_1.statementRemovalRange)(sourceCode, declaration);
226
+ if (!range)
227
+ return null;
228
+ ranges.push(range);
229
+ }
230
+ for (const variable of variables) {
231
+ if (nameSurvivesRemoval(variable, [...removed, ...ranges]))
232
+ return null;
233
+ }
234
+ return ranges;
235
+ };
236
+ /**
237
+ * The extra ranges the suggestion must delete so that replacing the chain
238
+ * leaves no binding bound to nothing, `[]` when the rewrite strands nothing,
239
+ * or `null` when something would be stranded that cannot be unbound safely.
240
+ *
241
+ * `null` withholds the whole suggestion. Rewriting anyway would trade this
242
+ * rule's report for a `no-unused-vars` one on a file that was clean, and
243
+ * since accepting the suggestion resolves the report, nothing revisits the
244
+ * debt (#2026).
245
+ *
246
+ * Deleting a declaration strands whatever that declaration READ, so the
247
+ * question is re-asked until it settles: `const { after } = change` goes
248
+ * first, and only then is `const { data: change } = event` unreferenced. The
249
+ * binding the replacement text itself names is exempt throughout — the
250
+ * inserted `event.params` is a reference the source does not yet contain, so
251
+ * scope analysis alone would read the event binding as stranded.
252
+ */
253
+ const planOrphanCleanup = (chainRange, eventVariable) => {
254
+ const removed = [chainRange];
255
+ const extra = [];
256
+ const handled = new Set();
257
+ for (let round = 0; round < MAX_CLEANUP_ROUNDS; round++) {
258
+ const orphans = (0, importRemoval_1.orphanedBindings)(sourceCode, removed).filter((variable) => variable !== eventVariable &&
259
+ !handled.has(variable) &&
260
+ !isParameterBinding(variable));
261
+ if (orphans.length === 0) {
262
+ return extra.sort((left, right) => left[0] - right[0]);
263
+ }
264
+ const patterns = [];
265
+ const identifiers = [];
266
+ for (const variable of orphans) {
267
+ handled.add(variable);
268
+ const declarator = declaratorOf(variable);
269
+ // An orphan bound by an import, a type alias or anything else this
270
+ // rule cannot rewrite declines rather than being guessed at.
271
+ if (!declarator)
272
+ return null;
273
+ if (declarator.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
274
+ patterns.push(variable);
275
+ }
276
+ else if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier) {
277
+ identifiers.push(variable);
278
+ }
279
+ else {
280
+ return null;
281
+ }
282
+ }
283
+ const planned = [];
284
+ if (patterns.length > 0) {
285
+ const plan = (0, patternBindingRemoval_1.planPatternBindingRemoval)(sourceCode, patterns, removed);
286
+ if (!plan)
287
+ return null;
288
+ planned.push(...plan);
289
+ }
290
+ if (identifiers.length > 0) {
291
+ const plan = planIdentifierBindingRemoval(identifiers, removed);
292
+ if (!plan)
293
+ return null;
294
+ planned.push(...plan);
295
+ }
296
+ if (planned.length === 0)
297
+ return null;
298
+ extra.push(...planned);
299
+ removed.push(...planned);
300
+ }
301
+ return null;
302
+ };
303
+ /**
304
+ * The edits of the suggestion, or `null` to withdraw it. ESLint drops a
305
+ * suggestion whose fix yields nothing, which is how the unsafe cases above
306
+ * decline.
307
+ */
308
+ const buildSuggestionFixes = (fixer, outermost, eventParamName, eventVariable) => {
309
+ const cleanup = planOrphanCleanup(outermost.range, eventVariable);
310
+ if (!cleanup)
311
+ return null;
312
+ // ESLint throws out the whole report when one fix's ranges overlap, so a
313
+ // cleanup that collides with the rewrite (or with itself) is withheld
314
+ // rather than allowed to take the pass down with it.
315
+ const ranges = [outermost.range, ...cleanup];
316
+ const collides = ranges.some((range, index) => ranges.some((other, otherIndex) => index !== otherIndex && rangesOverlap(range, other)));
317
+ if (collides)
318
+ return null;
319
+ return [
320
+ fixer.replaceText(outermost, `${eventParamName}.params`),
321
+ ...cleanup.map((range) => fixer.removeRange([range[0], range[1]])),
322
+ ];
323
+ };
87
324
  const hasRefProperty = (node) => {
88
325
  let current = node;
89
326
  while (current) {
@@ -282,6 +519,9 @@ exports.noExcessiveParentChain = (0, createRule_1.createRule)({
282
519
  return;
283
520
  }
284
521
  const eventParamName = resolveEventParamName(rootIdentifier);
522
+ const eventVariable = eventParamName
523
+ ? resolveInScope(context.getScope(), eventParamName)
524
+ : null;
285
525
  context.report({
286
526
  node,
287
527
  messageId: 'excessiveParentChain',
@@ -296,7 +536,7 @@ exports.noExcessiveParentChain = (0, createRule_1.createRule)({
296
536
  count: parentCount,
297
537
  },
298
538
  fix(fixer) {
299
- return fixer.replaceText(getOutermostParentHop(node), `${eventParamName}.params`);
539
+ return buildSuggestionFixes(fixer, getOutermostParentHop(node), eventParamName, eventVariable);
300
540
  },
301
541
  },
302
542
  ]
@@ -13,8 +13,15 @@
13
13
  * through `planOrphanedBindingRemoval`, which re-examines the plan for exactly
14
14
  * that.
15
15
  */
16
- import { TSESLint } from '@typescript-eslint/utils';
16
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
17
17
  import { ImportRemovalSource, TextRange } from './importRemoval';
18
+ /**
19
+ * Whether the declaration is a statement in its own right, and so can be
20
+ * deleted whole. A `for (const { data } of events)` head reads as a declaration
21
+ * too, and deleting one leaves a `for` with no binding at all; an
22
+ * `export const` keeps its `export` keyword outside the declaration's own range.
23
+ */
24
+ export declare function isFreestandingStatement(declaration: TSESTree.VariableDeclaration): boolean;
18
25
  /**
19
26
  * The ranges that unbind `variables` from the destructuring patterns declaring
20
27
  * them, or `null` when any of them cannot be unbound safely.
@@ -15,7 +15,7 @@
15
15
  * that.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.planPatternBindingRemoval = void 0;
18
+ exports.planPatternBindingRemoval = exports.isFreestandingStatement = void 0;
19
19
  const utils_1 = require("@typescript-eslint/utils");
20
20
  const importRemoval_1 = require("./importRemoval");
21
21
  function patternBindingOf(variable) {
@@ -50,6 +50,7 @@ function isFreestandingStatement(declaration) {
50
50
  parent?.type === utils_1.AST_NODE_TYPES.SwitchCase ||
51
51
  parent?.type === utils_1.AST_NODE_TYPES.TSModuleBlock);
52
52
  }
53
+ exports.isFreestandingStatement = isFreestandingStatement;
53
54
  /**
54
55
  * The properties of `pattern` that bind nothing but orphans, or `null` when one
55
56
  * of them cannot be dropped without changing what the declaration does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.157",
3
+ "version": "1.20.159",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.159",
4
+ "date": "2026-08-17T02:05:07.694Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-f-extension-for-entry-points",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2027
11
+ ],
12
+ "summary": "extend the default entry points rather than replacing them (closes #2027)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.158",
18
+ "date": "2026-08-16T14:52:13.506Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-excessive-parent-chain",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2026
25
+ ],
26
+ "summary": "remove the binding the suggestion de-references"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.157",
4
32
  "date": "2026-08-16T06:24:21.626Z",