@blumintinc/eslint-plugin-blumint 1.20.91 → 1.20.93

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.91',
226
+ version: '1.20.93',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1 +1,2 @@
1
- export declare const enforceDateTTime: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"enforceDateTTime", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const enforceDateTTime: TSESLint.RuleModule<"enforceDateTTime", [], TSESLint.RuleListener>;
@@ -27,6 +27,7 @@ exports.enforceDateTTime = void 0;
27
27
  const utils_1 = require("@typescript-eslint/utils");
28
28
  const ts = __importStar(require("typescript"));
29
29
  const createRule_1 = require("../utils/createRule");
30
+ const importRemoval_1 = require("../utils/importRemoval");
30
31
  exports.enforceDateTTime = (0, createRule_1.createRule)({
31
32
  name: 'enforce-date-ttime',
32
33
  meta: {
@@ -43,7 +44,8 @@ exports.enforceDateTTime = (0, createRule_1.createRule)({
43
44
  },
44
45
  defaultOptions: [],
45
46
  create(context) {
46
- const sourceCode = context.sourceCode ?? context.getSourceCode();
47
+ const sourceCode = context
48
+ .sourceCode ?? context.getSourceCode();
47
49
  const parserServices = sourceCode?.parserServices ?? context.parserServices;
48
50
  const checker = parserServices?.program?.getTypeChecker();
49
51
  if (!parserServices || !checker) {
@@ -139,14 +141,46 @@ exports.enforceDateTTime = (0, createRule_1.createRule)({
139
141
  });
140
142
  }
141
143
  else if (!isExactDate(tTimeArg)) {
142
- // TTime is provided but is not exactly Date
144
+ // TTime is provided but is not exactly Date. Overwriting the argument
145
+ // deletes every name it mentions, so the rewrite and the unbinding of
146
+ // whatever it was the last reference to are one fix: applying either
147
+ // half alone leaves the file worse than applying neither, and since
148
+ // this rule's own report is resolved by the fix, nothing re-reports
149
+ // the debt an orphaned declaration becomes.
150
+ //
151
+ // Orphanhood is judged against this one argument's own range and the
152
+ // file as it stands, never against what the rest of the `--fix` run
153
+ // might also overwrite. A sibling argument naming the same alias may
154
+ // be `eslint-disable`d — which a rule cannot see, since suppression is
155
+ // applied to reports after they are emitted — so an edit assuming its
156
+ // sibling will also go unbinds an import the survivor still
157
+ // references, trading an unused import for a dangling type. Judging
158
+ // one edit at a time is suppression-safe by construction.
159
+ const importRanges = (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [
160
+ tTimeArg.range,
161
+ ]);
143
162
  context.report({
144
163
  node: tTimeArg,
145
164
  messageId: 'enforceDateTTime',
146
165
  data: { typeName },
147
- fix(fixer) {
148
- return fixer.replaceText(tTimeArg, 'Date');
149
- },
166
+ // No plan means the argument holds the last reference to something
167
+ // the helper cannot rewrite — a locally declared alias, or the
168
+ // enclosing declaration's own type parameter — so the argument
169
+ // stays as written: the report without a fixer is the lesser
170
+ // damage. Deleting a declaration is a materially riskier edit than
171
+ // dropping an import specifier, and a type parameter left unread by
172
+ // its own body fails `no-unused-vars` and `noUnusedParameters`
173
+ // exactly as an orphaned alias does, on top of turning a
174
+ // pass-through generic into one whose argument no longer means
175
+ // anything.
176
+ ...(importRanges
177
+ ? {
178
+ fix: (fixer) => [
179
+ fixer.replaceText(tTimeArg, 'Date'),
180
+ ...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
181
+ ],
182
+ }
183
+ : {}),
150
184
  });
151
185
  }
152
186
  },
@@ -29,6 +29,7 @@ const visitor_keys_1 = require("@typescript-eslint/visitor-keys");
29
29
  const ts = __importStar(require("typescript"));
30
30
  const ASTHelpers_1 = require("../utils/ASTHelpers");
31
31
  const createRule_1 = require("../utils/createRule");
32
+ const importRemoval_1 = require("../utils/importRemoval");
32
33
  /**
33
34
  * Type string formatting flags chosen to keep comparisons stable and predictable.
34
35
  * - NoTruncation avoids `...` elisions that can hide important differences.
@@ -143,10 +144,15 @@ function findTypeAnnotationStart(typeAnnotation, sourceCode) {
143
144
  }
144
145
  return removalStart;
145
146
  }
146
- function removeTypeAnnotation(fixer, typeAnnotation, sourceCode) {
147
- const end = typeAnnotation.range[1];
148
- const removalStart = findTypeAnnotationStart(typeAnnotation, sourceCode);
149
- return fixer.removeRange([removalStart, end]);
147
+ /**
148
+ * The slice a fix deletes to drop `typeAnnotation`: the `:` and the whitespace
149
+ * separating it from the declared name go with the type.
150
+ */
151
+ function annotationRemovalRange(typeAnnotation, sourceCode) {
152
+ return [
153
+ findTypeAnnotationStart(typeAnnotation, sourceCode),
154
+ typeAnnotation.range[1],
155
+ ];
150
156
  }
151
157
  function typeText(type, checker) {
152
158
  return checker.typeToString(type, undefined, typeFormatFlags());
@@ -338,15 +344,49 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
338
344
  return {};
339
345
  }
340
346
  const checker = parserServices.program.getTypeChecker();
347
+ /**
348
+ * Reports the redundant annotation, taking with it any import it was the
349
+ * only consumer of. The two are one fix: applying either half alone leaves
350
+ * the file worse than applying neither — a stripped annotation with its
351
+ * import left behind fails `no-unused-vars`, and since this rule's own
352
+ * report is resolved by the fix, nothing re-reports the debt.
353
+ *
354
+ * Orphanhood is judged against this one annotation's own removal and the
355
+ * file as it stands, never against what the rest of the `--fix` run might
356
+ * also delete. A sibling annotation naming the same type may be
357
+ * `eslint-disable`d — which a rule cannot see, since suppression is applied
358
+ * to reports after they are emitted — so an edit that assumes its sibling
359
+ * will also go deletes an import the surviving annotation still references,
360
+ * trading an unused import for a dangling type. Judging one edit at a time
361
+ * is suppression-safe by construction: a suppressed report's fix never
362
+ * applies, so it can never have been depended on.
363
+ *
364
+ * A type the annotation names but the helper cannot rewrite — a local alias,
365
+ * an interface, a type parameter — declines the fix outright. Deleting a
366
+ * declaration is a materially riskier edit than dropping an import
367
+ * specifier, and the author is better placed to decide whether the type
368
+ * should go or be used elsewhere.
369
+ */
341
370
  function reportIfRedundant(annotation, assertion, reportNode, fixerTarget) {
342
371
  const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices);
343
372
  if (!matchingType)
344
373
  return;
374
+ const removal = annotationRemovalRange(fixerTarget, sourceCode);
375
+ const importRanges = (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [removal]);
345
376
  context.report({
346
377
  node: reportNode,
347
378
  messageId: 'redundantAnnotationAndAssertion',
348
379
  data: { type: matchingType },
349
- fix: (fixer) => removeTypeAnnotation(fixer, fixerTarget, sourceCode),
380
+ // No plan means no binding can be unbound safely, so the annotation
381
+ // stays too: the report without a fixer is the lesser damage.
382
+ ...(importRanges
383
+ ? {
384
+ fix: (fixer) => [
385
+ fixer.removeRange([removal[0], removal[1]]),
386
+ ...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
387
+ ],
388
+ }
389
+ : {}),
350
390
  });
351
391
  }
352
392
  return {
@@ -203,6 +203,15 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
203
203
  * Resolve a case-test / equality-test node to a literal key. Handles inline
204
204
  * literals directly and constant references (e.g. `THIS_DEVICE_STATUS.active`)
205
205
  * via the checker.
206
+ *
207
+ * A test the checker had to resolve carries its source text along with the
208
+ * value: the value proves the key is a literal, but emitting the value as
209
+ * the key destroys the expression it came from. For a constant reference
210
+ * that orphans the constant (an imported one then trips `no-unused-vars`)
211
+ * and bakes its value into the call site, discarding the single source of
212
+ * truth the constant exists to provide; for `case -1:` the resolved value
213
+ * does not even print as a legal object key (`{ -1: v }` does not parse).
214
+ * The fixer emits a computed key from the source text instead.
206
215
  */
207
216
  function resolveLiteralKey(node) {
208
217
  if (node.type === utils_1.AST_NODE_TYPES.Literal) {
@@ -218,7 +227,11 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
218
227
  if (!type) {
219
228
  return null;
220
229
  }
221
- return literalValueOf(type);
230
+ const literal = literalValueOf(type);
231
+ if (!literal) {
232
+ return null;
233
+ }
234
+ return { ...literal, sourceText: sourceCode.getText(node) };
222
235
  }
223
236
  function computeValueTypeText(exprs) {
224
237
  const seen = new Set();
@@ -589,6 +602,13 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
589
602
  }
590
603
  // ---- Fix construction ---------------------------------------------------
591
604
  function formatKey(key) {
605
+ if (key.sourceText !== undefined) {
606
+ // Computed key preserving the constant reference the case tested on.
607
+ // Type-safe by construction: the fix is reached only after the checker
608
+ // resolved this expression to a string/number *literal* type, so the
609
+ // computed key is a literal key and the Record stays exhaustive.
610
+ return `[${key.sourceText}]`;
611
+ }
592
612
  if (key.kind === 'number') {
593
613
  return String(key.value);
594
614
  }
@@ -6,6 +6,7 @@ const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const importInsertion_1 = require("../utils/importInsertion");
8
8
  const importRemoval_1 = require("../utils/importRemoval");
9
+ const disableDirectives_1 = require("../utils/disableDirectives");
9
10
  const DEEP_COMPARE_MODULE = '@blumintinc/use-deep-compare';
10
11
  const DEEP_COMPARE_HOOK = 'useDeepCompareMemo';
11
12
  // Consider these as memoizing hooks producing stable references
@@ -200,6 +201,50 @@ function bindsHookImport(variable, hookImport) {
200
201
  variable.defs.length > 0 &&
201
202
  variable.defs.every((def) => def.node === hookImport));
202
203
  }
204
+ /**
205
+ * Whether the name the rewrite spells resolves, at this call site, to nothing or
206
+ * to the very import this fix would insert. Any other binding makes the edit
207
+ * wrong twice over: the inserted import declares the name a second time
208
+ * (TS2440/TS2300), and a shadowing parameter or local silently routes the call
209
+ * to the wrong value with no diagnostic at all. Such a call site keeps its
210
+ * report and stays out of the batch, so the author migrates it deliberately —
211
+ * and, still spelling `useMemo`, it holds the specifier the batch would
212
+ * otherwise unbind.
213
+ */
214
+ function emitsResolvableHook(call, hookImport) {
215
+ const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(call.scope, DEEP_COMPARE_HOOK);
216
+ return !existing || bindsHookImport(existing, hookImport);
217
+ }
218
+ /**
219
+ * The single fix that converts every call in `calls`, imports the hook once, and
220
+ * unbinds whatever the conversions stop reading.
221
+ *
222
+ * Batching is what makes the unbinding reachable at all. Judged one call at a
223
+ * time, a file with two convertible calls never sees either as the specifier's
224
+ * sole remaining reference, and once both are rewritten the rule no longer
225
+ * reports — so nothing revisits the stranded import. The batch is sound only
226
+ * because it contains exactly the calls this one fix rewrites: siblings the
227
+ * caller has already dropped for being suppressed or unfixable are absent, so no
228
+ * unbinding is ever claimed on the strength of an edit that does not happen.
229
+ */
230
+ function convertCallsFixes(context, fixer, calls) {
231
+ // The callees are the text this fix deletes, so they are also the text
232
+ // whatever carried the hook stops being read from: `useMemo` for a bare call,
233
+ // `React` for a member call. A binding left with no reference at all is
234
+ // unbound here, in this same fix — stripping its last use and keeping the
235
+ // declaration trades this rule's report for an unused-import one, and nothing
236
+ // re-reports that debt once the rewrite has resolved the original violation.
237
+ const importRemoval = (0, importRemoval_1.planOrphanedImportRemoval)(context.sourceCode, calls.map((call) => call.node.callee.range));
238
+ // No plan means a binding is orphaned yet cannot be unbound safely, so the
239
+ // rewrite stays too: the report without a fixer is the lesser damage.
240
+ if (!importRemoval)
241
+ return null;
242
+ return [
243
+ ...calls.map((call) => fixer.replaceText(call.node.callee, DEEP_COMPARE_HOOK)),
244
+ ...ensureDeepCompareImportFixes(context, fixer),
245
+ ...importRemoval.map((range) => fixer.removeRange([range[0], range[1]])),
246
+ ];
247
+ }
203
248
  function ensureDeepCompareImportFixes(context, fixer) {
204
249
  const sourceCode = context.sourceCode;
205
250
  const program = sourceCode.ast;
@@ -312,6 +357,18 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
312
357
  defaultOptions: [],
313
358
  create(context) {
314
359
  const memoizedIds = collectMemoizedIdentifiers(context);
360
+ // Reporting is deferred to Program:exit because the import rewrite depends
361
+ // on knowing every conversion in the file: the `useMemo` specifier may only
362
+ // be unbound once no reference to it survives the fix, and a file where two
363
+ // call sites convert in the same pass has no later pass to notice that.
364
+ const calls = [];
365
+ /**
366
+ * A suppressed report is discarded together with its fix, yet its
367
+ * `useMemo(...)` call stays in the file. Counting such a call as converted
368
+ * would unbind an import the surviving text still spells — trading an unused
369
+ * import for a dangling reference, a lint warning for a compile error.
370
+ */
371
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
315
372
  return {
316
373
  CallExpression(node) {
317
374
  if (!isUseMemoCallee(node.callee))
@@ -370,66 +427,35 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
370
427
  }
371
428
  if (!hasUnmemoizedNonPrimitive)
372
429
  return;
373
- // Captured during traversal because the fix runs afterwards, when an
374
- // ESLint version lacking sourceCode.getScope can only report the
375
- // global scope and would miss a narrower shadow.
376
- const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
377
- context.report({
378
- node,
379
- messageId: 'preferUseDeepCompareMemo',
380
- data: {
381
- hook: 'useMemo',
382
- },
383
- fix(fixer) {
384
- // Resolve the emitted name through the scope chain at the call
385
- // site. A binding that is not this fix's own import makes the edit
386
- // wrong twice over: the inserted import declares the name a second
387
- // time (TS2440/TS2300), and a shadowing parameter or local silently
388
- // routes the call to the wrong value with no diagnostic at all.
389
- // Declining leaves the report so the author migrates deliberately —
390
- // including the useMemo specifier removal below, which would
391
- // otherwise strip an import the untouched call site still needs.
392
- const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, DEEP_COMPARE_HOOK);
393
- if (existing &&
394
- !bindsHookImport(existing, findDeepCompareMemoImport(context.sourceCode.ast))) {
395
- return null;
396
- }
397
- // The callee is the text this fix deletes, so it is also the text
398
- // whatever carried the hook stops being read from: `useMemo` for a
399
- // bare call, `React` for a member call. A binding left with no
400
- // reference at all is unbound here, in this same fix — stripping
401
- // its last use and keeping the declaration trades this rule's
402
- // report for an unused-import one, and nothing re-reports that debt
403
- // once the rewrite has resolved the original violation.
404
- //
405
- // Orphanhood is judged against this one callee's removal and the
406
- // file as it stands. A second `useMemo` call site keeps the
407
- // specifier, even one this rule also reports: that sibling may be
408
- // `eslint-disable`d (suppression is applied after a rule emits its
409
- // reports, so its fix never runs) or lose its fix to a conflict, and
410
- // either way the surviving call would spell a name nothing binds. A
411
- // later pass, reading a source where the sibling is already
412
- // converted, finds the specifier orphaned and removes it then.
413
- const importRemoval = (0, importRemoval_1.planOrphanedImportRemoval)(context.sourceCode, [node.callee.range]);
414
- // No plan means a binding is orphaned yet cannot be unbound safely,
415
- // so the rewrite stays too: the report without a fixer is the lesser
416
- // damage.
417
- if (!importRemoval)
418
- return null;
419
- const fixes = [];
420
- // Replace callee
421
- if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
422
- fixes.push(fixer.replaceText(node.callee, 'useDeepCompareMemo'));
423
- }
424
- else if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
425
- fixes.push(fixer.replaceText(node.callee, 'useDeepCompareMemo'));
426
- }
427
- // Ensure import exists
428
- fixes.push(...ensureDeepCompareImportFixes(context, fixer));
429
- fixes.push(...importRemoval.map((range) => fixer.removeRange([range[0], range[1]])));
430
- return fixes;
431
- },
432
- });
430
+ calls.push({ node, scope: ASTHelpers_1.ASTHelpers.getScope(context, node) });
431
+ },
432
+ 'Program:exit'() {
433
+ if (calls.length === 0)
434
+ return;
435
+ const hookImport = findDeepCompareMemoImport(context.sourceCode.ast);
436
+ // Exactly the calls the carrier's fix rewrites: a suppressed report
437
+ // loses its fix, and one whose scope binds the hook name to something
438
+ // else must not be rewritten at all.
439
+ const converted = calls.filter((call) => !isReportSuppressed(call.node) &&
440
+ emitsResolvableHook(call, hookImport));
441
+ // The carrier is the first violation whose fix actually survives, so a
442
+ // suppressed or unfixable leading violation cannot take the batch down
443
+ // with it. Every other report emits without a fixer: the carrier's one
444
+ // pass already resolves them, and a second fixer would either duplicate
445
+ // its edits or contradict them.
446
+ const [carrier] = converted;
447
+ for (const call of calls) {
448
+ context.report({
449
+ node: call.node,
450
+ messageId: 'preferUseDeepCompareMemo',
451
+ data: {
452
+ hook: 'useMemo',
453
+ },
454
+ fix: call === carrier
455
+ ? (fixer) => convertCallsFixes(context, fixer, converted)
456
+ : null,
457
+ });
458
+ }
433
459
  },
434
460
  };
435
461
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.91",
3
+ "version": "1.20.93",
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.93",
4
+ "date": "2026-08-03T22:34:05.616Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-date-ttime",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1667
11
+ ],
12
+ "summary": "keep the alias it rewrites away (closes #1667)"
13
+ },
14
+ {
15
+ "name": "no-redundant-annotation-assertion",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1666
19
+ ],
20
+ "summary": "keep the type its annotation named (closes #1666)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.92",
26
+ "date": "2026-08-03T19:30:56.799Z",
27
+ "rules": [
28
+ {
29
+ "name": "prefer-map-over-conditional-dispatch",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1663
33
+ ],
34
+ "summary": "emit computed keys for resolved case tests (closes #1663)"
35
+ },
36
+ {
37
+ "name": "prefer-use-deep-compare-memo",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1662
41
+ ],
42
+ "summary": "convert every call site in one edit (closes #1662)"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "1.20.91",
4
48
  "date": "2026-08-03T15:15:42.831Z",