@blumintinc/eslint-plugin-blumint 1.20.49 → 1.20.50

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.49',
226
+ version: '1.20.50',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -52,7 +52,7 @@ module.exports = (0, createRule_1.createRule)({
52
52
  messages: {
53
53
  callbackPropPrefix: 'Callback prop "{{propName}}" is a function but lacks the "on" prefix. ' +
54
54
  'Consistent "on" prefixes signal event handlers to consumers and distinguish callbacks from data props. ' +
55
- 'Rename to "on{{eventName}}".',
55
+ 'Rename to "on{{eventName}}" here, in the props type that declares it, and in every reader of that prop.',
56
56
  callbackFunctionPrefix: 'Function "{{functionName}}" uses the "handle" prefix. ' +
57
57
  'The "handle" prefix is redundant and less descriptive than action-oriented verb phrases. ' +
58
58
  'Rename using a descriptive verb (e.g., click instead of handleClick).',
@@ -252,6 +252,18 @@ module.exports = (0, createRule_1.createRule)({
252
252
  !isRenderFunction(node.value.expression) &&
253
253
  !isReactComponentType(node.value.expression)) {
254
254
  const eventName = propName.charAt(0).toUpperCase() + propName.slice(1);
255
+ // Reported without an autofix (Bug #1522). A JSX attribute name is
256
+ // one end of a props contract: the other end is the declaration
257
+ // that binds the name — a props `type`/`interface`, a
258
+ // `JSX.IntrinsicElements` augmentation for host elements — plus
259
+ // every reader of that member (`props.validate`, destructuring) and
260
+ // every other call site of the component. Rewriting only the
261
+ // attribute yields TS2322, and renaming the local declaration
262
+ // as well merely relocates the break: readers in the same file fail
263
+ // with TS2551 and call sites in other files (which a single-file
264
+ // fixer cannot see, let alone edit atomically) fail with TS2322.
265
+ // No subset of the rename is safe to apply in isolation, so the
266
+ // report carries the full instruction instead of a broken fix.
255
267
  context.report({
256
268
  node,
257
269
  messageId: 'callbackPropPrefix',
@@ -259,10 +271,6 @@ module.exports = (0, createRule_1.createRule)({
259
271
  propName,
260
272
  eventName,
261
273
  },
262
- fix(fixer) {
263
- // Convert camelCase to PascalCase for the event name
264
- return fixer.replaceText(node.name, `on${eventName}`);
265
- },
266
274
  });
267
275
  }
268
276
  }
@@ -407,8 +407,9 @@ function renderArrayPatternWithDefaults(pattern, sourceCode) {
407
407
  return `${leftText} = ${sourceCode.getText(element.right)}`;
408
408
  }
409
409
  if (element.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
410
- const nested = renderObjectPatternWithDefaults(element, sourceCode);
411
- return `${nested} = {}`;
410
+ // No synthesized `= {}` here either, for the reason spelled out in
411
+ // formatPropertyText: the default is checked against the bindings under it.
412
+ return renderObjectPatternWithDefaults(element, sourceCode);
412
413
  }
413
414
  if (element.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
414
415
  const nested = renderArrayPatternWithDefaults(element, sourceCode);
@@ -441,10 +442,19 @@ function formatPropertyText(property, sourceCode) {
441
442
  return renderPropertyWithAssignment(property, value, keyText, sourceCode);
442
443
  }
443
444
  if (value.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
445
+ // A nested object pattern is re-emitted as authored, without a synthesized
446
+ // `= {}`. TypeScript checks such a default against every binding element
447
+ // beneath it, so `{ profile: { name, age } = {} }` reports TS2525 once per
448
+ // name: `{}` supplies no value and the names carry no defaults of their own.
449
+ // The default also only ever guarded a nullish parent (an explicitly `null`
450
+ // one still throws), so dropping it costs a partial runtime guard and buys
451
+ // back the invariant that compiling input yields compiling output.
444
452
  const nested = renderObjectPatternWithDefaults(value, sourceCode);
445
- return `${keyText}: ${nested} = {}`;
453
+ return `${keyText}: ${nested}`;
446
454
  }
447
455
  if (value.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
456
+ // An array pattern's `= []` default is safe to synthesize: TypeScript does
457
+ // not push it down onto the element bindings the way it does for objects.
448
458
  const nested = renderArrayPatternWithDefaults(value, sourceCode);
449
459
  return `${keyText}: ${nested} = []`;
450
460
  }
@@ -3,6 +3,143 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceObjectLiteralAsConst = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const ASTHelpers_1 = require("../utils/ASTHelpers");
6
+ const FUNCTION_TYPES = new Set([
7
+ 'FunctionDeclaration',
8
+ 'FunctionExpression',
9
+ 'ArrowFunctionExpression',
10
+ ]);
11
+ /**
12
+ * A `return` inside a generator yields the generator type's *second* type
13
+ * argument; the first types the `yield`s. `IterableIterator` and friends leave
14
+ * `TReturn` unparameterised, so they carry no constraint on the returned value.
15
+ */
16
+ const GENERATOR_TYPE_NAMES = new Set(['Generator', 'AsyncGenerator']);
17
+ const PROMISE_TYPE_NAMES = new Set(['Promise', 'PromiseLike']);
18
+ /**
19
+ * A readonly tuple is assignable to none of these, so a union member spelled
20
+ * this way cannot rescue an `as const` the rest of the union rejects.
21
+ */
22
+ const NON_ARRAY_KEYWORDS = new Set([
23
+ 'TSBigIntKeyword',
24
+ 'TSBooleanKeyword',
25
+ 'TSLiteralType',
26
+ 'TSNeverKeyword',
27
+ 'TSNullKeyword',
28
+ 'TSNumberKeyword',
29
+ 'TSStringKeyword',
30
+ 'TSSymbolKeyword',
31
+ 'TSUndefinedKeyword',
32
+ 'TSVoidKeyword',
33
+ ]);
34
+ /**
35
+ * Type arguments are `typeParameters` on this parser version and
36
+ * `typeArguments` on newer ones; both spell the same `<T>` after the name.
37
+ */
38
+ function typeArgumentsOf(node) {
39
+ const withTypeArgs = node;
40
+ return ((withTypeArgs.typeArguments ?? withTypeArgs.typeParameters)?.params ?? []);
41
+ }
42
+ function typeReferenceNameOf(node) {
43
+ if (node.type !== 'TSTypeReference' || node.typeName.type !== 'Identifier') {
44
+ return undefined;
45
+ }
46
+ return node.typeName.name;
47
+ }
48
+ /**
49
+ * Whether a readonly tuple — what `as const` makes of an array literal — can be
50
+ * assigned to this annotation, judged from syntax alone.
51
+ *
52
+ * Only shapes the annotation states outright are treated as hostile. Anything
53
+ * the rule cannot resolve (a type reference, a type parameter, an object type)
54
+ * counts as accepting, because declining on no evidence would silence the rule
55
+ * across most annotated code.
56
+ */
57
+ function acceptsReadonlyArray(typeNode) {
58
+ switch (typeNode.type) {
59
+ // `string[]` and `[string, number]` are mutable: TS4104 rejects a readonly
60
+ // tuple assigned to either.
61
+ case 'TSArrayType':
62
+ case 'TSTupleType':
63
+ return false;
64
+ // `readonly string[]` / `readonly [string, number]`.
65
+ case 'TSTypeOperator':
66
+ return typeNode.operator === 'readonly';
67
+ case 'TSTypeReference':
68
+ return typeReferenceNameOf(typeNode) !== 'Array';
69
+ // Assignable to the union as a whole iff assignable to some member.
70
+ case 'TSUnionType':
71
+ return typeNode.types.some(acceptsReadonlyArray);
72
+ case 'TSIntersectionType':
73
+ return typeNode.types.every(acceptsReadonlyArray);
74
+ default:
75
+ return !NON_ARRAY_KEYWORDS.has(typeNode.type);
76
+ }
77
+ }
78
+ /**
79
+ * The type a function type annotation declares for its return value, or
80
+ * `undefined` when the annotation is not a function type (a type reference to
81
+ * an aliased signature, say) and so states nothing resolvable here.
82
+ */
83
+ function returnTypeOfFunctionType(typeNode) {
84
+ if (typeNode?.type !== 'TSFunctionType') {
85
+ return undefined;
86
+ }
87
+ return typeNode.returnType?.typeAnnotation;
88
+ }
89
+ /**
90
+ * The declared return type visible for `fn`, whether written on the function
91
+ * itself (`function f(): string[]`) or on the site that declares it — a typed
92
+ * variable, a typed class property, or an assertion on the function expression.
93
+ *
94
+ * A callback passed as a call argument is deliberately not resolved: its
95
+ * contextual type lives on the callee's declaration, which is usually in
96
+ * another file, and the in-file shapes that do reach here (`useMemo`, `.map`)
97
+ * annotate their callbacks generically rather than with a mutable array.
98
+ */
99
+ function declaredReturnTypeOf(fn) {
100
+ if (fn.returnType) {
101
+ return fn.returnType.typeAnnotation;
102
+ }
103
+ const { parent } = fn;
104
+ if (!parent) {
105
+ return undefined;
106
+ }
107
+ if (parent.type === 'VariableDeclarator') {
108
+ return parent.id.type === 'Identifier'
109
+ ? returnTypeOfFunctionType(parent.id.typeAnnotation?.typeAnnotation)
110
+ : undefined;
111
+ }
112
+ if (parent.type === 'PropertyDefinition') {
113
+ return returnTypeOfFunctionType(parent.typeAnnotation?.typeAnnotation);
114
+ }
115
+ if (parent.type === 'TSAsExpression') {
116
+ return returnTypeOfFunctionType(parent.typeAnnotation);
117
+ }
118
+ return undefined;
119
+ }
120
+ /**
121
+ * The type the *returned expression* must satisfy. For an async function or a
122
+ * generator the declared return type wraps that expression's type, so the
123
+ * wrapper is peeled off before the annotation is judged.
124
+ */
125
+ function returnedValueTypeOf(fn) {
126
+ const declared = declaredReturnTypeOf(fn);
127
+ if (!declared) {
128
+ return undefined;
129
+ }
130
+ const referenceName = typeReferenceNameOf(declared);
131
+ if (fn.generator) {
132
+ return referenceName && GENERATOR_TYPE_NAMES.has(referenceName)
133
+ ? typeArgumentsOf(declared)[1]
134
+ : undefined;
135
+ }
136
+ if (fn.async) {
137
+ return referenceName && PROMISE_TYPE_NAMES.has(referenceName)
138
+ ? typeArgumentsOf(declared)[0]
139
+ : undefined;
140
+ }
141
+ return declared;
142
+ }
6
143
  exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
7
144
  name: 'enforce-object-literal-as-const',
8
145
  meta: {
@@ -55,6 +192,44 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
55
192
  function isArrayLiteral(node) {
56
193
  return node.type === 'ArrayExpression';
57
194
  }
195
+ /**
196
+ * The function the `return` belongs to — the nearest one, so a `return`
197
+ * inside a nested callback is judged against that callback's annotation
198
+ * rather than the outer function's.
199
+ */
200
+ function enclosingFunctionOf(ancestors) {
201
+ for (let i = ancestors.length - 1; i >= 0; i--) {
202
+ const ancestor = ancestors[i];
203
+ if (FUNCTION_TYPES.has(ancestor.type)) {
204
+ return ancestor;
205
+ }
206
+ }
207
+ return undefined;
208
+ }
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).
217
+ *
218
+ * Object literals are unaffected: `readonly` property modifiers do not
219
+ * enter assignability, so `{ a: 1 } as const` still satisfies a mutable
220
+ * `{ a: number }`.
221
+ */
222
+ function conflictsWithDeclaredType(literal, ancestors) {
223
+ if (!isArrayLiteral(literal)) {
224
+ return false;
225
+ }
226
+ const enclosingFunction = enclosingFunctionOf(ancestors);
227
+ if (!enclosingFunction) {
228
+ return false;
229
+ }
230
+ const returnedValueType = returnedValueTypeOf(enclosingFunction);
231
+ return !!returnedValueType && !acceptsReadonlyArray(returnedValueType);
232
+ }
58
233
  return {
59
234
  ReturnStatement(node) {
60
235
  // Skip if there's no argument in the return statement
@@ -102,12 +277,18 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
102
277
  argument.elements.some((elem) => elem !== null && elem.type === 'SpreadElement'))) {
103
278
  return;
104
279
  }
280
+ const literal = argument.type === 'TSAsExpression'
281
+ ? argument.expression
282
+ : argument;
105
283
  // Skip arrays returned from React hooks (memoized data/prop lists that
106
284
  // must not be frozen into readonly tuples — see #511 and #1324)
107
- if (isInsideReactHook(ancestors) &&
108
- isArrayLiteral(argument.type === 'TSAsExpression'
109
- ? argument.expression
110
- : argument)) {
285
+ if (isInsideReactHook(ancestors) && isArrayLiteral(literal)) {
286
+ return;
287
+ }
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)) {
111
292
  return;
112
293
  }
113
294
  // Report the issue and provide a fix
@@ -1 +1,2 @@
1
- export declare const enforceTimestampNow: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"preferTimestampNow", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const enforceTimestampNow: TSESLint.RuleModule<"preferTimestampNow", [], TSESLint.RuleListener>;
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceTimestampNow = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const FIRESTORE_MODULES = new Set([
7
+ 'firebase-admin/firestore',
8
+ 'firebase/firestore',
9
+ ]);
6
10
  exports.enforceTimestampNow = (0, createRule_1.createRule)({
7
11
  name: 'enforce-timestamp-now',
8
12
  meta: {
@@ -35,8 +39,102 @@ exports.enforceTimestampNow = (0, createRule_1.createRule)({
35
39
  if (filename.includes('.test.') || filename.includes('.spec.')) {
36
40
  return {};
37
41
  }
38
- // Track Timestamp imports and aliases
42
+ // Names that may denote the Firestore `Timestamp` class when matching
43
+ // `X.fromDate(new Date())` / `X.fromMillis(Date.now())`. Seeded with the
44
+ // default name so detection still works when the class reaches the file
45
+ // through a re-export or a `require()` the rule cannot see. Those reports
46
+ // rewrite an expression whose object identifier is already written in the
47
+ // source, so a seeded name can never produce an unbound reference.
39
48
  const timestampAliases = new Set(['Timestamp']);
49
+ // Names actually bound by an observed `Timestamp` import. Tracked apart
50
+ // from `timestampAliases` because the `new Date()` fix synthesizes an
51
+ // identifier the original code never mentions: with no real import the
52
+ // rewrite emits an unbound `Timestamp` and turns compiling code into
53
+ // TS2304 (issue #1521).
54
+ const importedTimestampAliases = [];
55
+ function recordTimestampAlias(localName) {
56
+ timestampAliases.add(localName);
57
+ if (!importedTimestampAliases.includes(localName)) {
58
+ importedTimestampAliases.push(localName);
59
+ }
60
+ }
61
+ /** Local names a static Firestore import binds to `Timestamp`. */
62
+ function staticTimestampAliases(node) {
63
+ if (typeof node.source.value !== 'string' ||
64
+ !FIRESTORE_MODULES.has(node.source.value)) {
65
+ return [];
66
+ }
67
+ return node.specifiers
68
+ .filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
69
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
70
+ specifier.imported.name === 'Timestamp')
71
+ .map((specifier) => specifier.local.name);
72
+ }
73
+ /**
74
+ * Local names a `const { Timestamp } = await import(...)` declarator binds
75
+ * to `Timestamp`.
76
+ */
77
+ function dynamicTimestampAliases(node) {
78
+ if (node.init?.type !== utils_1.AST_NODE_TYPES.AwaitExpression ||
79
+ node.init.argument.type !== utils_1.AST_NODE_TYPES.ImportExpression ||
80
+ node.id.type !== utils_1.AST_NODE_TYPES.ObjectPattern) {
81
+ return [];
82
+ }
83
+ const source = node.init.argument.source;
84
+ if (source.type !== utils_1.AST_NODE_TYPES.Literal ||
85
+ typeof source.value !== 'string' ||
86
+ !FIRESTORE_MODULES.has(source.value)) {
87
+ return [];
88
+ }
89
+ const aliases = [];
90
+ node.id.properties.forEach((prop) => {
91
+ if (prop.type === utils_1.AST_NODE_TYPES.Property &&
92
+ prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
93
+ prop.key.name === 'Timestamp' &&
94
+ prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
95
+ aliases.push(prop.value.name);
96
+ }
97
+ });
98
+ return aliases;
99
+ }
100
+ /** Whether a resolved binding is the Firestore `Timestamp` class itself. */
101
+ function isTimestampImportBinding(variable) {
102
+ return variable.defs.some((def) => {
103
+ if (def.node.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
104
+ const declaration = def.node.parent;
105
+ return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
106
+ staticTimestampAliases(declaration).includes(variable.name));
107
+ }
108
+ if (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
109
+ return dynamicTimestampAliases(def.node).includes(variable.name);
110
+ }
111
+ return false;
112
+ });
113
+ }
114
+ /** The binding a name resolves to at the node currently being visited. */
115
+ function resolveBinding(name) {
116
+ let scope = context.getScope();
117
+ while (scope) {
118
+ const binding = scope.variables.find((variable) => variable.name === name);
119
+ if (binding) {
120
+ return binding;
121
+ }
122
+ scope = scope.upper;
123
+ }
124
+ return undefined;
125
+ }
126
+ // A synthesized `Timestamp.now()` is only safe when the alias resolves to
127
+ // the import at the rewrite site. Resolution has to run per name from the
128
+ // innermost scope outward: an alias bound by a dynamic import inside another
129
+ // function is unreachable here, and an inner binding of the same name would
130
+ // capture the emitted reference and silently swap in a different value
131
+ // (issues #1455/#1456).
132
+ function findTimestampAliasInScope() {
133
+ return importedTimestampAliases.find((alias) => {
134
+ const binding = resolveBinding(alias);
135
+ return !!binding && isTimestampImportBinding(binding);
136
+ });
137
+ }
40
138
  function isTimestampFromDateWithNewDate(node) {
41
139
  // Check if it's a Timestamp.fromDate(new Date()) call
42
140
  if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -118,41 +216,20 @@ exports.enforceTimestampNow = (0, createRule_1.createRule)({
118
216
  });
119
217
  }
120
218
  return {
121
- ImportDeclaration(node) {
122
- // Track Timestamp imports from Firebase
123
- if (node.source.value === 'firebase-admin/firestore' ||
124
- node.source.value === 'firebase/firestore') {
125
- node.specifiers.forEach((specifier) => {
126
- if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
127
- specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
128
- specifier.imported.name === 'Timestamp') {
129
- timestampAliases.add(specifier.local.name);
130
- }
131
- });
132
- }
219
+ Program(node) {
220
+ // Collect static imports before any usage is visited. An import is
221
+ // hoisted and module-scoped, so it binds `Timestamp` for the whole file
222
+ // regardless of where it sits; visiting imports in traversal order
223
+ // would make the guard depend on the import preceding the usage.
224
+ node.body.forEach((statement) => {
225
+ if (statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration) {
226
+ staticTimestampAliases(statement).forEach(recordTimestampAlias);
227
+ }
228
+ });
133
229
  },
134
230
  VariableDeclarator(node) {
135
231
  // Track dynamic imports of Timestamp
136
- if (node.init?.type === utils_1.AST_NODE_TYPES.AwaitExpression &&
137
- node.init.argument.type === utils_1.AST_NODE_TYPES.ImportExpression) {
138
- const importSource = node.init.argument.source;
139
- if (importSource.type === utils_1.AST_NODE_TYPES.Literal &&
140
- (importSource.value === 'firebase-admin/firestore' ||
141
- importSource.value === 'firebase/firestore')) {
142
- // Handle destructured imports
143
- if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
144
- node.id.properties.forEach((prop) => {
145
- if (prop.type === utils_1.AST_NODE_TYPES.Property &&
146
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
147
- prop.key.name === 'Timestamp') {
148
- if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
149
- timestampAliases.add(prop.value.name);
150
- }
151
- }
152
- });
153
- }
154
- }
155
- }
232
+ dynamicTimestampAliases(node).forEach(recordTimestampAlias);
156
233
  },
157
234
  CallExpression(node) {
158
235
  if (isTimestampFromDateWithNewDate(node)) {
@@ -216,22 +293,26 @@ exports.enforceTimestampNow = (0, createRule_1.createRule)({
216
293
  // If the Date is being modified, don't flag it
217
294
  return;
218
295
  }
219
- // Check if we have a Timestamp import before suggesting
220
- if (timestampAliases.size > 0) {
221
- const timestampName = Array.from(timestampAliases)[0];
222
- const expressionText = sourceCode.getText(node);
223
- context.report({
224
- node,
225
- messageId: 'preferTimestampNow',
226
- data: {
227
- expression: expressionText,
228
- timestampAlias: timestampName,
229
- },
230
- fix(fixer) {
231
- return fixer.replaceText(node, `${timestampName}.now()`);
232
- },
233
- });
296
+ // Stay silent unless a real `Timestamp` binding is in scope. The
297
+ // rewrite names an identifier the source never mentions, and a
298
+ // file with no Firestore import is almost certainly using the
299
+ // `Date` for something other than a Firestore document anyway.
300
+ const timestampName = findTimestampAliasInScope();
301
+ if (!timestampName) {
302
+ return;
234
303
  }
304
+ const expressionText = sourceCode.getText(node);
305
+ context.report({
306
+ node,
307
+ messageId: 'preferTimestampNow',
308
+ data: {
309
+ expression: expressionText,
310
+ timestampAlias: timestampName,
311
+ },
312
+ fix(fixer) {
313
+ return fixer.replaceText(node, `${timestampName}.now()`);
314
+ },
315
+ });
235
316
  }
236
317
  }
237
318
  }
@@ -1 +1,2 @@
1
- export declare const noClassInstanceDestructuring: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noClassInstanceDestructuring", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const noClassInstanceDestructuring: TSESLint.RuleModule<"noClassInstanceDestructuring", [], TSESLint.RuleListener>;
@@ -3,6 +3,94 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noClassInstanceDestructuring = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ /**
7
+ * A synthesized binding may not be a keyword, and may not be one of the
8
+ * bare-word globals whose rebinding would be legal but ruinous.
9
+ */
10
+ const UNUSABLE_NAMES = new Set([
11
+ 'break',
12
+ 'case',
13
+ 'catch',
14
+ 'class',
15
+ 'const',
16
+ 'continue',
17
+ 'debugger',
18
+ 'default',
19
+ 'delete',
20
+ 'do',
21
+ 'else',
22
+ 'enum',
23
+ 'export',
24
+ 'extends',
25
+ 'false',
26
+ 'finally',
27
+ 'for',
28
+ 'function',
29
+ 'if',
30
+ 'implements',
31
+ 'import',
32
+ 'in',
33
+ 'instanceof',
34
+ 'interface',
35
+ 'let',
36
+ 'new',
37
+ 'null',
38
+ 'package',
39
+ 'private',
40
+ 'protected',
41
+ 'public',
42
+ 'return',
43
+ 'static',
44
+ 'super',
45
+ 'switch',
46
+ 'this',
47
+ 'throw',
48
+ 'true',
49
+ 'try',
50
+ 'typeof',
51
+ 'var',
52
+ 'void',
53
+ 'while',
54
+ 'with',
55
+ 'yield',
56
+ 'await',
57
+ 'arguments',
58
+ 'eval',
59
+ 'undefined',
60
+ 'NaN',
61
+ 'Infinity',
62
+ ]);
63
+ /**
64
+ * Node types whose children are statements, so a single statement may be
65
+ * replaced by several. Anything else (a `for` initializer, an unbraced `if`
66
+ * body, a label) can hold exactly one.
67
+ */
68
+ const STATEMENT_CONTAINERS = new Set([
69
+ utils_1.AST_NODE_TYPES.Program,
70
+ utils_1.AST_NODE_TYPES.BlockStatement,
71
+ utils_1.AST_NODE_TYPES.StaticBlock,
72
+ utils_1.AST_NODE_TYPES.SwitchCase,
73
+ utils_1.AST_NODE_TYPES.TSModuleBlock,
74
+ ]);
75
+ const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
76
+ function isUsableName(name) {
77
+ return IDENTIFIER_PATTERN.test(name) && !UNUSABLE_NAMES.has(name);
78
+ }
79
+ /**
80
+ * `Person` -> `person`, `URL` -> `url`, `URLParser` -> `urlParser`. The leading
81
+ * run of capitals is treated as one word so an acronym does not become `uRL`.
82
+ */
83
+ function lowerFirstWord(name) {
84
+ const leadingCapitals = /^[A-Z]+/.exec(name);
85
+ if (!leadingCapitals)
86
+ return name;
87
+ const run = leadingCapitals[0];
88
+ if (run.length === name.length)
89
+ return name.toLowerCase();
90
+ if (run.length === 1)
91
+ return name[0].toLowerCase() + name.slice(1);
92
+ return run.slice(0, -1).toLowerCase() + name.slice(run.length - 1);
93
+ }
6
94
  exports.noClassInstanceDestructuring = (0, createRule_1.createRule)({
7
95
  name: 'no-class-instance-destructuring',
8
96
  meta: {
@@ -41,12 +129,12 @@ exports.noClassInstanceDestructuring = (0, createRule_1.createRule)({
41
129
  }
42
130
  return 'member';
43
131
  }
44
- function buildAccessPath(initText, prop) {
132
+ function buildAccessPath(receiverText, prop) {
45
133
  const keyText = sourceCode.getText(prop.key);
46
134
  if (prop.key.type === utils_1.AST_NODE_TYPES.Identifier && !prop.computed) {
47
- return `${initText}.${keyText}`;
135
+ return `${receiverText}.${keyText}`;
48
136
  }
49
- return `${initText}[${keyText}]`;
137
+ return `${receiverText}[${keyText}]`;
50
138
  }
51
139
  function formatMembers(properties) {
52
140
  const memberNames = properties.map(describeMember).filter(Boolean);
@@ -54,12 +142,12 @@ exports.noClassInstanceDestructuring = (0, createRule_1.createRule)({
54
142
  return '`<members>`';
55
143
  return memberNames.map((name) => `\`${name}\``).join(', ');
56
144
  }
57
- function formatAccessExamples(properties, initText) {
145
+ function formatAccessExamples(properties, receiverText) {
58
146
  const accessPaths = properties
59
147
  .filter((prop) => prop.type === utils_1.AST_NODE_TYPES.Property)
60
- .map((prop) => buildAccessPath(initText, prop));
148
+ .map((prop) => buildAccessPath(receiverText, prop));
61
149
  if (accessPaths.length === 0) {
62
- return `\`${initText}.<member>\``;
150
+ return `\`${receiverText}.<member>\``;
63
151
  }
64
152
  return accessPaths.map((path) => `\`${path}\``).join(', ');
65
153
  }
@@ -81,20 +169,134 @@ exports.noClassInstanceDestructuring = (0, createRule_1.createRule)({
81
169
  }
82
170
  return false;
83
171
  }
172
+ /**
173
+ * `new Person` and `new Person()` construct alike, but only the latter can
174
+ * carry a member access: `new Person.name` reads `name` off the class and
175
+ * constructs that instead.
176
+ */
177
+ function callableInitText(init) {
178
+ const text = sourceCode.getText(init);
179
+ if (init.type === utils_1.AST_NODE_TYPES.NewExpression && init.arguments) {
180
+ const lastToken = sourceCode.getLastToken(init);
181
+ if (lastToken?.value !== ')')
182
+ return `${text}()`;
183
+ }
184
+ return text;
185
+ }
186
+ /**
187
+ * Every name that is visible where the temp binding would be inserted,
188
+ * including names only referenced (globals, imports resolved elsewhere) and
189
+ * names bound by nested closures that read through this scope. Reusing any
190
+ * of them would either redeclare or shadow a live binding — see the
191
+ * shadow-capture guard in `src/tests/fixer-shadow-capture.test.ts`.
192
+ */
193
+ function collectVisibleNames(scope) {
194
+ const names = new Set();
195
+ for (let current = scope; current; current = current.upper) {
196
+ for (const variable of current.variables) {
197
+ names.add(variable.name);
198
+ }
199
+ for (const reference of current.through) {
200
+ names.add(reference.identifier.name);
201
+ }
202
+ }
203
+ return names;
204
+ }
205
+ function calleeName(init) {
206
+ const { callee } = init;
207
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier)
208
+ return callee.name;
209
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
210
+ !callee.computed &&
211
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
212
+ return callee.property.name;
213
+ }
214
+ return null;
215
+ }
216
+ function baseNameFor(init) {
217
+ if (init.type !== utils_1.AST_NODE_TYPES.NewExpression)
218
+ return 'instance';
219
+ const raw = calleeName(init);
220
+ if (!raw)
221
+ return 'instance';
222
+ const lowered = lowerFirstWord(raw);
223
+ // An already-lowercase callee would have the temp shadow the class itself.
224
+ if (lowered !== raw && isUsableName(lowered))
225
+ return lowered;
226
+ const suffixed = `${lowered}Instance`;
227
+ return isUsableName(suffixed) ? suffixed : 'instance';
228
+ }
229
+ function uniqueName(base, taken) {
230
+ if (!taken.has(base))
231
+ return base;
232
+ for (let suffix = 2; suffix <= taken.size + 2; suffix++) {
233
+ const candidate = `${base}${suffix}`;
234
+ if (!taken.has(candidate))
235
+ return candidate;
236
+ }
237
+ return base;
238
+ }
239
+ function indentOf(node) {
240
+ const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
241
+ const leading = /^[\t ]*/.exec(line);
242
+ return leading ? leading[0] : '';
243
+ }
244
+ /**
245
+ * The statement that may be swapped for several declarations, or `null`
246
+ * when the declarator does not own a whole statement of its own.
247
+ */
248
+ function resolveStatement(node) {
249
+ const declaration = node.parent;
250
+ if (declaration?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration)
251
+ return null;
252
+ // Splitting one declarator out of `const {a, b} = inst, c = 1;` would have
253
+ // to hoist emitted lines past declarators this fix does not own.
254
+ if (declaration.declarations.length !== 1)
255
+ return null;
256
+ const exportDeclaration = declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration
257
+ ? declaration.parent
258
+ : null;
259
+ const statement = exportDeclaration ?? declaration;
260
+ if (!STATEMENT_CONTAINERS.has(statement.parent?.type ?? ''))
261
+ return null;
262
+ const text = sourceCode.getText(statement);
263
+ return {
264
+ statement,
265
+ kind: declaration.kind,
266
+ // Re-exporting each extracted member keeps the module's public surface,
267
+ // while the temp binding stays private.
268
+ exportPrefix: exportDeclaration ? 'export ' : '',
269
+ indent: indentOf(statement),
270
+ // Matching the source's own terminator keeps semicolon-free files intact.
271
+ terminator: text.endsWith(';') ? ';' : '',
272
+ };
273
+ }
84
274
  return {
85
275
  VariableDeclarator(node) {
86
276
  if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern &&
87
277
  node.init &&
88
278
  isClassInstance(node.init)) {
89
279
  const objectPattern = node.id;
90
- const initText = sourceCode.getText(node.init);
280
+ const init = node.init;
281
+ const initText = sourceCode.getText(init);
282
+ const propertyCount = objectPattern.properties.filter((prop) => prop.type === utils_1.AST_NODE_TYPES.Property).length;
283
+ // Reading two members off `new Person()` twice would construct twice,
284
+ // so the instance is bound once and read from. A plain identifier is
285
+ // already such a binding, and a lone member read constructs once.
286
+ const needsTempBinding = init.type !== utils_1.AST_NODE_TYPES.Identifier && propertyCount > 1;
287
+ const tempName = needsTempBinding
288
+ ? uniqueName(baseNameFor(init), collectVisibleNames(context.getScope()))
289
+ : null;
290
+ const receiverText = tempName ?? callableInitText(init);
91
291
  context.report({
92
292
  node,
93
293
  messageId: 'noClassInstanceDestructuring',
94
294
  data: {
95
295
  members: formatMembers(objectPattern.properties),
96
296
  instance: `\`${initText}\``,
97
- suggestion: formatAccessExamples(objectPattern.properties, initText),
297
+ suggestion: tempName
298
+ ? `\`const ${tempName} = ${initText};\` then ${formatAccessExamples(objectPattern.properties, tempName)}`
299
+ : formatAccessExamples(objectPattern.properties, receiverText),
98
300
  },
99
301
  fix(fixer) {
100
302
  const properties = objectPattern.properties;
@@ -108,33 +310,39 @@ exports.noClassInstanceDestructuring = (0, createRule_1.createRule)({
108
310
  // the code to something more weakly typed than the author wrote.
109
311
  if (objectPattern.typeAnnotation)
110
312
  return null;
111
- // For single property, use simple replacement
112
- if (properties.length === 1) {
113
- const prop = properties[0];
114
- if (prop.type === utils_1.AST_NODE_TYPES.Property) {
115
- const value = prop.value.type === utils_1.AST_NODE_TYPES.Identifier
116
- ? prop.value.name
117
- : sourceCode.getText(prop.value);
118
- const accessPath = buildAccessPath(initText, prop);
119
- return fixer.replaceText(node, `${value} = ${accessPath}`);
120
- }
313
+ if (properties.length === 0)
314
+ return null;
315
+ // A rest element collects the members no other property names, a
316
+ // set only the type checker knows, so it cannot become a member
317
+ // read. Rewriting the siblings alone would delete the binding.
318
+ if (properties.some((prop) => prop.type !== utils_1.AST_NODE_TYPES.Property)) {
121
319
  return null;
122
320
  }
123
- // For multiple properties, create multiple declarations
124
- const declarations = properties
125
- .filter((prop) => prop.type === utils_1.AST_NODE_TYPES.Property)
126
- .map((prop) => {
127
- const value = prop.value.type === utils_1.AST_NODE_TYPES.Identifier
128
- ? prop.value.name
129
- : sourceCode.getText(prop.value);
130
- const accessPath = buildAccessPath(initText, prop);
131
- return `${value} = ${accessPath}`;
132
- })
133
- .join(';\nconst ');
134
- // Only apply the fix if we have valid declarations
135
- if (!declarations)
321
+ const props = properties;
322
+ // `const {a = 1} = inst` applies the default only when the member
323
+ // is `undefined`; a plain member read cannot express that.
324
+ if (props.some((prop) => prop.value.type === utils_1.AST_NODE_TYPES.AssignmentPattern)) {
136
325
  return null;
137
- return fixer.replaceText(node, declarations);
326
+ }
327
+ const targetOf = (prop) => prop.value.type === utils_1.AST_NODE_TYPES.Identifier
328
+ ? prop.value.name
329
+ : sourceCode.getText(prop.value);
330
+ // A single binding stays a single declarator, so it can be
331
+ // rewritten in place wherever the declaration sits.
332
+ if (props.length === 1) {
333
+ return fixer.replaceText(node, `${targetOf(props[0])} = ${buildAccessPath(receiverText, props[0])}`);
334
+ }
335
+ const target = resolveStatement(node);
336
+ if (!target)
337
+ return null;
338
+ const lines = tempName ? [`const ${tempName} = ${initText}`] : [];
339
+ for (const prop of props) {
340
+ lines.push(`${target.exportPrefix}${target.kind} ${targetOf(prop)} = ${buildAccessPath(receiverText, prop)}`);
341
+ }
342
+ // One replacement, so the whole rewrite lands or none of it does.
343
+ return fixer.replaceText(target.statement, lines
344
+ .map((line) => `${line}${target.terminator}`)
345
+ .join(`\n${target.indent}`));
138
346
  },
139
347
  });
140
348
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.49",
3
+ "version": "1.20.50",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,50 @@
1
1
  [
2
+ {
3
+ "version": "1.20.50",
4
+ "date": "2026-07-31T12:53:09.792Z",
5
+ "rules": [
6
+ {
7
+ "name": "consistent-callback-naming",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1522
11
+ ],
12
+ "summary": "stop autofixing prop renames, which broke compilation (closes #1522)"
13
+ },
14
+ {
15
+ "name": "enforce-early-destructuring",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1523
19
+ ],
20
+ "summary": "drop the synthesized `= {}` on nested object patterns (closes #1523)"
21
+ },
22
+ {
23
+ "name": "enforce-object-literal-as-const",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1526
27
+ ],
28
+ "summary": "skip array literals a signature declares mutable (closes #1526)"
29
+ },
30
+ {
31
+ "name": "enforce-timestamp-now",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1521
35
+ ],
36
+ "summary": "gate the new Date() autofix on an in-scope Timestamp import (closes #1521)"
37
+ },
38
+ {
39
+ "name": "no-class-instance-destructuring",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1524
43
+ ],
44
+ "summary": "bind the instance once instead of reconstructing per property (closes #1524)"
45
+ }
46
+ ]
47
+ },
2
48
  {
3
49
  "version": "1.20.49",
4
50
  "date": "2026-07-31T10:04:24.469Z",