@blumintinc/eslint-plugin-blumint 1.20.41 → 1.20.42

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.41',
226
+ version: '1.20.42',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -663,7 +663,43 @@ function statementDeclaresAny(statement, names) {
663
663
  }
664
664
  return false;
665
665
  }
666
- function findEarliestSafeIndex(body, startIndex, dependencies, { allowHooks }) {
666
+ /**
667
+ * Whether the subtree contains a property read. `parent` is skipped because it
668
+ * points back up the tree and would make this walk unbounded.
669
+ */
670
+ function containsMemberRead(node) {
671
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
672
+ return true;
673
+ }
674
+ return Object.entries(node).some(([key, value]) => {
675
+ if (key === 'parent') {
676
+ return false;
677
+ }
678
+ if (Array.isArray(value)) {
679
+ return value.some((item) => ASTHelpers_1.ASTHelpers.isNode(item) && containsMemberRead(item));
680
+ }
681
+ return ASTHelpers_1.ASTHelpers.isNode(value) && containsMemberRead(value);
682
+ });
683
+ }
684
+ /**
685
+ * Whether a declaration captures a value read off some object's property.
686
+ *
687
+ * Such a read is side-effect-*free*, which is what `isPureDeclaration` answers,
688
+ * but it is not order-*independent*: the value is whatever that property held at
689
+ * this point in the block. Those are different questions, and only the second
690
+ * licenses moving an effect across the declaration. Conflating them let a
691
+ * hoisted call land above a deliberate before-snapshot, turning a before/after
692
+ * comparison into a self-referential one that still type-checks and still lints
693
+ * clean.
694
+ */
695
+ function capturesObservableState(statement) {
696
+ if (statement.type !== utils_1.AST_NODE_TYPES.VariableDeclaration) {
697
+ return false;
698
+ }
699
+ return statement.declarations.some((declarator) => Boolean(declarator.init) &&
700
+ containsMemberRead(declarator.init));
701
+ }
702
+ function findEarliestSafeIndex(body, startIndex, dependencies, { allowHooks, stopAtObservableState = false, }) {
667
703
  // Reuse the backward scan so guard/side-effect movers stop before impure work or any declaration/reference of tracked dependencies.
668
704
  let targetIndex = startIndex;
669
705
  for (let cursor = startIndex - 1; cursor >= 0; cursor -= 1) {
@@ -677,6 +713,9 @@ function findEarliestSafeIndex(body, startIndex, dependencies, { allowHooks }) {
677
713
  if (statementReferencesAny(candidate, dependencies)) {
678
714
  break;
679
715
  }
716
+ if (stopAtObservableState && capturesObservableState(candidate)) {
717
+ break;
718
+ }
680
719
  targetIndex = cursor;
681
720
  }
682
721
  return targetIndex;
@@ -1354,6 +1393,7 @@ function handleSideEffects(sink, body) {
1354
1393
  }
1355
1394
  const targetIndex = findEarliestSafeIndex(body, index, dependencies, {
1356
1395
  allowHooks: false,
1396
+ stopAtObservableState: true,
1357
1397
  });
1358
1398
  if (targetIndex === index) {
1359
1399
  return;
@@ -329,6 +329,16 @@ const returnExpressionIsComponent = (expression, reactImports) => {
329
329
  if (isFunctionExpression(unwrapped)) {
330
330
  return Boolean(functionCreatesComponent(unwrapped, reactImports));
331
331
  }
332
+ // A factory that hands its component back inside an object is still an HOC
333
+ // factory, not a render body — the ES-module interop shape
334
+ // `{ __esModule: true, default: Component }` that every `jest.mock()` factory
335
+ // uses puts the component one property deep. Recursing covers nesting; render
336
+ // bodies return JSX rather than object literals, so this does not blur the
337
+ // two.
338
+ if (unwrapped.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
339
+ return unwrapped.properties.some((property) => property.type === utils_1.AST_NODE_TYPES.Property &&
340
+ returnExpressionIsComponent(property.value, reactImports));
341
+ }
332
342
  return false;
333
343
  };
334
344
  const returnExpressionIsJsx = (expression) => {
@@ -420,6 +430,107 @@ const collectDirectReturnExpressions = (fn) => {
420
430
  fn.body.body.forEach(visitStatement);
421
431
  return returns;
422
432
  };
433
+ /** Module-mock registrars whose factory argument runs at registration time. */
434
+ const MODULE_MOCK_REGISTRARS = new Set(['mock', 'doMock']);
435
+ /**
436
+ * Test-runner callbacks. A `describe` body runs once at collection time and an
437
+ * `it`/hook body once per test — neither re-renders, so a component defined
438
+ * directly in one has an identity as stable as a module-scope export.
439
+ */
440
+ const TEST_RUNNER_NAMES = new Set([
441
+ 'describe',
442
+ 'it',
443
+ 'test',
444
+ 'beforeEach',
445
+ 'beforeAll',
446
+ 'afterEach',
447
+ 'afterAll',
448
+ ]);
449
+ /**
450
+ * The identifier a callee is rooted at, so `it`, `it.only`, `it.each(...)` and
451
+ * `describe.each`...`` all resolve to the runner's own name.
452
+ */
453
+ const calleeRootName = (callee) => {
454
+ switch (callee.type) {
455
+ case utils_1.AST_NODE_TYPES.Identifier:
456
+ return callee.name;
457
+ case utils_1.AST_NODE_TYPES.MemberExpression:
458
+ return calleeRootName(callee.object);
459
+ case utils_1.AST_NODE_TYPES.CallExpression:
460
+ return calleeRootName(callee.callee);
461
+ case utils_1.AST_NODE_TYPES.TaggedTemplateExpression:
462
+ return calleeRootName(callee.tag);
463
+ default:
464
+ return undefined;
465
+ }
466
+ };
467
+ /**
468
+ * True when `fn` is a callback handed to a test-runner call.
469
+ *
470
+ * Requiring the call to stand alone as a statement keeps an ordinary helper that
471
+ * merely shares a name — a `test(...)` used for its return value, say — from
472
+ * collecting the exemption, since runner calls are always statements.
473
+ */
474
+ const isTestRunnerCallback = (fn) => {
475
+ const call = fn.parent;
476
+ if (!call || call.type !== utils_1.AST_NODE_TYPES.CallExpression) {
477
+ return false;
478
+ }
479
+ if (!call.arguments.includes(fn)) {
480
+ return false;
481
+ }
482
+ if (call.parent?.type !== utils_1.AST_NODE_TYPES.ExpressionStatement) {
483
+ return false;
484
+ }
485
+ const root = calleeRootName(call.callee);
486
+ return root !== undefined && TEST_RUNNER_NAMES.has(root);
487
+ };
488
+ /**
489
+ * True when `fn` is the factory argument of a `jest.mock()` / `jest.doMock()`
490
+ * call.
491
+ *
492
+ * Such a factory runs once when the module registry resolves the mock, never
493
+ * per render, so a component defined inside it is as identity-stable as a
494
+ * module-scope export and the remount harm this rule describes cannot occur.
495
+ * Keyed on the call rather than on what the factory returns because the
496
+ * component is often unreachable from the return expression — the common
497
+ * registry shape hands it back through
498
+ * `Object.fromEntries(ids.map((id) => [id, Stub]))`, where no amount of
499
+ * unwrapping the returned object literal finds it.
500
+ */
501
+ const isModuleMockFactory = (fn) => {
502
+ const call = fn.parent;
503
+ if (!call || call.type !== utils_1.AST_NODE_TYPES.CallExpression) {
504
+ return false;
505
+ }
506
+ if (!call.arguments.includes(fn)) {
507
+ return false;
508
+ }
509
+ const { callee } = call;
510
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
511
+ !callee.computed &&
512
+ callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
513
+ callee.object.name === 'jest' &&
514
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
515
+ MODULE_MOCK_REGISTRARS.has(callee.property.name));
516
+ };
517
+ /**
518
+ * Whether `fn` sits lexically inside a test-runner callback, at any depth.
519
+ *
520
+ * Unlike {@link isTestRunnerCallback}, which asks whether the function *is* the
521
+ * callback, this reaches helpers declared within one. Safe to widen this far
522
+ * only because the caller pairs it with "does not return JSX".
523
+ */
524
+ const isWithinTestRunnerCallback = (fn) => {
525
+ let current = fn;
526
+ while (current) {
527
+ if (isTestRunnerCallback(current)) {
528
+ return true;
529
+ }
530
+ current = current.parent;
531
+ }
532
+ return false;
533
+ };
423
534
  /**
424
535
  * True when the nearest enclosing function is an HOC factory—it returns a
425
536
  * component (memo/forwardRef/component reference) and never returns JSX. Such
@@ -432,10 +543,25 @@ const isInsideHocFactory = (node, reactImports) => {
432
543
  if (!enclosing) {
433
544
  return false;
434
545
  }
546
+ // Both checks look only at the NEAREST enclosing function, so a component
547
+ // nested inside another component that itself sits in an it() body is still
548
+ // reported — the exemption does not leak down the tree.
549
+ if (isModuleMockFactory(enclosing) || isTestRunnerCallback(enclosing)) {
550
+ return true;
551
+ }
552
+ // A helper *declared* in a test body — `renderUpdater` and friends, which
553
+ // build a tree and hand it to `render()` — runs once per test rather than per
554
+ // render. It is only scaffolding if it is not itself a component, which the
555
+ // `returnsJsx` check below establishes; a real component in the same describe
556
+ // body still returns JSX and is still reported.
557
+ const withinTest = isWithinTestRunnerCallback(enclosing);
435
558
  const returns = collectDirectReturnExpressions(enclosing);
436
559
  const returnsComponent = returns.some((expression) => returnExpressionIsComponent(expression, reactImports));
437
560
  const returnsJsx = returns.some((expression) => returnExpressionIsJsx(expression));
438
- return returnsComponent && !returnsJsx;
561
+ if (returnsJsx) {
562
+ return false;
563
+ }
564
+ return returnsComponent || withinTest;
439
565
  };
440
566
  exports.memoNestedReactComponents = (0, createRule_1.createRule)({
441
567
  name: 'memo-nested-react-components',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@typescript-eslint/utils");
4
+ const eslint_1 = require("eslint");
5
+ const validCaseFalsifiability_1 = require("./validCaseFalsifiability");
6
+ /** Marks a prototype as patched so a re-import cannot double-wrap `run`. */
7
+ const INSTALLED = Symbol.for('blumint.validCaseGuard.installed');
8
+ function install(prototype) {
9
+ if (!prototype || prototype[INSTALLED]) {
10
+ return;
11
+ }
12
+ const original = prototype.run;
13
+ prototype.run = function patchedRun(...args) {
14
+ const [name, , tests] = args;
15
+ (0, validCaseFalsifiability_1.assertValidCasesCanFail)(name, tests?.valid ?? []);
16
+ return original.apply(this, args);
17
+ };
18
+ prototype[INSTALLED] = true;
19
+ }
20
+ // Both are patched because the typescript-eslint tester does not necessarily
21
+ // inherit `run` from the core one; whichever a suite reaches for is covered.
22
+ install(utils_1.ESLintUtils.RuleTester?.prototype);
23
+ install(eslint_1.RuleTester?.prototype);
24
+ //# sourceMappingURL=installValidCaseGuard.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `RuleTester` registers the rule under exactly the bare name passed to `run()`,
3
+ * so a block `eslint-disable` written *inside a test fixture* silences the very
4
+ * rule the case is asserting about. A `valid` case carrying one passes for every
5
+ * possible implementation — including one that reports on every node — so it
6
+ * proves nothing while looking like false-positive protection.
7
+ *
8
+ * That is the worst place to lose coverage: CLAUDE.md ranks a false positive
9
+ * above a false negative, and agora disables a rule outright on any report, so
10
+ * `valid` cases are what stand between a regression and a rule being switched
11
+ * off downstream.
12
+ *
13
+ * Line-scoped directives (`eslint-disable-line`, `eslint-disable-next-line`) are
14
+ * deliberately not treated this way. They pin suppression to one line, so a rule
15
+ * that moved or widened its report still escapes them and fails the case.
16
+ * Likewise a plugin-prefixed id (`@blumintinc/blumint/<name>`) does not suppress
17
+ * anything under `RuleTester`, which knows the rule only by its bare name.
18
+ *
19
+ * Known blind spot: a fixture whose line-scoped directives happen to cover
20
+ * *every* reportable line is inert too, and this check does not see it —
21
+ * deciding that needs to know which lines a rule would report on, which is a
22
+ * whole-suite mutation run rather than something a per-suite check can afford.
23
+ * Two such cases exist (both in `prefer-fragment-component`'s suppression
24
+ * matrix). `.claude/tmp/vacuous-sweep.sh` is the exhaustive dynamic check and
25
+ * puts the true total at 23 against the 21 counted here.
26
+ */
27
+ /** Whether `code` blanket-disables `ruleName` somewhere in the fixture. */
28
+ export declare function suppressesRuleUnderTest(code: string, ruleName: string): boolean;
29
+ type ValidCase = string | {
30
+ code?: string;
31
+ };
32
+ /**
33
+ * Cases that carry a blanket disable *on purpose*: they exist to prove a rule
34
+ * honours suppression (the #1404 import-carrier class), so the directive is the
35
+ * subject of the test rather than an accident.
36
+ *
37
+ * Counts are exact — not a minimum — so a newly added inert case fails here
38
+ * instead of shipping. Resolving #1489, which decides whether these are
39
+ * redundant with the guards already in each rule's `invalid` array, is what
40
+ * removes entries; the map should only ever shrink.
41
+ */
42
+ export declare const CASES_ALLOWED_TO_SUPPRESS: Readonly<Record<string, number>>;
43
+ /**
44
+ * Throws when a rule's `valid` array contains more blanket-suppressed cases than
45
+ * are deliberately allowed.
46
+ */
47
+ export declare function assertValidCasesCanFail(ruleName: string, valid: readonly ValidCase[]): void;
48
+ export {};
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ /**
3
+ * `RuleTester` registers the rule under exactly the bare name passed to `run()`,
4
+ * so a block `eslint-disable` written *inside a test fixture* silences the very
5
+ * rule the case is asserting about. A `valid` case carrying one passes for every
6
+ * possible implementation — including one that reports on every node — so it
7
+ * proves nothing while looking like false-positive protection.
8
+ *
9
+ * That is the worst place to lose coverage: CLAUDE.md ranks a false positive
10
+ * above a false negative, and agora disables a rule outright on any report, so
11
+ * `valid` cases are what stand between a regression and a rule being switched
12
+ * off downstream.
13
+ *
14
+ * Line-scoped directives (`eslint-disable-line`, `eslint-disable-next-line`) are
15
+ * deliberately not treated this way. They pin suppression to one line, so a rule
16
+ * that moved or widened its report still escapes them and fails the case.
17
+ * Likewise a plugin-prefixed id (`@blumintinc/blumint/<name>`) does not suppress
18
+ * anything under `RuleTester`, which knows the rule only by its bare name.
19
+ *
20
+ * Known blind spot: a fixture whose line-scoped directives happen to cover
21
+ * *every* reportable line is inert too, and this check does not see it —
22
+ * deciding that needs to know which lines a rule would report on, which is a
23
+ * whole-suite mutation run rather than something a per-suite check can afford.
24
+ * Two such cases exist (both in `prefer-fragment-component`'s suppression
25
+ * matrix). `.claude/tmp/vacuous-sweep.sh` is the exhaustive dynamic check and
26
+ * puts the true total at 23 against the 21 counted here.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.assertValidCasesCanFail = exports.CASES_ALLOWED_TO_SUPPRESS = exports.suppressesRuleUnderTest = void 0;
30
+ /** ESLint separates a directive's rule list from its justification with ` -- `. */
31
+ const JUSTIFICATION = /\s--\s[\s\S]*$/;
32
+ /**
33
+ * Block-form disables only. `eslint-disable` written as a line comment is not a
34
+ * directive at all, and the `-line` / `-next-line` forms are line-scoped.
35
+ */
36
+ const BLOCK_DISABLE = /\/\*\s*eslint-disable(?!-next-line\b|-line\b)([\s\S]*?)\*\//g;
37
+ /**
38
+ * The rule ids a block disable turns off, or `null` for a bare directive, which
39
+ * turns off everything.
40
+ */
41
+ function disabledRules(directiveBody) {
42
+ const body = directiveBody.replace(JUSTIFICATION, '').trim();
43
+ if (body === '') {
44
+ return null;
45
+ }
46
+ return new Set(body
47
+ .split(',')
48
+ .map((entry) => entry.trim())
49
+ .filter(Boolean));
50
+ }
51
+ /** Whether `code` blanket-disables `ruleName` somewhere in the fixture. */
52
+ function suppressesRuleUnderTest(code, ruleName) {
53
+ BLOCK_DISABLE.lastIndex = 0;
54
+ let match = BLOCK_DISABLE.exec(code);
55
+ while (match !== null) {
56
+ const rules = disabledRules(match[1]);
57
+ if (rules === null || rules.has(ruleName)) {
58
+ return true;
59
+ }
60
+ match = BLOCK_DISABLE.exec(code);
61
+ }
62
+ return false;
63
+ }
64
+ exports.suppressesRuleUnderTest = suppressesRuleUnderTest;
65
+ function codeOf(testCase) {
66
+ if (typeof testCase === 'string') {
67
+ return testCase;
68
+ }
69
+ return testCase?.code ?? '';
70
+ }
71
+ /**
72
+ * Cases that carry a blanket disable *on purpose*: they exist to prove a rule
73
+ * honours suppression (the #1404 import-carrier class), so the directive is the
74
+ * subject of the test rather than an accident.
75
+ *
76
+ * Counts are exact — not a minimum — so a newly added inert case fails here
77
+ * instead of shipping. Resolving #1489, which decides whether these are
78
+ * redundant with the guards already in each rule's `invalid` array, is what
79
+ * removes entries; the map should only ever shrink.
80
+ */
81
+ exports.CASES_ALLOWED_TO_SUPPRESS = Object.freeze({
82
+ 'enforce-assert-safe-object-key': 2,
83
+ 'enforce-memoize-async': 2,
84
+ 'enforce-memoize-getters': 2,
85
+ 'enforce-querykey-ts': 2,
86
+ 'enforce-stable-hash-spread-props': 2,
87
+ 'fast-deep-equal-over-microdiff': 2,
88
+ 'no-array-length-in-deps': 2,
89
+ 'prefer-fragment-component': 3,
90
+ 'prefer-usecallback-over-usememo-for-functions': 2,
91
+ 'require-memoize-jsx-returners': 2,
92
+ });
93
+ /**
94
+ * Throws when a rule's `valid` array contains more blanket-suppressed cases than
95
+ * are deliberately allowed.
96
+ */
97
+ function assertValidCasesCanFail(ruleName, valid) {
98
+ const suppressed = valid.filter((testCase) => suppressesRuleUnderTest(codeOf(testCase), ruleName));
99
+ const allowed = exports.CASES_ALLOWED_TO_SUPPRESS[ruleName] ?? 0;
100
+ if (suppressed.length <= allowed) {
101
+ return;
102
+ }
103
+ const excerpts = suppressed
104
+ .map((testCase) => codeOf(testCase).trim().split('\n')[0])
105
+ .map((line) => ` ${line.slice(0, 100)}`)
106
+ .join('\n');
107
+ throw new Error(`${ruleName}: ${suppressed.length} valid case(s) blanket-disable the rule ` +
108
+ `under test, but only ${allowed} are allowed.\n` +
109
+ `RuleTester registers this rule as '${ruleName}', so a block ` +
110
+ `/* eslint-disable */ (bare or naming it) inside the fixture silences it ` +
111
+ `— the case then passes for every possible implementation and asserts ` +
112
+ `nothing.\n` +
113
+ `Remove the directive (the case should pass on its own), or use a ` +
114
+ `line-scoped eslint-disable-next-line, which still pins the report ` +
115
+ `location.\nOffending case(s):\n${excerpts}`);
116
+ }
117
+ exports.assertValidCasesCanFail = assertValidCasesCanFail;
118
+ //# sourceMappingURL=validCaseFalsifiability.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.41",
3
+ "version": "1.20.42",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -42,7 +42,7 @@
42
42
  "resolve-comments": "./scripts/pr-resolve-comments.sh",
43
43
  "apply-comment-decisions": "./scripts/pr-apply-comment-decisions.sh",
44
44
  "docs": "./scripts/make-docs.sh && npm run update:eslint-docs",
45
- "update:eslint-docs": "eslint-doc-generator --init-rule-docs",
45
+ "update:eslint-docs": "eslint-doc-generator --init-rule-docs || true; eslint-doc-generator",
46
46
  "build": "tsc",
47
47
  "prepare": "husky install && npm run build",
48
48
  "version": "git add -A src",
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "1.20.42",
4
+ "date": "2026-07-31T04:16:11.179Z",
5
+ "rules": [
6
+ {
7
+ "name": "logical-top-to-bottom-grouping",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1493
11
+ ],
12
+ "summary": "stop hoisting effects above captured state (closes #1493)"
13
+ },
14
+ {
15
+ "name": "memo-nested-react-components",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1491,
19
+ 1494,
20
+ 1495
21
+ ],
22
+ "summary": "exempt stubs inside test helper functions (closes #1495); exempt components defined in test bodies (closes #1494); exempt components defined in jest.mock factories (closes #1491)"
23
+ }
24
+ ]
25
+ },
2
26
  {
3
27
  "version": "1.20.41",
4
28
  "date": "2026-07-31T01:39:15.741Z",