@blumintinc/eslint-plugin-blumint 1.20.69 → 1.20.71

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.69',
226
+ version: '1.20.71',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -80,25 +80,42 @@ exports.classMethodsReadTopToBottom = (0, createRule_1.createRule)({
80
80
  if (actualMember !== expectedMember) {
81
81
  const classNameReport = className || 'this class';
82
82
  const sourceCode = context.getSourceCode();
83
- const newClassBody = sortedOrder
84
- .map((n) => {
85
- // Fetch the actual AST node corresponding to the name
86
- const memberNode = node.body.find((member) => getMemberName(member) === n);
87
- if (!memberNode) {
88
- return '';
89
- }
90
- const comments = sourceCode.getCommentsBefore(memberNode) || [];
91
- const nodeRange = memberNode.range;
92
- const newRange = comments.length > 0
93
- ? [
94
- Math.min(nodeRange[0], Math.min(...comments.map((comment) => comment.range[0]))),
95
- Math.max(nodeRange[1], Math.max(...comments.map((comment) => comment.range[1]))),
96
- ]
97
- : nodeRange;
98
- return sourceCode.getText({ ...memberNode, range: newRange });
99
- })
100
- .filter(Boolean)
101
- .join('\n');
83
+ const sourceText = sourceCode.getText();
84
+ // A member's block spans its leading comments through its own end,
85
+ // so documentation travels with the member it describes. Because
86
+ // every comment in the body is thereby absorbed into some block,
87
+ // the text between two adjacent blocks is pure whitespace.
88
+ const memberBlocks = node.body.map((member) => {
89
+ const comments = sourceCode.getCommentsBefore(member) || [];
90
+ const start = Math.min(member.range[0], ...comments.map((comment) => comment.range[0]));
91
+ return {
92
+ name: getMemberName(member),
93
+ text: sourceText.slice(start, member.range[1]),
94
+ start,
95
+ end: member.range[1],
96
+ };
97
+ });
98
+ // Reuse those whitespace runs positionally instead of joining with
99
+ // a bare '\n'. The blank lines between members are the part that
100
+ // matters: prettier preserves existing blank lines but never
101
+ // inserts new ones, so collapsing them is irreversible (#1592).
102
+ // Carrying the runs verbatim also reproduces the newline and
103
+ // indentation after `{` and the newline before `}` for free, since
104
+ // every member sits at the same depth.
105
+ const separators = memberBlocks
106
+ .slice(1)
107
+ .map((block, index) => sourceText.slice(memberBlocks[index].end, block.start));
108
+ const prefix = sourceText.slice(node.range[0] + 1, memberBlocks[0].start);
109
+ const suffix = sourceText.slice(memberBlocks[memberBlocks.length - 1].end, node.range[1] - 1);
110
+ const newClassBody = prefix +
111
+ sortedOrder
112
+ .map((n) => {
113
+ const block = memberBlocks.find(({ name }) => name === n);
114
+ return block ? block.text : '';
115
+ })
116
+ .map((text, index) => index === 0 ? text : separators[index - 1] + text)
117
+ .join('') +
118
+ suffix;
102
119
  return context.report({
103
120
  node,
104
121
  messageId: 'classMethodsReadTopToBottom',
@@ -43,6 +43,42 @@ const isParenthesizedType = (node) => {
43
43
  return (candidate.type === 'TSParenthesizedType' &&
44
44
  candidate.typeAnnotation !== undefined);
45
45
  };
46
+ const unwrapParenthesizedTypeNode = (node) => {
47
+ let current = node;
48
+ // Cap iterations for the same reason as unwrapArrayElementType: wrappers are
49
+ // finite, but a future wrapper case must not be able to loop forever.
50
+ for (let i = 0; i < 10; i++) {
51
+ if (isParenthesizedType(current)) {
52
+ current = current.typeAnnotation;
53
+ continue;
54
+ }
55
+ break;
56
+ }
57
+ return current;
58
+ };
59
+ // Assertion wrappers never change the runtime value, so `[...] as const` and
60
+ // `[...] as const satisfies readonly string[]` both still describe an array.
61
+ const EXPRESSION_ASSERTION_TYPES = new Set([
62
+ utils_1.AST_NODE_TYPES.TSAsExpression,
63
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
64
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
65
+ 'TSSatisfiesExpression',
66
+ ]);
67
+ const unwrapExpressionAssertions = (node) => {
68
+ let current = node;
69
+ for (let i = 0; i < 10; i++) {
70
+ if (EXPRESSION_ASSERTION_TYPES.has(current.type)) {
71
+ const inner = current
72
+ .expression;
73
+ if (!inner)
74
+ break;
75
+ current = inner;
76
+ continue;
77
+ }
78
+ break;
79
+ }
80
+ return current;
81
+ };
46
82
  const unwrapArrayElementType = (node) => {
47
83
  let current = node;
48
84
  // Fixpoint loop: peel wrappers in any order until none remain
@@ -181,6 +217,7 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
181
217
  const aliasNameToType = new Map();
182
218
  const interfaceNames = new Set();
183
219
  const enumNames = new Set();
220
+ const constArrayNameToLiteral = new Map();
184
221
  const visitNode = (n) => {
185
222
  switch (n.type) {
186
223
  case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration: {
@@ -195,6 +232,22 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
195
232
  enumNames.add(n.id.name);
196
233
  break;
197
234
  }
235
+ case utils_1.AST_NODE_TYPES.VariableDeclaration: {
236
+ // Only `const` bindings can back a `(typeof X)[number]` element union
237
+ if (n.kind !== 'const')
238
+ break;
239
+ for (const declarator of n.declarations) {
240
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
241
+ !declarator.init) {
242
+ continue;
243
+ }
244
+ const init = unwrapExpressionAssertions(declarator.init);
245
+ if (init.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
246
+ constArrayNameToLiteral.set(declarator.id.name, init);
247
+ }
248
+ }
249
+ break;
250
+ }
198
251
  case utils_1.AST_NODE_TYPES.ExportNamedDeclaration: {
199
252
  if (n.declaration)
200
253
  visitNode(n.declaration);
@@ -235,6 +288,85 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
235
288
  }
236
289
  const seenAlias = new Set();
237
290
  const visitingAliases = new Set();
291
+ const isPrimitiveLiteralElement = (element, visitedConstArrays) => {
292
+ // Array holes resolve to `undefined`, but the shape is unusual enough
293
+ // that refusing to classify it keeps the narrowing conservative.
294
+ if (!element)
295
+ return false;
296
+ if (element.type === utils_1.AST_NODE_TYPES.SpreadElement) {
297
+ const argument = unwrapExpressionAssertions(element.argument);
298
+ if (argument.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
299
+ return argument.elements.every((nested) => isPrimitiveLiteralElement(nested, visitedConstArrays));
300
+ }
301
+ if (argument.type === utils_1.AST_NODE_TYPES.Identifier) {
302
+ return isPrimitiveConstArray(argument.name, visitedConstArrays);
303
+ }
304
+ return false;
305
+ }
306
+ const expression = unwrapExpressionAssertions(element);
307
+ switch (expression.type) {
308
+ case utils_1.AST_NODE_TYPES.Literal: {
309
+ // A regex literal is an object; its `value` is engine-dependent, so
310
+ // reject it explicitly rather than relying on the typeof check.
311
+ if (expression.regex)
312
+ return false;
313
+ const value = expression.value;
314
+ return (value === null ||
315
+ typeof value === 'string' ||
316
+ typeof value === 'number' ||
317
+ typeof value === 'boolean' ||
318
+ typeof value === 'bigint');
319
+ }
320
+ case utils_1.AST_NODE_TYPES.TemplateLiteral:
321
+ return true; // a template literal always produces a string
322
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
323
+ // Nested primitive arrays mirror the allowance for `string[][]` and
324
+ // tuples of primitives elsewhere in this rule.
325
+ return expression.elements.every((nested) => isPrimitiveLiteralElement(nested, visitedConstArrays));
326
+ case utils_1.AST_NODE_TYPES.UnaryExpression: {
327
+ const unary = expression;
328
+ if (unary.operator !== '-' && unary.operator !== '+')
329
+ return false;
330
+ return isPrimitiveLiteralElement(unary.argument, visitedConstArrays);
331
+ }
332
+ case utils_1.AST_NODE_TYPES.Identifier:
333
+ return expression.name === 'undefined';
334
+ default:
335
+ return false;
336
+ }
337
+ };
338
+ const isPrimitiveConstArray = (name, visitedConstArrays) => {
339
+ // A cyclic spread cannot be resolved syntactically; refuse to classify it
340
+ if (visitedConstArrays.has(name))
341
+ return false;
342
+ const arrayLiteral = constArrayNameToLiteral.get(name);
343
+ if (!arrayLiteral)
344
+ return false;
345
+ visitedConstArrays.add(name);
346
+ const result = arrayLiteral.elements.every((element) => isPrimitiveLiteralElement(element, visitedConstArrays));
347
+ visitedConstArrays.delete(name);
348
+ return result;
349
+ };
350
+ /**
351
+ * Recognizes `(typeof VALUES)[number]` where VALUES is a same-file const
352
+ * array of primitive literals. That form denotes the union of those
353
+ * literals, not an object lookup, and it is exactly what the sibling
354
+ * rule prefer-union-from-const-array autofixes toward.
355
+ */
356
+ const isConstArrayElementUnion = (node) => {
357
+ const indexType = unwrapParenthesizedTypeNode(node.indexType);
358
+ // Only a `number` index yields the element union; `['length']` or any key
359
+ // lookup resolves to something this syntactic check cannot vouch for.
360
+ if (indexType.type !== utils_1.AST_NODE_TYPES.TSNumberKeyword)
361
+ return false;
362
+ const objectType = unwrapParenthesizedTypeNode(node.objectType);
363
+ if (objectType.type !== utils_1.AST_NODE_TYPES.TSTypeQuery)
364
+ return false;
365
+ const exprName = objectType.exprName;
366
+ if (exprName.type !== utils_1.AST_NODE_TYPES.Identifier)
367
+ return false;
368
+ return isPrimitiveConstArray(exprName.name, new Set());
369
+ };
238
370
  const isPrimitiveLikeAlias = (name, recursionDepth) => {
239
371
  if (seenAlias.has(name))
240
372
  return true;
@@ -286,6 +418,8 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
286
418
  return false;
287
419
  case utils_1.AST_NODE_TYPES.TSLiteralType:
288
420
  return true; // string/number/boolean literals
421
+ case utils_1.AST_NODE_TYPES.TSIndexedAccessType:
422
+ return isConstArrayElementUnion(node);
289
423
  case utils_1.AST_NODE_TYPES.TSTypeReference: {
290
424
  // Allow known primitive-like references and enums or primitive-like aliases
291
425
  const ref = node;
@@ -361,8 +495,10 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
361
495
  case utils_1.AST_NODE_TYPES.TSMappedType:
362
496
  return true;
363
497
  case utils_1.AST_NODE_TYPES.TSIndexedAccessType:
364
- // Treat indexed access as object-like to align with existing tests
365
- return true;
498
+ // An indexed access such as `DataShape['user']` is an object lookup,
499
+ // but `(typeof VALUES)[number]` over a const array of primitive
500
+ // literals is a primitive union and must not be flagged.
501
+ return !isConstArrayElementUnion(node);
366
502
  case utils_1.AST_NODE_TYPES.TSTypeOperator:
367
503
  if (node.operator === 'readonly') {
368
504
  return isObjectType(node
@@ -381,6 +381,22 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
381
381
  valueKind,
382
382
  },
383
383
  fix(fixer) {
384
+ // Inlining replaces the entire useMemo(...) call with the returned
385
+ // expression's text, so any comment inside the call but outside
386
+ // that expression — an eslint-disable-next-line directive on the
387
+ // return statement among them — has no representation in the
388
+ // replacement and would be silently destroyed, changing which
389
+ // rules report on the file (#1591). The inlined expression lands
390
+ // mid-line (e.g. `const label = <expr>;`), where a -next-line
391
+ // directive cannot be hosted, so the autofix declines and leaves
392
+ // the report for a manual fix.
393
+ const strandedComments = sourceCode
394
+ .getCommentsInside(node)
395
+ .filter((comment) => comment.range[0] < returnedExpression.range[0] ||
396
+ comment.range[1] > returnedExpression.range[1]);
397
+ if (strandedComments.length > 0) {
398
+ return null;
399
+ }
384
400
  const replacement = `(${sourceCode.getText(returnedExpression)})`;
385
401
  return fixer.replaceText(node, replacement);
386
402
  },
@@ -785,6 +785,61 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
785
785
  return null;
786
786
  }
787
787
  const awaitArguments = awaitExpressions.map((expr) => sourceCode.getText(expr.argument));
788
+ const startPos = awaitNodes[0].range[0];
789
+ const endPos = awaitNodes[awaitNodes.length - 1].range[1];
790
+ // The replacement text is rebuilt from the awaited expressions alone, so
791
+ // a comment inside the replaced span has no representation in it and
792
+ // would be silently deleted. Deleting an eslint-disable-next-line
793
+ // directive re-enables the suppressed rule on the code that survives
794
+ // inside the Promise.all (#1589). Each span comment is therefore either
795
+ // re-hosted directly above the array element built from the statement it
796
+ // annotates, or the fix is declined so no comment is ever destroyed. The
797
+ // report fires either way.
798
+ const spanComments = sourceCode
799
+ .getAllComments()
800
+ .filter((comment) => comment.range[0] >= startPos && comment.range[1] <= endPos);
801
+ const hostedComments = awaitNodes.map(() => []);
802
+ if (spanComments.length > 0) {
803
+ // Re-hosting maps the comments preceding statement i onto element i,
804
+ // which requires the element list to line up 1:1 with the statement
805
+ // list.
806
+ if (awaitExpressions.length !== awaitNodes.length) {
807
+ return null;
808
+ }
809
+ for (const comment of spanComments) {
810
+ const hostIndex = awaitNodes.findIndex((node, index) => index > 0 &&
811
+ comment.range[0] >= awaitNodes[index - 1].range[1] &&
812
+ comment.range[1] <= node.range[0]);
813
+ if (hostIndex === -1) {
814
+ // The comment sits inside one of the merged statements. A comment
815
+ // within the awaited expression itself travels verbatim with
816
+ // getText; anywhere else (between `await` and its operand, or
817
+ // around a declarator's `=`) has no slot in the rebuilt text.
818
+ const isInsideArgument = awaitExpressions.some((expr) => comment.range[0] >= expr.argument.range[0] &&
819
+ comment.range[1] <= expr.argument.range[1]);
820
+ if (!isInsideArgument) {
821
+ return null;
822
+ }
823
+ continue;
824
+ }
825
+ // A comment that shares the previous statement's last line is a
826
+ // trailing comment (e.g. an eslint-disable-line directive) governing
827
+ // THAT line; moving it above the next element would change which
828
+ // line it applies to.
829
+ if (comment.loc.start.line <= awaitNodes[hostIndex - 1].loc.end.line) {
830
+ return null;
831
+ }
832
+ // A directive above `const x = await f();` may target the
833
+ // declaration's identifier, which the rewrite moves into the
834
+ // destructuring pattern on the Promise.all line — away from every
835
+ // line the re-hosted directive could govern.
836
+ if (/^\s*eslint-/u.test(comment.value) &&
837
+ awaitNodes[hostIndex].type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
838
+ return null;
839
+ }
840
+ hostedComments[hostIndex].push(comment);
841
+ }
842
+ }
788
843
  const idsText = [];
789
844
  const declKinds = new Set();
790
845
  let hasVariableDeclarations = false;
@@ -817,7 +872,17 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
817
872
  // the contents of a template literal, where whitespace is significant
818
873
  // data rather than formatting and re-indenting would silently change the
819
874
  // produced string.
820
- const elementsText = awaitArguments.join(`,\n${elementIndent}`);
875
+ const elementsText = awaitArguments
876
+ .map((argumentText, index) => {
877
+ // Each re-hosted comment lands on its own line directly above the
878
+ // element, which is the only placement where a disable-next-line
879
+ // directive keeps suppressing it.
880
+ const leadingText = (hostedComments[index] ?? [])
881
+ .map((comment) => `${sourceCode.getText(comment)}\n${elementIndent}`)
882
+ .join('');
883
+ return `${leadingText}${argumentText}`;
884
+ })
885
+ .join(`,\n${elementIndent}`);
821
886
  let promiseAllText;
822
887
  if (hasVariableDeclarations) {
823
888
  if (declKinds.size !== 1) {
@@ -834,8 +899,6 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
834
899
  // Simple Promise.all without variable assignments
835
900
  promiseAllText = `await Promise.all([\n${elementIndent}${elementsText}\n${baseIndent}]);`;
836
901
  }
837
- const startPos = awaitNodes[0].range[0];
838
- const endPos = awaitNodes[awaitNodes.length - 1].range[1];
839
902
  return fixer.replaceTextRange([startPos, endPos], promiseAllText);
840
903
  }
841
904
  /**
@@ -62,6 +62,37 @@ function parenthesizeForUnion(text) {
62
62
  const needsParens = text.includes('=>') || /^new\b/.test(text) || /\bextends\b/.test(text);
63
63
  return needsParens ? `(${text})` : text;
64
64
  }
65
+ /**
66
+ * How position-sensitive a comment is when the fix relocates it onto the
67
+ * generated Record:
68
+ *
69
+ * - `next-line`: suppresses exactly the following line
70
+ * (`eslint-disable-next-line`, `@ts-expect-error`, `@ts-ignore`) — hosting
71
+ * it anywhere but directly above the line it suppressed silently changes
72
+ * which rules report.
73
+ * - `own-line`: suppresses its own line (`eslint-disable-line`) — must stay on
74
+ * the same line as the value it annotates.
75
+ * - `range`: opens/closes a suppression region or configures the linter
76
+ * (`eslint-disable`, `eslint-enable`, `eslint-env`, `eslint`, `global`,
77
+ * `exported`) — ESLint honors these only in block-comment form, and
78
+ * relocation can move the region boundary across reported lines, so the fix
79
+ * is never attempted around one.
80
+ * - `none`: prose; content preservation suffices.
81
+ */
82
+ function directiveKindOf(comment) {
83
+ const text = comment.value.trim();
84
+ if (/^(eslint-disable-next-line|@ts-expect-error|@ts-ignore)\b/.test(text)) {
85
+ return 'next-line';
86
+ }
87
+ if (/^eslint-disable-line\b/.test(text)) {
88
+ return 'own-line';
89
+ }
90
+ if (comment.type === utils_1.AST_TOKEN_TYPES.Block &&
91
+ /^(eslint-disable|eslint-enable|eslint-env|eslint\b|globals?\b|exported\b)/.test(text)) {
92
+ return 'range';
93
+ }
94
+ return 'none';
95
+ }
65
96
  exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
66
97
  name: 'prefer-map-over-conditional-dispatch',
67
98
  meta: {
@@ -360,7 +391,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
360
391
  }
361
392
  const stmt = stmts[0];
362
393
  if (stmt.type === utils_1.AST_NODE_TYPES.ReturnStatement && stmt.argument) {
363
- return { kind: 'return', expr: stmt.argument };
394
+ return { kind: 'return', expr: stmt.argument, stmt };
364
395
  }
365
396
  if (stmt.type === utils_1.AST_NODE_TYPES.ExpressionStatement &&
366
397
  stmt.expression.type === utils_1.AST_NODE_TYPES.AssignmentExpression &&
@@ -369,6 +400,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
369
400
  kind: 'assign',
370
401
  target: stmt.expression.left,
371
402
  expr: stmt.expression.right,
403
+ stmt,
372
404
  };
373
405
  }
374
406
  return null;
@@ -504,13 +536,92 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
504
536
  return match ? match[0] : '';
505
537
  }
506
538
  function buildRecordText(name, dText, vText, entries, baseIndent) {
507
- const lines = entries.map((e) => `${baseIndent} ${formatKey(e.key)}: ${e.valueText},`);
539
+ const lines = entries.flatMap((e) => {
540
+ const hosted = (e.leadingComments ?? []).map((comment) => `${baseIndent} ${comment}`);
541
+ const trailing = e.trailingComments && e.trailingComments.length > 0
542
+ ? ` ${e.trailingComments.join(' ')}`
543
+ : '';
544
+ hosted.push(`${baseIndent} ${formatKey(e.key)}: ${e.valueText},${trailing}`);
545
+ return hosted;
546
+ });
508
547
  return [
509
548
  `const ${name}: Record<${dText}, ${vText}> = {`,
510
549
  ...lines,
511
550
  `${baseIndent}};`,
512
551
  ].join('\n');
513
552
  }
553
+ /**
554
+ * Decides where every comment inside the replaced construct lands on the
555
+ * generated Record, mutating `entries` with the hosted text. Comments
556
+ * inside a carried span (a branch value expression, the discriminant, the
557
+ * assignment target) survive verbatim inside the copied text; comments
558
+ * inside a dropped span (an unreachable default/tail) die together with
559
+ * the code they annotate, which is not an orphaning. Every other comment
560
+ * must be hosted onto a map entry — leading comments go on the line(s)
561
+ * directly above the entry, same-line trailing comments append after it.
562
+ * Returns false when a comment cannot be hosted without changing what it
563
+ * annotates or suppresses; the caller then withholds the autofix so a
564
+ * directive is never silently deleted or retargeted.
565
+ */
566
+ function planCommentHosting(args) {
567
+ const { container, carriedSpans, droppedSpans, anchors, entries } = args;
568
+ const ordered = [...anchors].sort((a, b) => a.range[0] - b.range[0]);
569
+ const hostLeading = (entry, text) => {
570
+ entry.leadingComments = [...(entry.leadingComments ?? []), text];
571
+ };
572
+ const hostTrailing = (entry, text) => {
573
+ entry.trailingComments = [...(entry.trailingComments ?? []), text];
574
+ };
575
+ for (const comment of sourceCode.getCommentsInside(container)) {
576
+ const [cStart, cEnd] = comment.range;
577
+ const within = (spans) => spans.some(([start, end]) => cStart >= start && cEnd <= end);
578
+ if (within(carriedSpans) || within(droppedSpans)) {
579
+ continue;
580
+ }
581
+ const kind = directiveKindOf(comment);
582
+ if (kind === 'range') {
583
+ return false;
584
+ }
585
+ const text = sourceCode.getText(comment);
586
+ // Same-line trailing (`doThing(); // note`): append to the entry line
587
+ // so an `eslint-disable-line` keeps suppressing the line its value
588
+ // lands on. The last matching anchor wins so a comment trailing two
589
+ // same-line statements attaches to the nearer one.
590
+ const trailingCandidates = ordered.filter((anchor) => anchor.endLine === comment.loc.start.line &&
591
+ cStart >= anchor.range[1]);
592
+ const trailingAnchor = trailingCandidates[trailingCandidates.length - 1];
593
+ if (trailingAnchor) {
594
+ if (kind === 'next-line') {
595
+ // A trailing disable-next-line targets whatever follows the
596
+ // statement; the generated map has no equivalent following line.
597
+ return false;
598
+ }
599
+ if (kind === 'own-line' && trailingAnchor.entryCount > 1) {
600
+ return false;
601
+ }
602
+ hostTrailing(entries[trailingAnchor.entryIndex], text);
603
+ continue;
604
+ }
605
+ // A comment inside the value statement but outside the copied
606
+ // expression (`return /* why */ value;`) belongs to that branch.
607
+ const containingAnchor = ordered.find((anchor) => cStart >= anchor.range[0] && cEnd <= anchor.range[1]);
608
+ const leadingAnchor = containingAnchor ?? ordered.find((anchor) => anchor.range[0] >= cEnd);
609
+ if (!leadingAnchor) {
610
+ return false;
611
+ }
612
+ if (kind === 'next-line') {
613
+ // Host a line-targeted directive only when it provably targeted the
614
+ // branch's value line, so relocation preserves — never widens or
615
+ // drops — the suppression.
616
+ if (leadingAnchor.entryCount > 1 ||
617
+ leadingAnchor.startLine !== comment.loc.end.line + 1) {
618
+ return false;
619
+ }
620
+ }
621
+ hostLeading(entries[leadingAnchor.entryIndex], text);
622
+ }
623
+ return true;
624
+ }
514
625
  /**
515
626
  * Given ordered explicit branches + optional tail, resolve coverage against
516
627
  * the union's literal keys and (when full) the ordered Record entries.
@@ -564,20 +675,28 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
564
675
  if (!flags.canPlaceFix) {
565
676
  return 'the dispatch sits inside an expression-bodied function; extract the Record manually so it stays in scope';
566
677
  }
678
+ if (!flags.commentSafe) {
679
+ return 'a comment inside the dispatch cannot be carried onto the generated Record without changing what it annotates or suppresses — relocate the comment, then convert';
680
+ }
567
681
  return 'a collision-free lookup name could not be derived from the discriminant';
568
682
  }
569
683
  function report(node, analysis) {
570
- const { entries, contributingValues, dText, form, assignTargetText, fullCoverage, hasNullish, canPlaceFix, } = analysis;
684
+ const { entries, contributingValues, dText, form, assignTargetText, fullCoverage, hasNullish, canPlaceFix, commentBlocked, } = analysis;
571
685
  const eagerSafe = contributingValues.every((expr) => !containsEagerUnsafe(expr));
572
686
  let name = null;
573
687
  // Name derivation is only needed for the autofix path.
574
- if (fullCoverage && !hasNullish && eagerSafe && canPlaceFix) {
688
+ if (fullCoverage &&
689
+ !hasNullish &&
690
+ eagerSafe &&
691
+ canPlaceFix &&
692
+ !commentBlocked) {
575
693
  name = deriveLookupName(discriminantOf(node));
576
694
  }
577
695
  const autofixable = fullCoverage &&
578
696
  !hasNullish &&
579
697
  eagerSafe &&
580
698
  canPlaceFix &&
699
+ !commentBlocked &&
581
700
  name !== null;
582
701
  if (!autofixable) {
583
702
  context.report({
@@ -589,6 +708,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
589
708
  hasNullish,
590
709
  eagerSafe,
591
710
  canPlaceFix,
711
+ commentSafe: !commentBlocked,
592
712
  }),
593
713
  },
594
714
  });
@@ -700,7 +820,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
700
820
  continue;
701
821
  }
702
822
  const value = extractBranchValue(c.consequent);
703
- parsed.push({ tests: [...pending, c.test], value });
823
+ parsed.push({ tests: [...pending, c.test], value, caseNode: c });
704
824
  pending = [];
705
825
  }
706
826
  if (pending.length > 0) {
@@ -714,6 +834,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
714
834
  const explicit = [];
715
835
  let defaultValue;
716
836
  let hasDefault = false;
837
+ let defaultCaseNode = null;
717
838
  for (const branch of parsed) {
718
839
  const literalTests = branch.tests.filter((t) => t !== null);
719
840
  const isDefaultGroup = literalTests.length !== branch.tests.length;
@@ -724,6 +845,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
724
845
  }
725
846
  hasDefault = true;
726
847
  defaultValue = branch.value;
848
+ defaultCaseNode = branch.caseNode;
727
849
  continue;
728
850
  }
729
851
  if (!branch.value) {
@@ -798,6 +920,57 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
798
920
  if (isNarrowingExempt(node.discriminant, contributingValues)) {
799
921
  return;
800
922
  }
923
+ // Comment hosting only gates the autofix path (partial coverage is
924
+ // already report-only), so it is planned for full coverage alone.
925
+ let commentBlocked = false;
926
+ if (coverage.fullCoverage) {
927
+ const tailUsed = coverage.remainingCount === 1 && defaultVal !== null;
928
+ const carriedSpans = [node.discriminant.range];
929
+ const droppedSpans = [];
930
+ const anchors = [];
931
+ let entryIndex = 0;
932
+ for (const branch of explicit) {
933
+ carriedSpans.push(branch.value.expr.range);
934
+ anchors.push({
935
+ range: branch.value.stmt.range,
936
+ startLine: branch.value.stmt.loc.start.line,
937
+ endLine: branch.value.stmt.loc.end.line,
938
+ entryIndex,
939
+ entryCount: branch.keys.length,
940
+ });
941
+ entryIndex += branch.keys.length;
942
+ }
943
+ if (kind === 'assign' && explicit[0].value.kind === 'assign') {
944
+ carriedSpans.push(explicit[0].value.target.range);
945
+ }
946
+ if (tailUsed && defaultVal) {
947
+ carriedSpans.push(defaultVal.expr.range);
948
+ anchors.push({
949
+ range: defaultVal.stmt.range,
950
+ startLine: defaultVal.stmt.loc.start.line,
951
+ endLine: defaultVal.stmt.loc.end.line,
952
+ entryIndex: coverage.entries.length - 1,
953
+ entryCount: 1,
954
+ });
955
+ }
956
+ else if (defaultCaseNode) {
957
+ // The unreachable default is deleted wholesale; its comments —
958
+ // including any directly above the `default:` label — annotate
959
+ // deleted code and die with it.
960
+ const caseIndex = node.cases.indexOf(defaultCaseNode);
961
+ const droppedStart = caseIndex > 0
962
+ ? node.cases[caseIndex - 1].range[1]
963
+ : node.discriminant.range[1];
964
+ droppedSpans.push([droppedStart, defaultCaseNode.range[1]]);
965
+ }
966
+ commentBlocked = !planCommentHosting({
967
+ container: node,
968
+ carriedSpans,
969
+ droppedSpans,
970
+ anchors,
971
+ entries: coverage.entries,
972
+ });
973
+ }
801
974
  report(node, {
802
975
  entries: coverage.entries,
803
976
  contributingValues,
@@ -807,6 +980,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
807
980
  fullCoverage: coverage.fullCoverage,
808
981
  hasNullish,
809
982
  canPlaceFix: true,
983
+ commentBlocked,
810
984
  });
811
985
  }
812
986
  // ---- Ternary form -------------------------------------------------------
@@ -906,6 +1080,47 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
906
1080
  crossesFunction = true;
907
1081
  }
908
1082
  }
1083
+ let commentBlocked = false;
1084
+ if (coverage.fullCoverage) {
1085
+ const tailUsed = coverage.remainingCount === 1;
1086
+ const carriedSpans = [
1087
+ head.discNode.range,
1088
+ ...links.map((l) => l.expr.range),
1089
+ ];
1090
+ const droppedSpans = [];
1091
+ const anchors = links.map((l, i) => ({
1092
+ range: l.expr.range,
1093
+ startLine: l.expr.loc.start.line,
1094
+ endLine: l.expr.loc.end.line,
1095
+ entryIndex: i,
1096
+ entryCount: 1,
1097
+ }));
1098
+ if (tailUsed) {
1099
+ carriedSpans.push(tailExpr.range);
1100
+ anchors.push({
1101
+ range: tailExpr.range,
1102
+ startLine: tailExpr.loc.start.line,
1103
+ endLine: tailExpr.loc.end.line,
1104
+ entryIndex: coverage.entries.length - 1,
1105
+ entryCount: 1,
1106
+ });
1107
+ }
1108
+ else {
1109
+ // The unreachable tail is deleted; comments between the last kept
1110
+ // consequent and the tail's end annotate deleted code.
1111
+ droppedSpans.push([
1112
+ links[links.length - 1].expr.range[1],
1113
+ tailExpr.range[1],
1114
+ ]);
1115
+ }
1116
+ commentBlocked = !planCommentHosting({
1117
+ container: node,
1118
+ carriedSpans,
1119
+ droppedSpans,
1120
+ anchors,
1121
+ entries: coverage.entries,
1122
+ });
1123
+ }
909
1124
  discriminantMap.set(node, head.discNode);
910
1125
  report(node, {
911
1126
  entries: coverage.entries,
@@ -915,6 +1130,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
915
1130
  fullCoverage: coverage.fullCoverage,
916
1131
  hasNullish,
917
1132
  canPlaceFix: !crossesFunction,
1133
+ commentBlocked,
918
1134
  });
919
1135
  }
920
1136
  // ---- if / else-if form --------------------------------------------------
@@ -935,6 +1151,10 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
935
1151
  }
936
1152
  const links = [];
937
1153
  let tail = null;
1154
+ // The final else statement/block and the end of the consequent before
1155
+ // it — the source region a dropped tail's comments die with.
1156
+ let tailNode = null;
1157
+ let tailPrevEnd = 0;
938
1158
  let cur = node;
939
1159
  while (cur) {
940
1160
  const link = equalityDiscriminant(cur.test);
@@ -960,6 +1180,8 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
960
1180
  return;
961
1181
  }
962
1182
  tail = tailValue;
1183
+ tailNode = alt;
1184
+ tailPrevEnd = cur.consequent.range[1];
963
1185
  cur = null;
964
1186
  }
965
1187
  if (links.length === 0) {
@@ -1012,6 +1234,48 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1012
1234
  if (isNarrowingExempt(head.discNode, contributingValues)) {
1013
1235
  return;
1014
1236
  }
1237
+ let commentBlocked = false;
1238
+ if (coverage.fullCoverage) {
1239
+ const tailUsed = coverage.remainingCount === 1 && tail !== null;
1240
+ const carriedSpans = [head.discNode.range];
1241
+ const droppedSpans = [];
1242
+ const anchors = [];
1243
+ links.forEach((link, i) => {
1244
+ carriedSpans.push(link.value.expr.range);
1245
+ anchors.push({
1246
+ range: link.value.stmt.range,
1247
+ startLine: link.value.stmt.loc.start.line,
1248
+ endLine: link.value.stmt.loc.end.line,
1249
+ entryIndex: i,
1250
+ entryCount: 1,
1251
+ });
1252
+ });
1253
+ if (kind === 'assign' && links[0].value.kind === 'assign') {
1254
+ carriedSpans.push(links[0].value.target.range);
1255
+ }
1256
+ if (tailUsed && tail) {
1257
+ carriedSpans.push(tail.expr.range);
1258
+ anchors.push({
1259
+ range: tail.stmt.range,
1260
+ startLine: tail.stmt.loc.start.line,
1261
+ endLine: tail.stmt.loc.end.line,
1262
+ entryIndex: coverage.entries.length - 1,
1263
+ entryCount: 1,
1264
+ });
1265
+ }
1266
+ else if (tailNode) {
1267
+ // The unreachable else is deleted; comments from the last kept
1268
+ // consequent through the else's end annotate deleted code.
1269
+ droppedSpans.push([tailPrevEnd, tailNode.range[1]]);
1270
+ }
1271
+ commentBlocked = !planCommentHosting({
1272
+ container: node,
1273
+ carriedSpans,
1274
+ droppedSpans,
1275
+ anchors,
1276
+ entries: coverage.entries,
1277
+ });
1278
+ }
1015
1279
  discriminantMap.set(node, head.discNode);
1016
1280
  report(node, {
1017
1281
  entries: coverage.entries,
@@ -1022,6 +1286,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1022
1286
  fullCoverage: coverage.fullCoverage,
1023
1287
  hasNullish,
1024
1288
  canPlaceFix: true,
1289
+ commentBlocked,
1025
1290
  });
1026
1291
  }
1027
1292
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.69",
3
+ "version": "1.20.71",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,56 @@
1
1
  [
2
+ {
3
+ "version": "1.20.71",
4
+ "date": "2026-08-02T01:25:22.489Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-firestore-object-arrays",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1594
11
+ ],
12
+ "summary": "treat (typeof X)[number] over a primitive const array as a primitive union (closes #1594)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.70",
18
+ "date": "2026-08-01T23:31:02.214Z",
19
+ "rules": [
20
+ {
21
+ "name": "class-methods-read-top-to-bottom",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1592
25
+ ],
26
+ "summary": "preserve the whitespace separating reordered class members (closes #1592)"
27
+ },
28
+ {
29
+ "name": "no-useless-usememo-primitives",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1591
33
+ ],
34
+ "summary": "decline the autofix when inlining would strand a comment (closes #1591)"
35
+ },
36
+ {
37
+ "name": "parallelize-async-operations",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1589
41
+ ],
42
+ "summary": "preserve comments between merged awaits (closes #1589)"
43
+ },
44
+ {
45
+ "name": "prefer-map-over-conditional-dispatch",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1590
49
+ ],
50
+ "summary": "preserve branch comments in the generated map (closes #1590)"
51
+ }
52
+ ]
53
+ },
2
54
  {
3
55
  "version": "1.20.69",
4
56
  "date": "2026-08-01T20:48:40.484Z",