@blumintinc/eslint-plugin-blumint 1.20.169 → 1.20.170

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.169',
226
+ version: '1.20.170',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -28,8 +28,9 @@ const utils_1 = require("@typescript-eslint/utils");
28
28
  const ts = __importStar(require("typescript"));
29
29
  const createRule_1 = require("../utils/createRule");
30
30
  /**
31
- * Node types whose presence anywhere in a branch value means the value is NOT
32
- * safe to eager-evaluate inside a `Record` (all entries construct at once).
31
+ * Node types whose presence in a branch value outside any function the value
32
+ * itself carries — means the value is NOT safe to eager-evaluate inside a
33
+ * `Record` (all entries construct at once).
33
34
  */
34
35
  const EAGER_UNSAFE_NODES = new Set([
35
36
  utils_1.AST_NODE_TYPES.CallExpression,
@@ -959,7 +960,28 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
959
960
  }
960
961
  return null;
961
962
  }
962
- /** True when any node in the subtree is unsafe to eager-evaluate. */
963
+ /**
964
+ * True when any node in the subtree is unsafe to eager-evaluate.
965
+ *
966
+ * The walk stops at a function boundary, because what the `Record` literal
967
+ * evaluates per entry is the branch value itself, not what that value does
968
+ * when it is later invoked. A branch value that IS a function is already
969
+ * the thunk the report-only message asks for: its body runs on invocation,
970
+ * after the lookup, so a call inside it can no more fire per entry than a
971
+ * call inside any other uninvoked function. Scanning through the boundary
972
+ * declined every function-valued dispatch whose body did anything but
973
+ * arithmetic, and told the author to write the very shape they had (#2062).
974
+ *
975
+ * The boundary answers `await` too, and answers it correctly in both
976
+ * directions: an `await` is eager exactly when it is evaluated where the
977
+ * literal is built, so a top-level one still blocks, while one inside an
978
+ * `async` branch value — the only place it can legally appear inside a
979
+ * function — is suspended until that value is called.
980
+ *
981
+ * A class expression is deliberately not a boundary: its static
982
+ * initializers, static blocks and computed member names all run when the
983
+ * class definition is evaluated, which is per entry.
984
+ */
963
985
  function containsEagerUnsafe(node) {
964
986
  let found = false;
965
987
  const visit = (n) => {
@@ -974,6 +996,19 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
974
996
  found = true;
975
997
  return;
976
998
  }
999
+ if (FUNCTION_TYPES.has(anyNode.type)) {
1000
+ // A parameter decorator is the one position inside a function that
1001
+ // does not wait for a call: it is evaluated with the enclosing class
1002
+ // definition, which the `Record` literal performs for every entry.
1003
+ for (const param of anyNode.params ?? []) {
1004
+ const decorators = param
1005
+ ?.decorators;
1006
+ for (const decorator of decorators ?? []) {
1007
+ visit(decorator);
1008
+ }
1009
+ }
1010
+ return;
1011
+ }
977
1012
  for (const key of Object.keys(anyNode)) {
978
1013
  if (key === 'parent') {
979
1014
  continue;
@@ -1410,15 +1445,98 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1410
1445
  }
1411
1446
  }
1412
1447
  /**
1413
- * The ternary's replacement, taking the host's line break with it when the
1414
- * shortened host fits on one line; null when only the expression itself
1415
- * should be replaced.
1448
+ * The opening parenthesis the PARENT's own syntax puts in front of `node` —
1449
+ * an `if`/`while`/`switch`/`with` head, a lone call or `new` argument, a
1450
+ * `do…while` test, `import(...)`. Such a pair delimits the parent rather
1451
+ * than grouping the expression, and dropping it does not parse.
1452
+ *
1453
+ * A call with more than one argument needs no entry: the token on one side
1454
+ * of any argument is then a comma, so no pair is seen around it at all.
1455
+ */
1456
+ function parentSyntaxParen(node, parent) {
1457
+ const parenAfter = (preceding) => sourceCode.getTokenAfter(preceding, {
1458
+ filter: (token) => token.value === '(',
1459
+ });
1460
+ switch (parent.type) {
1461
+ case utils_1.AST_NODE_TYPES.CallExpression:
1462
+ case utils_1.AST_NODE_TYPES.NewExpression:
1463
+ return parent.arguments.length === 1 && parent.arguments[0] === node
1464
+ ? parenAfter(parent.callee)
1465
+ : null;
1466
+ case utils_1.AST_NODE_TYPES.DoWhileStatement:
1467
+ return parent.test === node ? parenAfter(parent.body) : null;
1468
+ case utils_1.AST_NODE_TYPES.IfStatement:
1469
+ case utils_1.AST_NODE_TYPES.WhileStatement:
1470
+ return parent.test === node
1471
+ ? sourceCode.getFirstToken(parent, 1)
1472
+ : null;
1473
+ case utils_1.AST_NODE_TYPES.SwitchStatement:
1474
+ return parent.discriminant === node
1475
+ ? sourceCode.getFirstToken(parent, 1)
1476
+ : null;
1477
+ case utils_1.AST_NODE_TYPES.WithStatement:
1478
+ return parent.object === node
1479
+ ? sourceCode.getFirstToken(parent, 1)
1480
+ : null;
1481
+ case utils_1.AST_NODE_TYPES.ImportExpression:
1482
+ return parent.source === node
1483
+ ? sourceCode.getFirstToken(parent, 1)
1484
+ : null;
1485
+ default:
1486
+ return null;
1487
+ }
1488
+ }
1489
+ /**
1490
+ * The parentheses standing around `node` that the emitted lookup does not
1491
+ * need — the outermost such pair, so a nested grouping goes with it.
1492
+ *
1493
+ * Precedence is a property of the REPLACEMENT text, not of the expression
1494
+ * being replaced: a computed member access is the tightest-binding
1495
+ * production there is, so no enclosing operator can require a pair around
1496
+ * one. Parentheses a ternary genuinely needed (`(k === 'a' ? 1 : 2) > 0`
1497
+ * parses differently without them) are therefore redundant the moment the
1498
+ * lookup takes its place, and keeping them ships
1499
+ * `if ((RESULT_BY_KIND[kind]) > 0)` — text the next `prettier --write`
1500
+ * strips, so the fix is not a fixed point (#2063).
1501
+ *
1502
+ * Two pairs are not the expression's own grouping and stay: one the
1503
+ * parent's syntax requires, and one a decorator requires, whose grammar
1504
+ * admits no computed access at all (`@(cond)` cannot become `@X[k]`).
1505
+ */
1506
+ function redundantParens(node) {
1507
+ const parent = node.parent;
1508
+ if (!parent || parent.type === utils_1.AST_NODE_TYPES.Decorator) {
1509
+ return null;
1510
+ }
1511
+ const syntaxParen = parentSyntaxParen(node, parent);
1512
+ let pair = null;
1513
+ let open = sourceCode.getTokenBefore(node);
1514
+ let close = sourceCode.getTokenAfter(node);
1515
+ while (open !== null &&
1516
+ close !== null &&
1517
+ open !== syntaxParen &&
1518
+ open.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
1519
+ open.value === '(' &&
1520
+ close.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
1521
+ close.value === ')') {
1522
+ pair = { open, close };
1523
+ open = sourceCode.getTokenBefore(open);
1524
+ close = sourceCode.getTokenAfter(close);
1525
+ }
1526
+ return pair;
1527
+ }
1528
+ /**
1529
+ * The ternary's replacement, widened over text the lookup makes redundant —
1530
+ * the parentheses that used to bind the conditional, and the host's line
1531
+ * break when the shortened host fits on one line; null when only the
1532
+ * expression itself should be replaced.
1416
1533
  *
1417
1534
  * The break exists only because the ternary used to be wide; a lookup is
1418
1535
  * short, and Prettier answers the shortened host by joining it back onto one
1419
1536
  * line (#2060). This is the mirror of measuring an inserted head: the fixer
1420
1537
  * SHORTENS, so every line it writes is well inside the width and nothing
1421
- * about the emitted text reveals the stale wrap.
1538
+ * about the emitted text reveals the stale wrap — which is equally true of
1539
+ * the stale parentheses (#2063).
1422
1540
  *
1423
1541
  * Measure, do not always-join: a host that is over-width for a reason the
1424
1542
  * ternary never caused — a long binding name, a wide type annotation —
@@ -1429,9 +1547,28 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1429
1547
  if (!parent) {
1430
1548
  return null;
1431
1549
  }
1432
- const edit = absorbedOperatorWrap(node, parent, lookupText) ??
1433
- absorbedArgumentWrap(node, parent, lookupText);
1434
- return edit !== null && absorbsComment(node, edit) ? null : edit;
1550
+ const parens = redundantParens(node);
1551
+ const span = {
1552
+ node,
1553
+ left: parens?.open ?? node,
1554
+ right: parens?.close ?? node,
1555
+ };
1556
+ const parenEdit = parens === null
1557
+ ? null
1558
+ : {
1559
+ range: [parens.open.range[0], parens.close.range[1]],
1560
+ text: lookupText,
1561
+ };
1562
+ // Widest first: a host join is measured from the parentheses' outer
1563
+ // edges, so where one applies it already carries the paren widening. A
1564
+ // candidate whose span would delete a comment steps down to the next,
1565
+ // narrower one rather than withholding the conversion.
1566
+ const candidates = [
1567
+ absorbedOperatorWrap(span, parent, lookupText),
1568
+ absorbedArgumentWrap(span, parent, lookupText),
1569
+ parenEdit,
1570
+ ];
1571
+ return (candidates.find((edit) => edit !== null && !absorbsComment(node, edit)) ?? null);
1435
1572
  }
1436
1573
  /**
1437
1574
  * Whether the margins an absorbing edit adds around `node` hold a comment.
@@ -1447,6 +1584,11 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1447
1584
  * declining there would cost the join and save nothing. The join is a layout
1448
1585
  * optimization, so dropping it and replacing the expression in place keeps
1449
1586
  * both the conversion and the comment.
1587
+ *
1588
+ * Measured against the reported node whatever the edit spans, this is the
1589
+ * single gate for every widening: the parenthesis absorption (#2063) adds
1590
+ * exactly such a margin, and a comment written between a parenthesis and the
1591
+ * expression it groups sits in it.
1450
1592
  */
1451
1593
  function absorbsComment(node, edit) {
1452
1594
  return sourceCode
@@ -1457,35 +1599,39 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1457
1599
  comment.range[0] < edit.range[1]));
1458
1600
  }
1459
1601
  /** The join for a host that broke after `=` / `:` (see `absorbedLookupEdit`). */
1460
- function absorbedOperatorWrap(node, parent, lookupText) {
1602
+ function absorbedOperatorWrap(span, parent, lookupText) {
1461
1603
  if (!WRAP_ABSORBING_PARENTS.has(parent.type) ||
1462
- !isWrapAbsorbingPosition(node, parent)) {
1604
+ !isWrapAbsorbingPosition(span.node, parent)) {
1463
1605
  return null;
1464
1606
  }
1465
- const prevToken = sourceCode.getTokenBefore(node);
1466
- if (!prevToken || prevToken.loc.end.line === node.loc.start.line) {
1607
+ const prevToken = sourceCode.getTokenBefore(span.left);
1608
+ if (!prevToken || prevToken.loc.end.line === span.left.loc.start.line) {
1467
1609
  return null;
1468
1610
  }
1469
1611
  // Joining across a comment would pull the lookup onto a `//` line, which
1470
1612
  // comments the value out.
1471
- if (sourceCode.getCommentsBefore(node).length > 0) {
1613
+ if (sourceCode.getCommentsBefore(span.left).length > 0) {
1472
1614
  return null;
1473
1615
  }
1474
1616
  const headLine = sourceCode.lines[prevToken.loc.end.line - 1] ?? '';
1475
1617
  if (headLine.slice(prevToken.loc.end.column).trim() !== '') {
1476
1618
  return null;
1477
1619
  }
1478
- const tailLine = sourceCode.lines[node.loc.end.line - 1] ?? '';
1479
- const tail = tailLine.slice(node.loc.end.column).trimEnd();
1480
- // Anything after the expression beyond its own terminator (a closing
1481
- // paren, a trailing comment) means joining does not produce one line.
1620
+ const tailLine = sourceCode.lines[span.right.loc.end.line - 1] ?? '';
1621
+ const tail = tailLine.slice(span.right.loc.end.column).trimEnd();
1622
+ // Anything after the span beyond its own terminator (a closing paren the
1623
+ // span does not own, a trailing comment) means joining does not produce
1624
+ // one line.
1482
1625
  if (!/^[,;]?$/.test(tail)) {
1483
1626
  return null;
1484
1627
  }
1485
1628
  const head = headLine.slice(0, prevToken.loc.end.column);
1486
1629
  const joined = `${head} ${lookupText}${tail}`;
1487
1630
  return joined.length <= printWidth
1488
- ? { range: [prevToken.range[1], node.range[1]], text: ` ${lookupText}` }
1631
+ ? {
1632
+ range: [prevToken.range[1], span.right.range[1]],
1633
+ text: ` ${lookupText}`,
1634
+ }
1489
1635
  : null;
1490
1636
  }
1491
1637
  /**
@@ -1494,14 +1640,14 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1494
1640
  * them — is what makes the shortened call a fixed point; keeping the comma
1495
1641
  * would leave `render(RESULT[x],);`, which Prettier rewrites again.
1496
1642
  */
1497
- function absorbedArgumentWrap(node, parent, lookupText) {
1643
+ function absorbedArgumentWrap(span, parent, lookupText) {
1498
1644
  if ((parent.type !== utils_1.AST_NODE_TYPES.CallExpression &&
1499
1645
  parent.type !== utils_1.AST_NODE_TYPES.NewExpression) ||
1500
1646
  parent.arguments.length !== 1 ||
1501
- parent.arguments[0] !== node) {
1647
+ parent.arguments[0] !== span.node) {
1502
1648
  return null;
1503
1649
  }
1504
- const openParen = sourceCode.getTokenBefore(node);
1650
+ const openParen = sourceCode.getTokenBefore(span.left);
1505
1651
  const closeParen = sourceCode.getLastToken(parent);
1506
1652
  if (!openParen ||
1507
1653
  !closeParen ||
@@ -1510,18 +1656,18 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1510
1656
  openParen.loc.end.line === closeParen.loc.start.line) {
1511
1657
  return null;
1512
1658
  }
1513
- // The argument's own parentheses would otherwise be swallowed: the token
1514
- // trail from the expression to the call's closer must hold nothing but
1515
- // Prettier's dangling comma.
1516
- const afterNode = sourceCode.getTokenAfter(node);
1659
+ // Parentheses the span does not own would otherwise be swallowed: the
1660
+ // token trail from the span's right edge to the call's closer must hold
1661
+ // nothing but Prettier's dangling comma.
1662
+ const afterNode = sourceCode.getTokenAfter(span.right);
1517
1663
  const afterComma = afterNode && afterNode.value === ','
1518
1664
  ? sourceCode.getTokenAfter(afterNode)
1519
1665
  : afterNode;
1520
1666
  if (afterComma !== closeParen) {
1521
1667
  return null;
1522
1668
  }
1523
- if (sourceCode.getCommentsBefore(node).length > 0 ||
1524
- sourceCode.getCommentsAfter(node).length > 0) {
1669
+ if (sourceCode.getCommentsBefore(span.left).length > 0 ||
1670
+ sourceCode.getCommentsAfter(span.right).length > 0) {
1525
1671
  return null;
1526
1672
  }
1527
1673
  const headLine = sourceCode.lines[openParen.loc.end.line - 1] ?? '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.169",
3
+ "version": "1.20.170",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "1.20.170",
4
+ "date": "2026-08-19T23:48:20.873Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-map-over-conditional-dispatch",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2062,
11
+ 2063
12
+ ],
13
+ "summary": "drop the parentheses the replaced ternary needed (closes #2063); stop the eager-call scan at function boundaries (closes #2062)"
14
+ }
15
+ ]
16
+ },
2
17
  {
3
18
  "version": "1.20.169",
4
19
  "date": "2026-08-19T20:16:38.161Z",