@blumintinc/eslint-plugin-blumint 1.20.135 → 1.20.136

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.135',
226
+ version: '1.20.136',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -607,11 +607,27 @@ module.exports = (0, createRule_1.createRule)({
607
607
  globalVar.references.forEach((ref) => references.add(ref));
608
608
  }
609
609
  }
610
+ // A binding that leaves the module is one end of a cross-file
611
+ // contract. Renaming `export const handleClick` to `click` strands
612
+ // every `import { handleClick }` with TS2724, and a single-file fixer
613
+ // cannot reach those importers — the same reasoning that already
614
+ // withholds the JSX prop rename and the destructured one, which is
615
+ // where `isExportedBinding` was first needed. The violation still
616
+ // reports; only the rename is withheld.
617
+ const declaredVariable = context
618
+ .getDeclaredVariables(node)
619
+ .find((v) => v.identifiers.includes(node.id));
620
+ const leavesModule = declaredVariable
621
+ ? isExportedBinding(declaredVariable)
622
+ : isExportedDeclaration(node);
610
623
  context.report({
611
624
  node,
612
625
  messageId: 'callbackFunctionPrefix',
613
626
  data: { functionName },
614
627
  fix(fixer) {
628
+ if (leavesModule) {
629
+ return null;
630
+ }
615
631
  // Remove 'handle' prefix and convert first character to lowercase
616
632
  const newName = stripHandlePrefix(functionName);
617
633
  // `const handleDelete = fn` would become `const delete = fn`,
@@ -256,6 +256,54 @@ function isNumericCall(node) {
256
256
  callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
257
257
  callee.object.name === 'Math');
258
258
  }
259
+ /**
260
+ * The property names `assertSafe` exists to reject. A key that provably cannot
261
+ * spell one of these cannot reach the prototype surface, which is the entire
262
+ * hazard — so proving it is what earns an exemption, the same standard the
263
+ * numeric analysis already meets.
264
+ */
265
+ const PROTOTYPE_REACHING_KEYS = ['__proto__', 'constructor', 'prototype'];
266
+ /**
267
+ * Whether a template's FIXED text still leaves room to spell `target`.
268
+ *
269
+ * The producible set is `q0 + * + q1 + * + … + * + qN`, each `*` an arbitrary
270
+ * substitution. `target` is producible iff it starts with `q0`, ends with `qN`,
271
+ * and the interior quasis occur in order in between without overlapping. So
272
+ * `` `user-${id}` `` can never be `__proto__` (no such prefix) while
273
+ * `` `__pro${x}` `` can — with `x` = `'to__'`, which resolves to
274
+ * `Object.prototype` at runtime.
275
+ *
276
+ * Interior quasis are matched greedily from the left. That is sufficient
277
+ * because they are fixed strings: taking the earliest occurrence never consumes
278
+ * a character a later quasi needed, so no backtracking can succeed where the
279
+ * greedy pass fails.
280
+ *
281
+ * A template with no substitutions produces exactly one string and is a static
282
+ * key like any other string literal, so it is never treated as reaching.
283
+ */
284
+ function templateCanSpell(quasis, target) {
285
+ if (quasis.length < 2) {
286
+ return false;
287
+ }
288
+ const first = quasis[0];
289
+ const last = quasis[quasis.length - 1];
290
+ if (!target.startsWith(first) || !target.endsWith(last)) {
291
+ return false;
292
+ }
293
+ const limit = target.length - last.length;
294
+ let cursor = first.length;
295
+ if (cursor > limit) {
296
+ return false;
297
+ }
298
+ for (const middle of quasis.slice(1, -1)) {
299
+ const at = target.indexOf(middle, cursor);
300
+ if (at < 0 || at + middle.length > limit) {
301
+ return false;
302
+ }
303
+ cursor = at + middle.length;
304
+ }
305
+ return true;
306
+ }
259
307
  /**
260
308
  * A `: number` annotation on a binding name. Parameters and variable
261
309
  * declarators are the bindings that carry one, and TypeScript checks every
@@ -1133,19 +1181,34 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1133
1181
  if (isLikelyArray) {
1134
1182
  return;
1135
1183
  }
1136
- // Only flag simple template literals that are just `${id}`
1137
- // Complex templates with additional text like `prefix_${id}_suffix` are allowed
1184
+ // A template whose every substitution is provably numeric can only
1185
+ // widen into digits, and no dangerous property name is the string
1186
+ // form of a number — the same proof the identifier path accepts.
1187
+ const canWidenToText = property.expressions.some((expr) => !isStaticallyNumeric(expr));
1188
+ const quasis = property.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw);
1189
+ const reachesPrototype = canWidenToText &&
1190
+ PROTOTYPE_REACHING_KEYS.some((key) => templateCanSpell(quasis, key));
1191
+ // Fixed text on either side of the substitution can rule a property
1192
+ // name out — `user-${id}` is never `__proto__` — and the rule skips
1193
+ // a key it can prove harmless. What it must NOT do is assume that:
1194
+ // `__pro${x}` carries fixed text too and still reaches the
1195
+ // prototype (#1880).
1196
+ if (!reachesPrototype) {
1197
+ return;
1198
+ }
1199
+ // `${id}` alone is the whole key, so the remedy names the inner
1200
+ // expression and the fix wraps it directly. A template carrying
1201
+ // fixed text has no such inner key — the string it builds is the
1202
+ // key — so that whole template is what gets wrapped, which is the
1203
+ // shape the docs show for `assertSafe(`${id}_suffix`)`.
1138
1204
  const isSimpleVarInterpolation = property.expressions.length === 1 &&
1139
1205
  property.quasis.length === 2 &&
1140
1206
  property.quasis[0].value.raw === '' &&
1141
1207
  property.quasis[1].value.raw === '';
1142
- if (!isSimpleVarInterpolation) {
1143
- // Complex template literals with additional text are fine
1144
- return;
1145
- }
1146
- const expr = property.expressions[0];
1147
- const exprText = context.sourceCode.getText(expr);
1148
- reportWrittenKey(written, property, exprText);
1208
+ const unwrapped = isSimpleVarInterpolation
1209
+ ? property.expressions[0]
1210
+ : property;
1211
+ reportWrittenKey(written, property, context.sourceCode.getText(unwrapped));
1149
1212
  return;
1150
1213
  }
1151
1214
  // Check for direct variable usage (identifiers)
@@ -340,6 +340,33 @@ exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
340
340
  }
341
341
  });
342
342
  }
343
+ /**
344
+ * The member name a `this.<x>` access reads, whatever its spelling.
345
+ *
346
+ * Keying the check on the dot spelling alone left `this['settings']`
347
+ * invisible, so the rename shipped and stranded it — the class no longer had
348
+ * the member the getter reads (#1881). A computed access with a static
349
+ * string is the SAME member as the dot form, and the fixer cannot rewrite it
350
+ * either, so it has to count. `null` marks a genuinely dynamic key, which
351
+ * names no member statically.
352
+ */
353
+ function staticMemberName(node) {
354
+ if (!node.computed) {
355
+ return node.property.type === utils_1.AST_NODE_TYPES.Identifier
356
+ ? node.property.name
357
+ : null;
358
+ }
359
+ if (node.property.type === utils_1.AST_NODE_TYPES.Literal &&
360
+ typeof node.property.value === 'string') {
361
+ return node.property.value;
362
+ }
363
+ if (node.property.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
364
+ node.property.expressions.length === 0 &&
365
+ node.property.quasis.length === 1) {
366
+ return node.property.quasis[0].value.cooked;
367
+ }
368
+ return null;
369
+ }
343
370
  // Determine whether renaming a constructor parameter property is unsafe to
344
371
  // autofix. A parameter property (`private readonly foo: T`) creates BOTH a
345
372
  // constructor-local binding and a `this.foo` class field, so a
@@ -355,8 +382,7 @@ exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
355
382
  }
356
383
  if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
357
384
  node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
358
- node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
359
- node.property.name === name) {
385
+ staticMemberName(node) === name) {
360
386
  unsafe = true;
361
387
  return;
362
388
  }
@@ -42,6 +42,33 @@ const getEnclosingClass = (node) => {
42
42
  * cannot be resolved through scope analysis, the fix is withheld whenever the
43
43
  * name occurs anywhere in the class other than at its declaration.
44
44
  */
45
+ /**
46
+ * The member name a `this.<x>` access reads, whatever its spelling.
47
+ *
48
+ * Keying the check on the dot spelling alone left `this['settings']` invisible,
49
+ * so the rename shipped and stranded it — the class no longer had the member the
50
+ * getter reads (#1882, the sibling of #1881). A computed access with a static
51
+ * string is the SAME member as the dot form, and the fixer cannot rewrite it
52
+ * either, so it has to count. `null` marks a genuinely dynamic key, which names
53
+ * no member statically and therefore strands nothing.
54
+ */
55
+ const staticMemberName = (node) => {
56
+ if (!node.computed) {
57
+ return node.property.type === utils_1.AST_NODE_TYPES.Identifier
58
+ ? node.property.name
59
+ : null;
60
+ }
61
+ if (node.property.type === utils_1.AST_NODE_TYPES.Literal &&
62
+ typeof node.property.value === 'string') {
63
+ return node.property.value;
64
+ }
65
+ if (node.property.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
66
+ node.property.expressions.length === 0 &&
67
+ node.property.quasis.length === 1) {
68
+ return node.property.quasis[0].value.cooked;
69
+ }
70
+ return null;
71
+ };
45
72
  const parameterPropertyRenameIsUnsafe = (classNode, name, declarationId) => {
46
73
  let unsafe = false;
47
74
  const visit = (node) => {
@@ -50,8 +77,7 @@ const parameterPropertyRenameIsUnsafe = (classNode, name, declarationId) => {
50
77
  }
51
78
  if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
52
79
  node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
53
- node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
54
- node.property.name === name) {
80
+ staticMemberName(node) === name) {
55
81
  unsafe = true;
56
82
  return;
57
83
  }
@@ -77,12 +77,12 @@ function isTraversalBoundary(node) {
77
77
  node.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
78
78
  node.type === utils_1.AST_NODE_TYPES.ClassExpression);
79
79
  }
80
- function recordReturnStatement(node, assertions) {
80
+ function recordReturnStatement(node, assertionSites) {
81
81
  if (!node.argument)
82
82
  return;
83
83
  const assertion = extractAssertionTypeNode(node.argument);
84
84
  if (assertion)
85
- assertions.push(assertion);
85
+ assertionSites.push({ assertion, expression: node.argument });
86
86
  }
87
87
  function addChildNodesToStack(node, stack) {
88
88
  const keys = visitor_keys_1.visitorKeys[node.type];
@@ -103,22 +103,38 @@ function addChildNodesToStack(node, stack) {
103
103
  }
104
104
  }
105
105
  }
106
+ function collectReturnArguments(body) {
107
+ const args = [];
108
+ const stack = [...body.body];
109
+ while (stack.length) {
110
+ const current = stack.pop();
111
+ if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
112
+ if (current.argument)
113
+ args.push(current.argument);
114
+ continue;
115
+ }
116
+ if (isTraversalBoundary(current))
117
+ continue;
118
+ addChildNodesToStack(current, stack);
119
+ }
120
+ return args;
121
+ }
106
122
  function collectReturnInfo(body) {
107
- const assertions = [];
123
+ const assertionSites = [];
108
124
  let returnCount = 0;
109
125
  const stack = [...body.body];
110
126
  while (stack.length) {
111
127
  const current = stack.pop();
112
128
  if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
113
129
  returnCount += 1;
114
- recordReturnStatement(current, assertions);
130
+ recordReturnStatement(current, assertionSites);
115
131
  continue;
116
132
  }
117
133
  if (isTraversalBoundary(current))
118
134
  continue;
119
135
  addChildNodesToStack(current, stack);
120
136
  }
121
- return { assertions, returnCount };
137
+ return { assertionSites, returnCount };
122
138
  }
123
139
  function findTypeAnnotationStart(typeAnnotation, sourceCode) {
124
140
  const start = typeAnnotation.range[0];
@@ -176,6 +192,64 @@ function unwrapAlias(type, checker) {
176
192
  }
177
193
  return type;
178
194
  }
195
+ /**
196
+ * Readonly-ness reaches a property symbol by two disjoint routes, and a key
197
+ * built from only one of them equates shapes that differ:
198
+ *
199
+ * - A property *written* `readonly` (interface member, type-alias member, class
200
+ * field) carries the modifier on its declaration and no check flag.
201
+ * - A property *synthesized* as readonly — an `as const` object literal, a
202
+ * `Readonly<T>` mapped type — has no declaration modifier and carries
203
+ * `CheckFlags.Readonly` instead.
204
+ *
205
+ * Reading only declarations makes `as const` compare equal to a mutable
206
+ * annotation, and deleting that annotation changes the value's type (see #1883).
207
+ *
208
+ * `getCheckFlags` is not published in every TypeScript release's type
209
+ * definitions, so it is reached through a guarded lookup: where it is absent the
210
+ * key falls back to declaration modifiers alone, which is exactly the
211
+ * information available without it. The enum is dereferenced here rather than at
212
+ * module scope because the plugin barrel loads every rule eagerly and a missing
213
+ * compiler export at module scope fails the whole plugin, not just this rule.
214
+ */
215
+ function isReadonlyProperty(prop) {
216
+ const compiler = ts;
217
+ const readonlyCheckFlag = compiler.CheckFlags?.Readonly;
218
+ if (typeof compiler.getCheckFlags === 'function' && readonlyCheckFlag) {
219
+ if ((compiler.getCheckFlags(prop) & readonlyCheckFlag) !== 0)
220
+ return true;
221
+ }
222
+ // A getter with no setter is readonly by shape rather than by modifier or
223
+ // check flag, so neither route above sees it — `{ get x(): number }` carries
224
+ // `getCheckFlags` 0 and `getCombinedModifierFlags` 0, and would otherwise
225
+ // format identically to a mutable `{ x: number }` (#1887).
226
+ const flags = prop.getFlags();
227
+ if ((flags & ts.SymbolFlags.Accessor) !== 0 &&
228
+ (flags & ts.SymbolFlags.SetAccessor) === 0) {
229
+ return true;
230
+ }
231
+ return (prop.declarations ?? []).some((declaration) => (ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Readonly) !==
232
+ 0);
233
+ }
234
+ /**
235
+ * The index signatures a type declares, with their readonly-ness.
236
+ *
237
+ * They belong in the structural key for the same reason the properties do: an
238
+ * index-signature-only type otherwise keys to the empty string, so every such
239
+ * type compares equal to every other. Readonly-ness in particular does not
240
+ * affect bidirectional assignability of an index signature, so a readonly one
241
+ * would match a mutable annotation and removing that annotation ships TS2542
242
+ * (#1887).
243
+ */
244
+ function getFormattedIndexSignatures(type, checker) {
245
+ const compiler = checker;
246
+ if (typeof compiler.getIndexInfosOfType !== 'function')
247
+ return [];
248
+ return compiler
249
+ .getIndexInfosOfType(type)
250
+ .map((info) => `${info.isReadonly ? 'readonly ' : ''}[${typeText(info.keyType, checker)}]:${typeText(unwrapAlias(info.type, checker), checker)}`)
251
+ .sort();
252
+ }
179
253
  function formatPropertySignature(prop, parentType, checker) {
180
254
  const declaration = prop.valueDeclaration ??
181
255
  prop.declarations?.[0] ??
@@ -186,7 +260,8 @@ function formatPropertySignature(prop, parentType, checker) {
186
260
  const propType = checker.getTypeOfSymbolAtLocation(prop, declaration);
187
261
  const text = typeText(unwrapAlias(propType, checker), checker);
188
262
  const isOptional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0;
189
- return `${prop.getName()}${isOptional ? '?' : ''}:${text}`;
263
+ const readonlyPrefix = isReadonlyProperty(prop) ? 'readonly ' : '';
264
+ return `${readonlyPrefix}${prop.getName()}${isOptional ? '?' : ''}:${text}`;
190
265
  }
191
266
  function getFormattedTypeProperties(type, checker) {
192
267
  return checker
@@ -205,7 +280,8 @@ function structuralKey(type, checker) {
205
280
  const apparent = checker.getApparentType(type);
206
281
  const properties = getFormattedTypeProperties(apparent, checker);
207
282
  const signatures = getFormattedCallSignatures(apparent, checker);
208
- return `${properties.join('|')}::${signatures.join('|')}`;
283
+ const indexes = getFormattedIndexSignatures(apparent, checker);
284
+ return `${properties.join('|')}::${signatures.join('|')}::${indexes.join('|')}`;
209
285
  }
210
286
  function getComparableType(typeNode, checker, services) {
211
287
  const tsNode = services.esTreeNodeToTSNodeMap.get(typeNode);
@@ -297,11 +373,11 @@ function haveMatchingTypes(annotation, assertion, checker, services) {
297
373
  }
298
374
  return selectMatchingTypeRepresentation(representations);
299
375
  }
300
- function getReturnAssertion(node) {
376
+ function getReturnAssertionSite(node) {
301
377
  const value = node.type === utils_1.AST_NODE_TYPES.MethodDefinition ? node.value : node;
302
378
  const body = value.body;
303
379
  if (body?.type === utils_1.AST_NODE_TYPES.BlockStatement) {
304
- const { assertions, returnCount } = collectReturnInfo(body);
380
+ const { assertionSites, returnCount } = collectReturnInfo(body);
305
381
  // Skip functions with multiple returns because different branches can assert different types.
306
382
  if (returnCount !== 1)
307
383
  return null;
@@ -311,11 +387,426 @@ function getReturnAssertion(node) {
311
387
  if (lastStatement?.type !== utils_1.AST_NODE_TYPES.ReturnStatement) {
312
388
  return null;
313
389
  }
314
- return assertions[0] ?? null;
390
+ return assertionSites[0] ?? null;
315
391
  }
316
392
  if (!body)
317
393
  return null;
318
- return extractAssertionTypeNode(body);
394
+ const assertion = extractAssertionTypeNode(body);
395
+ return assertion ? { assertion, expression: body } : null;
396
+ }
397
+ /**
398
+ * The identifiers that name `node`, in the AST. A self-reference inside the
399
+ * function's own return expression resolves to one of these, whichever shape
400
+ * the function is written in: a declaration's own name, a named function
401
+ * expression's name, the variable or property an anonymous function is assigned
402
+ * to, or a method's key.
403
+ */
404
+ /**
405
+ * The key a member is declared under, whatever its spelling.
406
+ *
407
+ * A computed key whose key expression is a literal names exactly the member a
408
+ * dotted or bracketed read resolves to — `{ ['build']() {} }` declares `build`
409
+ * — and the reader side already resolves both spellings. Refusing the computed
410
+ * form left such a candidate with NO owner at all, so it could not be found
411
+ * circular even by a direct self-reference (#1888). A genuinely dynamic key
412
+ * names no member statically and still yields nothing.
413
+ */
414
+ function memberKeyNameNode(node) {
415
+ if (!node.computed)
416
+ return node.key;
417
+ return node.key.type === utils_1.AST_NODE_TYPES.Literal &&
418
+ (typeof node.key.value === 'string' || typeof node.key.value === 'number')
419
+ ? node.key
420
+ : null;
421
+ }
422
+ /** Every binding a destructuring pattern introduces. */
423
+ function patternBindingNames(pattern) {
424
+ const names = [];
425
+ const visit = (node) => {
426
+ if (!node)
427
+ return;
428
+ switch (node.type) {
429
+ case utils_1.AST_NODE_TYPES.Identifier:
430
+ names.push(node);
431
+ return;
432
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
433
+ for (const property of node.properties) {
434
+ visit(property.type === utils_1.AST_NODE_TYPES.Property
435
+ ? property.value
436
+ : property.argument);
437
+ }
438
+ return;
439
+ case utils_1.AST_NODE_TYPES.ArrayPattern:
440
+ for (const element of node.elements)
441
+ visit(element);
442
+ return;
443
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
444
+ visit(node.left);
445
+ return;
446
+ case utils_1.AST_NODE_TYPES.RestElement:
447
+ visit(node.argument);
448
+ return;
449
+ default:
450
+ return;
451
+ }
452
+ };
453
+ visit(pattern);
454
+ return names;
455
+ }
456
+ /** A pattern carries its annotation on the pattern node itself. */
457
+ function patternTypeAnnotation(pattern) {
458
+ return pattern
459
+ .typeAnnotation;
460
+ }
461
+ function ownerNameNodes(node) {
462
+ if (node.type === utils_1.AST_NODE_TYPES.MethodDefinition) {
463
+ const key = memberKeyNameNode(node);
464
+ return key ? [key] : [];
465
+ }
466
+ const names = [];
467
+ if (node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression && node.id) {
468
+ names.push(node.id);
469
+ }
470
+ const parent = node.parent;
471
+ if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
472
+ parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
473
+ names.push(parent.id);
474
+ }
475
+ if (parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
476
+ parent?.type === utils_1.AST_NODE_TYPES.Property) {
477
+ const key = memberKeyNameNode(parent);
478
+ if (key)
479
+ names.push(key);
480
+ }
481
+ return names;
482
+ }
483
+ /**
484
+ * A binding is identified by the declarations behind its symbol rather than by
485
+ * the symbol object. Reading `obj.build` yields a *clone* of the symbol declared
486
+ * by `build: () => {}` — widening an object literal's type rebuilds its property
487
+ * symbols — so symbol identity reports a self-reference as unrelated. The clone
488
+ * keeps the original declaration, which therefore is the stable key.
489
+ */
490
+ function declarationsOfSymbol(symbol) {
491
+ return symbol.declarations ?? [];
492
+ }
493
+ function declaredAt(nodes, checker, services) {
494
+ const declarations = new Set();
495
+ for (const node of nodes) {
496
+ const tsNode = services.esTreeNodeToTSNodeMap.get(node);
497
+ const symbol = tsNode ? checker.getSymbolAtLocation(tsNode) : undefined;
498
+ if (!symbol)
499
+ continue;
500
+ for (const declaration of declarationsOfSymbol(symbol)) {
501
+ declarations.add(declaration);
502
+ }
503
+ }
504
+ return declarations;
505
+ }
506
+ /**
507
+ * Every binding the returned expression reads. Resolving through the checker
508
+ * rather than matching identifier text keeps a shadowing inner declaration of
509
+ * the same name from reading as a self-reference.
510
+ */
511
+ function referencedDeclarationsOf(expression, checker, services, pruneTypedFunctions = false) {
512
+ const declarations = new Set();
513
+ const root = services.esTreeNodeToTSNodeMap.get(expression);
514
+ if (!root)
515
+ return declarations;
516
+ /**
517
+ * Only a VALUE read can make return-type inference circular.
518
+ *
519
+ * The returned expression subtree contains the assertion's own type node —
520
+ * `<Status>{…}` and `expr as Status` both carry `Status` inside it — so
521
+ * resolving every identifier would count that type reference as a
522
+ * self-reference whenever a binding and a type share a name
523
+ * (`type Status = …; const Status = (): Status => <Status>{…}`). TypeScript
524
+ * resolves a type annotation without needing any function's return type, so
525
+ * such a reference can never close the cycle.
526
+ *
527
+ * `typeof f` is the one type-position spelling that reads a VALUE, and it does
528
+ * depend on `f`'s return type, so it counts. Checking it before the general
529
+ * type-node test is what lets it through — a type query is itself a type node.
530
+ */
531
+ const readsAValue = (identifier) => {
532
+ let current = identifier.parent;
533
+ while (current) {
534
+ if (ts.isTypeQueryNode(current))
535
+ return true;
536
+ if (ts.isTypeNode(current))
537
+ return false;
538
+ if (current === root)
539
+ return true;
540
+ current = current.parent;
541
+ }
542
+ return true;
543
+ };
544
+ /**
545
+ * A property read spells its name as an identifier in `obj.build` and as a
546
+ * string in `obj['build']`, and both resolve to the same property symbol —
547
+ * so testing for an identifier alone recognises only one of two spellings of
548
+ * one reference. The literal is only a name where it indexes something;
549
+ * elsewhere a string is data and resolves to nothing worth following.
550
+ */
551
+ const isNameNode = (tsNode) => {
552
+ if (ts.isIdentifier(tsNode))
553
+ return true;
554
+ const parent = tsNode.parent;
555
+ return (ts.isStringLiteralLike(tsNode) &&
556
+ Boolean(parent) &&
557
+ ts.isElementAccessExpression(parent) &&
558
+ parent.argumentExpression === tsNode);
559
+ };
560
+ /**
561
+ * The name a declaration gives itself is not a read of it. Walking an object
562
+ * literal or a class body reaches the names of its own members, and counting
563
+ * those makes the literal look like it reads every function it contains —
564
+ * enough to close a cycle that TypeScript does not have, since it resolves an
565
+ * object literal's properties one at a time rather than as a whole.
566
+ *
567
+ * A shorthand property is the exception in both directions: `{ build }`
568
+ * declares a property and reads a binding, and it is the read that matters.
569
+ */
570
+ const isDeclarationName = (tsNode) => {
571
+ const parent = tsNode.parent;
572
+ if (!parent)
573
+ return false;
574
+ if (ts.isShorthandPropertyAssignment(parent) ||
575
+ ts.isPropertyAccessExpression(parent) ||
576
+ ts.isElementAccessExpression(parent) ||
577
+ ts.isQualifiedName(parent)) {
578
+ return false;
579
+ }
580
+ return parent.name === tsNode;
581
+ };
582
+ const symbolAt = (tsNode) => {
583
+ const parent = tsNode.parent;
584
+ // A shorthand property's own identifier resolves to the property, so the
585
+ // binding it abbreviates has to be asked for by name.
586
+ if (parent && ts.isShorthandPropertyAssignment(parent)) {
587
+ return checker.getShorthandAssignmentValueSymbol(parent);
588
+ }
589
+ return checker.getSymbolAtLocation(tsNode);
590
+ };
591
+ /**
592
+ * A nested function that writes its own return type down answers without
593
+ * consulting anything, so nothing beneath it can be a link in a cycle. It is
594
+ * already its own graph node, related to its own dependencies, and reaching
595
+ * through it would attribute its body to the binding that merely contains it
596
+ * — `const cache = { get: (): Q => build() }` does not make `cache` depend on
597
+ * `build`, because `get` is typed by its annotation.
598
+ */
599
+ const answersWithoutInference = (tsNode) => pruneTypedFunctions &&
600
+ (ts.isArrowFunction(tsNode) ||
601
+ ts.isFunctionExpression(tsNode) ||
602
+ ts.isFunctionDeclaration(tsNode) ||
603
+ ts.isMethodDeclaration(tsNode)) &&
604
+ tsNode.type !== undefined;
605
+ const visit = (tsNode) => {
606
+ if (isNameNode(tsNode) &&
607
+ !isDeclarationName(tsNode) &&
608
+ readsAValue(tsNode)) {
609
+ const symbol = symbolAt(tsNode);
610
+ if (symbol) {
611
+ for (const declaration of declarationsOfSymbol(symbol)) {
612
+ declarations.add(declaration);
613
+ }
614
+ }
615
+ }
616
+ if (tsNode !== root && answersWithoutInference(tsNode)) {
617
+ return;
618
+ }
619
+ ts.forEachChild(tsNode, visit);
620
+ };
621
+ visit(root);
622
+ return declarations;
623
+ }
624
+ function isFunctionLike(node) {
625
+ return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
626
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression);
627
+ }
628
+ /** The expressions a function's own return type is inferred from. */
629
+ function returnExpressionsOf(node) {
630
+ const value = node.type === utils_1.AST_NODE_TYPES.MethodDefinition ? node.value : node;
631
+ const body = value.body;
632
+ if (!body)
633
+ return [];
634
+ return body.type === utils_1.AST_NODE_TYPES.BlockStatement
635
+ ? collectReturnArguments(body)
636
+ : [body];
637
+ }
638
+ function returnTypeAnnotationOf(node) {
639
+ return node.type === utils_1.AST_NODE_TYPES.MethodDefinition
640
+ ? node.value.returnType
641
+ : node.returnType;
642
+ }
643
+ /**
644
+ * Whether the binding this function is assigned to declares its own type. A
645
+ * contextually typed function is not inferred from its body — `const f: () => Q
646
+ * = () => g()` types `f` from the annotation regardless of what `g` returns —
647
+ * so it cannot carry a cycle even without a return annotation of its own.
648
+ */
649
+ function ownerCarriesTypeAnnotation(node) {
650
+ const parent = node.parent;
651
+ if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
652
+ return (parent.id.type === utils_1.AST_NODE_TYPES.Identifier &&
653
+ Boolean(parent.id.typeAnnotation));
654
+ }
655
+ if (parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
656
+ return Boolean(parent.typeAnnotation);
657
+ }
658
+ return false;
659
+ }
660
+ function addInferenceEdges(graph, owners, dependencies, needsInference) {
661
+ for (const owner of owners) {
662
+ const existing = graph.get(owner);
663
+ if (!existing) {
664
+ graph.set(owner, {
665
+ needsInference,
666
+ dependencies: new Set(dependencies),
667
+ });
668
+ continue;
669
+ }
670
+ existing.needsInference ||= needsInference;
671
+ for (const dependency of dependencies) {
672
+ existing.dependencies.add(dependency);
673
+ }
674
+ }
675
+ }
676
+ function unionReferences(expressions, checker, services, pruneTypedFunctions = false) {
677
+ const references = new Set();
678
+ for (const expression of expressions) {
679
+ for (const declaration of referencedDeclarationsOf(expression, checker, services, pruneTypedFunctions)) {
680
+ references.add(declaration);
681
+ }
682
+ }
683
+ return references;
684
+ }
685
+ /**
686
+ * Every function-like node in the file, related to the declarations its return
687
+ * type is inferred from. Functions the rule is not reporting on belong in the
688
+ * graph too: an unannotated helper is precisely the kind of node a cycle runs
689
+ * through, and it is invisible to a relation drawn between candidates only.
690
+ */
691
+ function addFunctionNodes(graph, functions, checker, services) {
692
+ for (const node of functions) {
693
+ const owners = declaredAt(ownerNameNodes(node), checker, services);
694
+ if (owners.size === 0)
695
+ continue;
696
+ // A written-down type normally answers without consulting anything, which
697
+ // is what lets the walk stop at it — unless the annotation itself reads a
698
+ // value through `typeof`, which is a dependency like any other and can
699
+ // close the cycle it was supposed to break (#1888). Such a node keeps
700
+ // needing inference, and contributes what its annotation reads.
701
+ const annotationReads = annotationValueReads(node, checker, services);
702
+ const writtenDown = Boolean(returnTypeAnnotationOf(node)) || ownerCarriesTypeAnnotation(node);
703
+ const dependencies = unionReferences(returnExpressionsOf(node), checker, services);
704
+ for (const declaration of annotationReads)
705
+ dependencies.add(declaration);
706
+ addInferenceEdges(graph, owners, dependencies, !writtenDown || annotationReads.size > 0);
707
+ }
708
+ }
709
+ /**
710
+ * The values this function's declared type reads through `typeof`.
711
+ *
712
+ * `referencedDeclarationsOf` already treats a type query as a value read and
713
+ * every other type position as not one, so handing it the annotation yields
714
+ * exactly the `typeof` operands and nothing else.
715
+ */
716
+ function annotationValueReads(node, checker, services) {
717
+ const reads = new Set();
718
+ const parent = node.parent;
719
+ const annotations = [
720
+ returnTypeAnnotationOf(node),
721
+ parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
722
+ parent.id.type === utils_1.AST_NODE_TYPES.Identifier
723
+ ? parent.id.typeAnnotation
724
+ : undefined,
725
+ parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition
726
+ ? parent.typeAnnotation
727
+ : undefined,
728
+ ];
729
+ for (const annotation of annotations) {
730
+ if (!annotation)
731
+ continue;
732
+ for (const declaration of referencedDeclarationsOf(annotation.typeAnnotation, checker, services)) {
733
+ reads.add(declaration);
734
+ }
735
+ }
736
+ return reads;
737
+ }
738
+ function addValueNodes(graph, values, checker, services) {
739
+ for (const { name, init } of values) {
740
+ const owners = declaredAt([name], checker, services);
741
+ if (owners.size === 0)
742
+ continue;
743
+ addInferenceEdges(graph, owners, unionReferences([init], checker, services, true), true);
744
+ }
745
+ }
746
+ /**
747
+ * Whether following what this declaration's type is inferred from leads back to
748
+ * the declaration itself.
749
+ *
750
+ * The walk stops at any declaration whose type is written down: an annotated
751
+ * function, an annotated binding, an ambient declaration. Such a node answers
752
+ * the question it is asked without consulting anything further, which is
753
+ * exactly what breaks a cycle — TypeScript's own error says a type is inferred
754
+ * "directly or indirectly" from itself, and every link in that indirection has
755
+ * to be a type it must infer.
756
+ */
757
+ function reachesOwnDeclaration(owners, dependencies, graph) {
758
+ const frontier = [...dependencies];
759
+ const seen = new Set();
760
+ while (frontier.length) {
761
+ const current = frontier.pop();
762
+ if (owners.has(current))
763
+ return true;
764
+ if (seen.has(current))
765
+ continue;
766
+ seen.add(current);
767
+ const node = graph.get(current);
768
+ if (!node?.needsInference)
769
+ continue;
770
+ for (const dependency of node.dependencies)
771
+ frontier.push(dependency);
772
+ }
773
+ return false;
774
+ }
775
+ /**
776
+ * The candidates whose annotation must survive because removing it would make
777
+ * the function's return type circular.
778
+ *
779
+ * A function that reaches itself through what its return type is inferred from
780
+ * can only be typed by its annotation: dropping it leaves TypeScript inferring
781
+ * the return type from an expression whose type depends on that same return
782
+ * type (TS7023/TS7022), or — where the assertion pins enough of the shape to
783
+ * break the cycle — silently widening a member to `any`. That circularity is
784
+ * invisible to the equality test that proves redundancy, because the equality
785
+ * only holds *while* the annotation is there.
786
+ *
787
+ * The reach is transitive rather than a hop between candidates, because a cycle
788
+ * closes through whatever happens to lie on it: an unannotated helper, an
789
+ * object holding a callback, a plain alias. Those are not candidates — they
790
+ * have no annotation to remove — yet they relay a dependency, and a relation
791
+ * drawn candidate-to-candidate cannot see them.
792
+ *
793
+ * Every candidate is treated as needing inference, since this rule's fixes ship
794
+ * as one batch and every annotation in it goes together. That over-approximates
795
+ * — declining one member of a cycle would free the rest — and the surplus costs
796
+ * a missed report rather than a broken fix. It also subsumes the direct
797
+ * self-reference case, which is a cycle of length one.
798
+ */
799
+ function findCircularReturnCandidates(candidates, graph) {
800
+ for (const candidate of candidates) {
801
+ addInferenceEdges(graph, candidate.owners, candidate.references, true);
802
+ }
803
+ const circular = new Set();
804
+ for (const candidate of candidates) {
805
+ if (reachesOwnDeclaration(candidate.owners, candidate.references, graph)) {
806
+ circular.add(candidate.site);
807
+ }
808
+ }
809
+ return circular;
319
810
  }
320
811
  exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
321
812
  name: 'no-redundant-annotation-assertion',
@@ -355,6 +846,17 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
355
846
  * re-reports the debt.
356
847
  */
357
848
  const sites = [];
849
+ /** Return-position sites, kept with the symbols that decide circularity. */
850
+ const returnCandidates = [];
851
+ /**
852
+ * The nodes the inference graph is built from, gathered as AST during the
853
+ * traversal and resolved to symbols only if a return-position candidate
854
+ * turns up. Resolving every binding in the file up front would charge a
855
+ * type-checker query per declaration to answer a question no file without a
856
+ * return annotation ever asks.
857
+ */
858
+ const functionLikeNodes = [];
859
+ const inferredValueDeclarations = [];
358
860
  /**
359
861
  * Suppression is applied to reports after a rule emits them, so a suppressed
360
862
  * site keeps its annotation while losing its fix. Counting its removal
@@ -365,12 +867,32 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
365
867
  function collectIfRedundant(annotation, assertion, reportNode, fixerTarget) {
366
868
  const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices);
367
869
  if (!matchingType)
368
- return;
369
- sites.push({
870
+ return null;
871
+ const site = {
370
872
  reportNode,
371
873
  removal: annotationRemovalRange(fixerTarget, sourceCode),
372
874
  matchingType,
373
- });
875
+ };
876
+ sites.push(site);
877
+ return site;
878
+ }
879
+ /**
880
+ * The one gate every return-position visitor passes through, so a declined
881
+ * return annotation loses both its report and its fix whichever of the four
882
+ * function shapes carries it.
883
+ */
884
+ function collectReturnSite(node, annotation, reportNode) {
885
+ const assertionSite = getReturnAssertionSite(node);
886
+ if (!assertionSite)
887
+ return;
888
+ const owners = declaredAt(ownerNameNodes(node), checker, parserServices);
889
+ const references = referencedDeclarationsOf(assertionSite.expression, checker, parserServices);
890
+ // Whether the annotation is load-bearing is decided at `Program:exit`:
891
+ // the cycle can run through a function elsewhere in the file, and every
892
+ // annotation in it goes in the same batched fix.
893
+ const site = collectIfRedundant(annotation, assertionSite.assertion, reportNode, annotation);
894
+ if (site)
895
+ returnCandidates.push({ site, owners, references });
374
896
  }
375
897
  /**
376
898
  * The sites whose fixes actually ship. A site is excluded when its report
@@ -384,15 +906,36 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
384
906
  * vetoing the rest: orphanhood grows monotonically with the removed set, so
385
907
  * a site that cannot be planned alone can only ever poison the batch.
386
908
  */
387
- function selectFixableSites() {
388
- return sites.filter((site) => !isReportSuppressed(site.reportNode) &&
909
+ function selectFixableSites(candidates) {
910
+ return candidates.filter((site) => !isReportSuppressed(site.reportNode) &&
389
911
  (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [site.removal]) !== null);
390
912
  }
913
+ /**
914
+ * The file's inference graph, built only where a return annotation is at
915
+ * stake. Both the functions and the value bindings go in: a cycle runs
916
+ * through whichever of them happens to lie on it.
917
+ */
918
+ function buildInferenceGraph() {
919
+ const graph = new Map();
920
+ addFunctionNodes(graph, functionLikeNodes, checker, parserServices);
921
+ addValueNodes(graph, inferredValueDeclarations, checker, parserServices);
922
+ return graph;
923
+ }
391
924
  return {
392
925
  'Program:exit'() {
393
926
  if (sites.length === 0)
394
927
  return;
395
- const fixable = selectFixableSites();
928
+ // Recursion only becomes circular once every annotation on the cycle is
929
+ // gone, which is exactly what this rule's single batched fix does, so
930
+ // the check has to see the whole batch — and the whole file, because
931
+ // what closes the cycle need not be a candidate at all.
932
+ const circular = returnCandidates.length > 0
933
+ ? findCircularReturnCandidates(returnCandidates, buildInferenceGraph())
934
+ : new Set();
935
+ const reportable = sites.filter((site) => !circular.has(site));
936
+ if (reportable.length === 0)
937
+ return;
938
+ const fixable = selectFixableSites(reportable);
396
939
  const removals = fixable.map((site) => site.removal);
397
940
  // One plan over every surviving removal: an import referenced solely by
398
941
  // annotations that all go in this pass is orphaned by their union, even
@@ -404,7 +947,7 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
404
947
  // others that the import's orphanhood was judged against. The rest
405
948
  // report without a fixer; the carrier's pass already resolves them.
406
949
  const carrier = importRanges ? fixable[0] : undefined;
407
- for (const site of sites) {
950
+ for (const site of reportable) {
408
951
  context.report({
409
952
  node: site.reportNode,
410
953
  messageId: 'redundantAnnotationAndAssertion',
@@ -419,10 +962,29 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
419
962
  }
420
963
  },
421
964
  VariableDeclarator(node) {
422
- if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
423
- !node.id.typeAnnotation ||
424
- node.id.optional ||
425
- node.definite) {
965
+ // A pattern introduces bindings that relay a dependency exactly as a
966
+ // plain one does — `const { run } = { run: () => build() }` is the
967
+ // destructured spelling of a shape already covered — but the early
968
+ // return below skipped registering them at all (#1888). Each name gets
969
+ // the whole initializer, which is the same over-approximation a plain
970
+ // binding carries.
971
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
972
+ if (node.init && !patternTypeAnnotation(node.id)) {
973
+ for (const name of patternBindingNames(node.id)) {
974
+ inferredValueDeclarations.push({ name, init: node.init });
975
+ }
976
+ }
977
+ return;
978
+ }
979
+ // A binding whose initializer is a function is already related to what
980
+ // it reads by that function's own graph node, and by its return
981
+ // expressions rather than its whole body.
982
+ if (node.init &&
983
+ !node.id.typeAnnotation &&
984
+ !isFunctionLike(node.init)) {
985
+ inferredValueDeclarations.push({ name: node.id, init: node.init });
986
+ }
987
+ if (!node.id.typeAnnotation || node.id.optional || node.definite) {
426
988
  return;
427
989
  }
428
990
  const assertionType = extractAssertionTypeNode(node.init);
@@ -430,7 +992,26 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
430
992
  return;
431
993
  collectIfRedundant(node.id.typeAnnotation, assertionType, node.id, node.id.typeAnnotation);
432
994
  },
995
+ // A parameter default relays a dependency the same way a body `const`
996
+ // does — `function helper(seed = build())` is the parameter spelling of
997
+ // `const seed = build()` — and nothing visited parameters, so the link was
998
+ // invisible (#1888). An annotated parameter is typed without consulting
999
+ // its default, so it breaks the chain like any written-down type.
1000
+ AssignmentPattern(node) {
1001
+ if (node.parent?.type === utils_1.AST_NODE_TYPES.Property ||
1002
+ node.left.type !== utils_1.AST_NODE_TYPES.Identifier ||
1003
+ node.left.typeAnnotation) {
1004
+ return;
1005
+ }
1006
+ inferredValueDeclarations.push({ name: node.left, init: node.right });
1007
+ },
433
1008
  PropertyDefinition(node) {
1009
+ if (node.value &&
1010
+ !node.typeAnnotation &&
1011
+ !node.computed &&
1012
+ !isFunctionLike(node.value)) {
1013
+ inferredValueDeclarations.push({ name: node.key, init: node.value });
1014
+ }
434
1015
  if (!node.typeAnnotation ||
435
1016
  !node.value ||
436
1017
  node.optional ||
@@ -442,39 +1023,31 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
442
1023
  collectIfRedundant(node.typeAnnotation, assertionType, node.key, node.typeAnnotation);
443
1024
  },
444
1025
  FunctionDeclaration(node) {
1026
+ functionLikeNodes.push(node);
445
1027
  if (!node.returnType)
446
1028
  return;
447
- const assertionType = getReturnAssertion(node);
448
- if (!assertionType)
449
- return;
450
- collectIfRedundant(node.returnType, assertionType, node.id ?? node, node.returnType);
1029
+ collectReturnSite(node, node.returnType, node.id ?? node);
451
1030
  },
452
1031
  FunctionExpression(node) {
453
1032
  if (node.parent?.type === utils_1.AST_NODE_TYPES.MethodDefinition) {
454
1033
  return;
455
1034
  }
1035
+ functionLikeNodes.push(node);
456
1036
  if (!node.returnType)
457
1037
  return;
458
- const assertionType = getReturnAssertion(node);
459
- if (!assertionType)
460
- return;
461
- collectIfRedundant(node.returnType, assertionType, node, node.returnType);
1038
+ collectReturnSite(node, node.returnType, node);
462
1039
  },
463
1040
  ArrowFunctionExpression(node) {
1041
+ functionLikeNodes.push(node);
464
1042
  if (!node.returnType)
465
1043
  return;
466
- const assertionType = getReturnAssertion(node);
467
- if (!assertionType)
468
- return;
469
- collectIfRedundant(node.returnType, assertionType, node, node.returnType);
1044
+ collectReturnSite(node, node.returnType, node);
470
1045
  },
471
1046
  MethodDefinition(node) {
1047
+ functionLikeNodes.push(node);
472
1048
  if (!node.value.returnType)
473
1049
  return;
474
- const assertionType = getReturnAssertion(node);
475
- if (!assertionType)
476
- return;
477
- collectIfRedundant(node.value.returnType, assertionType, node.key, node.value.returnType);
1050
+ collectReturnSite(node, node.value.returnType, node.key);
478
1051
  },
479
1052
  };
480
1053
  },
@@ -248,6 +248,27 @@ function declaredTypeNode(node) {
248
248
  function checksExcessProperties(typeNode) {
249
249
  return typeNode !== null && !UNCHECKED_ANNOTATION_TYPES.has(typeNode.type);
250
250
  }
251
+ /** `as const` declares no members of its own, so it pins no name. */
252
+ function isConstAssertionType(typeNode) {
253
+ return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
254
+ typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
255
+ typeNode.typeName.name === 'const');
256
+ }
257
+ /**
258
+ * An `as T` / `<T>` whose target type declares members the value must still
259
+ * have. Renaming one of them makes the assertion uncomparable (TS2352), so the
260
+ * name is dictated by `T` rather than chosen by the author — the same reasoning
261
+ * the annotation arms use, applied to the check an assertion actually performs.
262
+ */
263
+ function isCheckedTypeAssertion(node) {
264
+ if (node.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
265
+ node.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) {
266
+ return false;
267
+ }
268
+ const { typeAnnotation } = node;
269
+ return (checksExcessProperties(typeAnnotation) &&
270
+ !isConstAssertionType(typeAnnotation));
271
+ }
251
272
  function isFunctionNode(node) {
252
273
  return (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
253
274
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
@@ -310,10 +331,18 @@ const EXPRESSION_ASSERTION_TYPES = new Set([
310
331
  * members, and climbs THROUGH assertion wrappers, which change no runtime value
311
332
  * and so cannot detach a literal from the declared type it is assigned to
312
333
  * (#1597) — `enforce-object-literal-as-const` ships in the same recommended
313
- * config and appends `as const` to exactly these literals by `--fix`. An
314
- * assertion is transparent, never itself a signal: `{...} as T` still reports,
315
- * because an `as` clause does not reject undeclared members the way an
316
- * annotation does. The walk stops at anything else.
334
+ * config and appends `as const` to exactly these literals by `--fix`.
335
+ *
336
+ * An `as T` is itself a signal when `T` declares members. It is true that an
337
+ * `as` clause does not reject EXCESS members the way an annotation does
338
+ * (`{ orderBy, extra } as Q` compiles; `const c: Q = { orderBy, extra }` is
339
+ * TS2322) — but the operation being gated is a RENAME, which removes a REQUIRED
340
+ * member, and that an assertion does reject: `{ order } as Q` is TS2352 when `Q`
341
+ * requires `orderBy`. Reasoning from excess properties alone made the rule
342
+ * demand a rename that does not compile (#1885). `as const` is the exception
343
+ * and stays transparent, declaring no members of its own.
344
+ *
345
+ * The walk stops at anything else.
317
346
  */
318
347
  function hasConformanceSignal(node) {
319
348
  let current = node;
@@ -330,6 +359,19 @@ function hasConformanceSignal(node) {
330
359
  checksExcessProperties(parent.typeAnnotation)) {
331
360
  return true;
332
361
  }
362
+ // An `as T` DOES pin the member names it requires, even though it does not
363
+ // reject excess ones. The two are different checks: excess-property
364
+ // checking is what an annotation adds, but a RENAME removes a REQUIRED
365
+ // member, and that breaks comparability — `{ beta: 1 } as T` is TS2352 when
366
+ // `T` requires `alpha`. Reasoning from excess properties alone made the rule
367
+ // demand a rename that does not compile (#1885). `as const` stays
368
+ // transparent: it declares no members of its own, and
369
+ // `enforce-object-literal-as-const` appends one to exactly these literals.
370
+ if (isCheckedTypeAssertion(parent) &&
371
+ parent
372
+ .expression === current) {
373
+ return true;
374
+ }
333
375
  if (EXPRESSION_ASSERTION_TYPES.has(parent.type)) {
334
376
  current = parent;
335
377
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.135",
3
+ "version": "1.20.136",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,61 @@
1
1
  [
2
+ {
3
+ "version": "1.20.136",
4
+ "date": "2026-08-08T14:34:06.547Z",
5
+ "rules": [
6
+ {
7
+ "name": "consistent-callback-naming",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1878
11
+ ],
12
+ "summary": "withhold the rename on exported bindings"
13
+ },
14
+ {
15
+ "name": "enforce-assert-safe-object-key",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1880
19
+ ],
20
+ "summary": "check that a template's fixed text rules out a dangerous key (closes #1880)"
21
+ },
22
+ {
23
+ "name": "enforce-props-argument-name",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1881
27
+ ],
28
+ "summary": "withhold the parameter-property rename on every static spelling of the member (closes #1881)"
29
+ },
30
+ {
31
+ "name": "enforce-props-naming-consistency",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1882
35
+ ],
36
+ "summary": "withhold the parameter-property rename on every static spelling of the member (closes #1882)"
37
+ },
38
+ {
39
+ "name": "no-redundant-annotation-assertion",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1883,
43
+ 1886,
44
+ 1887,
45
+ 1888
46
+ ],
47
+ "summary": "make every spelling of a relay a node in the inference graph (closes #1888); compare index signatures, and read readonly through the accessor route (closes #1887); follow the circular-return check transitively (closes #1886); keep annotations that are load-bearing (closes #1883)"
48
+ },
49
+ {
50
+ "name": "no-unnecessary-verb-suffix",
51
+ "changeType": "fix",
52
+ "issues": [
53
+ 1885
54
+ ],
55
+ "summary": "treat an `as T` as the conformance signal it is (closes #1885)"
56
+ }
57
+ ]
58
+ },
2
59
  {
3
60
  "version": "1.20.135",
4
61
  "date": "2026-08-08T08:06:35.865Z",