@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/README.md +196 -197
- package/lib/index.js +1 -1
- package/lib/rules/enforce-callback-memo.js +9 -0
- package/lib/rules/logical-top-to-bottom-grouping.js +41 -1
- package/lib/rules/memo-nested-react-components.js +127 -1
- package/lib/utils/ASTHelpers.d.ts +41 -0
- package/lib/utils/ASTHelpers.js +146 -0
- package/lib/utils/installValidCaseGuard.d.ts +1 -0
- package/lib/utils/installValidCaseGuard.js +24 -0
- package/lib/utils/validCaseFalsifiability.d.ts +48 -0
- package/lib/utils/validCaseFalsifiability.js +118 -0
- package/package.json +2 -2
- package/release-manifest.json +38 -0
package/lib/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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).
|
package/lib/utils/ASTHelpers.js
CHANGED
|
@@ -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.
|
|
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",
|