@blumintinc/eslint-plugin-blumint 1.20.174 → 1.20.176

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.176',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1302,11 +1302,18 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1302
1302
  * position the author wrote it, and hands assertSafe what it produces — the
1303
1303
  * rewrite adds a validation, it does not move a dereference.
1304
1304
  *
1305
- * A key written without a wrapper keeps the narrower argument the fix has
1306
- * always emitted: `String(id)` and `` `${id}` `` collapse to `id`, whose
1307
- * conversion assertSafe subsumes.
1305
+ * A coercion the author wrote is part of the key, so it is wrapped rather
1306
+ * than replaced: `assertSafe(String(id))` and ``assertSafe(`${id}`)``, not
1307
+ * `assertSafe(id)`. assertSafe VALIDATES a key, it does not coerce one —
1308
+ * it throws on any argument whose `typeof` is neither `string` nor
1309
+ * `number` — so dropping the `String()` call or the template hands it the
1310
+ * raw operand and changes what the key evaluates to. A boolean operand
1311
+ * turns a working `` record[`${flag}`] `` lookup into a throw (#2144).
1312
+ * Wrapping the key exactly as written is also what makes the sole
1313
+ * substitution behave like every other template: `` `${a}${b}` `` has
1314
+ * always been wrapped whole.
1308
1315
  */
1309
- const reportWrittenKey = (written, unwrapped, innerText) => reportUseAssertSafe(written, written === unwrapped ? innerText : context.sourceCode.getText(written));
1316
+ const reportWrittenKey = (written) => reportUseAssertSafe(written, context.sourceCode.getText(written));
1310
1317
  /**
1311
1318
  * Returns true when the identifier was initialized directly from an
1312
1319
  * assertSafe(...) call, e.g. `const safeKey = assertSafe(rawKey)`.
@@ -1830,9 +1837,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1830
1837
  if (key.type === utils_1.AST_NODE_TYPES.CallExpression &&
1831
1838
  key.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
1832
1839
  key.callee.name === 'String') {
1833
- const arg = key.arguments[0];
1834
- const argText = context.sourceCode.getText(arg);
1835
- reportWrittenKey(written, key, argText);
1840
+ reportWrittenKey(written);
1836
1841
  }
1837
1842
  // Check for template literals like `${id}`
1838
1843
  if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
@@ -1840,9 +1845,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1840
1845
  key.quasis.length === 2 &&
1841
1846
  key.quasis[0].value.raw === '' &&
1842
1847
  key.quasis[1].value.raw === '') {
1843
- const expr = key.expressions[0];
1844
- const exprText = context.sourceCode.getText(expr);
1845
- reportWrittenKey(written, key, exprText);
1848
+ reportWrittenKey(written);
1846
1849
  }
1847
1850
  }
1848
1851
  },
@@ -1855,9 +1858,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1855
1858
  if (left.type === utils_1.AST_NODE_TYPES.CallExpression &&
1856
1859
  left.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
1857
1860
  left.callee.name === 'String') {
1858
- const arg = left.arguments[0];
1859
- const argText = context.sourceCode.getText(arg);
1860
- reportWrittenKey(written, left, argText);
1861
+ reportWrittenKey(written);
1861
1862
  }
1862
1863
  // Check for template literals like `${id}`
1863
1864
  if (left.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
@@ -1865,9 +1866,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1865
1866
  left.quasis.length === 2 &&
1866
1867
  left.quasis[0].value.raw === '' &&
1867
1868
  left.quasis[1].value.raw === '') {
1868
- const expr = left.expressions[0];
1869
- const exprText = context.sourceCode.getText(expr);
1870
- reportWrittenKey(written, left, exprText);
1869
+ reportWrittenKey(written);
1871
1870
  }
1872
1871
  }
1873
1872
  },
@@ -1909,9 +1908,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1909
1908
  if (property.type === utils_1.AST_NODE_TYPES.CallExpression &&
1910
1909
  property.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
1911
1910
  property.callee.name === 'String') {
1912
- const arg = property.arguments[0];
1913
- const argText = context.sourceCode.getText(arg);
1914
- reportWrittenKey(written, property, argText);
1911
+ reportWrittenKey(written);
1915
1912
  return;
1916
1913
  }
1917
1914
  // Check for template literals
@@ -1935,19 +1932,14 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1935
1932
  if (!reachesPrototype) {
1936
1933
  return;
1937
1934
  }
1938
- // `${id}` alone is the whole key, so the remedy names the inner
1939
- // expression and the fix wraps it directly. A template carrying
1940
- // fixed text has no such inner key — the string it builds is the
1941
- // key so that whole template is what gets wrapped, which is the
1942
- // shape the docs show for `assertSafe(`${id}_suffix`)`.
1943
- const isSimpleVarInterpolation = property.expressions.length === 1 &&
1944
- property.quasis.length === 2 &&
1945
- property.quasis[0].value.raw === '' &&
1946
- property.quasis[1].value.raw === '';
1947
- const unwrapped = isSimpleVarInterpolation
1948
- ? property.expressions[0]
1949
- : property;
1950
- reportWrittenKey(written, property, context.sourceCode.getText(unwrapped));
1935
+ // The string the template builds is the key, whether it carries
1936
+ // fixed text or is a lone substitution, so the whole template is
1937
+ // what gets wrapped — the shape the docs show for
1938
+ // `assertSafe(`${id}_suffix`)`. Reaching inside for `${id}` would
1939
+ // hand assertSafe the operand instead of the string the operand
1940
+ // widens to, and assertSafe throws on anything that is not already
1941
+ // a string or a number (#2144).
1942
+ reportWrittenKey(written);
1951
1943
  return;
1952
1944
  }
1953
1945
  // Check for direct variable usage (identifiers)
@@ -1971,8 +1963,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1971
1963
  if (isCompilerBoundedLookup(node, property)) {
1972
1964
  return;
1973
1965
  }
1974
- const propText = context.sourceCode.getText(property);
1975
- reportWrittenKey(written, property, propText);
1966
+ reportWrittenKey(written);
1976
1967
  return;
1977
1968
  }
1978
1969
  // Check for binary expressions (like index + 1)
@@ -1981,8 +1972,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1981
1972
  if (isLikelyArray) {
1982
1973
  return;
1983
1974
  }
1984
- const propText = context.sourceCode.getText(property);
1985
- reportWrittenKey(written, property, propText);
1975
+ reportWrittenKey(written);
1986
1976
  return;
1987
1977
  }
1988
1978
  // Check for boolean expressions and other literals
@@ -1993,8 +1983,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1993
1983
  if (isLikelyArray) {
1994
1984
  return;
1995
1985
  }
1996
- const propText = context.sourceCode.getText(property);
1997
- reportWrittenKey(written, property, propText);
1986
+ reportWrittenKey(written);
1998
1987
  return;
1999
1988
  }
2000
1989
  // Check for function calls (anything that isn't handled above)
@@ -2006,8 +1995,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
2006
1995
  if (isLikelyArray) {
2007
1996
  return;
2008
1997
  }
2009
- const propText = context.sourceCode.getText(property);
2010
- reportWrittenKey(written, property, propText);
1998
+ reportWrittenKey(written);
2011
1999
  return;
2012
2000
  }
2013
2001
  }
@@ -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
  }
@@ -41,6 +41,141 @@ const NON_PLURALIZABLE_SUFFIXES = [
41
41
  * the reference itself as a container.
42
42
  */
43
43
  const ARRAY_GENERIC_NAMES = new Set(['Array', 'ReadonlyArray']);
44
+ /**
45
+ * Word-final suffixes that are singular by shape. No English plural ends in
46
+ * `-sis` or `-ss`, so both classes are exempt wholesale rather than enumerated:
47
+ * every `-sis` noun (`Analysis`, `Basis`, `Thesis`, `Diagnosis`, `Synopsis`,
48
+ * `Chassis`) and every `-ss` noun (`Address`, `Progress`, `Class`, `Success`)
49
+ * is covered, including ones no list would anticipate.
50
+ *
51
+ * The neighbouring `-is`/`-us`/`-os` classes get no such blanket rule, because
52
+ * genuine plurals do end that way — `Emojis`, `Minis`, `Menus`, `Plateaus`,
53
+ * `Taxis`. Exempting those by shape would silence the rule on real collection
54
+ * names, so they are enumerated in SINGULAR_NOUNS_ENDING_IN_S instead.
55
+ */
56
+ const SINGULAR_WORD_SUFFIXES = ['sis', 'ss'];
57
+ /**
58
+ * Singular nouns that end in `s` and that `pluralize` mistakes for plurals.
59
+ * Stripping the trailing `s` yields a non-word — `Axis` → `Axi`, `Lens` →
60
+ * `Len`, `Chaos` → `Chao` — which is the tell that the identifier was singular
61
+ * all along. Enumeration is the only safe discriminator here: `Axis` and
62
+ * `Minis` share a shape, so nothing but the noun itself separates a Latin/Greek
63
+ * singular from an ordinary plural.
64
+ *
65
+ * Entries `pluralize` already classifies correctly (`Status`, `Corpus`) are
66
+ * listed too, so a change in that library's heuristics cannot quietly
67
+ * reintroduce the false positive.
68
+ */
69
+ const SINGULAR_NOUNS_ENDING_IN_S = new Set([
70
+ // Latin/Greek `-is` singulars outside the `-sis` family above.
71
+ 'axis',
72
+ 'praxis',
73
+ 'prophylaxis',
74
+ 'aegis',
75
+ 'cannabis',
76
+ 'chrysalis',
77
+ 'clematis',
78
+ 'dais',
79
+ 'dermis',
80
+ 'epidermis',
81
+ 'epiglottis',
82
+ 'glottis',
83
+ 'hubris',
84
+ 'ibis',
85
+ 'iris',
86
+ 'mantis',
87
+ 'marquis',
88
+ 'megalopolis',
89
+ 'metropolis',
90
+ 'pelvis',
91
+ 'portcullis',
92
+ 'proboscis',
93
+ 'tennis',
94
+ 'trellis',
95
+ // `-us` singulars.
96
+ 'alumnus',
97
+ 'apparatus',
98
+ 'bonus',
99
+ 'bus',
100
+ 'cactus',
101
+ 'campus',
102
+ 'census',
103
+ 'chorus',
104
+ 'consensus',
105
+ 'corpus',
106
+ 'exodus',
107
+ 'focus',
108
+ 'fungus',
109
+ 'genus',
110
+ 'hiatus',
111
+ 'impetus',
112
+ 'locus',
113
+ 'minus',
114
+ 'modulus',
115
+ 'nexus',
116
+ 'nucleus',
117
+ 'octopus',
118
+ 'opus',
119
+ 'plus',
120
+ 'prospectus',
121
+ 'radius',
122
+ 'sinus',
123
+ 'status',
124
+ 'stimulus',
125
+ 'surplus',
126
+ 'syllabus',
127
+ 'terminus',
128
+ 'thesaurus',
129
+ 'versus',
130
+ 'virus',
131
+ // Remaining singular nouns ending in `s`.
132
+ 'alias',
133
+ 'apropos',
134
+ 'asbestos',
135
+ 'atlas',
136
+ 'bias',
137
+ 'canvas',
138
+ 'chaos',
139
+ 'cosmos',
140
+ 'ethos',
141
+ 'fracas',
142
+ 'gas',
143
+ 'kudos',
144
+ 'lens',
145
+ 'news',
146
+ 'pancreas',
147
+ 'pathos',
148
+ 'rhinoceros',
149
+ 'series',
150
+ 'species',
151
+ 'thermos',
152
+ ]);
153
+ /**
154
+ * Splits a PascalCase/camelCase identifier into its words, keeping an
155
+ * all-caps run together: `DeferAxis` → `Defer`/`Axis`, `HTTPStatus` →
156
+ * `HTTP`/`Status`.
157
+ */
158
+ const IDENTIFIER_WORD_PATTERN = /[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+/g;
159
+ /**
160
+ * The exemption keys on the identifier's FINAL word rather than the whole
161
+ * identifier so it composes with any prefix: `Axis`, `DeferAxis` and
162
+ * `ChartRenderAxis` all resolve to `axis`. Whole-identifier matching would
163
+ * exempt only the bare noun and keep reporting every compound built on it.
164
+ */
165
+ function trailingWordOf(name) {
166
+ const words = name.match(IDENTIFIER_WORD_PATTERN);
167
+ return (words?.[words.length - 1] ?? name).toLowerCase();
168
+ }
169
+ /**
170
+ * True when the identifier's final word is a singular noun that merely ends in
171
+ * `s`. Checked before `pluralize`, whose naive trailing-`s` strip is what
172
+ * misreads these nouns in the first place.
173
+ */
174
+ function endsWithSingularNoun(name) {
175
+ const trailingWord = trailingWordOf(name);
176
+ return (SINGULAR_NOUNS_ENDING_IN_S.has(trailingWord) ||
177
+ SINGULAR_WORD_SUFFIXES.some((suffix) => trailingWord.endsWith(suffix)));
178
+ }
44
179
  /**
45
180
  * Union members that only express absence. Stripping them keeps a nullable
46
181
  * container recognisable as a container: `T[]` and `T[] | null` describe the
@@ -137,6 +272,11 @@ exports.enforceSingularTypeNames = (0, createRule_1.createRule)({
137
272
  // Skip checking if name ends with 'Props', 'Params', 'Data', etc.
138
273
  if (NON_PLURALIZABLE_SUFFIXES.some((suffix) => name.toLowerCase().endsWith(suffix.toLowerCase())))
139
274
  return false;
275
+ // Singular nouns that end in `s` must be settled BEFORE pluralize sees
276
+ // them: its trailing-`s` strip turns `DeferAxis` into the non-word
277
+ // `DeferAxi` and reports a rename to it.
278
+ if (endsWithSingularNoun(name))
279
+ return false;
140
280
  // Skip checking if name is already singular according to pluralize
141
281
  if (pluralize.isSingular(name))
142
282
  return false;
@@ -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.176",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "1.20.176",
4
+ "date": "2026-08-26T11:56:32.903Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-assert-safe-object-key",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2144
11
+ ],
12
+ "summary": "wrap a coerced key instead of replacing it (closes #2144)"
13
+ },
14
+ {
15
+ "name": "enforce-singular-type-names",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2143
19
+ ],
20
+ "summary": "exempt singular nouns that merely end in s (closes #2143)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.175",
26
+ "date": "2026-08-26T06:19:14.998Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-firestore-set-merge",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2142
33
+ ],
34
+ "summary": "place a block comment before the separator and fold a list that fits (closes #2142)"
35
+ },
36
+ {
37
+ "name": "prefer-nullish-coalescing-boolean-props",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 2141
41
+ ],
42
+ "summary": "discount an absorbed comment from the chain-break decision (closes #2141)"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "1.20.174",
4
48
  "date": "2026-08-26T04:47:21.242Z",