@blumintinc/eslint-plugin-blumint 1.19.0 → 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 +1 -1
- package/lib/rules/consistent-callback-naming.js +24 -23
- package/lib/rules/prefer-utility-function-own-file.d.ts +2 -1
- package/lib/rules/prefer-utility-function-own-file.js +65 -4
- package/lib/rules/vertically-group-related-functions.js +18 -1
- package/package.json +1 -1
- package/release-manifest.json +37 -0
package/lib/index.js
CHANGED
|
@@ -26,6 +26,19 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
26
26
|
const createRule_1 = require("../utils/createRule");
|
|
27
27
|
const utils_1 = require("@typescript-eslint/utils");
|
|
28
28
|
const ts = __importStar(require("typescript"));
|
|
29
|
+
// The "handle" prefix the rule targets is the verb-phrase pattern
|
|
30
|
+
// `handle<Something>` — six letters immediately followed by a capitalized word
|
|
31
|
+
// (handleClick, handleSubmit, handleFormSubmit). When "handle" is followed by a
|
|
32
|
+
// lowercase letter the six letters are part of an ordinary word, not the prefix:
|
|
33
|
+
// the past participle "handled" (and derived names like "handledFingerprints"),
|
|
34
|
+
// the noun "handler(s)", "handles", "handling", the adjective "handleable". Those
|
|
35
|
+
// are plain identifiers, so flagging them — and worse, autofix-stripping the
|
|
36
|
+
// leading "handle" to leave nonsense like `d`/`dFingerprints` — silently corrupts
|
|
37
|
+
// unrelated code (Bug #1301). A bare "handle" is likewise a whole word, not a
|
|
38
|
+
// prefix.
|
|
39
|
+
function hasHandlePrefix(name) {
|
|
40
|
+
return /^handle[A-Z]/.test(name);
|
|
41
|
+
}
|
|
29
42
|
module.exports = (0, createRule_1.createRule)({
|
|
30
43
|
name: 'consistent-callback-naming',
|
|
31
44
|
meta: {
|
|
@@ -48,9 +61,15 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
48
61
|
defaultOptions: [],
|
|
49
62
|
create(context) {
|
|
50
63
|
const parserServices = context.parserServices;
|
|
51
|
-
//
|
|
64
|
+
// This rule is type-aware, but a single eslint invocation routinely mixes
|
|
65
|
+
// in-project files with out-of-project ones (plain-Node `.mjs` scripts,
|
|
66
|
+
// config files, etc.) that the TS `project` never parses. Throwing here
|
|
67
|
+
// aborts rule loading for the ENTIRE run — one out-of-project file in argv
|
|
68
|
+
// kills diagnostics for every file (Bug #1302). Degrade gracefully instead:
|
|
69
|
+
// skip files without type information with a no-op visitor, matching how
|
|
70
|
+
// @typescript-eslint rules tolerate missing parser services per file.
|
|
52
71
|
if (!parserServices?.program || !parserServices?.esTreeNodeToTSNodeMap) {
|
|
53
|
-
|
|
72
|
+
return {};
|
|
54
73
|
}
|
|
55
74
|
const checker = parserServices.program.getTypeChecker();
|
|
56
75
|
function isReactComponentType(node) {
|
|
@@ -251,16 +270,7 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
251
270
|
// Check function declarations and variable declarations for callback functions
|
|
252
271
|
'FunctionDeclaration, VariableDeclarator'(node) {
|
|
253
272
|
const functionName = node.id?.type === 'Identifier' ? node.id.name : undefined;
|
|
254
|
-
if (functionName && functionName
|
|
255
|
-
// Skip autofixing for "handler" and "handlers"
|
|
256
|
-
if (functionName === 'handler' || functionName === 'handlers') {
|
|
257
|
-
context.report({
|
|
258
|
-
node,
|
|
259
|
-
messageId: 'callbackFunctionPrefix',
|
|
260
|
-
data: { functionName },
|
|
261
|
-
});
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
273
|
+
if (functionName && hasHandlePrefix(functionName) && node.id) {
|
|
264
274
|
// Skip autofixing for class parameters and getters
|
|
265
275
|
const parent = node.parent;
|
|
266
276
|
if (parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
|
|
@@ -342,17 +352,8 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
342
352
|
'MethodDefinition, Property'(node) {
|
|
343
353
|
if (node.key.type === 'Identifier' &&
|
|
344
354
|
node.key.name &&
|
|
345
|
-
node.key.name
|
|
355
|
+
hasHandlePrefix(node.key.name)) {
|
|
346
356
|
const name = node.key.name;
|
|
347
|
-
// Skip autofixing for "handler" and "handlers"
|
|
348
|
-
if (name === 'handler' || name === 'handlers') {
|
|
349
|
-
context.report({
|
|
350
|
-
node: node.key,
|
|
351
|
-
messageId: 'callbackFunctionPrefix',
|
|
352
|
-
data: { functionName: name },
|
|
353
|
-
});
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
357
|
// Skip autofixing for class parameters and getters
|
|
357
358
|
if (node.type === 'MethodDefinition' && node.kind === 'get') {
|
|
358
359
|
context.report({
|
|
@@ -377,7 +378,7 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
377
378
|
// Check constructor parameters
|
|
378
379
|
TSParameterProperty(node) {
|
|
379
380
|
if (node.parameter.type === 'Identifier' &&
|
|
380
|
-
node.parameter.name
|
|
381
|
+
hasHandlePrefix(node.parameter.name)) {
|
|
381
382
|
context.report({
|
|
382
383
|
node,
|
|
383
384
|
messageId: 'callbackFunctionPrefix',
|
|
@@ -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,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
|
|
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,41 @@
|
|
|
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
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "1.19.1",
|
|
26
|
+
"date": "2026-07-14T01:28:36.352Z",
|
|
27
|
+
"rules": [
|
|
28
|
+
{
|
|
29
|
+
"name": "consistent-callback-naming",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1301,
|
|
33
|
+
1302
|
|
34
|
+
],
|
|
35
|
+
"summary": "skip files without TS project services instead of aborting the run (closes #1302); require uppercase after \"handle\" so past-participle identifiers aren't stripped (closes #1301)"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
2
39
|
{
|
|
3
40
|
"version": "1.19.0",
|
|
4
41
|
"date": "2026-07-13T23:57:00.324Z",
|