@blumintinc/eslint-plugin-blumint 1.20.175 → 1.20.177

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.175',
226
+ version: '1.20.177',
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
  }
@@ -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;
@@ -108,22 +108,62 @@ exports.jsdocAboveField = (0, createRule_1.createRule)({
108
108
  : node.range[1],
109
109
  };
110
110
  };
111
+ const containerOf = (node) => {
112
+ const parent = node.parent;
113
+ if (parent?.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
114
+ return { node: parent, members: parent.members, separator: ';' };
115
+ }
116
+ if (parent?.type === utils_1.AST_NODE_TYPES.TSInterfaceBody) {
117
+ return { node: parent, members: parent.body, separator: ';' };
118
+ }
119
+ if (parent?.type === utils_1.AST_NODE_TYPES.ClassBody) {
120
+ return { node: parent, members: parent.body, separator: ';' };
121
+ }
122
+ if (parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
123
+ return { node: parent, members: parent.properties, separator: ',' };
124
+ }
125
+ return undefined;
126
+ };
127
+ /**
128
+ * Whether nothing but the container's closing brace follows the block, so
129
+ * the field it trails is the only member it can belong to.
130
+ */
131
+ const closesContainer = (node, comment) => {
132
+ const container = containerOf(node);
133
+ if (!container) {
134
+ return false;
135
+ }
136
+ const closeBrace = sourceCode.getLastToken(container.node);
137
+ if (!closeBrace || comment.range[1] > closeBrace.range[0]) {
138
+ return false;
139
+ }
140
+ return !container.members.some((member) => member.range[0] >= comment.range[1]);
141
+ };
111
142
  /**
112
143
  * Attaches a trailing JSDoc block by token order rather than by line.
113
144
  *
114
- * Prettier reflows a multi-line block that trails a field onto its own
115
- * line, ahead of the member's separator: the block that followed
116
- * `timeout: number;` ends up between `timeout: number` and its `;`. Keying
117
- * on the comment sharing the field's line makes the rule inert on exactly
118
- * the formatted source it has to police, so a block the separator still
119
- * follows counts as this member's however many lines down it starts.
145
+ * Prettier is not idempotent on a multi-line block that trails a field. One
146
+ * pass reflows it onto its own line ahead of the member's separator, so the
147
+ * block that followed `timeout: number;` sits between `timeout: number` and
148
+ * its `;`; the next pass moves the separator back in front of it, and that
149
+ * separator-first spelling is the fixed point formatted source converges
150
+ * to. Both intermediates and the fixed point document the field above them,
151
+ * so keying on the comment sharing the field's line — or on the separator
152
+ * still following it — leaves the rule inert on the shape it exists to
153
+ * police.
154
+ *
155
+ * Past the separator the member has ended and position alone stops naming
156
+ * an owner: an own-line block there reads as the leading documentation of
157
+ * the next field. That reading needs a next field, so it is unavailable on
158
+ * the last member of a container, where the preceding field is the only
159
+ * candidate left.
120
160
  *
121
- * Past the separator the member has ended and position alone no longer
122
- * identifies an owner: an own-line block there is the leading documentation
123
- * of the next field, or a note about the enclosing shape. Only the same-line
124
- * spelling can be claimed there, which is why that arm survives.
161
+ * A blank line is the one signal that survives the round trip intact:
162
+ * prettier preserves an authored one and never inserts one while reflowing,
163
+ * so a block held off by an empty line is a deliberate note about the
164
+ * enclosing shape rather than displaced documentation.
125
165
  */
126
- const trailingJSDocFor = (span) => {
166
+ const trailingJSDocFor = (node, span) => {
127
167
  return allComments.find((comment) => {
128
168
  if (!isJSDocBlock(comment)) {
129
169
  return false;
@@ -131,11 +171,13 @@ exports.jsdocAboveField = (0, createRule_1.createRule)({
131
171
  if (comment.range[0] < span.offset) {
132
172
  return false;
133
173
  }
174
+ const between = sourceCode.text.slice(span.offset, comment.range[0]);
134
175
  const precedesSeparator = comment.range[1] <= span.separatorEnd;
135
176
  if (!precedesSeparator && comment.loc.start.line !== span.line) {
136
- return false;
177
+ if (!closesContainer(node, comment) || /\n[^\S\n]*\n/.test(between)) {
178
+ return false;
179
+ }
137
180
  }
138
- const between = sourceCode.text.slice(span.offset, comment.range[0]);
139
181
  return /^[\s;,]*$/.test(between);
140
182
  });
141
183
  };
@@ -236,22 +278,6 @@ exports.jsdocAboveField = (0, createRule_1.createRule)({
236
278
  */
237
279
  const lineIndentOf = (node) => /^[ \t]*/.exec(sourceCode.lines[node.loc.start.line - 1] ?? '')?.[0] ??
238
280
  '';
239
- const containerOf = (node) => {
240
- const parent = node.parent;
241
- if (parent?.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
242
- return { node: parent, members: parent.members, separator: ';' };
243
- }
244
- if (parent?.type === utils_1.AST_NODE_TYPES.TSInterfaceBody) {
245
- return { node: parent, members: parent.body, separator: ';' };
246
- }
247
- if (parent?.type === utils_1.AST_NODE_TYPES.ClassBody) {
248
- return { node: parent, members: parent.body, separator: ';' };
249
- }
250
- if (parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
251
- return { node: parent, members: parent.properties, separator: ',' };
252
- }
253
- return undefined;
254
- };
255
281
  /**
256
282
  * A member's separator sits inside its range for type and class members
257
283
  * but outside it for object literal properties, so both spellings have to
@@ -314,7 +340,7 @@ exports.jsdocAboveField = (0, createRule_1.createRule)({
314
340
  jsdoc: member === node
315
341
  ? comment
316
342
  : isRelevantNode(member)
317
- ? trailingJSDocFor(fieldSpanOf(member))
343
+ ? trailingJSDocFor(member, fieldSpanOf(member))
318
344
  : undefined,
319
345
  };
320
346
  });
@@ -420,7 +446,7 @@ exports.jsdocAboveField = (0, createRule_1.createRule)({
420
446
  return;
421
447
  }
422
448
  const span = fieldSpanOf(node);
423
- const jsdocComment = trailingJSDocFor(span);
449
+ const jsdocComment = trailingJSDocFor(node, span);
424
450
  if (!jsdocComment) {
425
451
  return;
426
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.175",
3
+ "version": "1.20.177",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.20.177",
4
+ "date": "2026-08-26T16:36:01.459Z",
5
+ "rules": [
6
+ {
7
+ "name": "jsdoc-above-field",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2145
11
+ ],
12
+ "summary": "claim a trailing block that closes its container (closes #2145)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.176",
18
+ "date": "2026-08-26T11:56:32.903Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-assert-safe-object-key",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2144
25
+ ],
26
+ "summary": "wrap a coerced key instead of replacing it (closes #2144)"
27
+ },
28
+ {
29
+ "name": "enforce-singular-type-names",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2143
33
+ ],
34
+ "summary": "exempt singular nouns that merely end in s (closes #2143)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.175",
4
40
  "date": "2026-08-26T06:19:14.998Z",