@blumintinc/eslint-plugin-blumint 1.21.2 → 1.21.4

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.
@@ -140,6 +140,32 @@ function attributePrintedName(intended, printed, type) {
140
140
  }
141
141
  recordAttribution(intended, printed, symbol);
142
142
  }
143
+ /**
144
+ * The bare identifier a declaration's own type annotation is written as, or
145
+ * null when the annotation is anything else.
146
+ *
147
+ * Only an un-parameterised reference to a plain identifier qualifies. A
148
+ * qualified name (`Theme.Mode`) needs its namespace to resolve at the fix site,
149
+ * which this answer cannot establish; a parameterised one (`Alias<'x'>`) is not
150
+ * denoted by its head at all, and emitting the head alone would name a
151
+ * different type. Both keep the printed union rather than guess.
152
+ */
153
+ function annotatedTypeReferenceName(declaration) {
154
+ const annotation = ts.isParameter(declaration) ||
155
+ ts.isVariableDeclaration(declaration) ||
156
+ ts.isPropertySignature(declaration) ||
157
+ ts.isPropertyDeclaration(declaration)
158
+ ? declaration.type
159
+ : undefined;
160
+ if (!annotation ||
161
+ !ts.isTypeReferenceNode(annotation) ||
162
+ !ts.isIdentifier(annotation.typeName) ||
163
+ (annotation.typeArguments?.length ?? 0) > 0) {
164
+ return null;
165
+ }
166
+ const name = annotation.typeName.text;
167
+ return BARE_TYPE_NAME.test(name) ? name : null;
168
+ }
143
169
  /**
144
170
  * Function/constructor/conditional type notation must be parenthesized to
145
171
  * appear as a `|` union member, or the emitted annotation does not parse
@@ -833,11 +859,75 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
833
859
  return null;
834
860
  }
835
861
  }
862
+ /**
863
+ * The bare type-alias name written on the discriminant's own annotation,
864
+ * when that name is provably the union the checker printed the members of.
865
+ *
866
+ * `checker.typeToString()` keeps an alias only while the type still carries
867
+ * an `aliasSymbol`, and TypeScript drops that resolving an indexed access
868
+ * over a `typeof` query — `const M = ['a', 'b'] as const; type Mode =
869
+ * (typeof M)[number]`, which is exactly the shape the key-space companion
870
+ * rule named on this rule's docs page emits. The printed text there is the
871
+ * widened literal union, the one spelling this rule's exhaustiveness
872
+ * promise exists to avoid: a stale `Record<'a' | 'b', V>` keeps
873
+ * typechecking after the union grows and only the LOOKUP complains, as
874
+ * TS7053 — an implicit-any diagnostic that disappears entirely under
875
+ * `noImplicitAny: false` and leaves the new member yielding `undefined` at
876
+ * runtime (#2009).
877
+ *
878
+ * The name is read off SYNTAX because the type no longer carries it, so it
879
+ * ships only against evidence that it denotes this very union: a bare,
880
+ * un-parameterised type reference written on the declaration the
881
+ * discriminant resolves to, whose symbol — resolved where the `Record`
882
+ * lands, not where the annotation was written — declares exactly this type.
883
+ * Mere presence of the name is not enough. Naming the WRONG union in the
884
+ * emitted `Record` breaks the build where the widened union merely weakens
885
+ * it, so anything short of type identity declines and keeps the printed
886
+ * text. Flow narrowing declines through the same check: a discriminant
887
+ * narrowed above the dispatch has a type the alias does not declare, and
888
+ * the alias would demand keys the construct has no branches for.
889
+ */
890
+ function annotationAliasTypeText(discriminant, discriminantType) {
891
+ const tsNode = esTreeNodeToTSNodeMap.get(unwrapLink(discriminant));
892
+ const tsFixSite = esTreeNodeToTSNodeMap.get(discriminant);
893
+ if (!tsNode || !tsFixSite) {
894
+ return null;
895
+ }
896
+ try {
897
+ const symbol = checker.getSymbolAtLocation(tsNode);
898
+ const names = new Set((symbol?.declarations ?? [])
899
+ .map(annotatedTypeReferenceName)
900
+ .filter((name) => name !== null));
901
+ // Two declarations annotated with different names (a merged symbol, an
902
+ // overload set) leave no single spelling that is right for both.
903
+ if (names.size !== 1) {
904
+ return null;
905
+ }
906
+ const [name] = names;
907
+ const inScope = checker
908
+ .getSymbolsInScope(tsFixSite, ts.SymbolFlags.Type | ts.SymbolFlags.Alias)
909
+ .find((candidate) => candidate.name === name);
910
+ if (!inScope) {
911
+ return null;
912
+ }
913
+ const declaredSymbol = inScope.flags & ts.SymbolFlags.Alias
914
+ ? checker.getAliasedSymbol(inScope)
915
+ : inScope;
916
+ if (checker.getDeclaredTypeOfSymbol(declaredSymbol) !== discriminantType) {
917
+ return null;
918
+ }
919
+ return { text: name, symbol: declaredSymbol };
920
+ }
921
+ catch {
922
+ return null;
923
+ }
924
+ }
836
925
  /**
837
926
  * Key type for the emitted `Record`. A discriminant whose own type prints
838
927
  * as a bare name already carries the union's identity, so that name is
839
- * kept; otherwise the fix reaches for the tag's declaring type before
840
- * settling for the resolved literal union (#1926).
928
+ * kept; otherwise the fix reaches for the tag's declaring type (#1926),
929
+ * then for the alias its annotation names where the checker resolved past
930
+ * one (#2009), before settling for the resolved literal union.
841
931
  */
842
932
  function discriminantTypeText(type, discriminant) {
843
933
  let printed;
@@ -865,10 +955,27 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
865
955
  // already ships only when the name resolves, at the fix site, to exactly
866
956
  // the object's declared type, and identity of the TYPE accepts a nearer
867
957
  // alias that denotes the same type — which compiles, and reads the same.
868
- const text = indexedAccessTypeText(discriminant, type) ?? printed;
869
- return text === null
958
+ const indexed = indexedAccessTypeText(discriminant, type);
959
+ if (indexed !== null) {
960
+ return { text: normalizeTypeQuotes(indexed, singleQuote), intended };
961
+ }
962
+ // Recovery runs only where the printer already produced usable text, so
963
+ // it can only change the SPELLING of a key type the rule was going to
964
+ // emit anyway — never whether the rule fires. A discriminant the printer
965
+ // could not print at all stays silent exactly as before.
966
+ if (printed !== null) {
967
+ const alias = annotationAliasTypeText(discriminant, type);
968
+ if (alias) {
969
+ // Unlike the printed bare name, this one was never checked against
970
+ // the fix site's scope by the printer, so it carries its symbol into
971
+ // the gate for the same shadow check (#2229).
972
+ recordAttribution(intended, alias.text, alias.symbol);
973
+ return { text: alias.text, intended };
974
+ }
975
+ }
976
+ return printed === null
870
977
  ? null
871
- : { text: normalizeTypeQuotes(text, singleQuote), intended };
978
+ : { text: normalizeTypeQuotes(printed, singleQuote), intended };
872
979
  }
873
980
  /**
874
981
  * Whether the binding a type name reaches at the fix site is the symbol the
@@ -6,6 +6,7 @@ const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
8
  const importInsertion_1 = require("../utils/importInsertion");
9
+ const memoModule_1 = require("../utils/memoModule");
9
10
  const MEMOIZE_PREFERRED_MODULE = '@blumintinc/typescript-memoize';
10
11
  const MEMOIZE_MODULES = new Set([
11
12
  MEMOIZE_PREFERRED_MODULE,
@@ -292,7 +293,7 @@ function callExpressionReturnsJSX(expression, knownFunctions, cache, factoryCont
292
293
  if (isIdentifierReturningJsx(callee, knownFunctions, cache, factoryContext)) {
293
294
  return true;
294
295
  }
295
- if (factoryContext.reactMemoIdentifiers.has(callee.name) &&
296
+ if (factoryContext.memoIdentifiers.has(callee.name) &&
296
297
  firstNonSpreadArgument &&
297
298
  expressionReturnsJSX(firstNonSpreadArgument, knownFunctions, cache, factoryContext)) {
298
299
  return true;
@@ -567,11 +568,11 @@ exports.requireMemoizeJsxReturners = (0, createRule_1.createRule)({
567
568
  */
568
569
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
569
570
  const jsxReturnCache = new WeakMap();
570
- const reactMemoIdentifiers = new Set();
571
+ const memoIdentifiers = new Set();
571
572
  const reactNamespaceIdentifiers = new Set();
572
573
  const reactCreateElementIdentifiers = new Set();
573
574
  const factoryContext = {
574
- reactMemoIdentifiers,
575
+ memoIdentifiers,
575
576
  reactNamespaceIdentifiers,
576
577
  reactCreateElementIdentifiers,
577
578
  };
@@ -583,7 +584,7 @@ exports.requireMemoizeJsxReturners = (0, createRule_1.createRule)({
583
584
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
584
585
  specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
585
586
  specifier.imported.name === 'memo') {
586
- reactMemoIdentifiers.add(specifier.local?.name ?? specifier.imported.name);
587
+ memoIdentifiers.add(specifier.local?.name ?? specifier.imported.name);
587
588
  }
588
589
  else if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
589
590
  specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -598,6 +599,19 @@ exports.requireMemoizeJsxReturners = (0, createRule_1.createRule)({
598
599
  }
599
600
  });
600
601
  }
602
+ else if ((0, memoModule_1.isCustomMemoModuleImport)(sourceValue, filename)) {
603
+ // The wrapper re-exports React's `memo` and nothing else this rule
604
+ // reads, so only the `memo` binding is collected: `createElement` and
605
+ // a namespace object stay bound to a react import, keeping an
606
+ // unrelated `X.createElement` out.
607
+ node.specifiers.forEach((specifier) => {
608
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
609
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
610
+ specifier.imported.name === 'memo') {
611
+ memoIdentifiers.add(specifier.local?.name ?? specifier.imported.name);
612
+ }
613
+ });
614
+ }
601
615
  if (!MEMOIZE_MODULES.has(sourceValue)) {
602
616
  return;
603
617
  }
@@ -3,9 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useCustomMemo = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
- const MEMO_MODULE = `'src/util/memo'`;
7
- /** `MEMO_MODULE` carries the quotes the fixer emits; a path never does. */
8
- const MEMO_MODULE_PATH = MEMO_MODULE.slice(1, -1);
6
+ const memoModule_1 = require("../utils/memoModule");
9
7
  const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx)$/;
10
8
  /**
11
9
  * The module the fixer points every `memo` import at is the one module that must
@@ -20,10 +18,10 @@ const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx)$/;
20
18
  */
21
19
  const isMemoModule = (filename) => {
22
20
  const normalized = filename.replace(/\\/g, '/').replace(SOURCE_EXTENSION, '');
23
- if (!normalized.endsWith(MEMO_MODULE_PATH)) {
21
+ if (!normalized.endsWith(memoModule_1.CUSTOM_MEMO_MODULE_PATH)) {
24
22
  return false;
25
23
  }
26
- const suffixStart = normalized.length - MEMO_MODULE_PATH.length;
24
+ const suffixStart = normalized.length - memoModule_1.CUSTOM_MEMO_MODULE_PATH.length;
27
25
  return suffixStart === 0 || normalized[suffixStart - 1] === '/';
28
26
  };
29
27
  const isComment = (token) => token.type === utils_1.AST_TOKEN_TYPES.Line || token.type === utils_1.AST_TOKEN_TYPES.Block;
@@ -206,7 +204,7 @@ exports.useCustomMemo = (0, createRule_1.createRule)({
206
204
  messageId: 'useCustomMemo',
207
205
  fix(fixer) {
208
206
  const sourceCode = context.getSourceCode();
209
- const memoImport = buildImport(memoSpecifiers, MEMO_MODULE, node.importKind, sourceCode);
207
+ const memoImport = buildImport(memoSpecifiers, memoModule_1.CUSTOM_MEMO_MODULE_SOURCE, node.importKind, sourceCode);
210
208
  const survivingSpecifiers = node.specifiers.filter((specifier) => !isMemoSpecifier(specifier));
211
209
  const comments = sourceCode
212
210
  .getCommentsInside(node)
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The project's memo wrapper: the module `use-custom-memo`'s fixer points every
3
+ * `import { memo } from 'react'` at. It re-exports React's `memo`, so a rule
4
+ * that recognises a `memo(...)` call has to accept this module's binding as
5
+ * React's — keying recognition on the `'react'` spelling alone lets that fixer
6
+ * switch the recogniser off on the very convention this plugin enforces.
7
+ *
8
+ * The quotes live inside the value because the fixer splices the specifier into
9
+ * emitted import text verbatim, beside `sourceCode.getText(node.source)`, which
10
+ * carries its own quotes.
11
+ */
12
+ export declare const CUSTOM_MEMO_MODULE_SOURCE = "'src/util/memo'";
13
+ /** `CUSTOM_MEMO_MODULE_SOURCE` carries the quotes the fixer emits; a path never does. */
14
+ export declare const CUSTOM_MEMO_MODULE_PATH: string;
15
+ /** Whether an import specifier, read alone, names the memo wrapper module. */
16
+ export declare const isCustomMemoModuleSource: (source: string) => boolean;
17
+ /**
18
+ * Whether `source`, imported from `filename`, names the memo wrapper module.
19
+ *
20
+ * A relative specifier is resolved against the importing file before matching,
21
+ * because a file that sits inside the wrapper's own directory spells the same
22
+ * module `../memo` — a specifier naming no `util` segment of its own. Only
23
+ * relative specifiers resolve: a bare specifier is a package or an alias whose
24
+ * target the linter cannot know without a resolver.
25
+ */
26
+ export declare const isCustomMemoModuleImport: (source: string, filename?: string) => boolean;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isCustomMemoModuleImport = exports.isCustomMemoModuleSource = exports.CUSTOM_MEMO_MODULE_PATH = exports.CUSTOM_MEMO_MODULE_SOURCE = void 0;
7
+ const path_1 = __importDefault(require("path"));
8
+ /**
9
+ * The project's memo wrapper: the module `use-custom-memo`'s fixer points every
10
+ * `import { memo } from 'react'` at. It re-exports React's `memo`, so a rule
11
+ * that recognises a `memo(...)` call has to accept this module's binding as
12
+ * React's — keying recognition on the `'react'` spelling alone lets that fixer
13
+ * switch the recogniser off on the very convention this plugin enforces.
14
+ *
15
+ * The quotes live inside the value because the fixer splices the specifier into
16
+ * emitted import text verbatim, beside `sourceCode.getText(node.source)`, which
17
+ * carries its own quotes.
18
+ */
19
+ exports.CUSTOM_MEMO_MODULE_SOURCE = `'src/util/memo'`;
20
+ /** `CUSTOM_MEMO_MODULE_SOURCE` carries the quotes the fixer emits; a path never does. */
21
+ exports.CUSTOM_MEMO_MODULE_PATH = exports.CUSTOM_MEMO_MODULE_SOURCE.slice(1, -1);
22
+ /**
23
+ * The trailing segments that identify the wrapper module however it is spelled.
24
+ * Only the leading segments vary between spellings of the same module — the
25
+ * `src/` alias the fixer emits, a `@/` alias, or any depth of `../` — so the
26
+ * tail is what a specifier has to carry. It has to land on a segment boundary,
27
+ * so `./memo`, `lodash-memo` and `react-memo` name other modules and stay out.
28
+ */
29
+ const MODULE_TAIL = exports.CUSTOM_MEMO_MODULE_PATH.split('/').slice(-2).join('/');
30
+ const MODULE_TAIL_PATTERN = new RegExp(`(?:^|/)${MODULE_TAIL}$`);
31
+ /** Whether an import specifier, read alone, names the memo wrapper module. */
32
+ const isCustomMemoModuleSource = (source) => MODULE_TAIL_PATTERN.test(source);
33
+ exports.isCustomMemoModuleSource = isCustomMemoModuleSource;
34
+ /**
35
+ * Whether `source`, imported from `filename`, names the memo wrapper module.
36
+ *
37
+ * A relative specifier is resolved against the importing file before matching,
38
+ * because a file that sits inside the wrapper's own directory spells the same
39
+ * module `../memo` — a specifier naming no `util` segment of its own. Only
40
+ * relative specifiers resolve: a bare specifier is a package or an alias whose
41
+ * target the linter cannot know without a resolver.
42
+ */
43
+ const isCustomMemoModuleImport = (source, filename) => {
44
+ if ((0, exports.isCustomMemoModuleSource)(source)) {
45
+ return true;
46
+ }
47
+ if (!filename || !source.startsWith('.')) {
48
+ return false;
49
+ }
50
+ const importerDirectory = path_1.default.posix.dirname(filename.replace(/\\/g, '/'));
51
+ return (0, exports.isCustomMemoModuleSource)(path_1.default.posix.normalize(path_1.default.posix.join(importerDirectory, source)));
52
+ };
53
+ exports.isCustomMemoModuleImport = isCustomMemoModuleImport;
54
+ //# sourceMappingURL=memoModule.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.2",
3
+ "version": "1.21.4",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,152 @@
1
1
  [
2
+ {
3
+ "version": "1.21.4",
4
+ "date": "2026-09-04T11:47:38.427Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-map-over-conditional-dispatch",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2009
11
+ ],
12
+ "summary": "keep the alias when the checker resolves past it (closes #2009)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.21.3",
18
+ "date": "2026-09-03T23:34:34.310Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-boolean-naming-prefixes",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2308
25
+ ],
26
+ "summary": "accept `??` as a boolean fallback operator (closes #2308)"
27
+ },
28
+ {
29
+ "name": "enforce-centralized-mock-firestore",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2307
33
+ ],
34
+ "summary": "match the local mock by normalized name so a MOCK_FIRESTORE constant is flagged (closes #2307)"
35
+ },
36
+ {
37
+ "name": "enforce-fieldpath-syntax-in-docsetter",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 2303
41
+ ],
42
+ "summary": "decline the fix when a flattened key collides (closes #2303)"
43
+ },
44
+ {
45
+ "name": "enforce-firestore-set-merge",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 2311
49
+ ],
50
+ "summary": "decline the merge rewrite on update's varargs and precondition forms (closes #2311)"
51
+ },
52
+ {
53
+ "name": "enforce-object-literal-as-const",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 2319
57
+ ],
58
+ "summary": "fall back to the sole return's own assertion for the declared type (closes #2319)"
59
+ },
60
+ {
61
+ "name": "enforce-positive-naming",
62
+ "changeType": "fix",
63
+ "issues": [
64
+ 2306
65
+ ],
66
+ "summary": "make the boolean-like gate spelling-independent (closes #2306)"
67
+ },
68
+ {
69
+ "name": "enforce-snapshot-state-narrowing",
70
+ "changeType": "fix",
71
+ "issues": [
72
+ 2315
73
+ ],
74
+ "summary": "accept ?? alongside || in the logical guard (closes #2315)"
75
+ },
76
+ {
77
+ "name": "memo-nested-react-components",
78
+ "changeType": "fix",
79
+ "issues": [
80
+ 2313
81
+ ],
82
+ "summary": "admit useLatestCallback as a callback hook (closes #2313)"
83
+ },
84
+ {
85
+ "name": "no-entire-object-hook-deps",
86
+ "changeType": "fix",
87
+ "issues": [
88
+ 2309
89
+ ],
90
+ "summary": "govern the deep-compare hooks, which the useMemo rename walked out of view (closes #2309)"
91
+ },
92
+ {
93
+ "name": "no-hungarian",
94
+ "changeType": "fix",
95
+ "issues": [
96
+ 2317
97
+ ],
98
+ "summary": "read a leading single-letter segment as a prefix in SCREAMING_SNAKE_CASE (closes #2317)"
99
+ },
100
+ {
101
+ "name": "no-inline-component-prop",
102
+ "changeType": "fix",
103
+ "issues": [
104
+ 2314
105
+ ],
106
+ "summary": "resolve a memo(namedReference) argument to its declaration (closes #2314)"
107
+ },
108
+ {
109
+ "name": "no-restricted-properties-fix",
110
+ "changeType": "fix",
111
+ "issues": [
112
+ 2318
113
+ ],
114
+ "summary": "match a configured object through global-const-style's rename (closes #2318)"
115
+ },
116
+ {
117
+ "name": "no-useless-usememo-primitives",
118
+ "changeType": "fix",
119
+ "issues": [
120
+ 2312
121
+ ],
122
+ "summary": "recognise the deep-compare memo spelling so a renamed callee still reports (closes #2312)"
123
+ },
124
+ {
125
+ "name": "prefer-destructuring-no-class",
126
+ "changeType": "fix",
127
+ "issues": [
128
+ 2316
129
+ ],
130
+ "summary": "match a binding to its property ignoring case and underscores (closes #2316)"
131
+ },
132
+ {
133
+ "name": "prefer-document-flattening",
134
+ "changeType": "fix",
135
+ "issues": [
136
+ 2304
137
+ ],
138
+ "summary": "rewrite an existing shouldFlatten in place rather than appending a duplicate key (closes #2304)"
139
+ },
140
+ {
141
+ "name": "require-memoize-jsx-returners",
142
+ "changeType": "fix",
143
+ "issues": [
144
+ 2305
145
+ ],
146
+ "summary": "re-cut the degenerate-identifier duplicate ceiling for the wrapper-memo fixtures (closes #2305); recognise memo imported from the project memo wrapper (closes #2305)"
147
+ }
148
+ ]
149
+ },
2
150
  {
3
151
  "version": "1.21.2",
4
152
  "date": "2026-09-03T01:55:23.074Z",