@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 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.0',
225
+ version: '1.19.2',
226
226
  },
227
227
  parseOptions: {
228
228
  ecmaVersion: 2020,
@@ -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
- // Check if we have access to TypeScript services
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
- throw new Error('You have to enable the `project` setting in parser options to use this rule');
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.startsWith('handle') && node.id) {
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.startsWith('handle')) {
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.startsWith('handle')) {
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: 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.0",
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,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",