@blumintinc/eslint-plugin-blumint 1.20.56 → 1.20.58
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
|
@@ -42,6 +42,39 @@ function bindsMemoize(variable) {
|
|
|
42
42
|
ALLOWED_MEMOIZE_MODULES.has(String(declaration.source.value)));
|
|
43
43
|
}));
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a declared return type annotation promises no value: `void` or
|
|
47
|
+
* `Promise<void>`.
|
|
48
|
+
*
|
|
49
|
+
* Memoizing such a method caches nothing — there is no result to hand back —
|
|
50
|
+
* while changing runtime behaviour: the side effects run once per instance and
|
|
51
|
+
* every later call silently no-ops. Because the decision comes from the
|
|
52
|
+
* annotation node, spacing and line breaks inside the type are irrelevant.
|
|
53
|
+
*
|
|
54
|
+
* The check stays keyed to a plain `void`: a union (`Promise<void | undefined>`)
|
|
55
|
+
* or a type parameter (`Promise<T>`) can resolve to a value, so those still
|
|
56
|
+
* warrant caching. Absent annotations are likewise not exempt — this rule is
|
|
57
|
+
* syntactic (no `parserOptions.project`), so an unannotated body carries no
|
|
58
|
+
* declaration of intent to honour, and exempting inferred void would silently
|
|
59
|
+
* drop methods the author never marked value-less.
|
|
60
|
+
*/
|
|
61
|
+
function declaresVoidResult(returnType) {
|
|
62
|
+
const annotation = returnType?.typeAnnotation;
|
|
63
|
+
if (!annotation) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSVoidKeyword) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (annotation.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
70
|
+
annotation.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
71
|
+
annotation.typeName.name !== 'Promise') {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const typeArguments = annotation.typeParameters?.params;
|
|
75
|
+
return (typeArguments?.length === 1 &&
|
|
76
|
+
typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
|
|
77
|
+
}
|
|
45
78
|
/**
|
|
46
79
|
* Matches a memoize decorator in supported syntaxes:
|
|
47
80
|
* - @Alias()
|
|
@@ -148,6 +181,13 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
148
181
|
if (node.value.params.length > 1) {
|
|
149
182
|
return;
|
|
150
183
|
}
|
|
184
|
+
// A method declared to produce no value has no result to cache, so the
|
|
185
|
+
// decorator's benefit is unobtainable while its cost is real: the
|
|
186
|
+
// fixer would convert a repeatable side effect into a
|
|
187
|
+
// once-per-instance one, unattended, under `--fix`.
|
|
188
|
+
if (declaresVoidResult(node.value.returnType)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
151
191
|
const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
|
|
152
192
|
const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
|
|
153
193
|
// Check if method already has @Memoize or @Memoize() decorator
|
|
@@ -20,6 +20,58 @@ function isEffectHookCall(node) {
|
|
|
20
20
|
return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
21
21
|
EFFECT_HOOK_NAMES.has(callee.name));
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The rule whose suppression marks a dependency array as hand-maintained.
|
|
25
|
+
*/
|
|
26
|
+
const EXHAUSTIVE_DEPS_RULE = 'react-hooks/exhaustive-deps';
|
|
27
|
+
/**
|
|
28
|
+
* Matches the keyword of an `eslint-disable`, `eslint-disable-next-line` or
|
|
29
|
+
* `eslint-disable-line` directive, leaving the rule list as the remainder. The
|
|
30
|
+
* lookahead keeps `eslint-disabled-something` (and prose that merely opens with
|
|
31
|
+
* the same letters) from parsing as a directive.
|
|
32
|
+
*/
|
|
33
|
+
const DISABLE_DIRECTIVE = /^\s*eslint-disable(-next-line|-line)?(?![\w-])/u;
|
|
34
|
+
/**
|
|
35
|
+
* ESLint splits a directive's rule list from its ` -- justification` suffix on
|
|
36
|
+
* this separator, so the rule list must be read the same way.
|
|
37
|
+
*/
|
|
38
|
+
const JUSTIFICATION_SEPARATOR = /\s-{2,}\s/u;
|
|
39
|
+
/**
|
|
40
|
+
* The line an exhaustive-deps disable comment covers, `'file'` for the
|
|
41
|
+
* whole-file form, or null when the comment is not such a directive.
|
|
42
|
+
*
|
|
43
|
+
* why: `eslint-disable-next-line` covers the line after the comment ends, while
|
|
44
|
+
* `eslint-disable-line` covers the comment's own line. Only the block form of
|
|
45
|
+
* the bare `eslint-disable` is a file-level directive to ESLint, so a line
|
|
46
|
+
* comment starting with it is not treated as one here either. The file form
|
|
47
|
+
* counts for the whole file rather than from its own position onward: erring
|
|
48
|
+
* toward exempting a hook keeps a hand-managed array intact, which is the safe
|
|
49
|
+
* direction for a deleting fixer.
|
|
50
|
+
*/
|
|
51
|
+
function readExhaustiveDepsDisable(comment) {
|
|
52
|
+
const [directive] = comment.value.split(JUSTIFICATION_SEPARATOR);
|
|
53
|
+
const match = DISABLE_DIRECTIVE.exec(directive);
|
|
54
|
+
if (!match) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// why: a bare directive with no rule list says nothing about dependency
|
|
58
|
+
// management, so only an explicit mention of exhaustive-deps counts.
|
|
59
|
+
const namesExhaustiveDeps = directive
|
|
60
|
+
.slice(match[0].length)
|
|
61
|
+
.split(',')
|
|
62
|
+
.some((ruleId) => ruleId.trim() === EXHAUSTIVE_DEPS_RULE);
|
|
63
|
+
if (!namesExhaustiveDeps) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const scope = match[1];
|
|
67
|
+
if (scope === '-next-line') {
|
|
68
|
+
return comment.loc.end.line + 1;
|
|
69
|
+
}
|
|
70
|
+
if (scope === '-line') {
|
|
71
|
+
return comment.loc.start.line;
|
|
72
|
+
}
|
|
73
|
+
return comment.type === utils_1.AST_TOKEN_TYPES.Block ? 'file' : null;
|
|
74
|
+
}
|
|
23
75
|
/** `channelGroupActive` -> `setChannelGroupActive`, `a` -> `setA`. */
|
|
24
76
|
function toSetterName(dependencyName) {
|
|
25
77
|
return `set${dependencyName.charAt(0).toUpperCase()}${dependencyName.slice(1)}`;
|
|
@@ -609,6 +661,53 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
609
661
|
// In a real environment, we would want to enforce this
|
|
610
662
|
// throw new Error('You have to enable the `project` setting in parser options to use this rule');
|
|
611
663
|
}
|
|
664
|
+
const sourceCode = context.getSourceCode();
|
|
665
|
+
// why: scanning every comment once per file rather than once per hook call
|
|
666
|
+
// keeps the check off the hot path of files with many hooks.
|
|
667
|
+
let manuallyManagedLines = null;
|
|
668
|
+
let disabledForWholeFile = false;
|
|
669
|
+
function collectDisableDirectives() {
|
|
670
|
+
if (manuallyManagedLines) {
|
|
671
|
+
return manuallyManagedLines;
|
|
672
|
+
}
|
|
673
|
+
const lines = new Set();
|
|
674
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
675
|
+
const scope = readExhaustiveDepsDisable(comment);
|
|
676
|
+
if (scope === 'file') {
|
|
677
|
+
disabledForWholeFile = true;
|
|
678
|
+
}
|
|
679
|
+
else if (scope !== null) {
|
|
680
|
+
lines.add(scope);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
manuallyManagedLines = lines;
|
|
684
|
+
return lines;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Whether the author has taken manual control of this hook's dependency
|
|
688
|
+
* array by suppressing `react-hooks/exhaustive-deps` for it.
|
|
689
|
+
*
|
|
690
|
+
* why: exhaustive-deps is the rule that would otherwise force every read
|
|
691
|
+
* value into the array, so disabling it declares the array hand-maintained.
|
|
692
|
+
* Entries in such an array are load-bearing by construction — an unread one
|
|
693
|
+
* is a deliberate recompute trigger (a hydration flag, a change-detecting
|
|
694
|
+
* hash) whose deletion silently returns a stale value. The comment can sit
|
|
695
|
+
* above the hook call, above the dependency array, or above the closing
|
|
696
|
+
* `}, [...])` line, so any directive landing anywhere within the call
|
|
697
|
+
* counts.
|
|
698
|
+
*/
|
|
699
|
+
function hasManuallyManagedDeps(node) {
|
|
700
|
+
const lines = collectDisableDirectives();
|
|
701
|
+
if (disabledForWholeFile) {
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
for (let line = node.loc.start.line; line <= node.loc.end.line; line += 1) {
|
|
705
|
+
if (lines.has(line)) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
612
711
|
return {
|
|
613
712
|
CallExpression(node) {
|
|
614
713
|
if (!isHookCall(node)) {
|
|
@@ -628,6 +727,7 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
628
727
|
}
|
|
629
728
|
const callbackBody = callbackArg.body;
|
|
630
729
|
const isEffect = isEffectHookCall(node);
|
|
730
|
+
const manuallyManagedDeps = hasManuallyManagedDeps(node);
|
|
631
731
|
// Check each dependency in the array
|
|
632
732
|
depsArg.elements.forEach((element) => {
|
|
633
733
|
const unwrappedElement = element ? unwrapExpression(element) : null;
|
|
@@ -648,6 +748,16 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
648
748
|
const result = getObjectUsagesInHook(callbackBody, objectName);
|
|
649
749
|
// If the object is not used at all, suggest removing it
|
|
650
750
|
if (result.notUsed) {
|
|
751
|
+
// why: deleting an entry from an array the author maintains by
|
|
752
|
+
// hand is presumptuous — the suppression is the declaration that
|
|
753
|
+
// the entries were chosen deliberately, and an unread one is a
|
|
754
|
+
// recompute trigger whose removal yields a stale value. Narrowing
|
|
755
|
+
// an entire object (avoidEntireObject) is a different transform
|
|
756
|
+
// and stays enabled: it preserves the dependency, it does not
|
|
757
|
+
// drop it.
|
|
758
|
+
if (manuallyManagedDeps) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
651
761
|
// why: an effect reruns for its side effects, so a dependency the
|
|
652
762
|
// body never reads is normally a deliberate re-run trigger
|
|
653
763
|
// (React's reset-on-scope-change idiom) — deleting it silently
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.58",
|
|
4
|
+
"date": "2026-08-01T04:26:49.434Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-memoize-async",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1548
|
|
11
|
+
],
|
|
12
|
+
"summary": "skip methods declared Promise<void> (closes #1548)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.57",
|
|
18
|
+
"date": "2026-08-01T03:42:42.343Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "no-entire-object-hook-deps",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1547
|
|
25
|
+
],
|
|
26
|
+
"summary": "never prune deps from a hand-maintained dependency array (closes #1547)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.56",
|
|
4
32
|
"date": "2026-08-01T03:08:15.343Z",
|