@blumintinc/eslint-plugin-blumint 1.18.11 → 1.18.12

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
@@ -218,7 +218,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
218
218
  module.exports = {
219
219
  meta: {
220
220
  name: '@blumintinc/eslint-plugin-blumint',
221
- version: '1.18.11',
221
+ version: '1.18.12',
222
222
  },
223
223
  parseOptions: {
224
224
  ecmaVersion: 2020,
@@ -53,6 +53,8 @@ function collectReferencedIdentifiers(node) {
53
53
  // Record parameter names as locals
54
54
  for (const param of fn.params) {
55
55
  collectPatternNames(param, locals);
56
+ // Default values inside a parameter pattern may reference module scope
57
+ walkPatternDefaults(param);
56
58
  }
57
59
  walk(fn.body);
58
60
  break;
@@ -60,6 +62,10 @@ function collectReferencedIdentifiers(node) {
60
62
  case utils_1.AST_NODE_TYPES.VariableDeclarator: {
61
63
  const decl = n;
62
64
  collectPatternNames(decl.id, locals);
65
+ // Default values inside a destructuring pattern may reference module
66
+ // scope (e.g. `const { runner = runCli } = props`) — the binding names
67
+ // are locals, but the default expressions are real references.
68
+ walkPatternDefaults(decl.id);
63
69
  walk(decl.init);
64
70
  break;
65
71
  }
@@ -83,6 +89,40 @@ function collectReferencedIdentifiers(node) {
83
89
  }
84
90
  }
85
91
  }
92
+ // Walks only the right-hand sides of AssignmentPattern defaults nested inside
93
+ // a binding pattern. The binding names themselves are locals; their defaults
94
+ // are ordinary references that `walk` (which skips patterns via the dedicated
95
+ // VariableDeclarator/function cases) would otherwise never visit.
96
+ function walkPatternDefaults(p) {
97
+ if (!p)
98
+ return;
99
+ switch (p.type) {
100
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
101
+ walk(p.right);
102
+ walkPatternDefaults(p.left);
103
+ break;
104
+ case utils_1.AST_NODE_TYPES.ArrayPattern:
105
+ for (const el of p.elements) {
106
+ walkPatternDefaults(el);
107
+ }
108
+ break;
109
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
110
+ for (const prop of p.properties) {
111
+ if (prop.type === utils_1.AST_NODE_TYPES.RestElement) {
112
+ walkPatternDefaults(prop.argument);
113
+ }
114
+ else {
115
+ walkPatternDefaults(prop.value);
116
+ }
117
+ }
118
+ break;
119
+ case utils_1.AST_NODE_TYPES.RestElement:
120
+ walkPatternDefaults(p.argument);
121
+ break;
122
+ default:
123
+ break;
124
+ }
125
+ }
86
126
  walk(node);
87
127
  // Remove purely local names from the reference set
88
128
  for (const local of locals) {
@@ -219,6 +259,38 @@ function isExemptFile(filename) {
219
259
  return true;
220
260
  return false;
221
261
  }
262
+ /**
263
+ * Returns true if the module top-level self-invokes one of its own functions,
264
+ * e.g. `void autoRunIfMain();` or `main();`. This is the signature of a CLI
265
+ * entry-point module whose colocated helpers ARE its purpose, not foreign
266
+ * utilities that should be extracted.
267
+ */
268
+ function hasTopLevelSelfInvocation(program, topLevelFunctionNames) {
269
+ for (const statement of program.body) {
270
+ if (statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement)
271
+ continue;
272
+ // Unwrap `void expr`, `await expr`, and parenthesized wrappers to reach the
273
+ // underlying call (e.g. `void autoRunIfMain();`).
274
+ let expr = statement.expression;
275
+ while (expr &&
276
+ ((expr.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
277
+ expr.operator === 'void') ||
278
+ expr.type === utils_1.AST_NODE_TYPES.AwaitExpression ||
279
+ expr.type === 'ParenthesizedExpression')) {
280
+ expr =
281
+ expr.argument ??
282
+ expr.expression;
283
+ }
284
+ if (!expr || expr.type !== utils_1.AST_NODE_TYPES.CallExpression)
285
+ continue;
286
+ const callee = expr.callee;
287
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
288
+ topLevelFunctionNames.has(callee.name)) {
289
+ return true;
290
+ }
291
+ }
292
+ return false;
293
+ }
222
294
  exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
223
295
  name: 'prefer-utility-function-own-file',
224
296
  meta: {
@@ -265,6 +337,12 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
265
337
  const basename = fileBasename(filename);
266
338
  const topLevelFunctions = [];
267
339
  let hasExportDefault = false;
340
+ // Names of every top-level binding (functions, consts, lets, destructured
341
+ // bindings). Used by the closure exemption: a function that references any
342
+ // of these closes over module scope, so extraction would sever that link.
343
+ const topLevelBindingNames = new Set();
344
+ // Whether the module references `require.main` — a CLI entry-point signal.
345
+ let referencesRequireMain = false;
268
346
  // Names that appear as the handler inside `export default someWrapper(name)`
269
347
  const wrappedDefaultHandlerNames = new Set();
270
348
  // Names directly exported as default (export default myFunc style via ExportDefaultDeclaration referencing an identifier)
@@ -272,6 +350,16 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
272
350
  // Names exported via `export { foo, bar }` specifiers
273
351
  const specifierExportedNames = new Set();
274
352
  return {
353
+ // Detect `require.main` references (CLI entry-point signal)
354
+ MemberExpression(node) {
355
+ if (!node.computed &&
356
+ node.object.type === utils_1.AST_NODE_TYPES.Identifier &&
357
+ node.object.name === 'require' &&
358
+ node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
359
+ node.property.name === 'main') {
360
+ referencesRequireMain = true;
361
+ }
362
+ },
275
363
  // Collect export default declarations
276
364
  ExportDefaultDeclaration(node) {
277
365
  hasExportDefault = true;
@@ -331,6 +419,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
331
419
  if (!node.id)
332
420
  return;
333
421
  const name = node.id.name;
422
+ topLevelBindingNames.add(name);
334
423
  const isNamedExport = parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
335
424
  const isDefaultExport = parent?.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration;
336
425
  topLevelFunctions.push({
@@ -351,6 +440,9 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
351
440
  return;
352
441
  const isNamedExport = parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
353
442
  for (const declarator of node.declarations) {
443
+ // Every top-level binding (including non-function consts like a
444
+ // registry array and destructured bindings) is a closure target.
445
+ collectPatternNames(declarator.id, topLevelBindingNames);
354
446
  if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier)
355
447
  continue;
356
448
  const name = declarator.id.name;
@@ -366,7 +458,17 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
366
458
  });
367
459
  }
368
460
  },
369
- 'Program:exit'() {
461
+ 'Program:exit'(programNode) {
462
+ // CLI entry-point modules deliberately colocate their parser/printer/
463
+ // guard/compute helpers — those functions ARE the file's purpose, not
464
+ // foreign utilities. Recognize them via a `require.main` reference or a
465
+ // top-level self-invocation of one of their own functions
466
+ // (`void autoRunIfMain();`) and exempt the whole file.
467
+ const topLevelFunctionNames = new Set(topLevelFunctions.map((f) => f.name));
468
+ if (referencesRequireMain ||
469
+ hasTopLevelSelfInvocation(programNode, topLevelFunctionNames)) {
470
+ return;
471
+ }
370
472
  // Determine the file's primary export name (basename heuristic)
371
473
  // e.g. "modifyRoleMembers" in "modifyRoleMembers.f.ts"
372
474
  const primaryName = basename;
@@ -419,7 +521,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
419
521
  continue;
420
522
  // --- ignoreClosures: skip if the function body references module-scoped identifiers not passed as params ---
421
523
  if (ignoreClosures) {
422
- const closesOverModuleScope = functionClosesOverModuleScope(fn, topLevelFunctions);
524
+ const closesOverModuleScope = functionClosesOverModuleScope(fn, topLevelBindingNames);
423
525
  if (closesOverModuleScope)
424
526
  continue;
425
527
  }
@@ -447,14 +549,14 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
447
549
  * passed as parameters, making it non-trivially extractable.
448
550
  *
449
551
  * We identify "module-scope" identifiers as names of other top-level
450
- * declarations in the file (functions, variables) that are NOT imported.
451
- * Imported names and standard globals are considered "extractable" (you'd
452
- * just re-import them).
552
+ * declarations in the file (functions, variables, destructured bindings) that
553
+ * are NOT imported. Imported names and standard globals are considered
554
+ * "extractable" (you'd just re-import them).
453
555
  *
454
556
  * Strategy: collect referenced identifiers in the body, subtract param names,
455
557
  * then check if any remain that are names of other top-level declarations.
456
558
  */
457
- function functionClosesOverModuleScope(fn, topLevelFunctions) {
559
+ function functionClosesOverModuleScope(fn, topLevelBindingNames) {
458
560
  const body = getFunctionBody(fn);
459
561
  if (!body)
460
562
  return false;
@@ -469,12 +571,10 @@ function functionClosesOverModuleScope(fn, topLevelFunctions) {
469
571
  for (const param of paramNames) {
470
572
  referencedIds.delete(param);
471
573
  }
472
- // Get the set of top-level sibling function names
473
- const topLevelNames = new Set(topLevelFunctions.map((f) => f.name));
474
- // If ANY referenced identifier is a top-level sibling function name,
475
- // then this function closes over a module-scope binding.
574
+ // If ANY referenced identifier is a top-level sibling binding name, then this
575
+ // function closes over module scope.
476
576
  for (const ref of referencedIds) {
477
- if (topLevelNames.has(ref)) {
577
+ if (topLevelBindingNames.has(ref)) {
478
578
  return true;
479
579
  }
480
580
  }
@@ -232,18 +232,23 @@ function collectDependencies(fnNode, knownFunctionNames) {
232
232
  visit(fnNode.body);
233
233
  return [...dependencies];
234
234
  }
235
- function dependencyOrder(functions, direction) {
235
+ function dependencyOrder(functions, direction, groupOrder) {
236
236
  const dependencyMap = new Map();
237
237
  const originalIndexMap = new Map(functions.map((fn) => [fn.name, fn.originalIndex]));
238
+ const groupRankMap = new Map(functions.map((fn) => [fn.name, groupOrder.indexOf(classifyGroup(fn))]));
238
239
  functions.forEach((fn) => {
239
240
  dependencyMap.set(fn.name, fn.dependencies);
240
241
  });
242
+ // Tiebreak among functions the call graph does NOT order relative to each
243
+ // other (independent roots, sibling callees, cycle members): configured
244
+ // group order first, then original source position. Both keys are intrinsic
245
+ // to the function rather than its current arrangement, so the traversal is a
246
+ // fixed point once reached — obeying a move never spawns a contradicting one.
247
+ const tiebreak = (a, b) => (groupRankMap.get(a) ?? 0) - (groupRankMap.get(b) ?? 0) ||
248
+ (originalIndexMap.get(a) ?? 0) - (originalIndexMap.get(b) ?? 0);
241
249
  const visited = new Set();
242
250
  const order = [];
243
- const namesInOriginalOrder = functions
244
- .slice()
245
- .sort((a, b) => a.originalIndex - b.originalIndex)
246
- .map((fn) => fn.name);
251
+ const namesInTiebreakOrder = functions.map((fn) => fn.name).sort(tiebreak);
247
252
  const incomingCount = new Map();
248
253
  functions.forEach((fn) => {
249
254
  if (!incomingCount.has(fn.name)) {
@@ -253,18 +258,13 @@ function dependencyOrder(functions, direction) {
253
258
  incomingCount.set(dep, (incomingCount.get(dep) || 0) + 1);
254
259
  });
255
260
  });
256
- const roots = namesInOriginalOrder.filter((name) => (incomingCount.get(name) || 0) === 0);
261
+ const roots = namesInTiebreakOrder.filter((name) => (incomingCount.get(name) || 0) === 0);
257
262
  const visit = (name) => {
258
263
  if (visited.has(name)) {
259
264
  return;
260
265
  }
261
266
  visited.add(name);
262
- const deps = dependencyMap
263
- .get(name)
264
- ?.slice()
265
- .sort((a, b) => {
266
- return ((originalIndexMap.get(a) || 0) - (originalIndexMap.get(b) || 0));
267
- }) || [];
267
+ const deps = dependencyMap.get(name)?.slice().sort(tiebreak) || [];
268
268
  if (direction === 'callees-first') {
269
269
  deps.forEach(visit);
270
270
  order.push(name);
@@ -274,12 +274,15 @@ function dependencyOrder(functions, direction) {
274
274
  deps.forEach(visit);
275
275
  }
276
276
  };
277
- [...roots, ...namesInOriginalOrder].forEach(visit);
277
+ // Depth-first from roots keeps each caller immediately above its own helper
278
+ // subtree (callers-first) — grouping call chains vertically. The call graph
279
+ // is primary; group order only breaks ties the graph leaves open.
280
+ [...roots, ...namesInTiebreakOrder].forEach(visit);
278
281
  return order;
279
282
  }
280
283
  function computeExpectedOrder(functions, options) {
281
284
  const groupOrder = normalizeGroupOrder(options.groupOrder);
282
- const dependencySequence = dependencyOrder(functions, options.dependencyDirection);
285
+ const dependencySequence = dependencyOrder(functions, options.dependencyDirection, groupOrder);
283
286
  const dependencyRank = new Map(dependencySequence.map((name, idx) => [name, idx]));
284
287
  const exportRank = (fn) => {
285
288
  if (options.exportPlacement === 'ignore') {
@@ -290,10 +293,12 @@ function computeExpectedOrder(functions, options) {
290
293
  }
291
294
  return fn.isExported ? 1 : 0;
292
295
  };
293
- const groupRank = (fn) => groupOrder.indexOf(classifyGroup(fn));
296
+ // Export placement is the only concern allowed to outrank the call graph.
297
+ // The dependency sequence already folds group order in as a tiebreak, so a
298
+ // caller is never sorted below the helpers it invokes on account of its verb
299
+ // prefix — the defect that produced self-contradicting move instructions.
294
300
  return functions.slice().sort((a, b) => {
295
301
  return (exportRank(a) - exportRank(b) ||
296
- groupRank(a) - groupRank(b) ||
297
302
  (dependencyRank.get(a.name) ?? Number.MAX_SAFE_INTEGER) -
298
303
  (dependencyRank.get(b.name) ?? Number.MAX_SAFE_INTEGER) ||
299
304
  a.originalIndex - b.originalIndex);
@@ -415,7 +420,11 @@ exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
415
420
  : 'exports stay at the bottom of the file';
416
421
  const group = classifyGroup(misplacedInfo);
417
422
  const groupOrder = normalizeGroupOrder(normalizedOptions.groupOrder);
418
- const groupReason = groupOrder.indexOf(group) > 0
423
+ // Group order only settles ties the call graph leaves open, so cite it
424
+ // as a reason only when this function has no helpers of its own — never
425
+ // paired with "callers should sit above the helpers they invoke".
426
+ const groupReason = misplacedInfo.dependencies.length === 0 &&
427
+ groupOrder.indexOf(group) > 0
419
428
  ? `${group.replace('-', ' ')} should follow the configured group order`
420
429
  : '';
421
430
  const reasons = [dependencyReason, exportReason, groupReason]
@@ -459,7 +468,16 @@ exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
459
468
  const slice = node.body.slice(firstFunctionIndex, lastFunctionIndex + 1);
460
469
  const blockContainsOnlyFunctions = slice.every((statement) => functionStatements.has(statement));
461
470
  if (!blockContainsOnlyFunctions) {
462
- return null;
471
+ // Real modules interleave type aliases, consts, and top-level
472
+ // calls (e.g. `void autoRunIfMain();`) between functions. Rather
473
+ // than bail, reorder only the function statements among their own
474
+ // slots, leaving every other statement exactly where it is. Plain
475
+ // node ranges keep the edits disjoint (no comment-span overlap
476
+ // with the interleaved statements).
477
+ return sourceOrderedInfos.map((info, idx) => {
478
+ const target = expectedOrderInfos[idx];
479
+ return fixer.replaceTextRange(info.statementNode.range, sourceCode.getText(target.statementNode));
480
+ });
463
481
  }
464
482
  const [start] = statementRanges.get(node.body[firstFunctionIndex]) ||
465
483
  getStatementRangeWithComments(node.body[firstFunctionIndex], sourceCode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.18.11",
3
+ "version": "1.18.12",
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.18.12",
4
+ "date": "2026-07-12T03:45:44.071Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-utility-function-own-file",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1285
11
+ ],
12
+ "summary": "exempt CLI entry-point and registry modules (closes #1285)"
13
+ },
14
+ {
15
+ "name": "vertically-group-related-functions",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1286
19
+ ],
20
+ "summary": "make call graph primary over name-prefix groups (closes #1286)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.18.11",
4
26
  "date": "2026-07-11T16:25:05.418Z",