@blumintinc/eslint-plugin-blumint 1.20.41 → 1.20.43

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.43',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const createRule_1 = require("../utils/createRule");
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
5
6
  exports.default = (0, createRule_1.createRule)({
6
7
  name: 'enforce-callback-memo',
7
8
  meta: {
@@ -194,6 +195,14 @@ exports.default = (0, createRule_1.createRule)({
194
195
  node.value.type !== utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
195
196
  return;
196
197
  }
198
+ // The only remediation this rule offers is a hook call, which is legal
199
+ // solely inside a component or a hook. Module-scope JSX, a plain helper
200
+ // that happens to build JSX, and JSX rendered from a test body are all
201
+ // outside any render path, so wrapping there would throw
202
+ // "Invalid hook call" while saving no re-render.
203
+ if (!ASTHelpers_1.ASTHelpers.isInsideComponentOrHook(node, context)) {
204
+ return;
205
+ }
197
206
  // Props of JSX built inside a useMemo factory inherit the memo's stability
198
207
  if (isInsideUseMemoFactory(node)) {
199
208
  return;
@@ -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',
@@ -61,6 +61,47 @@ export declare class ASTHelpers {
61
61
  * parenthesized expressions to get to the underlying expression.
62
62
  */
63
63
  static unwrapTSAssertions(node: TSESTree.Node): TSESTree.Node;
64
+ /**
65
+ * Calls that wrap a component/hook definition without renaming it, so the
66
+ * binding they are assigned to still names the wrapped function
67
+ * (`const Component = memo(() => ...)`).
68
+ */
69
+ private static readonly TRANSPARENT_WRAPPER_CALLEES;
70
+ private static isFunctionNode;
71
+ private static isTransparentWrapperCall;
72
+ private static staticPropertyName;
73
+ /**
74
+ * Resolves the name a function is known by: its own identifier, or the
75
+ * binding it is assigned to (variable, object property, class field,
76
+ * assignment target). Returns null only when the function is truly
77
+ * anonymous, e.g. an inline callback argument such as `items.map(() => ...)`.
78
+ */
79
+ static inferFunctionName(node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration): string | null;
80
+ /**
81
+ * React's universal convention: only PascalCase-initial identifiers are
82
+ * components, and only `use`-prefixed ones are hooks. A camelCase name is a
83
+ * plain helper or a render-prop callback.
84
+ */
85
+ private static isComponentOrHookName;
86
+ /**
87
+ * Reports whether a node sits anywhere inside a React component or hook, so a
88
+ * rule whose remediation is a hook call (useCallback/useMemo/useState) can
89
+ * stay silent where that call would be a Rules-of-Hooks violation: module
90
+ * scope, a plain helper function, or a test body such as `it(() => ...)`.
91
+ *
92
+ * The whole enclosing-function ancestry is consulted, not just the nearest
93
+ * function: a `.map()` render callback inside a component is still a render
94
+ * path and must stay reportable.
95
+ *
96
+ * Classification is name-first. A function the developer named is judged by
97
+ * that name alone — `buildTree` is not a component even though it returns
98
+ * JSX, because the name is an explicit signal about its role. Only a truly
99
+ * anonymous function falls back to "does it return JSX", which is what makes
100
+ * `memo(() => <div />)` a component. That fallback is suppressed when some
101
+ * enclosing function carries a non-component name, since a callback nested in
102
+ * a plain helper is no more of a render path than the helper itself.
103
+ */
104
+ static isInsideComponentOrHook(node: TSESTree.Node, context?: Readonly<TSESLint.RuleContext<string, readonly unknown[]>>): boolean;
64
105
  /**
65
106
  * Helper to get ancestors of a node in a way that is compatible with both ESLint v8 and v9.
66
107
  * In ESLint v9, context.getAncestors() is deprecated and moved to context.sourceCode.getAncestors(node).
@@ -661,6 +661,140 @@ class ASTHelpers {
661
661
  }
662
662
  return inner;
663
663
  }
664
+ static isFunctionNode(node) {
665
+ return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
666
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
667
+ node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
668
+ }
669
+ static isTransparentWrapperCall(node) {
670
+ const { callee } = node;
671
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
672
+ return this.TRANSPARENT_WRAPPER_CALLEES.has(callee.name);
673
+ }
674
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
675
+ !callee.computed &&
676
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
677
+ this.TRANSPARENT_WRAPPER_CALLEES.has(callee.property.name));
678
+ }
679
+ static staticPropertyName(key, computed) {
680
+ if (computed) {
681
+ return null;
682
+ }
683
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
684
+ return key.name;
685
+ }
686
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
687
+ return key.value;
688
+ }
689
+ return null;
690
+ }
691
+ /**
692
+ * Resolves the name a function is known by: its own identifier, or the
693
+ * binding it is assigned to (variable, object property, class field,
694
+ * assignment target). Returns null only when the function is truly
695
+ * anonymous, e.g. an inline callback argument such as `items.map(() => ...)`.
696
+ */
697
+ static inferFunctionName(node) {
698
+ if (node.id?.name) {
699
+ return node.id.name;
700
+ }
701
+ let child = node;
702
+ let parent = node.parent;
703
+ while (parent) {
704
+ switch (parent.type) {
705
+ case utils_1.AST_NODE_TYPES.VariableDeclarator:
706
+ return parent.id.type === utils_1.AST_NODE_TYPES.Identifier
707
+ ? parent.id.name
708
+ : null;
709
+ case utils_1.AST_NODE_TYPES.Property:
710
+ return this.staticPropertyName(parent.key, parent.computed);
711
+ case utils_1.AST_NODE_TYPES.PropertyDefinition:
712
+ case utils_1.AST_NODE_TYPES.MethodDefinition:
713
+ return this.staticPropertyName(parent.key, parent.computed);
714
+ case utils_1.AST_NODE_TYPES.AssignmentExpression: {
715
+ const { left } = parent;
716
+ if (left.type === utils_1.AST_NODE_TYPES.Identifier) {
717
+ return left.name;
718
+ }
719
+ if (left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
720
+ return this.staticPropertyName(left.property, left.computed);
721
+ }
722
+ return null;
723
+ }
724
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
725
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
726
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
727
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
728
+ child = parent;
729
+ parent = parent.parent;
730
+ continue;
731
+ case utils_1.AST_NODE_TYPES.CallExpression:
732
+ // Only step through wrappers that preserve identity; an arbitrary
733
+ // callback argument (`items.map(fn)`) is not named by whatever the
734
+ // call's result is assigned to.
735
+ if (parent.arguments.includes(child) &&
736
+ this.isTransparentWrapperCall(parent)) {
737
+ child = parent;
738
+ parent = parent.parent;
739
+ continue;
740
+ }
741
+ return null;
742
+ default:
743
+ return null;
744
+ }
745
+ }
746
+ return null;
747
+ }
748
+ /**
749
+ * React's universal convention: only PascalCase-initial identifiers are
750
+ * components, and only `use`-prefixed ones are hooks. A camelCase name is a
751
+ * plain helper or a render-prop callback.
752
+ */
753
+ static isComponentOrHookName(name) {
754
+ return /^[A-Z]/.test(name) || /^use[A-Z0-9_]/.test(name);
755
+ }
756
+ /**
757
+ * Reports whether a node sits anywhere inside a React component or hook, so a
758
+ * rule whose remediation is a hook call (useCallback/useMemo/useState) can
759
+ * stay silent where that call would be a Rules-of-Hooks violation: module
760
+ * scope, a plain helper function, or a test body such as `it(() => ...)`.
761
+ *
762
+ * The whole enclosing-function ancestry is consulted, not just the nearest
763
+ * function: a `.map()` render callback inside a component is still a render
764
+ * path and must stay reportable.
765
+ *
766
+ * Classification is name-first. A function the developer named is judged by
767
+ * that name alone — `buildTree` is not a component even though it returns
768
+ * JSX, because the name is an explicit signal about its role. Only a truly
769
+ * anonymous function falls back to "does it return JSX", which is what makes
770
+ * `memo(() => <div />)` a component. That fallback is suppressed when some
771
+ * enclosing function carries a non-component name, since a callback nested in
772
+ * a plain helper is no more of a render path than the helper itself.
773
+ */
774
+ static isInsideComponentOrHook(node, context) {
775
+ const anonymousFunctions = [];
776
+ let hasNamedNonComponent = false;
777
+ let current = node.parent;
778
+ while (current) {
779
+ if (this.isFunctionNode(current)) {
780
+ const name = this.inferFunctionName(current);
781
+ if (name === null) {
782
+ anonymousFunctions.push(current);
783
+ }
784
+ else if (this.isComponentOrHookName(name)) {
785
+ return true;
786
+ }
787
+ else {
788
+ hasNamedNonComponent = true;
789
+ }
790
+ }
791
+ current = current.parent;
792
+ }
793
+ if (hasNamedNonComponent) {
794
+ return false;
795
+ }
796
+ return anonymousFunctions.some((fn) => this.returnsJSX(fn, context));
797
+ }
664
798
  /**
665
799
  * Helper to get ancestors of a node in a way that is compatible with both ESLint v8 and v9.
666
800
  * In ESLint v9, context.getAncestors() is deprecated and moved to context.sourceCode.getAncestors(node).
@@ -671,5 +805,17 @@ class ASTHelpers {
671
805
  (context.getAncestors ? context.getAncestors() : []));
672
806
  }
673
807
  }
808
+ /**
809
+ * Calls that wrap a component/hook definition without renaming it, so the
810
+ * binding they are assigned to still names the wrapped function
811
+ * (`const Component = memo(() => ...)`).
812
+ */
813
+ ASTHelpers.TRANSPARENT_WRAPPER_CALLEES = new Set([
814
+ 'forwardRef',
815
+ 'memo',
816
+ 'observer',
817
+ 'useCallback',
818
+ 'useMemo',
819
+ ]);
674
820
  exports.ASTHelpers = ASTHelpers;
675
821
  //# sourceMappingURL=ASTHelpers.js.map
@@ -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.43",
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",