@blumintinc/eslint-plugin-blumint 1.19.1 → 1.19.3

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.3',
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,11 +361,19 @@ 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 {};
337
368
  const basename = fileBasename(filename);
338
369
  const topLevelFunctions = [];
370
+ // Top-level `VariableDeclarator` initializer expressions that are NOT
371
+ // themselves functions — e.g. a `Record` registry object literal or an
372
+ // array literal whose entries invoke a factory helper. These are the
373
+ // sibling consumers a reverse-closure walk over function bodies alone would
374
+ // miss, so we retain them to complete the "does the file need the
375
+ // candidate?" check.
376
+ const nonFunctionInitializers = [];
339
377
  let hasExportDefault = false;
340
378
  // Names of every top-level binding (functions, consts, lets, destructured
341
379
  // bindings). Used by the closure exemption: a function that references any
@@ -447,8 +485,14 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
447
485
  continue;
448
486
  const name = declarator.id.name;
449
487
  const fn = extractFunctionInit(declarator.init);
450
- if (!fn)
488
+ if (!fn) {
489
+ // A non-function initializer (object/array literal, call, etc.) may
490
+ // still consume a sibling helper by name; retain it for the
491
+ // reverse-closure exemption.
492
+ if (declarator.init)
493
+ nonFunctionInitializers.push(declarator.init);
451
494
  continue;
495
+ }
452
496
  topLevelFunctions.push({
453
497
  node: declarator,
454
498
  name,
@@ -515,7 +559,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
515
559
  // (already covered above, but be explicit)
516
560
  // --- Size check: must be sizable ---
517
561
  const stmts = countStatements(fn);
518
- const lines = countLines(fn);
562
+ const lines = countLines(fn, sourceCode);
519
563
  const isSizable = stmts >= minStatements || lines >= minLines;
520
564
  if (!isSizable)
521
565
  continue;
@@ -524,6 +568,12 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
524
568
  const closesOverModuleScope = functionClosesOverModuleScope(fn, topLevelBindingNames);
525
569
  if (closesOverModuleScope)
526
570
  continue;
571
+ // Reverse-closure exemption: the candidate is the shared internal
572
+ // primitive its sibling exports build on. A cohesive multi-export
573
+ // utility module is already a utility file; extracting its core
574
+ // would sever the file's internal cohesion.
575
+ if (isReferencedBySibling(info, topLevelFunctions, nonFunctionInitializers))
576
+ continue;
527
577
  }
528
578
  // --- Co-location gate: file must have a distinct primary export ---
529
579
  // The file has a distinct primary purpose if:
@@ -580,6 +630,43 @@ function functionClosesOverModuleScope(fn, topLevelBindingNames) {
580
630
  }
581
631
  return false;
582
632
  }
633
+ /**
634
+ * Returns true if any *other* top-level sibling in the file references the
635
+ * candidate by name. When sibling exports build on the candidate, the candidate
636
+ * is the module's shared internal primitive — a cohesive multi-export utility
637
+ * module is already a utility file, and extracting its core would orphan the
638
+ * siblings from the primitive they depend on.
639
+ *
640
+ * Two kinds of sibling can consume the candidate:
641
+ * - Another top-level function that references it in its body.
642
+ * - A non-function top-level initializer expression that references it — e.g. a
643
+ * `Record` registry object literal or array literal whose entries invoke the
644
+ * candidate factory (`export const RENDERERS = { b: buildRenderer('b') }`).
645
+ * The function-body walk cannot see these, so the initializer expressions are
646
+ * inspected directly.
647
+ *
648
+ * This is the mirror of the closure exemption: `functionClosesOverModuleScope`
649
+ * asks "does the candidate need the file?"; this asks "does the file need the
650
+ * candidate?". Either direction of dependency means extraction severs cohesion.
651
+ */
652
+ function isReferencedBySibling(candidate, allFunctions, nonFunctionInitializers = []) {
653
+ for (const other of allFunctions) {
654
+ if (other.name === candidate.name)
655
+ continue;
656
+ const body = getFunctionBody(other.fn);
657
+ if (!body)
658
+ continue;
659
+ const refs = collectReferencedIdentifiers(body);
660
+ if (refs.has(candidate.name))
661
+ return true;
662
+ }
663
+ for (const init of nonFunctionInitializers) {
664
+ const refs = collectReferencedIdentifiers(init);
665
+ if (refs.has(candidate.name))
666
+ return true;
667
+ }
668
+ return false;
669
+ }
583
670
  /**
584
671
  * Determines whether the file has a distinct primary purpose (co-location gate).
585
672
  * 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.3",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.19.3",
4
+ "date": "2026-07-14T11:28:07.708Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-utility-function-own-file",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1305
11
+ ],
12
+ "summary": "exempt factory consumed only by sibling const initializer literal (closes #1305)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.19.2",
18
+ "date": "2026-07-14T07:32:08.575Z",
19
+ "rules": [
20
+ {
21
+ "name": "prefer-utility-function-own-file",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1303
25
+ ],
26
+ "summary": "exempt shared primitive of cohesive multi-export utility modules (closes #1303)"
27
+ },
28
+ {
29
+ "name": "vertically-group-related-functions",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1304
33
+ ],
34
+ "summary": "defer shared callees until all callers emitted (closes #1304)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.19.1",
4
40
  "date": "2026-07-14T01:28:36.352Z",