@blumintinc/eslint-plugin-blumint 1.20.173 → 1.20.175

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.173',
226
+ version: '1.20.175',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -647,9 +647,9 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
647
647
  * inline there emits text the formatter immediately re-breaks, so the fix
648
648
  * is never a fixed point of the consumer's own formatting pass (#2097).
649
649
  *
650
- * A list written across lines answers the first half outright: this fixer
651
- * never rewrites an argument's own text, so the flat layout is not among
652
- * the shapes it could emit at all.
650
+ * A list written across lines answers the first half outright and is
651
+ * handled before this is asked: the caller keeps it broken, except for
652
+ * the block-comment tail {@link flattenedListFixes} claims (#2142).
653
653
  *
654
654
  * The width half is answered only where the formatter's own answer is
655
655
  * modelled end to end. A trailing options object gets hugged against the
@@ -683,16 +683,93 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
683
683
  const widest = Math.max(...spans.map((span) => span.end - span.start), ...appended.map((argument) => argument.length));
684
684
  return body + widest + ','.length <= PRINT_WIDTH;
685
685
  }
686
+ /**
687
+ * The width the call prints at with its argument list riding one line:
688
+ * everything on the opening line through the parenthesis, each span
689
+ * joined by `, `, the tail comments a written comma strands outside the
690
+ * last span, the appended arguments, and whatever follows the closing
691
+ * parenthesis on its own line.
692
+ */
693
+ function flatListWidth(layout, appended, nameDelta, gapComments) {
694
+ const { openParen, closeParen, spans } = layout;
695
+ const lastSpanEnd = spans[spans.length - 1].end;
696
+ const suffix = (sourceCode.lines[closeParen.loc.start.line - 1] ?? '')
697
+ .slice(closeParen.loc.start.column)
698
+ .trimEnd();
699
+ return (openParen.loc.end.column +
700
+ nameDelta +
701
+ spans.reduce((width, span) => width + (span.end - span.start), 0) +
702
+ (spans.length - 1) * ', '.length +
703
+ gapComments
704
+ .filter((comment) => comment.range[0] >= lastSpanEnd)
705
+ .reduce((width, comment) => width + ' '.length + (comment.range[1] - comment.range[0]), 0) +
706
+ appended.reduce((width, argument) => width + ', '.length + argument.length, 0) +
707
+ suffix.length);
708
+ }
709
+ /**
710
+ * The edits that print an authored-broken list flat, with `appended` on
711
+ * its tail — or `null` where the flat layout is out of reach or out of
712
+ * scope.
713
+ *
714
+ * A list written across lines usually stays broken: a multi-line argument
715
+ * cannot be inlined without rewriting its text, and a line comment pins
716
+ * its break outright. A break held up by nothing but a BLOCK comment
717
+ * trailing the last argument is neither — a block comment is no line
718
+ * terminator, so the consumer's formatter collapses the whole call as
719
+ * soon as it fits the print width, and no broken emission is a fixed
720
+ * point of its formatting pass there (#2142). Only that annotated tail
721
+ * claims the flat layout; elsewhere the author's breaks are kept — a
722
+ * choice the formatter may fold, but one this fix did not create.
723
+ */
724
+ function flattenedListFixes(fixer, node, layout, appended, nameDelta, beforeClose, gapComments, ownLineComments) {
725
+ const { openParen, closeParen, spans } = layout;
726
+ if (gapComments.length === 0 ||
727
+ ownLineComments.length > 0 ||
728
+ gapComments.some(replacementSegments_1.requiresOwnLine) ||
729
+ calleeBreaksFirst(node.callee) ||
730
+ spans.some((span) => sourceCode.text.slice(span.start, span.end).includes('\n')) ||
731
+ flatListWidth(layout, appended, nameDelta, gapComments) > PRINT_WIDTH) {
732
+ return null;
733
+ }
734
+ const fixes = [
735
+ fixer.replaceTextRange([openParen.range[1], spans[0].start], ''),
736
+ ];
737
+ for (let index = 1; index < spans.length; index++) {
738
+ fixes.push(fixer.replaceTextRange([spans[index - 1].end, spans[index].start], ', '));
739
+ }
740
+ const lastSpanEnd = spans[spans.length - 1].end;
741
+ const flatTail = appended.map((argument) => `, ${argument}`).join('');
742
+ if (beforeClose.value !== ',') {
743
+ // With no written comma the tail comments sit inside the last span,
744
+ // so everything between the span and the parenthesis is whitespace.
745
+ fixes.push(fixer.replaceTextRange([lastSpanEnd, closeParen.range[0]], flatTail));
746
+ return fixes;
747
+ }
748
+ const pastComma = gapComments.filter((comment) => comment.range[0] >= beforeClose.range[1]);
749
+ if (pastComma.length === 0) {
750
+ // The written comma already trails the annotation; it is the
751
+ // separator the appended arguments ride on.
752
+ fixes.push(fixer.replaceTextRange([beforeClose.range[1], closeParen.range[0]], ` ${appended.join(', ')}`));
753
+ return fixes;
754
+ }
755
+ // The written comma moves past the comments it precedes: prettier
756
+ // prints a block comment on a list element BEFORE the separator.
757
+ fixes.push(fixer.removeRange(beforeClose.range));
758
+ fixes.push(fixer.replaceTextRange([pastComma[pastComma.length - 1].range[1], closeParen.range[0]], flatTail));
759
+ return fixes;
760
+ }
686
761
  /**
687
762
  * The edits that add `appended` to the end of a call's argument list, laid
688
- * out the way the consumer's formatter prints the result.
763
+ * out the way the consumer's formatter prints the result — or `null` for
764
+ * the one tail no edit can extend without retargeting a directive.
689
765
  *
690
- * Neither layout re-emits an argument from its text. The broken one
691
- * rewrites the SEPARATORS between the arguments and shifts the indentation
692
- * of the ones that span lines, so everything between them comments
693
- * included, and a dropped `eslint-disable` silently re-enables the rule it
694
- * was suppressing (#1877) stays where it was written, attached to the
695
- * argument it belongs to.
766
+ * No layout re-emits an argument from its text. The broken one rewrites
767
+ * the SEPARATORS between the arguments and shifts the indentation of the
768
+ * ones that span lines, and the flat one rewrites the separators alone,
769
+ * so everything between them comments included, and a dropped
770
+ * `eslint-disable` silently re-enables the rule it was suppressing
771
+ * (#1877) — stays where it was written, attached to the argument it
772
+ * belongs to.
696
773
  *
697
774
  * `nameDelta` is how much the caller's own rename widens the call, since
698
775
  * the two edits land on the same line and the width answer is about the
@@ -704,20 +781,53 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
704
781
  fixer.insertTextAfter(args[args.length - 1], appended.map((argument) => `, ${argument}`).join('')),
705
782
  ];
706
783
  const layout = callLayout(node);
707
- if (!layout || !requiresBrokenList(node, layout, appended, nameDelta)) {
784
+ if (!layout) {
708
785
  return inline();
709
786
  }
710
787
  const { openParen, closeParen, spans } = layout;
711
- const tail = spans[spans.length - 1];
712
- // Between the last span and the closing parenthesis is the one gap the
713
- // spans do not absorb, because a trailing comma may sit there. A comment
714
- // past it would be inside a replaced range, so the list is left as it is
715
- // and the option appended in place instead of deleting one.
716
- if (sourceCode
717
- .getCommentsInside(node)
718
- .some((comment) => comment.range[0] >= tail.end)) {
788
+ const listSpansLines = sourceCode.text
789
+ .slice(openParen.range[1], closeParen.range[0])
790
+ .includes('\n');
791
+ if (!listSpansLines &&
792
+ !requiresBrokenList(node, layout, appended, nameDelta)) {
793
+ return inline();
794
+ }
795
+ // Between the last argument's own last token and the closing parenthesis
796
+ // sit the trailing comma, if one was written, and any comment. The tail
797
+ // SPAN absorbs such a comment whenever no comma follows it, so the span's
798
+ // end is not a safe place to write a separator: a `,` emitted after a
799
+ // line comment is swallowed into the comment's text and the call no
800
+ // longer parses (#2140). Both boundaries are therefore taken from the
801
+ // TOKEN stream, where a comment can never be the answer.
802
+ const beforeClose = sourceCode.getTokenBefore(closeParen);
803
+ const lastArgumentToken = beforeClose?.value === ','
804
+ ? sourceCode.getTokenBefore(beforeClose)
805
+ : beforeClose;
806
+ if (!beforeClose || !lastArgumentToken) {
719
807
  return inline();
720
808
  }
809
+ const gapComments = sourceCode
810
+ .getCommentsInside(node)
811
+ .filter((comment) => comment.range[0] >= lastArgumentToken.range[1]);
812
+ const sameLineComments = gapComments.filter((comment) => comment.loc.start.line === lastArgumentToken.loc.end.line);
813
+ const ownLineComments = gapComments.filter((comment) => comment.loc.start.line !== lastArgumentToken.loc.end.line);
814
+ // A directive trailing the argument's line means the line that FOLLOWS
815
+ // it: emitting anything there hands `{ merge: true }` the suppression
816
+ // that was written for the closing line, and re-exposes whatever it was
817
+ // suppressing. No layout preserves both the option's position and the
818
+ // directive's subject, so the fix is withheld and the report left to the
819
+ // developer, who restructures the call with the directive in view
820
+ // (#1877). A directive on a line of its OWN is safe: the option lands
821
+ // BEFORE it, so everything the directive is adjacent to stays adjacent.
822
+ if (sameLineComments.some(isPositionalDirective)) {
823
+ return null;
824
+ }
825
+ if (listSpansLines) {
826
+ const flattened = flattenedListFixes(fixer, node, layout, appended, nameDelta, beforeClose, gapComments, ownLineComments);
827
+ if (flattened) {
828
+ return flattened;
829
+ }
830
+ }
721
831
  // A broken list is indented one step past the line its parenthesis opens
722
832
  // on, whatever depth that line sits at, and closes at that line's own
723
833
  // column. A constant indent is right for exactly one call site.
@@ -740,9 +850,68 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
740
850
  fixes.push(fixer.replaceText(argument, relocated));
741
851
  }
742
852
  }
743
- fixes.push(fixer.replaceTextRange([tail.end, closeParen.range[0]], `${appended
744
- .map((argument) => `,\n${body}${argument}`)
745
- .join('')},\n${indent}`));
853
+ if (gapComments.length === 0) {
854
+ fixes.push(fixer.replaceTextRange([lastArgumentToken.range[1], closeParen.range[0]], `${appended
855
+ .map((argument) => `,\n${body}${argument}`)
856
+ .join('')},\n${indent}`));
857
+ return fixes;
858
+ }
859
+ const separatorPresent = beforeClose.value === ',';
860
+ const lastSameLine = sameLineComments[sameLineComments.length - 1];
861
+ // WHERE the separator sits among the trailing comments is the
862
+ // formatter's call, not a constant: prettier prints a line comment on a
863
+ // list element after the comma and a block comment before it (#2142).
864
+ // The comma therefore lands past the leading run of block comments — at
865
+ // the argument's own last token when that run is empty — and a comma
866
+ // the author wrote on the wrong side of the run is moved rather than
867
+ // doubled. Everything else in the annotation is left byte for byte as
868
+ // written.
869
+ let blockRunEnd = lastArgumentToken.range[1];
870
+ for (const comment of sameLineComments) {
871
+ if ((0, replacementSegments_1.requiresLineBreakAfter)(comment)) {
872
+ break;
873
+ }
874
+ blockRunEnd = comment.range[1];
875
+ }
876
+ const commaMisplaced = separatorPresent && beforeClose.range[1] < blockRunEnd;
877
+ const needsComma = !separatorPresent || commaMisplaced;
878
+ if (commaMisplaced) {
879
+ fixes.push(fixer.removeRange(beforeClose.range));
880
+ }
881
+ if (ownLineComments.length === 0) {
882
+ // The comment trails the argument it annotates, so it keeps that line
883
+ // and the option opens the next one.
884
+ const annotationEnd = Math.max(lastSameLine.range[1], beforeClose.range[1]);
885
+ if (needsComma && blockRunEnd < annotationEnd) {
886
+ fixes.push(fixer.insertTextAfterRange([blockRunEnd, blockRunEnd], ','));
887
+ }
888
+ fixes.push(fixer.replaceTextRange([annotationEnd, closeParen.range[0]], `${needsComma && blockRunEnd >= annotationEnd ? ',' : ''}${appended
889
+ .map((argument) => `\n${body}${argument},`)
890
+ .join('')}\n${indent}`));
891
+ return fixes;
892
+ }
893
+ // A comment on a line of its own before the `)` was not written against
894
+ // the last argument, so the option lands BEFORE it — after the trailing
895
+ // annotation, if the line carries one — and the comment keeps both its
896
+ // bytes and its neighbours: what it sat above, it still sits above.
897
+ const appendedText = appended
898
+ .map((argument) => `\n${body}${argument},`)
899
+ .join('');
900
+ const anchor = Math.max(separatorPresent && !commaMisplaced
901
+ ? beforeClose.range[1]
902
+ : lastArgumentToken.range[1], lastSameLine ? lastSameLine.range[1] : 0);
903
+ if (!needsComma) {
904
+ fixes.push(fixer.insertTextAfterRange([anchor, anchor], appendedText));
905
+ return fixes;
906
+ }
907
+ if (blockRunEnd < anchor) {
908
+ fixes.push(fixer.insertTextAfterRange([blockRunEnd, blockRunEnd], ','));
909
+ fixes.push(fixer.insertTextAfterRange([anchor, anchor], appendedText));
910
+ return fixes;
911
+ }
912
+ // With the separator due at the annotation's own end, it and the option
913
+ // travel as one insertion, since both land at the same offset.
914
+ fixes.push(fixer.insertTextAfterRange([anchor, anchor], `,${appendedText}`));
746
915
  return fixes;
747
916
  }
748
917
  /**
@@ -800,10 +969,11 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
800
969
  `${indent}})`),
801
970
  ];
802
971
  }
803
- return [
804
- fixer.replaceText(callee.property, 'set'),
805
- ...appendArguments(fixer, node, [MERGE_OPTION], 'set'.length - callee.property.name.length),
806
- ];
972
+ const appended = appendArguments(fixer, node, [MERGE_OPTION], 'set'.length - callee.property.name.length);
973
+ if (!appended) {
974
+ return null;
975
+ }
976
+ return [fixer.replaceText(callee.property, 'set'), ...appended];
807
977
  }
808
978
  /**
809
979
  * The source the removal planner reads, with the comments inside a
@@ -970,17 +1140,23 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
970
1140
  const overlaps = ordered.some((rewrite, index) => index > 0 && rewrite.call.range[0] < ordered[index - 1].call.range[1]);
971
1141
  return overlaps ? null : rewrites;
972
1142
  }
973
- /** `updateDoc(ref, data)` → `setDoc(ref, data, { merge: true })`. */
1143
+ /**
1144
+ * `updateDoc(ref, data)` → `setDoc(ref, data, { merge: true })`, or `null`
1145
+ * where the argument list cannot be extended. No state is touched on the
1146
+ * way to that answer: the caller marks the call batched only once every
1147
+ * edit riding in the same fix is known to land, since a call marked by a
1148
+ * fix that never ships would make its own report decline too.
1149
+ */
974
1150
  function rewriteCall(fixer, rewrite) {
975
- batchedCalls.add(rewrite.call);
976
- return [
977
- fixer.replaceText(rewrite.identifier, SET_DOC),
978
- // `setDoc` takes the document data between the reference and the
979
- // options, so a call that passed no data gets an empty object to merge.
980
- ...appendArguments(fixer, rewrite.call, rewrite.call.arguments.length > 1
981
- ? [MERGE_OPTION]
982
- : [EMPTY_DATA, MERGE_OPTION], SET_DOC.length - rewrite.identifier.name.length),
983
- ];
1151
+ // `setDoc` takes the document data between the reference and the
1152
+ // options, so a call that passed no data gets an empty object to merge.
1153
+ const appended = appendArguments(fixer, rewrite.call, rewrite.call.arguments.length > 1
1154
+ ? [MERGE_OPTION]
1155
+ : [EMPTY_DATA, MERGE_OPTION], SET_DOC.length - rewrite.identifier.name.length);
1156
+ if (!appended) {
1157
+ return null;
1158
+ }
1159
+ return [fixer.replaceText(rewrite.identifier, SET_DOC), ...appended];
984
1160
  }
985
1161
  /**
986
1162
  * `updateDoc(ref, data)` becomes `setDoc(ref, data, { merge: true })`, which
@@ -1030,15 +1206,37 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
1030
1206
  if (!rewrites) {
1031
1207
  // A reference survives the pass, so the name stays bound and `setDoc` is
1032
1208
  // added alongside it. Only the first surviving violation carries the
1033
- // binding; the rest emit the call against it.
1209
+ // binding; the rest emit the call against it. The call rewrite is asked
1210
+ // for FIRST: a declined rewrite must leave the binding plan untouched,
1211
+ // or a later violation would trust an import this fix never emitted.
1212
+ const callFixes = rewriteCall(fixer, {
1213
+ identifier: callee,
1214
+ call: node,
1215
+ });
1216
+ if (!callFixes) {
1217
+ return null;
1218
+ }
1034
1219
  const fixes = [];
1035
1220
  if (!setDocVariable && !plannedSetDocBinding) {
1036
1221
  fixes.push(fixer.insertTextAfter(updateBinding.node, `, ${SET_DOC}`));
1037
1222
  plannedSetDocBinding = true;
1038
1223
  }
1039
- fixes.push(...rewriteCall(fixer, { identifier: callee, call: node }));
1224
+ batchedCalls.add(node);
1225
+ fixes.push(...callFixes);
1040
1226
  return fixes;
1041
1227
  }
1228
+ // One call whose tail cannot be extended declines the WHOLE batch, before
1229
+ // any flag records it as handled: a partial batch would retire an import
1230
+ // some call still reads, and a call marked batched by a fix that never
1231
+ // shipped would silently lose its own report's fix as well.
1232
+ const rewriteFixes = [];
1233
+ for (const rewrite of rewrites) {
1234
+ const callFixes = rewriteCall(fixer, rewrite);
1235
+ if (!callFixes) {
1236
+ return null;
1237
+ }
1238
+ rewriteFixes.push(...callFixes);
1239
+ }
1042
1240
  const fixes = [];
1043
1241
  if (setDocVariable) {
1044
1242
  // The name is already bound to firestore's own `setDoc`, so the entry
@@ -1062,8 +1260,9 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
1062
1260
  plannedSetDocBinding = true;
1063
1261
  }
1064
1262
  for (const rewrite of rewrites) {
1065
- fixes.push(...rewriteCall(fixer, rewrite));
1263
+ batchedCalls.add(rewrite.call);
1066
1264
  }
1265
+ fixes.push(...rewriteFixes);
1067
1266
  return fixes;
1068
1267
  }
1069
1268
  return {
@@ -519,8 +519,8 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
519
519
  // exactly as in the comment-free fix, never by the comments themselves.
520
520
  // The call can start mid-line, so the indentation of the line it opens
521
521
  // on is the only anchor the carried comments have.
522
- const startLine = sourceCode.lines[node.loc.start.line - 1] ?? '';
523
- const indent = /^[\t ]*/.exec(startLine)?.[0] ?? '';
522
+ const indentOf = (line) => /^[\t ]*/.exec(line)?.[0] ?? '';
523
+ const indent = indentOf(sourceCode.lines[node.loc.start.line - 1] ?? '');
524
524
  const text = sourceCode.getText();
525
525
  /**
526
526
  * A block comment's text carries the indentation of where it was
@@ -552,10 +552,11 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
552
552
  }),
553
553
  ].join('\n');
554
554
  };
555
- const toSegment = (comment) => ({
556
- text: reindented(comment, indent),
555
+ const segmentAt = (comment, toIndent) => ({
556
+ text: reindented(comment, toIndent),
557
557
  breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
558
558
  });
559
+ const toSegment = (comment) => segmentAt(comment, indent);
559
560
  // A stranded comment lies wholly on one side of the expression, since
560
561
  // a comment is a token and cannot straddle a node; keeping each on its
561
562
  // own side preserves what it annotates.
@@ -613,18 +614,43 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
613
614
  text: hoisted,
614
615
  });
615
616
  }
617
+ // A comment carried from behind the expression rides out past the
618
+ // punctuator that closes the statement the call stood in, where one
619
+ // is available to take over. Kept inside the replacement, the line
620
+ // break such a comment demands strands that punctuator on a line of
621
+ // its own — layout a formatter folds straight back, and the fold
622
+ // moves an `eslint-disable-next-line` written there onto a different
623
+ // subject, so the rule's own output decides which violations the
624
+ // next run enforces (#2138). Where no punctuator can be taken over,
625
+ // the comment stays inside the replacement and keeps its break.
626
+ const closingPunctuator = trailingComments.length > 0
627
+ ? (0, replacementSegments_1.absorbableClosingPunctuator)(sourceCode, node, trailingComments)
628
+ : null;
616
629
  const segments = [
617
630
  ...leadingComments
618
631
  .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
619
632
  .map(toSegment),
620
633
  { text: replacementText, breakAfter: false },
621
- ...trailingComments.map(toSegment),
634
+ ...(closingPunctuator ? [] : trailingComments.map(toSegment)),
622
635
  ];
623
636
  const body = (0, replacementSegments_1.joinSegmentBody)(segments, indent);
624
- const trailing = segments[segments.length - 1].breakAfter
625
- ? `\n${indent}`
626
- : '';
627
- edits.push({ range: node.range, text: `${body}${trailing}` });
637
+ if (!closingPunctuator) {
638
+ const trailing = segments[segments.length - 1].breakAfter
639
+ ? `\n${indent}`
640
+ : '';
641
+ edits.push({ range: node.range, text: `${body}${trailing}` });
642
+ }
643
+ else {
644
+ // A comment that lands out here annotates the statement rather
645
+ // than the expression, so it re-indents to the line the punctuator
646
+ // closes instead of to the line the call opened on.
647
+ const tailIndent = indentOf(sourceCode.lines[closingPunctuator.loc.end.line - 1] ?? '');
648
+ const tail = (0, replacementSegments_1.joinSegmentBody)(trailingComments.map((comment) => segmentAt(comment, tailIndent)), tailIndent);
649
+ edits.push({
650
+ range: [node.range[0], closingPunctuator.range[1]],
651
+ text: `${body}${closingPunctuator.value} ${tail}`,
652
+ });
653
+ }
628
654
  }
629
655
  }
630
656
  return {
@@ -883,14 +883,19 @@ function chainComments(node, sourceCode, operands) {
883
883
  * inside an operand's own brackets — a line comment in an object literal passed
884
884
  * to a call — belongs to that operand's layout and leaves the chain's own layout
885
885
  * alone, which is how prettier prints it.
886
+ *
887
+ * `absorbed` holds the comments the rewrite carries out past the statement's
888
+ * closing punctuator: those sit in no gap of the emitted chain, so counting one
889
+ * as a break would split a group that ends up holding no comment at all
890
+ * (#2141).
886
891
  */
887
- function chainBreaksLine(node, sourceCode) {
892
+ function chainBreaksLine(node, sourceCode, absorbed) {
888
893
  if (node.type !== utils_1.AST_NODE_TYPES.LogicalExpression) {
889
894
  return false;
890
895
  }
891
- return (strandedComments(node, sourceCode).some(replacementSegments_1.requiresLineBreakAfter) ||
892
- chainBreaksLine(node.left, sourceCode) ||
893
- chainBreaksLine(node.right, sourceCode));
896
+ return (strandedComments(node, sourceCode).some((comment) => !absorbed.has(comment) && (0, replacementSegments_1.requiresLineBreakAfter)(comment)) ||
897
+ chainBreaksLine(node.left, sourceCode, absorbed) ||
898
+ chainBreaksLine(node.right, sourceCode, absorbed));
894
899
  }
895
900
  /**
896
901
  * Whether the chain a link belongs to breaks the line, asked of the whole chain
@@ -902,12 +907,16 @@ function chainBreaksLine(node, sourceCode) {
902
907
  * broken packs two operands onto a line the formatter then splits (#2106).
903
908
  *
904
909
  * A comment trailing the chain, or leading it, is outside every gap: prettier
905
- * prints it on the line the chain already occupies and moves no operand.
910
+ * prints it on the line the chain already occupies and moves no operand. The
911
+ * same goes for a comment in `absorbed` — one the fix carries out past the
912
+ * statement's closing punctuator: wherever the INPUT held it, the emission
913
+ * holds it behind the statement, outside every gap (#2141).
906
914
  */
907
- function chainBreaksAtAGap(chain, sourceCode) {
915
+ function chainBreaksAtAGap(chain, sourceCode, absorbed) {
908
916
  const operands = chainOperands(chain);
909
917
  const { gaps } = chainComments(chain, sourceCode, operands);
910
- return (gaps.some((gap) => [...gap.beforeOperator, ...gap.afterOperator].some(replacementSegments_1.requiresLineBreakAfter)) || operands.some((operand) => chainBreaksLine(operand, sourceCode)));
918
+ return (gaps.some((gap) => [...gap.beforeOperator, ...gap.afterOperator].some((comment) => !absorbed.has(comment) && (0, replacementSegments_1.requiresLineBreakAfter)(comment))) ||
919
+ operands.some((operand) => chainBreaksLine(operand, sourceCode, absorbed)));
911
920
  }
912
921
  /**
913
922
  * The depths the rebuilt expression lands at.
@@ -1196,6 +1205,36 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
1196
1205
  });
1197
1206
  });
1198
1207
  segments.push(...comments.trailing.map(toSegment));
1208
+ // A comment trailing the whole expression rides out past the
1209
+ // punctuator that closes the statement the chain stands in,
1210
+ // where one is available to take over. Kept inside the
1211
+ // replacement, the line break such a comment demands strands
1212
+ // that punctuator on a line of its own — layout a formatter
1213
+ // folds straight back, and the fold moves an
1214
+ // `eslint-disable-next-line` written on the comment's line
1215
+ // onto a different subject, so the pipeline's formatting order
1216
+ // decides which violations are enforced (#2139). The span this
1217
+ // fix replaces can end PAST the node, at a redundant paren the
1218
+ // widening claims, so the lookup anchors on that paren where
1219
+ // the widening applies: asked of the node, it answers with the
1220
+ // paren — a token inside the span — instead of the punctuator
1221
+ // behind it. The landing widening reaches BACKWARD from the
1222
+ // node and never past its end, so everywhere else the span
1223
+ // ends at the node's own last token. The parenthesized
1224
+ // (selfParens) replacement keeps every comment inside the
1225
+ // emitted parens, where no punctuator can be stranded.
1226
+ const spanEndToken = redundantParens(node, sourceCode)
1227
+ ? sourceCode.getTokenAfter(node)
1228
+ : sourceCode.getLastToken(node);
1229
+ const closingPunctuator = !selfParens && comments.trailing.length > 0 && spanEndToken
1230
+ ? (0, replacementSegments_1.absorbableClosingPunctuator)(sourceCode, spanEndToken, comments.trailing)
1231
+ : null;
1232
+ // The absorption is decided BEFORE the break decision because
1233
+ // a comment carried past the punctuator sits in no gap of the
1234
+ // emitted chain: counted as a break it would put every operand
1235
+ // on a line of its own for a comment that no longer sits
1236
+ // between any of them (#2141).
1237
+ const absorbed = new Set(closingPunctuator ? comments.trailing : []);
1199
1238
  // Prettier prints a logical chain as ONE group: once anything
1200
1239
  // inside it breaks the line, EVERY operand takes a line of its
1201
1240
  // own. So a chain broken by a comment anywhere gets a break in
@@ -1204,7 +1243,7 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
1204
1243
  // The break rides on the last thing in the gap so a carried
1205
1244
  // comment keeps the line it was written on, and a gap already
1206
1245
  // holding a break needs no second separator.
1207
- const chainBreaks = chainBreaksAtAGap(chainRootOf(node), sourceCode);
1246
+ const chainBreaks = chainBreaksAtAGap(chainRootOf(node), sourceCode, absorbed);
1208
1247
  if (chainBreaks) {
1209
1248
  for (const { start, end } of gapSpans) {
1210
1249
  const separated = segments
@@ -1235,11 +1274,30 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
1235
1274
  ? landingOperator(node, sourceCode)
1236
1275
  : null;
1237
1276
  const leading = landing ? `\n${bodyIndent}` : '';
1238
- const body = (0, replacementSegments_1.joinSegmentBody)(segments, tailIndent);
1239
- const trailing = segments[segments.length - 1].breakAfter
1277
+ const range = replacementRange(node, sourceCode, landing);
1278
+ const bodySegments = closingPunctuator
1279
+ ? segments.slice(0, segments.length - comments.trailing.length)
1280
+ : segments;
1281
+ const body = (0, replacementSegments_1.joinSegmentBody)(bodySegments, tailIndent);
1282
+ const trailing = !closingPunctuator && segments[segments.length - 1].breakAfter
1240
1283
  ? `\n${lineIndent}`
1241
1284
  : '';
1242
- const replacement = fixer.replaceTextRange(replacementRange(node, sourceCode, landing), `${leading}${body}${trailing}`);
1285
+ // A comment that lands out past the punctuator annotates the
1286
+ // statement rather than the expression, so it re-indents to
1287
+ // the line the punctuator closes instead of to the chain's own
1288
+ // depth.
1289
+ const closingLine = closingPunctuator
1290
+ ? sourceCode.lines[closingPunctuator.loc.end.line - 1] ?? ''
1291
+ : '';
1292
+ const carriedTail = closingPunctuator
1293
+ ? (0, replacementSegments_1.joinSegmentBody)(comments.trailing.map(toSegment), /^[\t ]*/.exec(closingLine)?.[0] ?? '')
1294
+ : '';
1295
+ const claimedRange = closingPunctuator
1296
+ ? [range[0], closingPunctuator.range[1]]
1297
+ : range;
1298
+ const replacement = fixer.replaceTextRange(claimedRange, closingPunctuator
1299
+ ? `${leading}${body}${closingPunctuator.value} ${carriedTail}`
1300
+ : `${leading}${body}${trailing}`);
1243
1301
  if (!keyword || hoisted.length === 0) {
1244
1302
  return replacement;
1245
1303
  }
@@ -1,4 +1,4 @@
1
- import { TSESTree } from '@typescript-eslint/utils';
1
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
2
  /**
3
3
  * Building blocks for fixers that inline an expression in place of a larger
4
4
  * span (e.g. a `useMemo(...)` call) and must carry the comments stranded by
@@ -40,6 +40,36 @@ export declare function spansMultipleLines(comment: TSESTree.Comment): boolean;
40
40
  * and can keep such a comment inline.
41
41
  */
42
42
  export declare function requiresOwnLine(comment: TSESTree.Comment): boolean;
43
+ /**
44
+ * The punctuator a comment carried from behind the expression may move past.
45
+ *
46
+ * Such a comment annotates the statement the node stood in, and prettier
47
+ * prints it AFTER the token that closes that statement — `const x = 1; /* c
48
+ * *\/`, never `const x = 1 /* c *\/;`. Leaving it inside the statement is
49
+ * layout prettier rewrites, so the fixed file fails `prettier --check`
50
+ * (#2079). Leaving it there also strands the punctuator on a line of its own
51
+ * behind a line-bound comment, which retargets an `eslint-disable-next-line`
52
+ * onto that stray line the moment prettier folds it back (#2138).
53
+ *
54
+ * A comma is a weaker claim and takes only the comments that need a line of
55
+ * their own. Measured against this repo's prettier: a line comment on a list
56
+ * element is printed after the comma, while a block comment on one is
57
+ * printed before it — the opposite of what a semicolon gets. Moving a block
58
+ * comment past a comma would trade one layout prettier rewrites for another.
59
+ *
60
+ * Only a punctuator with nothing but line ending behind it may be taken
61
+ * over: a line-bound comment landing after it would otherwise swallow
62
+ * whatever shared that line. Asking for the next token INCLUDING comments
63
+ * also keeps a comment the fixer does not own from being stepped over.
64
+ *
65
+ * The anchor is whatever the replaced SPAN ends at. Where that span is
66
+ * exactly the node, the node itself serves; where a widening claims tokens
67
+ * past the node — redundant parentheses the rewrite discards — the anchor
68
+ * must be the span's own last token, because asked of the node the lookup
69
+ * answers with a token INSIDE the span instead of the punctuator behind it
70
+ * (#2139).
71
+ */
72
+ export declare function absorbableClosingPunctuator(sourceCode: Readonly<TSESLint.SourceCode>, anchor: TSESTree.Node | TSESTree.Token, trailingComments: readonly TSESTree.Comment[]): TSESTree.Token | null;
43
73
  export type ReplacementSegment = {
44
74
  text: string;
45
75
  breakAfter: boolean;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.joinSegments = exports.joinSegmentBody = exports.requiresOwnLine = exports.spansMultipleLines = exports.requiresLineBreakAfter = void 0;
3
+ exports.joinSegments = exports.joinSegmentBody = exports.absorbableClosingPunctuator = exports.requiresOwnLine = exports.spansMultipleLines = exports.requiresLineBreakAfter = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const disableDirectives_1 = require("./disableDirectives");
6
6
  /**
@@ -56,6 +56,49 @@ function requiresOwnLine(comment) {
56
56
  return requiresLineBreakAfter(comment) || spansMultipleLines(comment);
57
57
  }
58
58
  exports.requiresOwnLine = requiresOwnLine;
59
+ /**
60
+ * The punctuator a comment carried from behind the expression may move past.
61
+ *
62
+ * Such a comment annotates the statement the node stood in, and prettier
63
+ * prints it AFTER the token that closes that statement — `const x = 1; /* c
64
+ * *\/`, never `const x = 1 /* c *\/;`. Leaving it inside the statement is
65
+ * layout prettier rewrites, so the fixed file fails `prettier --check`
66
+ * (#2079). Leaving it there also strands the punctuator on a line of its own
67
+ * behind a line-bound comment, which retargets an `eslint-disable-next-line`
68
+ * onto that stray line the moment prettier folds it back (#2138).
69
+ *
70
+ * A comma is a weaker claim and takes only the comments that need a line of
71
+ * their own. Measured against this repo's prettier: a line comment on a list
72
+ * element is printed after the comma, while a block comment on one is
73
+ * printed before it — the opposite of what a semicolon gets. Moving a block
74
+ * comment past a comma would trade one layout prettier rewrites for another.
75
+ *
76
+ * Only a punctuator with nothing but line ending behind it may be taken
77
+ * over: a line-bound comment landing after it would otherwise swallow
78
+ * whatever shared that line. Asking for the next token INCLUDING comments
79
+ * also keeps a comment the fixer does not own from being stepped over.
80
+ *
81
+ * The anchor is whatever the replaced SPAN ends at. Where that span is
82
+ * exactly the node, the node itself serves; where a widening claims tokens
83
+ * past the node — redundant parentheses the rewrite discards — the anchor
84
+ * must be the span's own last token, because asked of the node the lookup
85
+ * answers with a token INSIDE the span instead of the punctuator behind it
86
+ * (#2139).
87
+ */
88
+ function absorbableClosingPunctuator(sourceCode, anchor, trailingComments) {
89
+ const next = sourceCode.getTokenAfter(anchor, { includeComments: true });
90
+ if (!next ||
91
+ next.type !== utils_1.AST_TOKEN_TYPES.Punctuator ||
92
+ (next.value !== ';' && next.value !== ',')) {
93
+ return null;
94
+ }
95
+ if (next.value === ',' && !trailingComments.every(requiresLineBreakAfter)) {
96
+ return null;
97
+ }
98
+ const line = sourceCode.lines[next.loc.end.line - 1] ?? '';
99
+ return line.slice(next.loc.end.column).trim() === '' ? next : null;
100
+ }
101
+ exports.absorbableClosingPunctuator = absorbableClosingPunctuator;
59
102
  /**
60
103
  * Joins the inlined expression and its carried comments into one run of text,
61
104
  * keeping each comment on the side of the expression it was written on and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.173",
3
+ "version": "1.20.175",
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.175",
4
+ "date": "2026-08-26T06:19:14.998Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-firestore-set-merge",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2142
11
+ ],
12
+ "summary": "place a block comment before the separator and fold a list that fits (closes #2142)"
13
+ },
14
+ {
15
+ "name": "prefer-nullish-coalescing-boolean-props",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2141
19
+ ],
20
+ "summary": "discount an absorbed comment from the chain-break decision (closes #2141)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.174",
26
+ "date": "2026-08-26T04:47:21.242Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-firestore-set-merge",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2140
33
+ ],
34
+ "summary": "emit the appended option's separator before a trailing comment (closes #2140)"
35
+ },
36
+ {
37
+ "name": "no-usememo-for-pass-by-value",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 2138
41
+ ],
42
+ "summary": "carry the trailing comment past the statement terminator (closes #2138)"
43
+ },
44
+ {
45
+ "name": "prefer-nullish-coalescing-boolean-props",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 2139
49
+ ],
50
+ "summary": "carry the trailing comment past the statement terminator (closes #2139)"
51
+ }
52
+ ]
53
+ },
2
54
  {
3
55
  "version": "1.20.173",
4
56
  "date": "2026-08-25T23:22:38.601Z",