@blumintinc/eslint-plugin-blumint 1.20.144 → 1.20.145

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.144',
226
+ version: '1.20.145',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -3,6 +3,81 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noAlwaysTrueFalseConditions = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const utils_1 = require("@typescript-eslint/utils");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const BREAKABLE = new Set([
8
+ utils_1.AST_NODE_TYPES.WhileStatement,
9
+ utils_1.AST_NODE_TYPES.DoWhileStatement,
10
+ utils_1.AST_NODE_TYPES.ForStatement,
11
+ utils_1.AST_NODE_TYPES.ForInStatement,
12
+ utils_1.AST_NODE_TYPES.ForOfStatement,
13
+ utils_1.AST_NODE_TYPES.SwitchStatement,
14
+ ]);
15
+ /**
16
+ * Whether `body` can leave the loop that owns it.
17
+ *
18
+ * An unlabeled `break` binds to the nearest enclosing loop or switch, so a
19
+ * nested breakable is still walked but its own unlabeled breaks do not count; a
20
+ * labeled one counts when it names this loop. `return` and `throw` leave the
21
+ * loop as well, unless they sit inside a nested function, which has its own.
22
+ */
23
+ function canExitLoop(body, label) {
24
+ let escapes = false;
25
+ const visit = (node, insideNestedBreakable) => {
26
+ if (escapes)
27
+ return;
28
+ switch (node.type) {
29
+ case utils_1.AST_NODE_TYPES.FunctionDeclaration:
30
+ case utils_1.AST_NODE_TYPES.FunctionExpression:
31
+ case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
32
+ return;
33
+ case utils_1.AST_NODE_TYPES.BreakStatement:
34
+ if (node.label ? node.label.name === label : !insideNestedBreakable) {
35
+ escapes = true;
36
+ }
37
+ return;
38
+ case utils_1.AST_NODE_TYPES.ReturnStatement:
39
+ case utils_1.AST_NODE_TYPES.ThrowStatement:
40
+ escapes = true;
41
+ return;
42
+ default:
43
+ break;
44
+ }
45
+ const nested = insideNestedBreakable || BREAKABLE.has(node.type);
46
+ for (const [key, value] of Object.entries(node)) {
47
+ if (key === 'parent')
48
+ continue;
49
+ if (Array.isArray(value)) {
50
+ for (const child of value) {
51
+ if (ASTHelpers_1.ASTHelpers.isNode(child))
52
+ visit(child, nested);
53
+ }
54
+ }
55
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
56
+ visit(value, nested);
57
+ }
58
+ }
59
+ };
60
+ visit(body, false);
61
+ return escapes;
62
+ }
63
+ /**
64
+ * `while (true)` and `do … while (true)` around a `break` are how a loop whose
65
+ * exit is only known mid-body is written — cursor pagination being the usual
66
+ * case. The condition is the point, and unlike an `if` it cannot be removed:
67
+ * the only way to satisfy the report is to rewrite the loop as `for (;;)`,
68
+ * which this rule already accepts because it has no test node to check. A
69
+ * literal `true` over a body with no way out is still reported, since that loop
70
+ * really does run forever (#1973).
71
+ */
72
+ function isDeliberateInfiniteLoop(loop) {
73
+ if (loop.test?.type !== utils_1.AST_NODE_TYPES.Literal || loop.test.value !== true) {
74
+ return false;
75
+ }
76
+ const label = loop.parent?.type === utils_1.AST_NODE_TYPES.LabeledStatement
77
+ ? loop.parent.label.name
78
+ : null;
79
+ return canExitLoop(loop.body, label);
80
+ }
6
81
  exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
7
82
  name: 'no-always-true-false-conditions',
8
83
  meta: {
@@ -1498,15 +1573,19 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
1498
1573
  },
1499
1574
  // Check while loops
1500
1575
  WhileStatement(node) {
1576
+ if (isDeliberateInfiniteLoop(node))
1577
+ return;
1501
1578
  checkCondition(node.test);
1502
1579
  },
1503
1580
  // Check do-while loops
1504
1581
  DoWhileStatement(node) {
1582
+ if (isDeliberateInfiniteLoop(node))
1583
+ return;
1505
1584
  checkCondition(node.test);
1506
1585
  },
1507
1586
  // Check for loop conditions
1508
1587
  ForStatement(node) {
1509
- if (node.test) {
1588
+ if (node.test && !isDeliberateInfiniteLoop(node)) {
1510
1589
  checkCondition(node.test);
1511
1590
  }
1512
1591
  },
@@ -8,6 +8,7 @@ const importRemoval_1 = require("../utils/importRemoval");
8
8
  const typeDeclarationRemoval_1 = require("../utils/typeDeclarationRemoval");
9
9
  const replacementSegments_1 = require("../utils/replacementSegments");
10
10
  const lexicalScope_1 = require("../utils/lexicalScope");
11
+ const arrowAnnotationGap_1 = require("../utils/arrowAnnotationGap");
11
12
  const defaultOptions = {
12
13
  allowRecursiveFunctions: true,
13
14
  allowOverloadedFunctions: true,
@@ -780,25 +781,6 @@ function batchAnnotations(source, candidates) {
780
781
  });
781
782
  return [...batches.values()];
782
783
  }
783
- /**
784
- * A comment whose meaning is tied to where it sits. Re-emitting one somewhere
785
- * else retargets it — a disable directive lands on an unrelated line and a
786
- * `@ts-expect-error` becomes an error of its own — so a removal that would move
787
- * one is withheld instead.
788
- */
789
- function isPositionalDirective(comment) {
790
- if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
791
- return true;
792
- }
793
- const value = comment.value.trim();
794
- return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
795
- }
796
- /** The indentation of the line `offset` sits on, for a carried line break. */
797
- function indentAt(source, offset) {
798
- const lineStart = source.text.lastIndexOf('\n', offset - 1) + 1;
799
- const [indent] = /^[ \t]*/.exec(source.text.slice(lineStart, offset)) ?? [''];
800
- return indent;
801
- }
802
784
  /**
803
785
  * Whether the span deletes a whole declaration of the program.
804
786
  *
@@ -827,9 +809,9 @@ function carriedText(source, range) {
827
809
  .filter((comment) => comment.range[0] >= range[0] && comment.range[1] <= range[1]);
828
810
  if (comments.length === 0)
829
811
  return '';
830
- if (comments.some(isPositionalDirective))
812
+ if (comments.some(arrowAnnotationGap_1.isPositionalDirective))
831
813
  return null;
832
- const indent = indentAt(source, range[0]);
814
+ const indent = (0, arrowAnnotationGap_1.indentAt)(source, range[0]);
833
815
  const segments = comments.map((comment) => ({
834
816
  text: source.text.slice(comment.range[0], comment.range[1]),
835
817
  breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
@@ -846,70 +828,20 @@ function carriedText(source, range) {
846
828
  : ' ';
847
829
  return `${lead}${body}${trail}`;
848
830
  }
849
- /** Every character the syntactic grammar counts as a LineTerminator. */
850
- const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
851
- const textOf = (source, range) => source.text.slice(range[0], range[1]);
852
- /**
853
- * The span an arrow's return annotation occupies between the parameter list and
854
- * the `=>`, together with that arrow token.
855
- *
856
- * The span holds the annotation, whitespace and comments and nothing else,
857
- * which is what makes it safe to rewrite wholesale: no binding reference can
858
- * hide in it beyond the ones the annotation itself names.
859
- */
860
- function arrowAnnotationGap(source, returnType) {
861
- const parametersEnd = source.getTokenBefore(returnType);
862
- const arrow = source.getTokenAfter(returnType, {
863
- filter: (token) => token.value === '=>',
864
- });
865
- if (!parametersEnd || !arrow)
866
- return null;
867
- const gap = [parametersEnd.range[1], arrow.range[0]];
868
- return containsRange(gap, returnType.range) ? { gap, arrow } : null;
869
- }
870
- /**
871
- * Re-emits `comments` on the far side of the arrow, where a line terminator is
872
- * inert, consuming the horizontal whitespace the arrow already had after it so
873
- * the body keeps a single separator.
874
- */
875
- function hoistPastArrow(source, arrow, comments) {
876
- const indent = indentAt(source, arrow.range[0]);
877
- const trailingText = source.text.slice(arrow.range[1]);
878
- const [spacing] = /^[ \t]*/.exec(trailingText) ?? [''];
879
- const body = (0, replacementSegments_1.joinSegmentBody)(comments.map((comment) => ({
880
- text: textOf(source, comment.range),
881
- breakAfter: true,
882
- })), indent);
883
- const rest = trailingText.slice(spacing.length);
884
- const separator = LINE_TERMINATOR.test(rest.charAt(0))
885
- ? ''
886
- : (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
887
- ? `\n${indent}`
888
- : ' ';
889
- return {
890
- range: [arrow.range[1], arrow.range[1] + spacing.length],
891
- text: ` ${body}${separator}`,
892
- };
893
- }
894
831
  /**
895
832
  * The edits that strip one annotation, carrying every comment the strip
896
833
  * strands rather than deleting it (#1877). `null` withholds the fix, for a
897
834
  * comment whose meaning is its position and which cannot stay where it is.
898
835
  *
899
836
  * An arrow is the one subject whose annotation sits inside a restricted
900
- * production: `ArrowParameters [no LineTerminator here] =>` forbids a line
901
- * terminator between the parameter list and the arrow, and a block comment
902
- * carrying a line terminator IS one to the grammar. A comment left there — or
903
- * carried there from inside the annotation — therefore turns the output into a
904
- * hard SyntaxError that only V8 reports, since `@typescript-eslint/parser`
905
- * accepts it (#1964). Such a comment is re-emitted past the `=>` instead, the
906
- * nearest position outside the restricted gap that cannot itself begin one;
907
- * hoisting it above the enclosing line would anchor an insertion at a column
908
- * zero that may sit inside a template literal or JSX text, where the comment
909
- * would become content rather than code.
837
+ * production, so its edits come from the shared planner that answers for that
838
+ * grammar (#1964). The removal span handed to it is the annotation's own
839
+ * range: unlike the planner's other caller, nothing here reaches back over the
840
+ * whitespace ahead of the `:`.
910
841
  *
911
842
  * Every other subject ends its parameter list at a body or a semicolon, so its
912
- * stranded comments stay where they were written.
843
+ * stranded comments stay where they were written and a deletion that carries
844
+ * them in place is correct.
913
845
  */
914
846
  function planAnnotationEdits(source, entry) {
915
847
  const range = entry.returnType.range;
@@ -917,53 +849,7 @@ function planAnnotationEdits(source, entry) {
917
849
  const carried = carriedText(source, range);
918
850
  return carried === null ? null : [{ range, text: carried }];
919
851
  }
920
- const gapInfo = arrowAnnotationGap(source, entry.returnType);
921
- if (!gapInfo)
922
- return null;
923
- const { gap, arrow } = gapInfo;
924
- const comments = source
925
- .getAllComments()
926
- .filter((comment) => containsRange(gap, comment.range));
927
- const stranded = comments.filter((comment) => containsRange(range, comment.range));
928
- // What the plain deletion would leave between the parameters and the arrow.
929
- // A comment left there contributes its own text, so a line comment or a
930
- // multi-line block comment shows up here as the line terminator it is.
931
- const residue = `${textOf(source, [gap[0], range[0]])}${textOf(source, [
932
- range[1],
933
- gap[1],
934
- ])}`;
935
- // The plain deletion is kept wherever it already lands a legal gap and
936
- // strands nothing, so no output that survives today moves by a byte.
937
- if (stranded.length === 0 && !LINE_TERMINATOR.test(residue)) {
938
- return [{ range, text: '' }];
939
- }
940
- // Rewriting the gap collapses the lines it spanned, which moves the line a
941
- // directive inside it points at, so the whole fix is withheld rather than
942
- // retargeting one. The gap a directive can share with nothing else is left
943
- // untouched by the branch above.
944
- if (comments.some(isPositionalDirective))
945
- return null;
946
- const hoisted = comments.filter(replacementSegments_1.requiresOwnLine);
947
- const inline = comments
948
- .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
949
- .map((comment) => textOf(source, comment.range));
950
- const edits = [
951
- { range: gap, text: inline.length === 0 ? ' ' : ` ${inline.join(' ')} ` },
952
- ];
953
- if (hoisted.length > 0) {
954
- edits.push(hoistPastArrow(source, arrow, hoisted));
955
- }
956
- return edits;
957
- }
958
- /**
959
- * ESLint applies a fix whole or not at all, and rejects one whose edits
960
- * overlap. Two spans planned independently — an annotation and the declaration
961
- * that strands it — can only overlap if a premise here is wrong, so an overlap
962
- * withdraws the fix rather than throwing at apply time.
963
- */
964
- function isDisjoint(edits) {
965
- const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
966
- return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
852
+ return (0, arrowAnnotationGap_1.planArrowAnnotationEdits)(source, entry.returnType, range);
967
853
  }
968
854
  /**
969
855
  * The edits a single fix makes for `batch`: the annotations themselves plus the
@@ -1002,7 +888,7 @@ function planRemoval(source, removalSource, batch) {
1002
888
  return null;
1003
889
  edits.push({ range, text: carried });
1004
890
  }
1005
- return isDisjoint(edits) ? edits : null;
891
+ return (0, arrowAnnotationGap_1.isDisjoint)(edits) ? edits : null;
1006
892
  }
1007
893
  exports.noExplicitReturnType = (0, createRule_1.createRule)({
1008
894
  name: 'no-explicit-return-type',
@@ -175,6 +175,49 @@ function annotationRemovalRange(typeAnnotation, sourceCode) {
175
175
  function typeText(type, checker) {
176
176
  return checker.typeToString(type, undefined, typeFormatFlags());
177
177
  }
178
+ /**
179
+ * Whether the checker failed to resolve `type` to a real one.
180
+ *
181
+ * `@typescript-eslint/parser` hands back parser services even with no
182
+ * `parserOptions.project`, but the program it builds then has no lib, so every
183
+ * type naming a global degrades: array types collapse to one shared anonymous
184
+ * `{}` (no symbol), and generic references such as `Map<K, V>` become the error
185
+ * type. Both are assignable to everything and stringify alike, so two unrelated
186
+ * types compare equal — `string[]` and `number[]` are literally the same object.
187
+ * Answering "no type information" with a report is what made this rule delete
188
+ * annotations and silently change a binding's type (#1972).
189
+ *
190
+ * A genuine `{}`, `{ a: number }` or `() => void` carries a `__type` symbol, so
191
+ * only the degraded forms match here. Under a real `tsconfig` nothing does.
192
+ */
193
+ function isUnresolvedType(type) {
194
+ const candidate = type;
195
+ if ((type.flags & ts.TypeFlags.Any) !== 0 &&
196
+ candidate.intrinsicName === 'error') {
197
+ return true;
198
+ }
199
+ return (candidate.objectFlags !== undefined &&
200
+ (candidate.objectFlags & ts.ObjectFlags.Anonymous) !== 0 &&
201
+ !type.symbol);
202
+ }
203
+ /**
204
+ * Whitespace inside a type is not part of it, so `Map<string, A>` and
205
+ * `Map<string,A>` must compare equal. Spaces between two word characters are
206
+ * kept so `keyof A` cannot collapse onto a type named `keyofA`.
207
+ */
208
+ function normalizeTypeSpelling(text) {
209
+ return text
210
+ .replace(/\s+/g, ' ')
211
+ .replace(/\s*([<>,[\]()|&{}:;?])\s*/g, '$1')
212
+ .trim();
213
+ }
214
+ /**
215
+ * The comparison form drops spacing the developer chose, so a type spanning
216
+ * several lines is flattened for the message but otherwise quoted as written.
217
+ */
218
+ function displayTypeSpelling(text) {
219
+ return text.replace(/\s+/g, ' ').trim();
220
+ }
178
221
  function unwrapAlias(type, checker) {
179
222
  const aliasSymbol = type
180
223
  .aliasSymbol;
@@ -335,8 +378,13 @@ function doTypeTextsMatch(representations) {
335
378
  annotationCanonical === assertionCanonical ||
336
379
  annotationStructural === assertionStructural);
337
380
  }
338
- function selectMatchingTypeRepresentation(representations) {
339
- return representations.annotationText;
381
+ /**
382
+ * The message names the type the developer is looking at, so it is the
383
+ * annotation's own spelling rather than `typeToString`, which renders
384
+ * `ValidResult[]` as `Array<ValidResult>` and an unresolved type as `{}`.
385
+ */
386
+ function selectMatchingTypeRepresentation(annotationSpelling) {
387
+ return annotationSpelling;
340
388
  }
341
389
  /**
342
390
  * Checks if two types are effectively equal for the purpose of identifying redundant assertions.
@@ -363,16 +411,32 @@ function areTypesEffectivelyEqual(annotationType, assertionType, representations
363
411
  * @param services The parser services.
364
412
  * @returns The matching type string if the types are effectively equal, null otherwise.
365
413
  */
366
- function haveMatchingTypes(annotation, assertion, checker, services) {
414
+ function haveMatchingTypes(annotation, assertion, checker, services, sourceCode) {
367
415
  const annotationType = getComparableType(annotation, checker, services);
368
416
  const assertionType = getComparableType(assertion, checker, services);
369
417
  if (!annotationType || !assertionType)
370
418
  return null;
419
+ const annotationText = sourceCode.getText(annotation);
420
+ const annotationSpelling = normalizeTypeSpelling(annotationText);
421
+ const reportedType = displayTypeSpelling(annotationText);
422
+ /**
423
+ * With either side unresolved the checker cannot separate "same type" from
424
+ * "no type information", so fall back to how the two are written. Identical
425
+ * spellings in one scope denote one type, which keeps every genuinely
426
+ * redundant pair reportable while the mismatched pairs that motivated this
427
+ * fallback — `string[]` against `number[]` — no longer match.
428
+ */
429
+ if (isUnresolvedType(annotationType) || isUnresolvedType(assertionType)) {
430
+ const assertionSpelling = normalizeTypeSpelling(sourceCode.getText(assertion));
431
+ return annotationSpelling === assertionSpelling
432
+ ? selectMatchingTypeRepresentation(reportedType)
433
+ : null;
434
+ }
371
435
  const representations = getTypeRepresentations(annotationType, assertionType, checker);
372
436
  if (!areTypesEffectivelyEqual(annotationType, assertionType, representations, checker)) {
373
437
  return null;
374
438
  }
375
- return selectMatchingTypeRepresentation(representations);
439
+ return selectMatchingTypeRepresentation(reportedType);
376
440
  }
377
441
  function getReturnAssertionSite(node) {
378
442
  const value = node.type === utils_1.AST_NODE_TYPES.MethodDefinition ? node.value : node;
@@ -866,7 +930,7 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
866
930
  */
867
931
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
868
932
  function collectIfRedundant(annotation, assertion, reportNode, fixerTarget, arrowReturnType) {
869
- const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices);
933
+ const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices, sourceCode);
870
934
  if (!matchingType)
871
935
  return null;
872
936
  const site = {
@@ -265,6 +265,21 @@ function checkImplicitReturn(node, context) {
265
265
  }
266
266
  if (!paramName)
267
267
  return;
268
+ /**
269
+ * A body that is the parameter itself — `(value) => value` — is not reported.
270
+ * What this rule looks for is a function that ANSWERS a nullish argument by
271
+ * handing the absence back: a guard, an `&&`, a ternary whose alternate is
272
+ * nullish. The identity function has no nullish-specific behaviour at all, so
273
+ * the prescribed remedy (validate up front, or return a concrete fallback)
274
+ * does not apply to it — least of all to `items.filter((x) => x)`, the
275
+ * standard truthiness filter, or to a generic `identity` helper.
276
+ *
277
+ * Every other spelling of that same function — a block-bodied arrow, a
278
+ * declaration, a function expression — was already accepted, so reporting
279
+ * only the implicit-return form made the verdict turn on body spelling. The
280
+ * test suite recorded that asymmetry as a deferred question and asked that a
281
+ * carve-out settle both spellings at once; this is that carve-out (#1974).
282
+ */
268
283
  if (isNullishPassthroughExpression(node.body, paramName)) {
269
284
  context.report({
270
285
  node,
@@ -272,14 +287,6 @@ function checkImplicitReturn(node, context) {
272
287
  data: { paramName },
273
288
  });
274
289
  }
275
- else if (node.body.type === 'Identifier' && node.body.name === paramName) {
276
- // Check for (param) => param
277
- context.report({
278
- node,
279
- messageId: 'unexpected',
280
- data: { paramName },
281
- });
282
- }
283
290
  }
284
291
  /**
285
292
  * Check if an expression is testing if a parameter is null or undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.144",
3
+ "version": "1.20.145",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "1.20.145",
4
+ "date": "2026-08-12T16:05:12.174Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-always-true-false-conditions",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1973
11
+ ],
12
+ "summary": "accept a literal loop test the body can exit (closes #1973)"
13
+ },
14
+ {
15
+ "name": "no-redundant-annotation-assertion",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1972
19
+ ],
20
+ "summary": "do not treat unresolved types as identical (closes #1972)"
21
+ },
22
+ {
23
+ "name": "no-undefined-null-passthrough",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1974
27
+ ],
28
+ "summary": "stop reporting the identity function (closes #1974)"
29
+ }
30
+ ]
31
+ },
2
32
  {
3
33
  "version": "1.20.144",
4
34
  "date": "2026-08-12T12:16:01.495Z",