@blumintinc/eslint-plugin-blumint 1.20.56 → 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.56',
226
+ version: '1.20.57',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.56",
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,18 @@
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
+ },
2
16
  {
3
17
  "version": "1.20.56",
4
18
  "date": "2026-08-01T03:08:15.343Z",