@blumintinc/eslint-plugin-blumint 1.21.7 → 1.21.9

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
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.7',
227
+ version: '1.21.9',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -313,8 +313,10 @@ const isWriteTarget = (node) => {
313
313
  * Storing a reference does not copy it: the same array stays reachable through
314
314
  * the container, so `holder.items.push(3)` writes through to the binding
315
315
  * exactly as a direct alias does, and freezing it raises the same TS2339. A
316
- * `SpreadElement` is excluded because it genuinely builds a fresh value
317
- * (`const COPY = [...ITEMS]`), and a computed key is excluded because it coerces
316
+ * `SpreadElement` is excluded because it builds a fresh VALUE
317
+ * (`const COPY = [...ITEMS]`) — it is not excluded from the walk entirely,
318
+ * because the copy still carries the constant's frozen TYPE, which
319
+ * `copyExpressionOf` handles. A computed key is excluded because it coerces
318
320
  * the reference to a property name rather than retaining it.
319
321
  */
320
322
  const storageContainerOf = (node) => {
@@ -370,9 +372,177 @@ const aliasDeclaratorOf = (identifier) => {
370
372
  }
371
373
  };
372
374
  /**
373
- * Whether the binding is written through anywhere in the file, under its own
374
- * name or through an alias of it. Answered from the scope manager's reference
375
- * list rather than a textual search for the name, so a same-named binding in
375
+ * Whether a callee spells `Object.assign`, in either the dotted or the
376
+ * bracketed form — read through `accessedPropertyName` so the two spellings
377
+ * cannot diverge from how the mutation walk already reads a method name.
378
+ */
379
+ const isObjectAssignCallee = (callee) => {
380
+ const value = outermostValueOf(callee);
381
+ return (value.type === utils_1.AST_NODE_TYPES.MemberExpression &&
382
+ value.object.type === utils_1.AST_NODE_TYPES.Identifier &&
383
+ value.object.name === 'Object' &&
384
+ accessedPropertyName(value) === 'assign');
385
+ };
386
+ /**
387
+ * Array methods whose result keeps the receiver's ELEMENT type. `map` is
388
+ * absent because its result is typed from the CALLBACK, so the constant's type
389
+ * reaches it only for a callback that returns its argument unchanged — a no-op
390
+ * `map`. Admitting it would withhold the assertion from every derived array
391
+ * anything is computed from, to cover a spelling nobody writes.
392
+ */
393
+ const TYPE_PRESERVING_COPY_METHODS = new Set(['concat', 'slice', 'filter']);
394
+ /**
395
+ * The expression that builds a COPY carrying this value's type — the literal
396
+ * around a spread of it, the call of a copying array method on it, or an
397
+ * `Object.assign` it feeds.
398
+ *
399
+ * A copy is a fresh, mutable value, which is why `storageContainerOf` refuses
400
+ * it: writing to the copy cannot write through to the constant. But `as const`
401
+ * changes the constant's TYPE as well as its mutability, and a copy inherits
402
+ * that type — `[...ITEMS]` of a frozen `readonly [1, 2]` is `(1 | 2)[]`, so
403
+ * `COPY.push(3)` is TS2345 for an input that compiled. The copy is therefore
404
+ * followed for exactly the same question the alias walk asks: is the derived
405
+ * binding written?
406
+ */
407
+ const copyExpressionOf = (node) => {
408
+ const parent = node.parent;
409
+ if (!parent) {
410
+ return null;
411
+ }
412
+ if (parent.type === utils_1.AST_NODE_TYPES.SpreadElement &&
413
+ parent.argument === node &&
414
+ (parent.parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression ||
415
+ parent.parent?.type === utils_1.AST_NODE_TYPES.ArrayExpression)) {
416
+ return parent.parent;
417
+ }
418
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
419
+ parent.arguments.includes(node) &&
420
+ isObjectAssignCallee(parent.callee)) {
421
+ return parent;
422
+ }
423
+ if (parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
424
+ parent.object === node) {
425
+ const method = accessedPropertyName(parent);
426
+ const callee = outermostValueOf(parent);
427
+ // A method REFERENCE (`const take = ITEMS.concat;`) builds nothing, so the
428
+ // copy only exists once the method is actually called.
429
+ if (method !== null &&
430
+ TYPE_PRESERVING_COPY_METHODS.has(method) &&
431
+ callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
432
+ callee.parent.callee === callee) {
433
+ return callee.parent;
434
+ }
435
+ }
436
+ return null;
437
+ };
438
+ /** Pattern nodes a parameter's binding can be nested inside. */
439
+ const PATTERN_CONTAINERS = new Set([
440
+ utils_1.AST_NODE_TYPES.AssignmentPattern,
441
+ utils_1.AST_NODE_TYPES.Property,
442
+ utils_1.AST_NODE_TYPES.ObjectPattern,
443
+ utils_1.AST_NODE_TYPES.ArrayPattern,
444
+ utils_1.AST_NODE_TYPES.RestElement,
445
+ ]);
446
+ const FUNCTION_TYPES = new Set([
447
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
448
+ utils_1.AST_NODE_TYPES.FunctionExpression,
449
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
450
+ utils_1.AST_NODE_TYPES.TSDeclareFunction,
451
+ ]);
452
+ /**
453
+ * Whether a default value is what a PARAMETER's type is inferred FROM.
454
+ *
455
+ * Answered false in the two cases where freezing the default cannot change a
456
+ * signature: the parameter carries a type annotation, so its type is declared
457
+ * rather than inferred — looked for up the whole pattern, since a destructured
458
+ * parameter carries it on the pattern (`({ distance = DEFAULT }: Props)`) and
459
+ * a plain one on its binding (`(model: ModelName = DEFAULT)`) — or the default
460
+ * belongs to a destructuring declaration rather than a parameter list, which
461
+ * declares no signature at all.
462
+ */
463
+ const isInferredParameterDefault = (pattern) => {
464
+ let current = pattern;
465
+ for (;;) {
466
+ if (current.typeAnnotation) {
467
+ return false;
468
+ }
469
+ const parent = current.parent;
470
+ if (!parent) {
471
+ return false;
472
+ }
473
+ if (FUNCTION_TYPES.has(parent.type)) {
474
+ return parent.params.includes(current);
475
+ }
476
+ if (!PATTERN_CONTAINERS.has(parent.type)) {
477
+ return false;
478
+ }
479
+ current = parent;
480
+ }
481
+ };
482
+ /**
483
+ * Whether a reference sits where TypeScript INFERS a type from it — a default
484
+ * parameter or a class property initializer — reached directly or through a
485
+ * composite literal it is stored into.
486
+ *
487
+ * `as const` does not only freeze: it makes the literal type NON-WIDENING, and
488
+ * an inference site that widened `'ready'` to `string` then keeps the literal.
489
+ * A parameter defaulted from the constant therefore narrows to that one value,
490
+ * and every call passing a different one stops compiling (TS2345) for an input
491
+ * that compiled. The mutation walk cannot see this: nothing is written, the
492
+ * declaration is simply inferred from a value the assertion changes.
493
+ *
494
+ * Both sites are answered on the same terms, because an annotation is what
495
+ * settles the question in each: a type written by hand is DECLARED, so nothing
496
+ * infers from the value and freezing it cannot move the declaration. Only the
497
+ * unannotated spelling narrows.
498
+ *
499
+ * A RETURN position infers in exactly the same way and is deliberately absent.
500
+ * Declining there costs 59 of 778 consumer reports (7.6%) — the constant need
501
+ * only be held in a literal that is returned — to prevent breaks that the
502
+ * consumer does not contain, so it is documented as a limitation instead. The
503
+ * comparable trade in #2330 was rejected at 5%.
504
+ */
505
+ const isInferenceSite = (identifier) => {
506
+ let value = outermostValueOf(identifier);
507
+ for (;;) {
508
+ const parent = value.parent;
509
+ if (parent?.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
510
+ parent.right === value) {
511
+ return isInferredParameterDefault(parent.left);
512
+ }
513
+ // A class property's type is inferred from its initializer exactly as a
514
+ // parameter's is from its default, so `session.stage = 'live'` becomes
515
+ // TS2322 once the constant behind `stage = DEFAULT_STAGE` is frozen.
516
+ if ((parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
517
+ parent?.type === utils_1.AST_NODE_TYPES.AccessorProperty) &&
518
+ parent.value === value) {
519
+ return !parent.typeAnnotation;
520
+ }
521
+ const container = storageContainerOf(value);
522
+ if (!container) {
523
+ return false;
524
+ }
525
+ value = outermostValueOf(container);
526
+ }
527
+ };
528
+ /**
529
+ * Whether anything in the file stops this binding taking `as const`, under its
530
+ * own name or through an alias of it.
531
+ *
532
+ * Two things disqualify it, because `as const` does two things. It freezes the
533
+ * value, so a WRITE — through the binding (`X.push(1)`), or to a binding that
534
+ * aliases it (`other = X`) — becomes TS2339/TS2540. And it makes the literal
535
+ * type NON-WIDENING, so an INFERENCE site that read the widened type keeps the
536
+ * literal instead, which rewrites a declaration the assertion was never asked
537
+ * to touch.
538
+ *
539
+ * The type half reaches further than the value half, so the walk follows one
540
+ * edge the mutation question does not need: a COPY (`[...X]`, `X.concat()`),
541
+ * which is a fresh value but not a fresh type, and breaks on a write to the
542
+ * copy rather than to `X`.
543
+ *
544
+ * Answered from the scope manager's reference list rather than a textual
545
+ * search for the name, so a same-named binding in
376
546
  * another scope (`const arr` shadowed inside a callback) contributes nothing,
377
547
  * and a same-named method on an unrelated receiver (`other.push(1)`) is never
378
548
  * even visited.
@@ -394,21 +564,39 @@ const aliasDeclaratorOf = (identifier) => {
394
564
  * check keyed on `const` would leave the `let` spelling breaking builds under
395
565
  * `--fix`.
396
566
  */
397
- const isBindingMutated = (variable, declaredVariablesOf) => {
567
+ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
398
568
  // Grown in place and walked by index: an alias found mid-walk is appended and
399
569
  // reached by the same loop, so the traversal needs no recursion of its own.
400
570
  const pending = [variable];
401
571
  const visited = new Set(pending);
402
572
  for (let index = 0; index < pending.length; index += 1) {
403
573
  for (const reference of pending[index].references) {
574
+ // Reassigning an alias is as disqualifying as writing through one. A
575
+ // binding that takes its type from the constant narrows to the frozen
576
+ // literal, so `let stage = DEFAULT; stage = 'live';` becomes TS2322 for
577
+ // an input that compiled. `init` excludes the declaration's own write,
578
+ // which is how the alias was established rather than a change to it.
579
+ if (reference.isWrite() && !reference.init) {
580
+ return true;
581
+ }
582
+ if (isInferenceSite(reference.identifier)) {
583
+ return true;
584
+ }
404
585
  const path = accessPathOf(reference.identifier);
405
- if (path !== null) {
406
- if (isMutatingMethodCall(path) || isWriteTarget(path)) {
407
- return true;
408
- }
409
- continue;
586
+ if (path !== null &&
587
+ (isMutatingMethodCall(path) || isWriteTarget(path))) {
588
+ return true;
410
589
  }
411
- const declarator = aliasDeclaratorOf(reference.identifier);
590
+ // A copy carries the constant's frozen type into a second binding, so it
591
+ // is enrolled on the same terms as an alias — but it is reached through a
592
+ // member access (`ITEMS.concat()`), which the alias walk deliberately
593
+ // refuses, so it is resolved before that refusal applies.
594
+ const copy = copyExpressionOf(outermostValueOf(reference.identifier));
595
+ const declarator = copy
596
+ ? aliasDeclaratorOf(copy)
597
+ : path === null
598
+ ? aliasDeclaratorOf(reference.identifier)
599
+ : null;
412
600
  if (!declarator) {
413
601
  continue;
414
602
  }
@@ -837,7 +1025,7 @@ exports.default = (0, createRule_1.createRule)({
837
1025
  .getDeclaredVariables(declaration)
838
1026
  .find((variable) => variable.name === name);
839
1027
  return (!declaredVariable ||
840
- !isBindingMutated(declaredVariable, declaredVariablesOf));
1028
+ !blocksAsConstAssertion(declaredVariable, declaredVariablesOf));
841
1029
  };
842
1030
  if (shouldHaveAsConst(init)) {
843
1031
  context.report({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.7",
3
+ "version": "1.21.9",
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.21.9",
4
+ "date": "2026-09-05T05:55:33.427Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2331
11
+ ],
12
+ "summary": "withhold the assertion where a copy or a class property carries the frozen type (closes #2331)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.21.8",
18
+ "date": "2026-09-05T03:15:57.605Z",
19
+ "rules": [
20
+ {
21
+ "name": "global-const-style",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2329
25
+ ],
26
+ "summary": "withhold the assertion where a type is inferred from the constant (closes #2329)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.21.7",
4
32
  "date": "2026-09-04T21:52:00.765Z",