Skip to content

Commit cd4c101

Browse files
committed
feat: initial working version with require-exhaustive-deps-comment rule
0 parents  commit cd4c101

File tree

11 files changed

+1648
-0
lines changed

11 files changed

+1648
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
console.log('PLUGIN LOADED')
2+
3+
module.exports = {
4+
rules: {
5+
"require-exhaustive-deps-comment": require("./lib/rules/require-exhaustive-deps-comment")
6+
}
7+
};
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"use strict";
2+
3+
// ✅ Persistent cache across `create()` calls
4+
const fileSeenHooks = new Map();
5+
const IGNORED_GLOBALS = new Set(["useEffect", "useMemo", "useCallback", "useLayoutEffect", "Component"]);
6+
7+
module.exports = {
8+
meta: {
9+
type: "problem",
10+
docs: {
11+
description: "Require comments with dependency names when disabling exhaustive-deps",
12+
category: "Best Practices",
13+
recommended: false
14+
},
15+
messages: {
16+
missingComment: "Disabling 'react-hooks/exhaustive-deps' requires '-- depName [optional reason]'.",
17+
missingDepMention: "Dependency '{{dep}}' is used in hook but not mentioned in comment.",
18+
staleDep: "Dependency '{{dep}}' is no longer used in hook but still mentioned in comment.",
19+
unnecessaryDisable: "No missing dependencies detected — remove 'eslint-disable-line react-hooks/exhaustive-deps'."
20+
},
21+
hasSuggestions: true,
22+
schema: [
23+
{
24+
type: "object",
25+
properties: {
26+
enabledHooks: { type: "array", items: { type: "string" } },
27+
disabledHooks: { type: "array", items: { type: "string" } },
28+
requireCommentPerDependency: { type: "boolean" },
29+
respectUpstreamRule: { type: "boolean" }
30+
},
31+
additionalProperties: false
32+
}
33+
]
34+
},
35+
36+
create(context) {
37+
const sourceCode = context.getSourceCode();
38+
const config = context.options[0] || {};
39+
const enabledHooks = new Set(config.enabledHooks || ["useEffect", "useMemo", "useCallback", "useLayoutEffect"]);
40+
const disabledHooks = new Set(config.disabledHooks || []);
41+
const requirePerDep = config.requireCommentPerDependency !== false;
42+
43+
function extractMentionedDeps(commentText) {
44+
const raw = commentText.split("--")[1] || "";
45+
const tokens = raw.split(/[,\n]/)
46+
.map(s => s.trim().split(":")[0])
47+
.map(s => s.split(/\s+/)[0])
48+
.filter(Boolean);
49+
50+
return [...new Set(tokens)];
51+
}
52+
53+
function getDisableComment(node) {
54+
const allComments = sourceCode.getAllComments();
55+
return allComments.find(comment =>
56+
comment.type === "Line" &&
57+
comment.loc.start.line === node.loc.end.line &&
58+
comment.value.trim().startsWith("eslint-disable-line react-hooks/exhaustive-deps")
59+
);
60+
}
61+
62+
function extractHookDeps(node) {
63+
if (!node.arguments || node.arguments.length !== 2) return [];
64+
const depsNode = node.arguments[1];
65+
if (!depsNode || depsNode.type !== "ArrayExpression") return [];
66+
return depsNode.elements
67+
.filter(el => el && el.type === "Identifier")
68+
.map(el => el.name);
69+
}
70+
71+
function extractUsedIdentifiers(hookNode) {
72+
const identifiers = new Set();
73+
const visited = new WeakSet();
74+
75+
const callback = hookNode.arguments?.[0];
76+
if (
77+
!callback ||
78+
(callback.type !== "FunctionExpression" && callback.type !== "ArrowFunctionExpression")
79+
) return [];
80+
81+
function walk(node) {
82+
if (!node || typeof node !== "object" || visited.has(node)) return;
83+
visited.add(node);
84+
85+
if (node.type === "Identifier" && node.name !== undefined) {
86+
identifiers.add(node.name);
87+
}
88+
89+
for (const key in node) {
90+
if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
91+
const value = node[key];
92+
if (Array.isArray(value)) {
93+
value.forEach(walk);
94+
} else if (value && typeof value === "object" && value.type) {
95+
walk(value);
96+
}
97+
}
98+
}
99+
100+
if (callback.body?.type === "BlockStatement") {
101+
for (const statement of callback.body.body) {
102+
walk(statement);
103+
}
104+
} else {
105+
walk(callback.body);
106+
}
107+
108+
const used = Array.from(identifiers).filter(name => !IGNORED_GLOBALS.has(name) && name !== undefined);
109+
110+
return used;
111+
}
112+
113+
return {
114+
CallExpression(node) {
115+
const callee = node.callee.name;
116+
const line = node.loc?.start?.line;
117+
const filename = context.getFilename();
118+
119+
if (!fileSeenHooks.has(filename)) {
120+
fileSeenHooks.set(filename, new Set());
121+
}
122+
const seenHooks = fileSeenHooks.get(filename);
123+
const hookKey = `${callee}@${line}`;
124+
if (seenHooks.has(hookKey)) return;
125+
seenHooks.add(hookKey);
126+
127+
if (disabledHooks.has(callee)) return;
128+
if (!enabledHooks.has(callee)) return;
129+
130+
const disableComment = getDisableComment(node);
131+
if (!disableComment) return;
132+
133+
const text = disableComment.value.trim();
134+
if (!text.includes("--")) {
135+
context.report({
136+
loc: disableComment.loc,
137+
messageId: "missingComment"
138+
});
139+
return;
140+
}
141+
142+
const mentioned = extractMentionedDeps(text);
143+
const usedIds = extractUsedIdentifiers(node);
144+
const hookDeps = extractHookDeps(node);
145+
const ignoredDeps = usedIds.filter(id => !hookDeps.includes(id));
146+
const staleMentions = mentioned.filter(dep => !usedIds.includes(dep));
147+
148+
if (!ignoredDeps.length && !mentioned.length) {
149+
context.report({
150+
loc: disableComment.loc,
151+
messageId: "unnecessaryDisable"
152+
});
153+
}
154+
155+
ignoredDeps.forEach(dep => {
156+
if (!mentioned.includes(dep)) {
157+
context.report({
158+
loc: disableComment.loc,
159+
messageId: "missingDepMention",
160+
data: { dep },
161+
suggest: [
162+
{
163+
desc: `Add '${dep}' to comment`,
164+
fix: fixer => {
165+
const insert = requirePerDep ? `${dep}: [reason]` : dep;
166+
const updated = text + (text.endsWith("--") ? " " : ", ") + insert;
167+
return fixer.replaceText(disableComment, `// ${updated}`);
168+
}
169+
}
170+
]
171+
});
172+
}
173+
});
174+
175+
staleMentions.forEach(dep => {
176+
context.report({
177+
loc: disableComment.loc,
178+
messageId: "staleDep",
179+
data: { dep }
180+
});
181+
});
182+
},
183+
184+
'Program:exit'() {
185+
// 🔥 Clean up cache for this file
186+
fileSeenHooks.delete(context.getFilename());
187+
}
188+
};
189+
}
190+
};

package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "eslint-plugin-strict-hooks",
3+
"version": "0.1.0",
4+
"main": "index.js",
5+
"scripts": {
6+
"test:sandbox": "node scripts/dev-test.js"
7+
},
8+
"keywords": [
9+
"eslint",
10+
"eslintplugin",
11+
"react",
12+
"hooks",
13+
"dependencies"
14+
],
15+
"devDependencies": {
16+
"eslint": "^8.57.0"
17+
},
18+
"peerDependencies": {
19+
"eslint": "^8.0.0"
20+
},
21+
"dependencies": {
22+
"eslint-utils": "^3.0.0"
23+
}
24+
}

sandbox/.eslintrc.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module.exports = {
2+
root: true,
3+
parserOptions: {
4+
ecmaVersion: 2021,
5+
sourceType: "module"
6+
},
7+
plugins: ["react-hooks", "strict-hooks"],
8+
rules: {
9+
"strict-hooks/require-exhaustive-deps-comment": ["warn", {
10+
enabledHooks: ["useEffect", "useMemo", "useCallback"],
11+
requireCommentPerDependency: true
12+
}],
13+
"react-hooks/exhaustive-deps": "warn"
14+
}
15+
};

sandbox/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

sandbox/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "sandbox",
3+
"version": "1.0.0",
4+
"main": "index.js",
5+
"license": "MIT",
6+
"devDependencies": {
7+
"eslint": "^8.57.0",
8+
"eslint-plugin-react-hooks": "^5.2.0",
9+
"eslint-utils": "^3.0.0"
10+
}
11+
}

sandbox/test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect, useMemo } from "react";
2+
3+
function Component() {
4+
const fetchData = () => {};
5+
const fetchData1 = () => {};
6+
const fetchData2 = () => {};
7+
useEffect(() => {
8+
fetchData();
9+
fetchData1();
10+
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- fetchData: memoized
11+
12+
useMemo(() => {
13+
fetchData2();
14+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
15+
}

0 commit comments

Comments
 (0)