@blumintinc/eslint-plugin-blumint 1.20.141 → 1.20.142

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.141',
226
+ version: '1.20.142',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -846,6 +846,115 @@ function carriedText(source, range) {
846
846
  : ' ';
847
847
  return `${lead}${body}${trail}`;
848
848
  }
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
+ /**
895
+ * The edits that strip one annotation, carrying every comment the strip
896
+ * strands rather than deleting it (#1877). `null` withholds the fix, for a
897
+ * comment whose meaning is its position and which cannot stay where it is.
898
+ *
899
+ * 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.
910
+ *
911
+ * Every other subject ends its parameter list at a body or a semicolon, so its
912
+ * stranded comments stay where they were written.
913
+ */
914
+ function planAnnotationEdits(source, entry) {
915
+ const range = entry.returnType.range;
916
+ if (entry.node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
917
+ const carried = carriedText(source, range);
918
+ return carried === null ? null : [{ range, text: carried }];
919
+ }
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
+ }
849
958
  /**
850
959
  * ESLint applies a fix whole or not at all, and rejects one whose edits
851
960
  * overlap. Two spans planned independently — an annotation and the declaration
@@ -866,14 +975,23 @@ function isDisjoint(edits) {
866
975
  * outright. The planner computes spans that reach across separators, so a
867
976
  * comment among the specifiers sits inside one; declining on that comment is no
868
977
  * remedy, since it lets a comment decide whether the annotations are stripped at
869
- * all, which is a comment changing the transform just the same (#1877).
978
+ * all, which is a comment changing the transform just the same (#1877). The
979
+ * annotation spans are carried the same way by {@link planAnnotationEdits},
980
+ * which additionally answers for the arrow whose annotation sits inside a
981
+ * restricted production.
870
982
  */
871
983
  function planRemoval(source, removalSource, batch) {
872
984
  const annotations = batch.map((entry) => entry.returnType.range);
873
985
  const cleanups = (0, importRemoval_1.planOrphanedBindingRemoval)(removalSource, annotations, (variables, planned) => (0, typeDeclarationRemoval_1.planTypeDeclarationRemoval)(removalSource, variables, planned));
874
986
  if (!cleanups)
875
987
  return null;
876
- const edits = annotations.map((range) => ({ range, text: '' }));
988
+ const edits = [];
989
+ for (const entry of batch) {
990
+ const planned = planAnnotationEdits(source, entry);
991
+ if (planned === null)
992
+ return null;
993
+ edits.push(...planned);
994
+ }
877
995
  for (const range of cleanups) {
878
996
  if (removesWholeStatement(source, range)) {
879
997
  edits.push({ range, text: '' });
@@ -551,11 +551,13 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
551
551
  // line is hoisted onto a full line of its own ABOVE the line the
552
552
  // call starts on. That insertion can never split a token pair, and
553
553
  // it lands a `-next-line` directive exactly one line above the
554
- // statement that now hosts its subject. Everything else stays
555
- // inline: a block comment beside the expression, and a trailing
556
- // line-bound comment followed by a line break, which is safe after
557
- // the expression has begun.
558
- const hoistedComments = leadingComments.filter(replacementSegments_1.requiresLineBreakAfter);
554
+ // statement that now hosts its subject. A block comment carrying a
555
+ // line terminator demands a line the same way, because the grammar
556
+ // reads it AS a line terminator (#1963). Everything else stays
557
+ // inline: a single-line block comment beside the expression, and a
558
+ // trailing line-bound comment followed by a line break, which is
559
+ // safe after the expression has begun.
560
+ const hoistedComments = leadingComments.filter(replacementSegments_1.requiresOwnLine);
559
561
  if (hoistedComments.length > 0) {
560
562
  const lineStartIndex = sourceCode.getIndexFromLoc({
561
563
  line: node.loc.start.line,
@@ -571,7 +573,7 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
571
573
  }
572
574
  const segments = [
573
575
  ...leadingComments
574
- .filter((comment) => !(0, replacementSegments_1.requiresLineBreakAfter)(comment))
576
+ .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
575
577
  .map(toSegment),
576
578
  { text: replacementText, breakAfter: false },
577
579
  ...trailingComments.map(toSegment),
@@ -43,7 +43,30 @@ const TESTS_DIR = path.join(__dirname, '..', 'tests');
43
43
  * (`const jsx = ruleTesterJsx`) before calling `run`, so a call-site pattern
44
44
  * drops it.
45
45
  */
46
- exports.IMPORTS_SHARED_TESTER = /from\s+'\.\.\/utils\/ruleTester'/;
46
+ exports.IMPORTS_SHARED_TESTER = /from\s+'(?:\.\.\/)+utils\/ruleTester'/;
47
+ /**
48
+ * Every suite file under the tests root, as a path relative to it.
49
+ *
50
+ * The enumeration is recursive because a suite in a subdirectory is still a
51
+ * suite: `src/tests/rules/` holds three that jest runs and that every
52
+ * harvest-based gate used to miss, while their rules' top-level namesakes kept
53
+ * the per-rule closure green — so the gap read as coverage from every angle
54
+ * that was checked.
55
+ *
56
+ * Paths stay relative rather than collapsing to a basename so that two suites
57
+ * with the same name (`no-circular-references.test.ts` exists at both depths)
58
+ * remain distinguishable, which is what keeps a per-file baseline or a dedupe
59
+ * key from silently merging them.
60
+ */
61
+ function suiteFilesUnder(root) {
62
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
63
+ const full = path.join(dir, entry.name);
64
+ if (entry.isDirectory())
65
+ return walk(full);
66
+ return entry.name.endsWith('.test.ts') ? [path.relative(root, full)] : [];
67
+ });
68
+ return walk(root).sort();
69
+ }
47
70
  /**
48
71
  * Jest registers a test for every `describe`/`it` a loaded module calls, so
49
72
  * loading 271 suites inside a suite would graft their entire test list onto
@@ -156,10 +179,7 @@ function harvestRuleTesterCases() {
156
179
  const realCwd = process.cwd();
157
180
  process.chdir(scratchRoot);
158
181
  try {
159
- const files = fs
160
- .readdirSync(TESTS_DIR)
161
- .filter((file) => file.endsWith('.test.ts'))
162
- .sort();
182
+ const files = suiteFilesUnder(TESTS_DIR);
163
183
  for (const file of files) {
164
184
  const fullPath = path.join(TESTS_DIR, file);
165
185
  if (!exports.IMPORTS_SHARED_TESTER.test(fs.readFileSync(fullPath, 'utf8'))) {
@@ -16,6 +16,25 @@ import { TSESTree } from '@typescript-eslint/utils';
16
16
  * silently retarget one line past its subject.
17
17
  */
18
18
  export declare function requiresLineBreakAfter(comment: TSESTree.Comment): boolean;
19
+ /**
20
+ * A comment that cannot be folded onto the code that follows it, so a fixer
21
+ * placing it ahead of an expression must give it a line of its own.
22
+ *
23
+ * Two kinds qualify. One is the line-bound comment {@link requiresLineBreakAfter}
24
+ * describes, whose meaning is tied to the line it occupies. The other is a block
25
+ * comment containing a line terminator: the syntactic grammar treats such a
26
+ * comment as a LineTerminator in its own right, so it triggers every restricted
27
+ * production a raw newline would. Measured with `node --check`, a block comment
28
+ * on one line between arrow parameters and their arrow parses, while the same
29
+ * comment broken across two lines is a SyntaxError; ahead of a `return`
30
+ * argument, the multi-line form is worse still — it parses, and ASI silently
31
+ * replaces the returned value with `undefined` (#1963).
32
+ *
33
+ * Only fixers emitting text where a newline is meaningful need this;
34
+ * a replacement wrapped in parentheses can never trip a restricted production
35
+ * and can keep such a comment inline.
36
+ */
37
+ export declare function requiresOwnLine(comment: TSESTree.Comment): boolean;
19
38
  export type ReplacementSegment = {
20
39
  text: string;
21
40
  breakAfter: boolean;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.joinSegments = exports.joinSegmentBody = exports.requiresLineBreakAfter = void 0;
3
+ exports.joinSegments = exports.joinSegmentBody = exports.requiresOwnLine = exports.requiresLineBreakAfter = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const disableDirectives_1 = require("./disableDirectives");
6
6
  /**
@@ -26,6 +26,35 @@ function requiresLineBreakAfter(comment) {
26
26
  return (0, disableDirectives_1.parseDisableDirectives)([comment]).some((directive) => directive.kind === 'disable-next-line');
27
27
  }
28
28
  exports.requiresLineBreakAfter = requiresLineBreakAfter;
29
+ /**
30
+ * Whether a comment's own text carries a line terminator, which only a block
31
+ * comment can do.
32
+ */
33
+ function spansMultipleLines(comment) {
34
+ return comment.loc.start.line !== comment.loc.end.line;
35
+ }
36
+ /**
37
+ * A comment that cannot be folded onto the code that follows it, so a fixer
38
+ * placing it ahead of an expression must give it a line of its own.
39
+ *
40
+ * Two kinds qualify. One is the line-bound comment {@link requiresLineBreakAfter}
41
+ * describes, whose meaning is tied to the line it occupies. The other is a block
42
+ * comment containing a line terminator: the syntactic grammar treats such a
43
+ * comment as a LineTerminator in its own right, so it triggers every restricted
44
+ * production a raw newline would. Measured with `node --check`, a block comment
45
+ * on one line between arrow parameters and their arrow parses, while the same
46
+ * comment broken across two lines is a SyntaxError; ahead of a `return`
47
+ * argument, the multi-line form is worse still — it parses, and ASI silently
48
+ * replaces the returned value with `undefined` (#1963).
49
+ *
50
+ * Only fixers emitting text where a newline is meaningful need this;
51
+ * a replacement wrapped in parentheses can never trip a restricted production
52
+ * and can keep such a comment inline.
53
+ */
54
+ function requiresOwnLine(comment) {
55
+ return requiresLineBreakAfter(comment) || spansMultipleLines(comment);
56
+ }
57
+ exports.requiresOwnLine = requiresOwnLine;
29
58
  /**
30
59
  * Joins the inlined expression and its carried comments into one run of text,
31
60
  * keeping each comment on the side of the expression it was written on and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.141",
3
+ "version": "1.20.142",
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.142",
4
+ "date": "2026-08-12T07:04:58.677Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-explicit-return-type",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1964
11
+ ],
12
+ "summary": "keep the annotation's comments clear of the arrow gap (closes #1964)"
13
+ },
14
+ {
15
+ "name": "no-usememo-for-pass-by-value",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1963
19
+ ],
20
+ "summary": "hoist a carried multi-line comment clear of the return (closes #1963)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.20.141",
4
26
  "date": "2026-08-12T00:57:09.340Z",