@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
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
package/release-manifest.json
CHANGED
|
@@ -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",
|