@blumintinc/eslint-plugin-blumint 1.19.1 → 1.19.2

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
@@ -222,7 +222,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
222
222
  module.exports = {
223
223
  meta: {
224
224
  name: '@blumintinc/eslint-plugin-blumint',
225
- version: '1.19.1',
225
+ version: '1.19.2',
226
226
  },
227
227
  parseOptions: {
228
228
  ecmaVersion: 2020,
@@ -1,3 +1,4 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type Options = [
2
3
  {
3
4
  minStatements?: number;
@@ -5,5 +6,5 @@ type Options = [
5
6
  ignoreClosures?: boolean;
6
7
  }
7
8
  ];
8
- export declare const preferUtilityFunctionOwnFile: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"extractUtility", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
9
+ export declare const preferUtilityFunctionOwnFile: TSESLint.RuleModule<"extractUtility", Options, TSESLint.RuleListener>;
9
10
  export {};
@@ -186,12 +186,42 @@ function countStatements(fn) {
186
186
  return 1;
187
187
  }
188
188
  /**
189
- * Counts the number of source lines spanned by a function node.
189
+ * Counts the number of significant source lines spanned by a function node
190
+ * lines that carry actual code. Blank lines and comment-only lines are ignored
191
+ * so a function's "size" does not depend on how heavily it is commented:
192
+ * identical code with and without JSDoc/inline comments lints identically.
190
193
  */
191
- function countLines(fn) {
194
+ function countLines(fn, sourceCode) {
192
195
  if (!fn.loc)
193
196
  return 0;
194
- return fn.loc.end.line - fn.loc.start.line + 1;
197
+ const startLine = fn.loc.start.line;
198
+ const endLine = fn.loc.end.line;
199
+ const lines = sourceCode.lines;
200
+ let count = 0;
201
+ for (let line = startLine; line <= endLine; line++) {
202
+ const text = lines[line - 1] ?? '';
203
+ if (text.trim() === '')
204
+ continue;
205
+ // Blank out any character ranges on this line covered by a comment, then
206
+ // check whether any non-whitespace code remains.
207
+ const chars = text.split('');
208
+ for (const comment of sourceCode.getAllComments()) {
209
+ if (!comment.loc)
210
+ continue;
211
+ if (comment.loc.start.line > line || comment.loc.end.line < line) {
212
+ continue;
213
+ }
214
+ const from = comment.loc.start.line === line ? comment.loc.start.column : 0;
215
+ const to = comment.loc.end.line === line ? comment.loc.end.column : chars.length;
216
+ for (let col = from; col < to && col < chars.length; col++) {
217
+ chars[col] = ' ';
218
+ }
219
+ }
220
+ if (chars.join('').trim() === '')
221
+ continue;
222
+ count++;
223
+ }
224
+ return count;
195
225
  }
196
226
  /**
197
227
  * Returns the function expression node if the VariableDeclarator initializer
@@ -331,6 +361,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
331
361
  const minLines = options.minLines ?? DEFAULT_MIN_LINES;
332
362
  const ignoreClosures = options.ignoreClosures ?? DEFAULT_IGNORE_CLOSURES;
333
363
  const filename = context.getFilename();
364
+ const sourceCode = context.getSourceCode();
334
365
  // Exempt test/mock/type files entirely
335
366
  if (isExemptFile(filename))
336
367
  return {};
@@ -515,7 +546,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
515
546
  // (already covered above, but be explicit)
516
547
  // --- Size check: must be sizable ---
517
548
  const stmts = countStatements(fn);
518
- const lines = countLines(fn);
549
+ const lines = countLines(fn, sourceCode);
519
550
  const isSizable = stmts >= minStatements || lines >= minLines;
520
551
  if (!isSizable)
521
552
  continue;
@@ -524,6 +555,12 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
524
555
  const closesOverModuleScope = functionClosesOverModuleScope(fn, topLevelBindingNames);
525
556
  if (closesOverModuleScope)
526
557
  continue;
558
+ // Reverse-closure exemption: the candidate is the shared internal
559
+ // primitive its sibling exports build on. A cohesive multi-export
560
+ // utility module is already a utility file; extracting its core
561
+ // would sever the file's internal cohesion.
562
+ if (isReferencedBySibling(info, topLevelFunctions))
563
+ continue;
527
564
  }
528
565
  // --- Co-location gate: file must have a distinct primary export ---
529
566
  // The file has a distinct primary purpose if:
@@ -580,6 +617,30 @@ function functionClosesOverModuleScope(fn, topLevelBindingNames) {
580
617
  }
581
618
  return false;
582
619
  }
620
+ /**
621
+ * Returns true if any *other* top-level function in the file references the
622
+ * candidate by name in its body. When sibling exports build on the candidate,
623
+ * the candidate is the module's shared internal primitive — a cohesive
624
+ * multi-export utility module is already a utility file, and extracting its core
625
+ * would orphan the siblings from the primitive they depend on.
626
+ *
627
+ * This is the mirror of the closure exemption: `functionClosesOverModuleScope`
628
+ * asks "does the candidate need the file?"; this asks "does the file need the
629
+ * candidate?". Either direction of dependency means extraction severs cohesion.
630
+ */
631
+ function isReferencedBySibling(candidate, allFunctions) {
632
+ for (const other of allFunctions) {
633
+ if (other.name === candidate.name)
634
+ continue;
635
+ const body = getFunctionBody(other.fn);
636
+ if (!body)
637
+ continue;
638
+ const refs = collectReferencedIdentifiers(body);
639
+ if (refs.has(candidate.name))
640
+ return true;
641
+ }
642
+ return false;
643
+ }
583
644
  /**
584
645
  * Determines whether the file has a distinct primary purpose (co-location gate).
585
646
  * A file has a distinct primary purpose if (excluding the function under test):
@@ -259,6 +259,12 @@ function dependencyOrder(functions, direction, groupOrder) {
259
259
  });
260
260
  });
261
261
  const roots = namesInTiebreakOrder.filter((name) => (incomingCount.get(name) || 0) === 0);
262
+ // Remaining unemitted callers per function, seeded from incomingCount. Used
263
+ // (callers-first only) to defer a shared callee until every caller that
264
+ // reaches it has been emitted, so the shared primitive lands below all of its
265
+ // callers rather than being greedily inlined beneath whichever caller the DFS
266
+ // reaches first — which would place its other callers below their own helper.
267
+ const remainingCallers = new Map(incomingCount);
262
268
  const visit = (name) => {
263
269
  if (visited.has(name)) {
264
270
  return;
@@ -271,7 +277,18 @@ function dependencyOrder(functions, direction, groupOrder) {
271
277
  }
272
278
  else {
273
279
  order.push(name);
274
- deps.forEach(visit);
280
+ for (const dep of deps) {
281
+ const remaining = (remainingCallers.get(dep) ?? 0) - 1;
282
+ remainingCallers.set(dep, remaining);
283
+ // Descend into the callee only once its last caller has been emitted.
284
+ // A shared callee is deferred here and picked up when the caller that
285
+ // drives its counter to zero recurses into it. If a callee is never
286
+ // driven to zero (e.g. a dependency cycle), the top-level sweep below
287
+ // still visits it, so no function is dropped from the order.
288
+ if (remaining <= 0) {
289
+ visit(dep);
290
+ }
291
+ }
275
292
  }
276
293
  };
277
294
  // Depth-first from roots keeps each caller immediately above its own helper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.19.1",
3
+ "version": "1.19.2",
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.19.2",
4
+ "date": "2026-07-14T07:32:08.575Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-utility-function-own-file",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1303
11
+ ],
12
+ "summary": "exempt shared primitive of cohesive multi-export utility modules (closes #1303)"
13
+ },
14
+ {
15
+ "name": "vertically-group-related-functions",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1304
19
+ ],
20
+ "summary": "defer shared callees until all callers emitted (closes #1304)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.19.1",
4
26
  "date": "2026-07-14T01:28:36.352Z",