@blumintinc/eslint-plugin-blumint 1.21.1 → 1.21.2

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
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.1',
227
+ version: '1.21.2',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -52,6 +52,33 @@ function finalSegmentOf(source) {
52
52
  const segments = source.split('/').filter(Boolean);
53
53
  return segments[segments.length - 1] ?? source;
54
54
  }
55
+ /**
56
+ * The verdict over alternatives only one of which renders: the branches of a
57
+ * conditional, the operands of a logical, the several returns of a function
58
+ * `sx`. A wrapping alternative is a real seam whatever the others say, and only
59
+ * an all-`false` set proves the absence of one.
60
+ */
61
+ function eitherBranchWraps(verdicts) {
62
+ if (verdicts.includes(true)) {
63
+ return true;
64
+ }
65
+ return verdicts.length > 0 && verdicts.every((verdict) => verdict === false)
66
+ ? false
67
+ : undefined;
68
+ }
69
+ /**
70
+ * The verdict over members merged in source order. An object literal and a MUI
71
+ * `sx` array are both last-write-wins, so a member that resolves `flexWrap`
72
+ * overrides every earlier one — including back to `false`, which is what makes
73
+ * `{ flexWrap: 'wrap', ...NOWRAP }` a non-wrapping object (#2299).
74
+ *
75
+ * An `undefined` member is opaque rather than empty: an imported or
76
+ * caller-supplied spread may or may not carry `flexWrap`, so it leaves the last
77
+ * known verdict standing rather than erasing it.
78
+ */
79
+ function mergeVerdict(previous, next) {
80
+ return next === undefined ? previous : next;
81
+ }
55
82
  exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
56
83
  name: 'enforce-use-flex-gap-on-wrap',
57
84
  meta: {
@@ -224,58 +251,56 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
224
251
  }
225
252
  return verdict;
226
253
  }
227
- case utils_1.AST_NODE_TYPES.ConditionalExpression: {
228
- const consequent = wrapVerdict(target.consequent, seen);
229
- const alternate = wrapVerdict(target.alternate, seen);
230
- if (consequent === true || alternate === true) {
231
- return true;
232
- }
233
- return consequent === false && alternate === false
234
- ? false
235
- : undefined;
236
- }
237
- case utils_1.AST_NODE_TYPES.LogicalExpression: {
238
- const left = wrapVerdict(target.left, seen);
239
- const right = wrapVerdict(target.right, seen);
240
- if (left === true || right === true) {
241
- return true;
242
- }
243
- return left === false && right === false ? false : undefined;
244
- }
254
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
255
+ return eitherBranchWraps([
256
+ wrapVerdict(target.consequent, seen),
257
+ wrapVerdict(target.alternate, seen),
258
+ ]);
259
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
260
+ return eitherBranchWraps([
261
+ wrapVerdict(target.left, seen),
262
+ wrapVerdict(target.right, seen),
263
+ ]);
245
264
  default:
246
265
  return undefined;
247
266
  }
248
267
  }
249
- /** Whether an object literal states a wrapping `flexWrap`. */
268
+ /**
269
+ * What an object literal states about `flexWrap`. The members are merged in
270
+ * source order rather than scanned for any wrapping one, because an `sx`
271
+ * object is last-write-wins: `{ flexWrap: 'wrap', ...BASE }` renders
272
+ * whatever `BASE` says, and only the mirror ordering wraps.
273
+ */
250
274
  function objectWraps(object, seen) {
275
+ let verdict = undefined;
251
276
  for (const property of object.properties) {
252
277
  if (property.type === utils_1.AST_NODE_TYPES.SpreadElement) {
253
278
  // A spread of a local constant is read through; a spread of an
254
279
  // imported or caller-supplied value stays opaque, because resolving it
255
280
  // would need the cross-module analysis this rule declines.
256
- if (sxWraps(property.argument, seen)) {
257
- return true;
258
- }
281
+ verdict = mergeVerdict(verdict, sxWraps(property.argument, seen));
259
282
  continue;
260
283
  }
261
284
  if (propertyNameOf(property) !== 'flexWrap') {
262
285
  continue;
263
286
  }
264
- if (wrapVerdict(property.value, seen) === true) {
265
- return true;
266
- }
287
+ verdict = mergeVerdict(verdict, wrapVerdict(property.value, seen));
267
288
  }
268
- return false;
289
+ return verdict;
269
290
  }
270
291
  /**
271
- * Whether an `sx` expression states a wrapping `flexWrap` anywhere the rule
272
- * can read statically. Four of the six wrapping Stacks in the consuming
273
- * codebase hoist their `sx` to a module constant, including both live
274
- * violations, so identifier resolution is the path that matters most.
292
+ * What an `sx` expression states about `flexWrap` where the rule can read it
293
+ * statically. Four of the six wrapping Stacks in the consuming codebase
294
+ * hoist their `sx` to a module constant, including both live violations, so
295
+ * identifier resolution is the path that matters most.
296
+ *
297
+ * The verdict is the tri-state rather than a boolean because this answer
298
+ * feeds a merge: a spread of `{ flexWrap: 'nowrap' }` has to override an
299
+ * earlier wrapping member, which a `false` meaning "nothing known" cannot.
275
300
  */
276
301
  function sxWraps(node, seen) {
277
302
  if (!node) {
278
- return false;
303
+ return undefined;
279
304
  }
280
305
  const target = unwrapAssertions(node);
281
306
  switch (target.type) {
@@ -283,7 +308,7 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
283
308
  return objectWraps(target, seen);
284
309
  case utils_1.AST_NODE_TYPES.Identifier: {
285
310
  if (seen.has(target)) {
286
- return false;
311
+ return undefined;
287
312
  }
288
313
  seen.add(target);
289
314
  return sxWraps(initializerOf(target), seen);
@@ -291,21 +316,31 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
291
316
  case utils_1.AST_NODE_TYPES.ArrowFunctionExpression: {
292
317
  const body = unwrapAssertions(target.body);
293
318
  if (body.type === utils_1.AST_NODE_TYPES.BlockStatement) {
294
- return body.body.some((statement) => statement.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
295
- sxWraps(statement.argument ?? undefined, seen));
319
+ return eitherBranchWraps(body.body
320
+ .filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ReturnStatement)
321
+ .map((statement) => sxWraps(statement.argument ?? undefined, seen)));
296
322
  }
297
323
  return sxWraps(body, seen);
298
324
  }
299
325
  case utils_1.AST_NODE_TYPES.ConditionalExpression:
300
326
  // Either branch renders, so either branch wrapping is a real seam.
301
- return (sxWraps(target.consequent, seen) || sxWraps(target.alternate, seen));
302
- case utils_1.AST_NODE_TYPES.ArrayExpression:
303
- // MUI merges an array of sx entries left to right.
304
- return target.elements.some((element) => element ? sxWraps(element, seen) : false);
327
+ return eitherBranchWraps([
328
+ sxWraps(target.consequent, seen),
329
+ sxWraps(target.alternate, seen),
330
+ ]);
331
+ case utils_1.AST_NODE_TYPES.ArrayExpression: {
332
+ // MUI merges an array of sx entries left to right, so the entry that
333
+ // resolves `flexWrap` last owns the value.
334
+ let verdict = undefined;
335
+ for (const element of target.elements) {
336
+ verdict = mergeVerdict(verdict, element ? sxWraps(element, seen) : undefined);
337
+ }
338
+ return verdict;
339
+ }
305
340
  default:
306
341
  // A call expression is opaque. Guessing at what it returns would
307
342
  // report on code the rule cannot read.
308
- return false;
343
+ return undefined;
309
344
  }
310
345
  }
311
346
  /**
@@ -417,8 +452,8 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
417
452
  // The attribute wins where both spell `flexWrap` and disagree; an
418
453
  // unreadable attribute is not a disagreement, so it falls through.
419
454
  const wraps = attributeVerdict ??
420
- (sx ? sxWraps(attributeValueOf(sx), new Set()) : false);
421
- if (!wraps) {
455
+ (sx ? sxWraps(attributeValueOf(sx), new Set()) : undefined);
456
+ if (wraps !== true) {
422
457
  return;
423
458
  }
424
459
  if (!resolvesToMuiStack(elementName)) {
@@ -202,6 +202,27 @@ const DOMAIN_CLASS_HEAD_NOUNS = new Set([
202
202
  'drug',
203
203
  'vehicle',
204
204
  ]);
205
+ // Conversion heads: the verb or preposition a CONVERTER function's name opens
206
+ // with. In <head><Type> the trailing type word names what the function
207
+ // PRODUCES (or consumes, for `from`), never the type of the value the
208
+ // identifier holds — the identifier holds a function. Hungarian notation tags a
209
+ // value with its own type, so `toNumber` is outside the notation entirely, and
210
+ // stripping the type word destroys the name (`to`, `parse` and `from` denote
211
+ // nothing on their own), which is this rule's own test for a domain compound
212
+ // versus a tag. The same reasoning the rule already applies to the type-concept
213
+ // names it exempts (`StringToNumber`) and to the `Parsed` / `Converted`
214
+ // suffixes, applied to function names (#2302).
215
+ //
216
+ // `convertto` is the two-segment head `convertTo`, stored joined because the
217
+ // lookup is done on the head segments concatenated and lowercased.
218
+ const CONVERSION_HEADS = new Set([
219
+ 'to',
220
+ 'as',
221
+ 'from',
222
+ 'parse',
223
+ 'into',
224
+ 'convertto',
225
+ ]);
205
226
  // Common built-in JavaScript prototype methods
206
227
  const BUILT_IN_METHODS = new Set([
207
228
  // String methods
@@ -578,6 +599,63 @@ function isClassValuedDeclaration(node) {
578
599
  return false;
579
600
  }
580
601
  }
602
+ // Is `name` a conversion compound of the form <head><Type> (toNumber,
603
+ // parseBoolean, fromString, asArray, convertToNumber), where the FINAL segment
604
+ // is a full type word and everything before it is a conversion head? The type
605
+ // word must be final and must follow the head directly, so a name that merely
606
+ // contains a head keeps firing: `toNumberValue` names a value (the type word is
607
+ // not the target), `numberToValue` leads with the type word, and `strToNumber`
608
+ // carries an abbreviation marker, which no English word is spelled with.
609
+ // Abbreviation markers are excluded by construction — FULL_TYPE_WORDS holds only
610
+ // the spelled-out type words — so `toNum` / `toStr` are untouched.
611
+ function isConversionTargetCompound(name) {
612
+ const segments = splitCamelSegments(name);
613
+ if (segments.length < 2) {
614
+ return false;
615
+ }
616
+ const target = segments[segments.length - 1];
617
+ if (!FULL_TYPE_WORDS.has(target.toLowerCase())) {
618
+ return false;
619
+ }
620
+ const head = segments.slice(0, -1).join('').toLowerCase();
621
+ return CONVERSION_HEADS.has(head);
622
+ }
623
+ // Does the declaration site PROVE, syntactically, that the named value is a
624
+ // function? Only a function declaration's own name, a function/arrow
625
+ // initializer, or a class METHOD are conclusive without type information — an
626
+ // aliased function (`const toNumber = parseFloat`) and a bare `(v: string) =>
627
+ // number` annotation are deliberately not read, keeping the carve-out on the
628
+ // shapes where the function body is written at the declaration itself.
629
+ //
630
+ // This is the mirror image of isSymbolTypedDeclaration (#1835) and
631
+ // isClassValuedDeclaration (#2030): there the syntactic proof WITHDRAWS a
632
+ // carve-out because it confirms the suffix encodes the value's type; here it
633
+ // GRANTS one, because a function value is precisely what the type word cannot
634
+ // be describing. Accessors are excluded (`get toNumber()` is read as a value at
635
+ // every use site, so its `Number` does tag that value), as are computed keys,
636
+ // whose identifier is a reference to some other binding rather than a
637
+ // declaration.
638
+ function isFunctionValuedDeclaration(node) {
639
+ const parent = node.parent;
640
+ if (!parent) {
641
+ return false;
642
+ }
643
+ const isFunctionValue = (value) => !!value &&
644
+ (value.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
645
+ value.type === utils_1.AST_NODE_TYPES.FunctionExpression);
646
+ switch (parent.type) {
647
+ case utils_1.AST_NODE_TYPES.FunctionDeclaration:
648
+ return parent.id === node;
649
+ case utils_1.AST_NODE_TYPES.VariableDeclarator:
650
+ return parent.id === node && isFunctionValue(parent.init);
651
+ case utils_1.AST_NODE_TYPES.MethodDefinition:
652
+ return (parent.key === node && !parent.computed && parent.kind === 'method');
653
+ case utils_1.AST_NODE_TYPES.PropertyDefinition:
654
+ return (parent.key === node && !parent.computed && isFunctionValue(parent.value));
655
+ default:
656
+ return false;
657
+ }
658
+ }
581
659
  // Rebuild a SCREAMING_SNAKE_CASE identifier's segments into a PascalCase compound
582
660
  // (["MATCH","NUMBER"] -> "MatchNumber") so the snake-case branch can reuse the
583
661
  // camelCase isDomainNumberCompound / DOMAIN_NUMBER_HEAD_NOUNS exemption verbatim,
@@ -616,7 +694,9 @@ exports.noHungarian = (0, createRule_1.createRule)({
616
694
  // `symbol` value, which vetoes the <domain>Symbol glyph exemption.
617
695
  // `isClassValued` is true when the declaration syntactically proves a JS
618
696
  // class value, which vetoes the <taxonomy>Class exemption.
619
- function hasTypeMarker(variableName, isTypeName = false, isSymbolTyped = false, isClassValued = false) {
697
+ // `isFunctionValued` is true when the declaration syntactically proves a
698
+ // function value, which GRANTS the converter-function exemption.
699
+ function hasTypeMarker(variableName, isTypeName = false, isSymbolTyped = false, isClassValued = false, isFunctionValued = false) {
620
700
  // Type names whose type-word denotes a concept/relation (StringToNumber,
621
701
  // CapitalizedString, FuncKeys, PromiseOrValue) are not Hungarian — the word
622
702
  // is part of the type's meaning, like the allowed compound noun PhoneNumber.
@@ -702,6 +782,17 @@ exports.noHungarian = (0, createRule_1.createRule)({
702
782
  if (index !== 0 && index !== lastIndex) {
703
783
  return false;
704
784
  }
785
+ // A trailing "..._TYPE" word directly after a conversion head on a
786
+ // declaration that proves a function (TO_NUMBER, PARSE_BOOLEAN as
787
+ // arrow consts) names the conversion TARGET, not the constant's own
788
+ // type — the same carve-out as camelCase toNumber (#2302), routed
789
+ // through the shared PascalCase helper so the two casings cannot
790
+ // diverge (the #1294 asymmetry).
791
+ if (isFunctionValued &&
792
+ index === lastIndex &&
793
+ isConversionTargetCompound(screamingSnakePartsToPascalCase(parts))) {
794
+ return false;
795
+ }
705
796
  // A trailing "..._NUMBER" whose preceding head noun is a domain
706
797
  // entity (MATCH_NUMBER, ISSUE_NUMBER, CURRENT_LINE_NUMBER) is a
707
798
  // domain compound, not a Hungarian type tag — route through the same
@@ -782,6 +873,18 @@ exports.noHungarian = (0, createRule_1.createRule)({
782
873
  normalizedVarName.length > normalizedMarker.length &&
783
874
  (/[A-Z0-9]/.test(variableName[variableName.length - normalizedMarker.length - 1]) ||
784
875
  /[A-Z]/.test(variableName[variableName.length - normalizedMarker.length]))) {
876
+ // A trailing full type word directly after a conversion head, on a
877
+ // declaration that syntactically proves a function (toNumber,
878
+ // parseBoolean, fromString, asArray, convertToNumber), names what the
879
+ // conversion PRODUCES — the identifier itself holds a function, so
880
+ // there is no value type being tagged, and stripping the word leaves
881
+ // `to` / `parse` / `from`, which name nothing (#2302). Scoped to
882
+ // full-word markers in SUFFIX position on a proven function, so
883
+ // `const toNumber = 5`, `toNum` / `toStr` / `strToNumber`, and
884
+ // `numberToValue` all keep firing.
885
+ if (isFunctionValued && isConversionTargetCompound(variableName)) {
886
+ return false;
887
+ }
785
888
  // A trailing "...Number" whose head noun is a domain entity
786
889
  // (issueNumber, lineNumber, roundNumber, versionNumber) is a domain
787
890
  // compound, not a Hungarian type tag: the suffix names WHAT the value
@@ -899,7 +1002,7 @@ exports.noHungarian = (0, createRule_1.createRule)({
899
1002
  if (isExternalOrBuiltIn(node))
900
1003
  return;
901
1004
  // Check for type markers
902
- if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node), isClassValuedDeclaration(node))) {
1005
+ if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node), isClassValuedDeclaration(node), isFunctionValuedDeclaration(node))) {
903
1006
  context.report({
904
1007
  node,
905
1008
  messageId: 'noHungarian',
@@ -441,29 +441,50 @@ const declaredKeysOf = (object) => {
441
441
  }
442
442
  return keys;
443
443
  };
444
+ /** The names carried by more than one of the moved props. */
445
+ const duplicatedNamesOf = (systemPropAttrs) => {
446
+ const seen = new Set();
447
+ const duplicated = new Set();
448
+ for (const attr of systemPropAttrs) {
449
+ if (attr.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier) {
450
+ continue;
451
+ }
452
+ const { name } = attr.name;
453
+ if (seen.has(name)) {
454
+ duplicated.add(name);
455
+ continue;
456
+ }
457
+ seen.add(name);
458
+ }
459
+ return duplicated;
460
+ };
444
461
  /**
445
- * The moved props whose name the `sx` object literal already declares. Splicing
446
- * one in emits `{ display: 'flex', display: 'block' }` — TS1117, and whichever
447
- * value the runtime keeps, one of the two spellings the author wrote is
448
- * discarded. The two disagree and only the author can say which wins, so the
449
- * fix stands down for those props while every other prop on the element still
450
- * merges (#2296).
462
+ * The moved props that cannot be spliced into one object literal without
463
+ * duplicating a key. Doing so emits `{ display: 'flex', display: 'block' }` —
464
+ * TS1117, and whichever value the runtime keeps, one of the two spellings the
465
+ * author wrote is discarded. The two disagree and only the author can say which
466
+ * wins, so the fix stands down for those props while every other prop on the
467
+ * element still merges (#2296).
451
468
  *
452
- * Only the object slot is merged into in place. A new `sx`, an array entry and
453
- * the `{ ...moved, ...expr }` wrap each emit a fresh object literal, whose keys
454
- * cannot duplicate a name written elsewhere.
469
+ * A name is duplicated two ways. The `sx` object literal may already declare
470
+ * it, which only the object slot can do because only that slot is merged into
471
+ * in place. Or two moved props may carry the SAME name (`<Box display="flex"
472
+ * display="block" />`, itself TS17001): the fresh object literal a new `sx`, an
473
+ * array entry or the `{ ...moved, ...expr }` wrap emits carries one entry per
474
+ * moved prop, so it duplicates that name on its own with no `sx` involved
475
+ * (#2300). Both duplicates stand down — the rule cannot know which the author
476
+ * meant — while the report on each stays, so the author is still told to move
477
+ * the prop.
455
478
  */
456
479
  const collidingPropsOf = (systemPropAttrs, sxAttr) => {
480
+ const duplicated = duplicatedNamesOf(systemPropAttrs);
457
481
  const slot = sxSlotOf(sxAttr);
458
- if (slot.kind !== 'object') {
459
- return new Set();
460
- }
461
- const declared = declaredKeysOf(slot.object);
482
+ const declared = slot.kind === 'object' ? declaredKeysOf(slot.object) : new Set();
462
483
  if (declared === null) {
463
484
  return new Set(systemPropAttrs);
464
485
  }
465
486
  return new Set(systemPropAttrs.filter((attr) => attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
466
- declared.has(attr.name.name)));
487
+ (declared.has(attr.name.name) || duplicated.has(attr.name.name))));
467
488
  };
468
489
  /**
469
490
  * Plans every edit the autofix makes for one JSX element.
@@ -1058,10 +1079,10 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
1058
1079
  if (systemPropAttrs.length === 0)
1059
1080
  return;
1060
1081
  const sourceCode = context.getSourceCode();
1061
- // A prop whose name the `sx` literal already declares is reported
1062
- // without a fix: merging it would duplicate the key. The rest of the
1063
- // element is still merged, so one disagreeing pair does not hold the
1064
- // other props back.
1082
+ // A prop whose name the `sx` literal already declares, or that another
1083
+ // moved prop on this element repeats, is reported without a fix:
1084
+ // merging it would duplicate the key. The rest of the element is still
1085
+ // merged, so one disagreeing pair does not hold the other props back.
1065
1086
  const collidingProps = collidingPropsOf(systemPropAttrs, sxAttr);
1066
1087
  const fixableAttrs = systemPropAttrs.filter((attr) => !collidingProps.has(attr));
1067
1088
  // Report each system prop. Only the first fixable one carries the fixer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.1",
3
+ "version": "1.21.2",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "1.21.2",
4
+ "date": "2026-09-03T01:55:23.074Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-use-flex-gap-on-wrap",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2299,
11
+ 2301
12
+ ],
13
+ "summary": "merge sx members last-write-wins (closes #2299, closes #2301)"
14
+ },
15
+ {
16
+ "name": "no-hungarian",
17
+ "changeType": "fix",
18
+ "issues": [
19
+ 2302
20
+ ],
21
+ "summary": "exempt converter functions whose final segment names the conversion target (closes #2302)"
22
+ },
23
+ {
24
+ "name": "prefer-sx-prop-over-system-props",
25
+ "changeType": "fix",
26
+ "issues": [
27
+ 2300
28
+ ],
29
+ "summary": "decline a name written twice among the moved props (closes #2300)"
30
+ }
31
+ ]
32
+ },
2
33
  {
3
34
  "version": "1.21.1",
4
35
  "date": "2026-09-02T22:30:34.104Z",