@blumintinc/eslint-plugin-blumint 1.20.174 → 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.174',
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,17 +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
763
  * out the way the consumer's formatter prints the result — or `null` for
689
764
  * the one tail no edit can extend without retargeting a directive.
690
765
  *
691
- * Neither layout re-emits an argument from its text. The broken one
692
- * rewrites the SEPARATORS between the arguments and shifts the indentation
693
- * of the ones that span lines, so everything between them comments
694
- * included, and a dropped `eslint-disable` silently re-enables the rule it
695
- * was suppressing (#1877) stays where it was written, attached to the
696
- * 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.
697
773
  *
698
774
  * `nameDelta` is how much the caller's own rename widens the call, since
699
775
  * the two edits land on the same line and the width answer is about the
@@ -705,10 +781,17 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
705
781
  fixer.insertTextAfter(args[args.length - 1], appended.map((argument) => `, ${argument}`).join('')),
706
782
  ];
707
783
  const layout = callLayout(node);
708
- if (!layout || !requiresBrokenList(node, layout, appended, nameDelta)) {
784
+ if (!layout) {
709
785
  return inline();
710
786
  }
711
787
  const { openParen, closeParen, spans } = layout;
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
+ }
712
795
  // Between the last argument's own last token and the closing parenthesis
713
796
  // sit the trailing comma, if one was written, and any comment. The tail
714
797
  // SPAN absorbs such a comment whenever no comma follows it, so the span's
@@ -739,6 +822,12 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
739
822
  if (sameLineComments.some(isPositionalDirective)) {
740
823
  return null;
741
824
  }
825
+ if (listSpansLines) {
826
+ const flattened = flattenedListFixes(fixer, node, layout, appended, nameDelta, beforeClose, gapComments, ownLineComments);
827
+ if (flattened) {
828
+ return flattened;
829
+ }
830
+ }
742
831
  // A broken list is indented one step past the line its parenthesis opens
743
832
  // on, whatever depth that line sits at, and closes at that line's own
744
833
  // column. A constant indent is right for exactly one call site.
@@ -769,18 +858,34 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
769
858
  }
770
859
  const separatorPresent = beforeClose.value === ',';
771
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
+ }
772
881
  if (ownLineComments.length === 0) {
773
882
  // The comment trails the argument it annotates, so it keeps that line
774
- // and the option opens the next one. The separator has to sit between
775
- // the argument and the comment — after a line comment it is comment
776
- // text — so one is inserted at the argument's own last token unless
777
- // the author already wrote it, and everything through the end of the
778
- // annotation is left byte for byte as written.
779
- if (!separatorPresent) {
780
- fixes.push(fixer.insertTextAfter(lastArgumentToken, ','));
781
- }
883
+ // and the option opens the next one.
782
884
  const annotationEnd = Math.max(lastSameLine.range[1], beforeClose.range[1]);
783
- fixes.push(fixer.replaceTextRange([annotationEnd, closeParen.range[0]], `${appended
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
784
889
  .map((argument) => `\n${body}${argument},`)
785
890
  .join('')}\n${indent}`));
786
891
  return fixes;
@@ -792,16 +897,20 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
792
897
  const appendedText = appended
793
898
  .map((argument) => `\n${body}${argument},`)
794
899
  .join('');
795
- const anchor = Math.max(separatorPresent ? beforeClose.range[1] : lastArgumentToken.range[1], lastSameLine ? lastSameLine.range[1] : 0);
796
- if (separatorPresent || lastSameLine) {
797
- if (!separatorPresent) {
798
- fixes.push(fixer.insertTextAfter(lastArgumentToken, ','));
799
- }
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], ','));
800
909
  fixes.push(fixer.insertTextAfterRange([anchor, anchor], appendedText));
801
910
  return fixes;
802
911
  }
803
- // With neither a comma nor a trailing comment, the separator and the
804
- // option travel as one insertion, since both land at the same offset.
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.
805
914
  fixes.push(fixer.insertTextAfterRange([anchor, anchor], `,${appendedText}`));
806
915
  return fixes;
807
916
  }
@@ -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
@@ -1236,26 +1275,6 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
1236
1275
  : null;
1237
1276
  const leading = landing ? `\n${bodyIndent}` : '';
1238
1277
  const range = replacementRange(node, sourceCode, landing);
1239
- // A comment trailing the whole expression rides out past the
1240
- // punctuator that closes the statement the chain stands in,
1241
- // where one is available to take over. Kept inside the
1242
- // replacement, the line break such a comment demands strands
1243
- // that punctuator on a line of its own — layout a formatter
1244
- // folds straight back, and the fold moves an
1245
- // `eslint-disable-next-line` written on the comment's line
1246
- // onto a different subject, so the pipeline's formatting order
1247
- // decides which violations are enforced (#2139). The span this
1248
- // fix replaces can end PAST the node, at a redundant paren the
1249
- // widening claims, so the lookup anchors on the span's own
1250
- // last token: asked of the node, it answers with that paren —
1251
- // a token inside the span — instead of the punctuator behind
1252
- // it.
1253
- const spanEndToken = range[1] > node.range[1]
1254
- ? sourceCode.getTokenAfter(node)
1255
- : sourceCode.getLastToken(node);
1256
- const closingPunctuator = comments.trailing.length > 0 && spanEndToken
1257
- ? (0, replacementSegments_1.absorbableClosingPunctuator)(sourceCode, spanEndToken, comments.trailing)
1258
- : null;
1259
1278
  const bodySegments = closingPunctuator
1260
1279
  ? segments.slice(0, segments.length - comments.trailing.length)
1261
1280
  : segments;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.174",
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,26 @@
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
+ },
2
24
  {
3
25
  "version": "1.20.174",
4
26
  "date": "2026-08-26T04:47:21.242Z",