@blumintinc/eslint-plugin-blumint 1.20.167 → 1.20.168

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.167',
226
+ version: '1.20.168',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -838,7 +838,86 @@ function isExternalDeclaration(declaration) {
838
838
  /[/\\]node_modules[/\\]/.test(fileName));
839
839
  }
840
840
  /**
841
- * Returns true when every declaration site of `prop` lives in a dependency.
841
+ * Classifies `type` itself by the declaration sites of the alias it was written
842
+ * as (`SystemProps`, `Readonly`) or, absent an alias, of its own symbol — an
843
+ * interface declaration, an object type literal, or the `MappedTypeNode` a
844
+ * mapped type is synthesized from.
845
+ */
846
+ function classifyTypeOrigin(type) {
847
+ const symbol = type.aliasSymbol ??
848
+ type.symbol;
849
+ const declarations = symbol?.declarations;
850
+ if (!declarations || declarations.length === 0)
851
+ return 'unknown';
852
+ return declarations.every(isExternalDeclaration) ? 'external' : 'authored';
853
+ }
854
+ /**
855
+ * The sub-types of `type` that could have contributed `propName`: intersection
856
+ * constituents, union members, and the arguments a generic alias was
857
+ * instantiated with. The alias arm is what sees through a dependency's wrapper
858
+ * — `Readonly<X>`/`Omit<X, K>` re-synthesize members and present themselves as
859
+ * `lib.es5.d.ts`, while the surface they wrap may belong to either side.
860
+ */
861
+ function propertyCarriersOf(type, propName, checker, ts) {
862
+ const candidates = [];
863
+ if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) {
864
+ candidates.push(...type.types);
865
+ }
866
+ const aliasTypeArguments = type
867
+ .aliasTypeArguments;
868
+ if (aliasTypeArguments)
869
+ candidates.push(...aliasTypeArguments);
870
+ return candidates.filter((candidate) => {
871
+ if (candidate === type)
872
+ return false;
873
+ try {
874
+ return Boolean(checker.getPropertyOfType?.(candidate, propName));
875
+ }
876
+ catch {
877
+ return false;
878
+ }
879
+ });
880
+ }
881
+ // Guards the descent against a self-referential alias; real props types nest a
882
+ // handful of wrappers at most.
883
+ const MAX_ORIGIN_SEARCH_DEPTH = 8;
884
+ /**
885
+ * Answers who wrote `propName` by finding the constituent of `type` that
886
+ * actually carries it, rather than by asking the outermost wrapper.
887
+ *
888
+ * Ownership is decided by the innermost carrier, so a dependency's wrapper over
889
+ * the author's type stays authored (`Readonly<{ [K in Keys]: … }>`) and the
890
+ * author's wrapper over a dependency's type stays external.
891
+ */
892
+ function classifyPropertyOrigin(propName, type, checker, ts, depth = 0) {
893
+ const carriers = depth < MAX_ORIGIN_SEARCH_DEPTH
894
+ ? propertyCarriersOf(type, propName, checker, ts)
895
+ : [];
896
+ if (carriers.length > 0) {
897
+ const origins = carriers.map((carrier) => classifyPropertyOrigin(propName, carrier, checker, ts, depth + 1));
898
+ // One authored carrier is enough: the author declared the prop somewhere in
899
+ // the composition, so the remedy is theirs to apply.
900
+ if (origins.some((origin) => origin === 'authored'))
901
+ return 'authored';
902
+ return origins.every((origin) => origin === 'external')
903
+ ? 'external'
904
+ : 'unknown';
905
+ }
906
+ const ownOrigin = classifyTypeOrigin(type);
907
+ if (ownOrigin !== 'external')
908
+ return ownOrigin;
909
+ // No carrier holds the prop, yet the type is a dependency's: a mapping
910
+ // construct such as `Record<OwnKeys, T>` synthesizes members from the
911
+ // author's inputs while presenting `lib.es5.d.ts` as its own declaration
912
+ // site. The library supplied the machinery, the author supplied the keys.
913
+ const aliasTypeArguments = type
914
+ .aliasTypeArguments;
915
+ const hasAuthoredInput = aliasTypeArguments?.some((argument) => classifyTypeOrigin(argument) === 'authored');
916
+ return hasAuthoredInput ? 'authored' : 'external';
917
+ }
918
+ /**
919
+ * Returns true when `prop` belongs to a dependency rather than to the component
920
+ * under lint.
842
921
  *
843
922
  * `checker.getPropertiesOfType` returns INHERITED members, so a props type that
844
923
  * extends or intersects a library interface (MUI's `TypographyProps`, React's
@@ -847,21 +926,27 @@ function isExternalDeclaration(declaration) {
847
926
  * the component neither declares nor receives them, and the lists run past a
848
927
  * hundred names — so the only available exit is a blanket rule disable.
849
928
  *
850
- * The gate is `every` rather than "first declaration", so a prop the author
851
- * redeclares alongside the library's (the intersection of two same-named
852
- * members yields one symbol carrying BOTH declarations) still counts as
853
- * authored and is still reported.
929
+ * The declaration gate is `every` rather than "first declaration", so a prop the
930
+ * author redeclares alongside the library's (the intersection of two same-named
931
+ * members yields one symbol carrying BOTH declarations) still counts as authored
932
+ * and is still reported.
854
933
  *
855
- * A prop with no declarations at all is treated as authored. Synthesized
856
- * symbols reach here from mapped and generic types over the component's own
857
- * props, and reporting them preserves the rule's core behaviour; a missing
858
- * declaration is not evidence of a dependency.
934
+ * A prop with NO declaration site cannot be answered that way at all, and a
935
+ * missing declaration is evidence of neither side. TypeScript propagates
936
+ * declarations only through HOMOMORPHIC mapped types (`Readonly<T>`,
937
+ * `Pick<T, K>`); a keyed mapped type (`{ [K in SystemKeys]?: … }` — the shape
938
+ * MUI's `SystemProps<Theme>` gives every `Box`-derived component) synthesizes
939
+ * fresh symbols with none, whoever wrote it. Such a prop is classified by the
940
+ * ORIGIN of the type that carries it, which keeps a library's ~100 style
941
+ * shorthands out of the report while the author's own mapped types stay in it.
859
942
  */
860
- function isExternallyDeclaredProperty(prop) {
943
+ function isDependencyOwnedProperty(prop, containingType, checker, ts) {
861
944
  const declarations = prop.declarations;
862
- if (!declarations || declarations.length === 0)
863
- return false;
864
- return declarations.every(isExternalDeclaration);
945
+ if (declarations && declarations.length > 0) {
946
+ return declarations.every(isExternalDeclaration);
947
+ }
948
+ return (classifyPropertyOrigin(prop.name, containingType, checker, ts) ===
949
+ 'external');
865
950
  }
866
951
  function getComplexPropertiesFromType(type, checker, tsNode, ts, treatAnyAsComplex = false, parentTypeFlags = 0) {
867
952
  const properties = checker.getPropertiesOfType(type);
@@ -869,7 +954,7 @@ function getComplexPropertiesFromType(type, checker, tsNode, ts, treatAnyAsCompl
869
954
  for (const prop of properties) {
870
955
  if (isReservedReactPropName(prop.name))
871
956
  continue;
872
- if (isExternallyDeclaredProperty(prop))
957
+ if (isDependencyOwnedProperty(prop, type, checker, ts))
873
958
  continue;
874
959
  if (isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentTypeFlags)) {
875
960
  complexProps.push(prop.name);
@@ -69,6 +69,122 @@ function isPossiblyNullish(type, checker) {
69
69
  ts.TypeFlags.Unknown)) !==
70
70
  0);
71
71
  }
72
+ /**
73
+ * The union members of a type, following a type parameter to its constraint so a
74
+ * generic operand is read through the same lens as a written-out union.
75
+ */
76
+ function unionMembers(type, checker) {
77
+ if (type.isUnion()) {
78
+ return type.types.flatMap((member) => unionMembers(member, checker));
79
+ }
80
+ if (type.getFlags() & ts.TypeFlags.TypeParameter) {
81
+ if (checker) {
82
+ const constraint = checker.getBaseConstraintOfType(type);
83
+ if (constraint && constraint !== type) {
84
+ return unionMembers(constraint, checker);
85
+ }
86
+ }
87
+ return [type];
88
+ }
89
+ return [type];
90
+ }
91
+ /**
92
+ * A member every value of which is falsy while none of them is nullish: the part
93
+ * of a union that `||` discards and `??` keeps.
94
+ */
95
+ function isNonNullishFalsy(type) {
96
+ const flags = type.getFlags();
97
+ if (flags & ts.TypeFlags.BooleanLiteral) {
98
+ return (type.intrinsicName === 'false');
99
+ }
100
+ if (flags & (ts.TypeFlags.NumberLiteral | ts.TypeFlags.StringLiteral)) {
101
+ const { value } = type;
102
+ // `-0` compares equal to `0`, so both numeric zeroes are covered.
103
+ return value === 0 || value === '';
104
+ }
105
+ if (flags & ts.TypeFlags.BigIntLiteral) {
106
+ const { value } = type;
107
+ return typeof value === 'object' && value.base10Value === '0';
108
+ }
109
+ return false;
110
+ }
111
+ function isNullishMember(type) {
112
+ return ((type.getFlags() &
113
+ (ts.TypeFlags.Null |
114
+ ts.TypeFlags.Undefined |
115
+ ts.TypeFlags.Void |
116
+ ts.TypeFlags.Any |
117
+ ts.TypeFlags.Unknown)) !==
118
+ 0);
119
+ }
120
+ /**
121
+ * The primitive domain a union member belongs to. Members of one domain form the
122
+ * unions the rule exists for (`string | undefined`, `boolean | undefined`,
123
+ * `0 | 1 | undefined`), where a falsy value is a value of the same kind as the
124
+ * fallback and preserving it is the point.
125
+ */
126
+ function domainOf(type) {
127
+ const flags = type.getFlags();
128
+ if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) {
129
+ return 'boolean';
130
+ }
131
+ if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) {
132
+ return 'number';
133
+ }
134
+ if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) {
135
+ return 'string';
136
+ }
137
+ if (flags & (ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral)) {
138
+ return 'bigint';
139
+ }
140
+ return 'other';
141
+ }
142
+ /**
143
+ * Whether the `||` is load-bearing because its left operand carries a falsy
144
+ * sentinel from outside the payload's domain.
145
+ *
146
+ * `cond && payload` evaluates to the falsy `cond` itself when it short-circuits,
147
+ * so the operand's type is `false | payload` (or `0 | payload`, `'' | payload`).
148
+ * The trailing `||` exists to strip that sentinel; `??` strips only `null` and
149
+ * `undefined`, so the rewrite leaks a `false` into a position typed for the
150
+ * payload alone and the program stops compiling. The same union written out by
151
+ * hand behaves identically, so the test is a property of the type rather than of
152
+ * the `&&` that usually produces it.
153
+ *
154
+ * A union confined to a single domain is left alone: there the falsy member is a
155
+ * value of the payload's own kind, which is exactly the state the rule asks
156
+ * callers to preserve.
157
+ *
158
+ * Positive evidence from the checker is required. Without type information the
159
+ * answer is unknowable, and guessing would silence the rule across every
160
+ * untyped operand.
161
+ */
162
+ function stripsForeignFalsyMember(node, checker, parserServices) {
163
+ if (!checker || !parserServices) {
164
+ return false;
165
+ }
166
+ let members;
167
+ try {
168
+ const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
169
+ const type = checker.getTypeAtLocation(tsNode);
170
+ if (!type) {
171
+ return false;
172
+ }
173
+ members = unionMembers(type, checker);
174
+ }
175
+ catch {
176
+ // esTreeNodeToTSNodeMap may fail for synthetic nodes and getTypeAtLocation
177
+ // may throw for nodes without type information; both mean no evidence.
178
+ return false;
179
+ }
180
+ const sentinelDomains = new Set(members.filter(isNonNullishFalsy).map(domainOf));
181
+ if (sentinelDomains.size === 0) {
182
+ return false;
183
+ }
184
+ return members.some((member) => !isNullishMember(member) &&
185
+ !isNonNullishFalsy(member) &&
186
+ !sentinelDomains.has(domainOf(member)));
187
+ }
72
188
  function isInJSXBooleanAttribute(node) {
73
189
  const parent = node.parent;
74
190
  if (parent?.type !== utils_1.AST_NODE_TYPES.JSXAttribute)
@@ -634,6 +750,11 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
634
750
  // Check if this could benefit from nullish coalescing
635
751
  // We only suggest nullish coalescing when the left operand could be nullish
636
752
  if (couldBeNullish(node.left, checker, parserServices)) {
753
+ // A `||` that strips a falsy sentinel from outside its payload's
754
+ // domain cannot become `??` without changing the expression's type.
755
+ if (stripsForeignFalsyMember(node.left, checker, parserServices)) {
756
+ return;
757
+ }
637
758
  const sourceCode = context.getSourceCode();
638
759
  const leftText = sourceCode.getText(node.left);
639
760
  const rightText = sourceCode.getText(node.right);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.167",
3
+ "version": "1.20.168",
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.168",
4
+ "date": "2026-08-18T08:47:17.522Z",
5
+ "rules": [
6
+ {
7
+ "name": "memo-compare-deeply-complex-props",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2039
11
+ ],
12
+ "summary": "classify zero-declaration props by their carrier type (closes #2039)"
13
+ },
14
+ {
15
+ "name": "prefer-nullish-coalescing-boolean-props",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2040
19
+ ],
20
+ "summary": "keep || where it strips a short-circuit sentinel (closes #2040)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.20.167",
4
26
  "date": "2026-08-18T04:28:02.499Z",