@blumintinc/eslint-plugin-blumint 1.20.55 → 1.20.57

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.55',
226
+ version: '1.20.57',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -5,10 +5,77 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const typescript_1 = require("typescript");
7
7
  const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
8
+ /**
9
+ * Hooks that run for their side effects rather than producing a value. An
10
+ * unread dependency means something different here than in useMemo/useCallback
11
+ * — see `callsCorrespondingSetter`.
12
+ */
13
+ const EFFECT_HOOK_NAMES = new Set(['useEffect']);
8
14
  function isHookCall(node) {
9
15
  const callee = node.callee;
10
16
  return (callee.type === utils_1.AST_NODE_TYPES.Identifier && HOOK_NAMES.has(callee.name));
11
17
  }
18
+ function isEffectHookCall(node) {
19
+ const callee = node.callee;
20
+ return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
21
+ EFFECT_HOOK_NAMES.has(callee.name));
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
+ }
75
+ /** `channelGroupActive` -> `setChannelGroupActive`, `a` -> `setA`. */
76
+ function toSetterName(dependencyName) {
77
+ return `set${dependencyName.charAt(0).toUpperCase()}${dependencyName.slice(1)}`;
78
+ }
12
79
  function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
13
80
  try {
14
81
  const tsNode = nodeMap.get(esTreeNode);
@@ -80,6 +147,55 @@ function unwrapExpression(expr) {
80
147
  }
81
148
  return current;
82
149
  }
150
+ /**
151
+ * Whether the hook body anywhere calls the state setter that corresponds to
152
+ * `dependencyName` (dep `count` -> `setCount(...)`).
153
+ *
154
+ * why: for an effect, an unread dependency is React's reset-on-scope-change
155
+ * idiom — a deliberate re-run trigger. The one shape where an unread dependency
156
+ * is genuinely wrong is the circular dependency, where the effect writes the
157
+ * very value it depends on and so re-triggers itself. The setter call is that
158
+ * signature. It can sit arbitrarily deep (inside an inner async function, a
159
+ * `startTransition` callback, a `.then()`), so the whole body is searched.
160
+ */
161
+ function callsCorrespondingSetter(hookBody, dependencyName) {
162
+ const setterName = toSetterName(dependencyName);
163
+ const visited = new Set();
164
+ function visit(node) {
165
+ if (!node || visited.has(node))
166
+ return false;
167
+ visited.add(node);
168
+ if (node.type === utils_1.AST_NODE_TYPES.CallExpression) {
169
+ const callee = unwrapExpression(node.callee);
170
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
171
+ callee.name === setterName) {
172
+ return true;
173
+ }
174
+ }
175
+ for (const key in node) {
176
+ if (key === 'parent')
177
+ continue; // Skip parent references to avoid cycles
178
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
+ const child = node[key];
180
+ if (!child || typeof child !== 'object')
181
+ continue;
182
+ if (Array.isArray(child)) {
183
+ for (const item of child) {
184
+ if (item && typeof item === 'object' && 'type' in item) {
185
+ if (visit(item))
186
+ return true;
187
+ }
188
+ }
189
+ }
190
+ else if ('type' in child) {
191
+ if (visit(child))
192
+ return true;
193
+ }
194
+ }
195
+ return false;
196
+ }
197
+ return visit(hookBody);
198
+ }
83
199
  function getObjectUsagesInHook(hookBody, objectName) {
84
200
  const usages = new Map(); // Track usage and its position
85
201
  // why: derived dependency paths (first-optional intermediate, array base)
@@ -545,6 +661,53 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
545
661
  // In a real environment, we would want to enforce this
546
662
  // throw new Error('You have to enable the `project` setting in parser options to use this rule');
547
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
+ }
548
711
  return {
549
712
  CallExpression(node) {
550
713
  if (!isHookCall(node)) {
@@ -562,6 +725,9 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
562
725
  callbackArg.type !== utils_1.AST_NODE_TYPES.FunctionExpression)) {
563
726
  return;
564
727
  }
728
+ const callbackBody = callbackArg.body;
729
+ const isEffect = isEffectHookCall(node);
730
+ const manuallyManagedDeps = hasManuallyManagedDeps(node);
565
731
  // Check each dependency in the array
566
732
  depsArg.elements.forEach((element) => {
567
733
  const unwrappedElement = element ? unwrapExpression(element) : null;
@@ -579,9 +745,30 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
579
745
  }
580
746
  }
581
747
  // For testing without TypeScript services, we'll assume all identifiers are objects
582
- const result = getObjectUsagesInHook(callbackArg.body, objectName);
748
+ const result = getObjectUsagesInHook(callbackBody, objectName);
583
749
  // If the object is not used at all, suggest removing it
584
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
+ }
761
+ // why: an effect reruns for its side effects, so a dependency the
762
+ // body never reads is normally a deliberate re-run trigger
763
+ // (React's reset-on-scope-change idiom) — deleting it silently
764
+ // stops the effect from rerunning. Only when the body also writes
765
+ // that value (setX for dep x) is the dependency a circular one
766
+ // worth removing. Value-producing hooks (useMemo/useCallback)
767
+ // gain nothing from an unread dependency, so they still report.
768
+ if (isEffect &&
769
+ !callsCorrespondingSetter(callbackBody, objectName)) {
770
+ return;
771
+ }
585
772
  context.report({
586
773
  node: element,
587
774
  messageId: 'removeUnusedDependency',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.55",
3
+ "version": "1.20.57",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.57",
4
+ "date": "2026-08-01T03:42:42.343Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-entire-object-hook-deps",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1547
11
+ ],
12
+ "summary": "never prune deps from a hand-maintained dependency array (closes #1547)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.56",
18
+ "date": "2026-08-01T03:08:15.343Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-entire-object-hook-deps",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1546
25
+ ],
26
+ "summary": "treat unread useEffect deps as re-run triggers unless the effect sets them (closes #1546)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.55",
4
32
  "date": "2026-08-01T02:47:13.295Z",