@kernlang/review 3.1.6 → 3.1.8
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/dist/cache.d.ts +1 -1
- package/dist/cache.js +5 -3
- package/dist/cache.js.map +1 -1
- package/dist/call-graph.d.ts +63 -0
- package/dist/call-graph.js +380 -0
- package/dist/call-graph.js.map +1 -0
- package/dist/concept-rules/boundary-mutation.d.ts +1 -1
- package/dist/concept-rules/boundary-mutation.js.map +1 -1
- package/dist/concept-rules/ignored-error.d.ts +1 -1
- package/dist/concept-rules/ignored-error.js.map +1 -1
- package/dist/concept-rules/illegal-dependency.d.ts +1 -1
- package/dist/concept-rules/illegal-dependency.js.map +1 -1
- package/dist/concept-rules/index.js +1 -6
- package/dist/concept-rules/index.js.map +1 -1
- package/dist/concept-rules/unguarded-effect.d.ts +1 -1
- package/dist/concept-rules/unguarded-effect.js.map +1 -1
- package/dist/concept-rules/unrecovered-effect.d.ts +1 -1
- package/dist/concept-rules/unrecovered-effect.js +2 -1
- package/dist/concept-rules/unrecovered-effect.js.map +1 -1
- package/dist/confidence.js +12 -8
- package/dist/confidence.js.map +1 -1
- package/dist/differ.js +3 -7
- package/dist/differ.js.map +1 -1
- package/dist/external-tools.js +5 -6
- package/dist/external-tools.js.map +1 -1
- package/dist/file-context.d.ts +21 -0
- package/dist/file-context.js +234 -0
- package/dist/file-context.js.map +1 -0
- package/dist/file-role.js +14 -7
- package/dist/file-role.js.map +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/graph.js +24 -16
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +44 -35
- package/dist/index.js +210 -121
- package/dist/index.js.map +1 -1
- package/dist/inferrer.d.ts +8 -2
- package/dist/inferrer.js +80 -47
- package/dist/inferrer.js.map +1 -1
- package/dist/kern-lint.d.ts +3 -4
- package/dist/kern-lint.js +7 -5
- package/dist/kern-lint.js.map +1 -1
- package/dist/llm-bridge.d.ts +23 -7
- package/dist/llm-bridge.js +267 -31
- package/dist/llm-bridge.js.map +1 -1
- package/dist/llm-review.d.ts +16 -2
- package/dist/llm-review.js +240 -35
- package/dist/llm-review.js.map +1 -1
- package/dist/mappers/ts-concepts.d.ts +1 -1
- package/dist/mappers/ts-concepts.js +303 -32
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/dist/norm-miner.d.ts +31 -0
- package/dist/norm-miner.js +119 -0
- package/dist/norm-miner.js.map +1 -0
- package/dist/obligations.d.ts +63 -0
- package/dist/obligations.js +158 -0
- package/dist/obligations.js.map +1 -0
- package/dist/quality-rules.d.ts +3 -3
- package/dist/quality-rules.js +4 -2
- package/dist/quality-rules.js.map +1 -1
- package/dist/reporter.d.ts +7 -2
- package/dist/reporter.js +82 -51
- package/dist/reporter.js.map +1 -1
- package/dist/rule-eval.d.ts +1 -2
- package/dist/rule-eval.js +5 -9
- package/dist/rule-eval.js.map +1 -1
- package/dist/rule-loader.js +16 -14
- package/dist/rule-loader.js.map +1 -1
- package/dist/rules/base.js +153 -69
- package/dist/rules/base.js.map +1 -1
- package/dist/rules/cli.js +23 -19
- package/dist/rules/cli.js.map +1 -1
- package/dist/rules/confidence.d.ts +1 -1
- package/dist/rules/confidence.js +5 -5
- package/dist/rules/confidence.js.map +1 -1
- package/dist/rules/dead-code.d.ts +10 -0
- package/dist/rules/dead-code.js +75 -0
- package/dist/rules/dead-code.js.map +1 -0
- package/dist/rules/dead-logic.js +35 -31
- package/dist/rules/dead-logic.js.map +1 -1
- package/dist/rules/express.d.ts +2 -1
- package/dist/rules/express.js +380 -126
- package/dist/rules/express.js.map +1 -1
- package/dist/rules/fastapi.js +53 -19
- package/dist/rules/fastapi.js.map +1 -1
- package/dist/rules/ground-layer.js +3 -3
- package/dist/rules/ground-layer.js.map +1 -1
- package/dist/rules/index.js +574 -105
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/ink.js +9 -8
- package/dist/rules/ink.js.map +1 -1
- package/dist/rules/kern-source.js +202 -63
- package/dist/rules/kern-source.js.map +1 -1
- package/dist/rules/nextjs.js +88 -33
- package/dist/rules/nextjs.js.map +1 -1
- package/dist/rules/null-safety.js +52 -26
- package/dist/rules/null-safety.js.map +1 -1
- package/dist/rules/nuxt.js +24 -29
- package/dist/rules/nuxt.js.map +1 -1
- package/dist/rules/react.js +355 -69
- package/dist/rules/react.js.map +1 -1
- package/dist/rules/security-v2.js +71 -57
- package/dist/rules/security-v2.js.map +1 -1
- package/dist/rules/security-v3.js.map +1 -1
- package/dist/rules/security-v4.js +54 -27
- package/dist/rules/security-v4.js.map +1 -1
- package/dist/rules/security.js +35 -5
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/terminal.js +17 -5
- package/dist/rules/terminal.js.map +1 -1
- package/dist/rules/vue.js +162 -107
- package/dist/rules/vue.js.map +1 -1
- package/dist/semantic-diff.d.ts +52 -0
- package/dist/semantic-diff.js +342 -0
- package/dist/semantic-diff.js.map +1 -0
- package/dist/spec-checker.js +11 -10
- package/dist/spec-checker.js.map +1 -1
- package/dist/suppression/apply-suppression.d.ts +2 -3
- package/dist/suppression/apply-suppression.js +3 -3
- package/dist/suppression/apply-suppression.js.map +1 -1
- package/dist/suppression/index.d.ts +2 -2
- package/dist/suppression/index.js +1 -1
- package/dist/suppression/index.js.map +1 -1
- package/dist/suppression/parse-directives.d.ts +1 -1
- package/dist/suppression/parse-directives.js +9 -4
- package/dist/suppression/parse-directives.js.map +1 -1
- package/dist/taint-ast.d.ts +20 -0
- package/dist/taint-ast.js +427 -0
- package/dist/taint-ast.js.map +1 -0
- package/dist/taint-crossfile.d.ts +28 -0
- package/dist/taint-crossfile.js +174 -0
- package/dist/taint-crossfile.js.map +1 -0
- package/dist/taint-findings.d.ts +17 -0
- package/dist/taint-findings.js +131 -0
- package/dist/taint-findings.js.map +1 -0
- package/dist/taint-regex.d.ts +61 -0
- package/dist/taint-regex.js +379 -0
- package/dist/taint-regex.js.map +1 -0
- package/dist/taint-types.d.ts +128 -0
- package/dist/taint-types.js +174 -0
- package/dist/taint-types.js.map +1 -0
- package/dist/taint.d.ts +13 -107
- package/dist/taint.js +16 -1067
- package/dist/taint.js.map +1 -1
- package/dist/template-detector.d.ts +2 -2
- package/dist/template-detector.js +11 -16
- package/dist/template-detector.js.map +1 -1
- package/dist/types.d.ts +35 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/rules/nuxt.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nuxt.js","sourceRoot":"","sources":["../../src/rules/nuxt.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAEtC,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"nuxt.js","sourceRoot":"","sources":["../../src/rules/nuxt.ts"],"names":[],"mappings":"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"}
|
package/dist/rules/react.js
CHANGED
|
@@ -3,33 +3,41 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Catches React-specific bugs that KERN IR + AST can detect mechanically.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
6
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
7
|
+
import { finding } from './utils.js';
|
|
8
|
+
/**
|
|
9
|
+
* Check if a file is actually a React file — has JSX syntax or React imports.
|
|
10
|
+
* Backend/utility files in a React-targeted project should not trigger React rules.
|
|
11
|
+
*/
|
|
12
|
+
function isReactFile(ctx) {
|
|
13
|
+
if (ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement).length > 0)
|
|
14
|
+
return true;
|
|
15
|
+
if (ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length > 0)
|
|
16
|
+
return true;
|
|
17
|
+
if (ctx.sourceFile.getImportDeclarations().some((i) => i.getModuleSpecifierValue() === 'react'))
|
|
18
|
+
return true;
|
|
19
|
+
const fullText = ctx.sourceFile.getFullText();
|
|
20
|
+
if (/\buse(?:State|Effect|Ref|Callback|Memo|Reducer|Context)\s*[<(]/.test(fullText))
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
22
23
|
}
|
|
23
24
|
// ── Rule 11: async-effect ────────────────────────────────────────────────
|
|
24
25
|
// useEffect(async () => ...) — React doesn't support async effect callbacks
|
|
25
26
|
function asyncEffect(ctx) {
|
|
26
27
|
const findings = [];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
28
|
+
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
29
|
+
const callee = call.getExpression().getText();
|
|
30
|
+
if (callee !== 'useEffect' && callee !== 'React.useEffect' && callee !== 'useLayoutEffect')
|
|
31
|
+
continue;
|
|
32
|
+
const args = call.getArguments();
|
|
33
|
+
if (args.length === 0)
|
|
34
|
+
continue;
|
|
35
|
+
const callback = args[0];
|
|
36
|
+
if (Node.isArrowFunction(callback) || Node.isFunctionExpression(callback)) {
|
|
37
|
+
if (callback.isAsync()) {
|
|
38
|
+
findings.push(finding('async-effect', 'error', 'bug', 'useEffect callback must not be async — use an inner async function instead', ctx.filePath, callback.getStartLineNumber(), 1, { suggestion: 'useEffect(() => { async function run() { ... } run(); }, [])' }));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
33
41
|
}
|
|
34
42
|
return findings;
|
|
35
43
|
}
|
|
@@ -37,6 +45,9 @@ function asyncEffect(ctx) {
|
|
|
37
45
|
// setState or fetch called directly in render body (outside hooks/handlers)
|
|
38
46
|
function renderSideEffect(ctx) {
|
|
39
47
|
const findings = [];
|
|
48
|
+
// Gate: skip non-React files
|
|
49
|
+
if (!isReactFile(ctx))
|
|
50
|
+
return findings;
|
|
40
51
|
function checkBlock(block, name) {
|
|
41
52
|
for (const stmt of block.getStatements()) {
|
|
42
53
|
if (stmt.getKind() === SyntaxKind.ReturnStatement)
|
|
@@ -52,12 +63,14 @@ function renderSideEffect(ctx) {
|
|
|
52
63
|
const exprText = exprStmt.getExpression().getText();
|
|
53
64
|
if (/\b(useEffect|useLayoutEffect|useCallback|useMemo|useInsertionEffect)\s*\(/.test(exprText))
|
|
54
65
|
continue;
|
|
55
|
-
if (/\bset[A-Z]\w*\(/.test(exprText) &&
|
|
66
|
+
if (/\bset[A-Z]\w*\(/.test(exprText) &&
|
|
67
|
+
!exprText.includes('useState') &&
|
|
56
68
|
!/\b(setTimeout|setInterval|setImmediate|setAttribute|setProperty|setHeader|setRequestHeader|setItem|setCustomValidity)\s*\(/.test(exprText)) {
|
|
57
|
-
findings.push(finding('render-side-effect', 'error', 'bug', `setState called in render body of '${name}' — move to useEffect or event handler`, ctx.filePath, stmt.getStartLineNumber()));
|
|
69
|
+
findings.push(finding('render-side-effect', 'error', 'bug', `setState called in render body of '${name}' — move to useEffect or event handler`, ctx.filePath, stmt.getStartLineNumber(), 1));
|
|
58
70
|
}
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
const expr = exprStmt.getExpression();
|
|
72
|
+
if (Node.isCallExpression(expr) && expr.getExpression().getText() === 'fetch') {
|
|
73
|
+
findings.push(finding('render-side-effect', 'error', 'bug', `fetch() called in render body of '${name}' — move to useEffect or event handler`, ctx.filePath, stmt.getStartLineNumber(), 1));
|
|
61
74
|
}
|
|
62
75
|
}
|
|
63
76
|
}
|
|
@@ -108,8 +121,7 @@ function unstableKey(ctx) {
|
|
|
108
121
|
if (args.length === 0)
|
|
109
122
|
continue;
|
|
110
123
|
const callback = args[0];
|
|
111
|
-
if (callback.getKind() !== SyntaxKind.ArrowFunction &&
|
|
112
|
-
callback.getKind() !== SyntaxKind.FunctionExpression)
|
|
124
|
+
if (callback.getKind() !== SyntaxKind.ArrowFunction && callback.getKind() !== SyntaxKind.FunctionExpression)
|
|
113
125
|
continue;
|
|
114
126
|
// Get the index parameter (second param of the callback)
|
|
115
127
|
const params = callback.getKind() === SyntaxKind.ArrowFunction
|
|
@@ -152,10 +164,12 @@ function unstableKey(ctx) {
|
|
|
152
164
|
break;
|
|
153
165
|
}
|
|
154
166
|
if (usesIndexKey) {
|
|
155
|
-
findings.push(finding('unstable-key', 'warning', 'bug', `key={${indexParam}} uses array index — use a stable identifier instead`, ctx.filePath, line, { suggestion: 'Use a unique ID from the data (e.g., key={item.id})' }));
|
|
167
|
+
findings.push(finding('unstable-key', 'warning', 'bug', `key={${indexParam}} uses array index — use a stable identifier instead`, ctx.filePath, line, 1, { suggestion: 'Use a unique ID from the data (e.g., key={item.id})' }));
|
|
156
168
|
}
|
|
157
169
|
else if (!hasKey) {
|
|
158
|
-
findings.push(finding('unstable-key', 'warning', 'bug', 'JSX in .map() is missing a key prop', ctx.filePath, line,
|
|
170
|
+
findings.push(finding('unstable-key', 'warning', 'bug', 'JSX in .map() is missing a key prop', ctx.filePath, line, 1, {
|
|
171
|
+
suggestion: 'Add key={item.id} to the root JSX element in .map()',
|
|
172
|
+
}));
|
|
159
173
|
}
|
|
160
174
|
}
|
|
161
175
|
return findings;
|
|
@@ -164,28 +178,26 @@ function unstableKey(ctx) {
|
|
|
164
178
|
// Timer captures state not in dependency array
|
|
165
179
|
function staleClosure(ctx) {
|
|
166
180
|
const findings = [];
|
|
167
|
-
// AST-based: find useEffect() calls and analyze deps + timer usage
|
|
168
181
|
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
169
|
-
const callee = call.getExpression();
|
|
170
|
-
if (callee
|
|
182
|
+
const callee = call.getExpression().getText();
|
|
183
|
+
if (callee !== 'useEffect' && callee !== 'useLayoutEffect')
|
|
171
184
|
continue;
|
|
172
185
|
const args = call.getArguments();
|
|
173
186
|
if (args.length < 2)
|
|
174
187
|
continue;
|
|
175
|
-
|
|
176
|
-
const callbackText = args[0].getText();
|
|
177
|
-
// Second arg: deps array
|
|
188
|
+
const callback = args[0];
|
|
178
189
|
const depsArg = args[1];
|
|
179
|
-
if (
|
|
180
|
-
continue;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
if (!Node.isArrayLiteralExpression(depsArg))
|
|
191
|
+
continue;
|
|
192
|
+
if (depsArg.getElements().length !== 0)
|
|
193
|
+
continue;
|
|
194
|
+
// Pure AST: find setInterval/setTimeout calls inside the callback
|
|
195
|
+
const timers = callback.getDescendantsOfKind(SyntaxKind.CallExpression).filter((c) => {
|
|
196
|
+
const name = c.getExpression().getText();
|
|
197
|
+
return name === 'setInterval' || name === 'setTimeout';
|
|
198
|
+
});
|
|
199
|
+
if (timers.length > 0) {
|
|
200
|
+
findings.push(finding('stale-closure', 'warning', 'bug', 'Timer in useEffect with empty deps [] may capture stale state', ctx.filePath, call.getStartLineNumber(), 1, { suggestion: 'Use a ref for the latest value or add dependencies' }));
|
|
189
201
|
}
|
|
190
202
|
}
|
|
191
203
|
return findings;
|
|
@@ -194,29 +206,29 @@ function staleClosure(ctx) {
|
|
|
194
206
|
// >5 useState calls in a single component — should be useReducer or machine
|
|
195
207
|
function stateExplosion(ctx) {
|
|
196
208
|
const findings = [];
|
|
197
|
-
|
|
209
|
+
function checkFn(fn, name) {
|
|
210
|
+
const useStates = fn.getDescendantsOfKind(SyntaxKind.CallExpression).filter((c) => {
|
|
211
|
+
const text = c.getExpression().getText();
|
|
212
|
+
return text === 'useState' || text === 'React.useState';
|
|
213
|
+
});
|
|
214
|
+
if (useStates.length > 5) {
|
|
215
|
+
findings.push(finding('state-explosion', 'warning', 'pattern', `Component '${name}' has ${useStates.length} useState calls — consider useReducer or a state machine`, ctx.filePath, fn.getStartLineNumber(), 1, { suggestion: 'Use useReducer for complex state, or a KERN machine node for state transitions' }));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
198
218
|
for (const fn of ctx.sourceFile.getFunctions()) {
|
|
199
219
|
const name = fn.getName() || '';
|
|
200
220
|
if (!name || name[0] !== name[0].toUpperCase())
|
|
201
221
|
continue;
|
|
202
|
-
|
|
203
|
-
const useStateCount = (body.match(/useState\s*[<(]/g) || []).length;
|
|
204
|
-
if (useStateCount > 5) {
|
|
205
|
-
findings.push(finding('state-explosion', 'warning', 'pattern', `Component '${name}' has ${useStateCount} useState calls — consider useReducer or a state machine`, ctx.filePath, fn.getStartLineNumber(), { suggestion: 'Use useReducer for complex state, or a KERN machine node for state transitions' }));
|
|
206
|
-
}
|
|
222
|
+
checkFn(fn, name);
|
|
207
223
|
}
|
|
208
|
-
// Also check arrow function components
|
|
209
224
|
for (const stmt of ctx.sourceFile.getVariableStatements()) {
|
|
210
225
|
for (const decl of stmt.getDeclarations()) {
|
|
211
226
|
const name = decl.getName();
|
|
212
227
|
if (!name || name[0] !== name[0].toUpperCase())
|
|
213
228
|
continue;
|
|
214
|
-
const init = decl.getInitializer()
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
const useStateCount = (init.match(/useState\s*[<(]/g) || []).length;
|
|
218
|
-
if (useStateCount > 5) {
|
|
219
|
-
findings.push(finding('state-explosion', 'warning', 'pattern', `Component '${name}' has ${useStateCount} useState calls — consider useReducer or a state machine`, ctx.filePath, stmt.getStartLineNumber()));
|
|
229
|
+
const init = decl.getInitializer();
|
|
230
|
+
if (init && Node.isArrowFunction(init)) {
|
|
231
|
+
checkFn(init, name);
|
|
220
232
|
}
|
|
221
233
|
}
|
|
222
234
|
}
|
|
@@ -224,10 +236,23 @@ function stateExplosion(ctx) {
|
|
|
224
236
|
}
|
|
225
237
|
// ── Rule 16: hook-order ──────────────────────────────────────────────────
|
|
226
238
|
// Conditional hook calls (hooks inside if/loop/early return)
|
|
227
|
-
const HOOK_NAMES = new Set([
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
239
|
+
const HOOK_NAMES = new Set([
|
|
240
|
+
'useState',
|
|
241
|
+
'useEffect',
|
|
242
|
+
'useCallback',
|
|
243
|
+
'useMemo',
|
|
244
|
+
'useRef',
|
|
245
|
+
'useContext',
|
|
246
|
+
'useReducer',
|
|
247
|
+
'useLayoutEffect',
|
|
248
|
+
'useImperativeHandle',
|
|
249
|
+
'useDebugValue',
|
|
250
|
+
'useDeferredValue',
|
|
251
|
+
'useTransition',
|
|
252
|
+
'useId',
|
|
253
|
+
'useSyncExternalStore',
|
|
254
|
+
'useInsertionEffect',
|
|
255
|
+
]);
|
|
231
256
|
function hookOrder(ctx) {
|
|
232
257
|
const findings = [];
|
|
233
258
|
// Collect all control-flow nodes (if/for/while/do)
|
|
@@ -241,9 +266,9 @@ function hookOrder(ctx) {
|
|
|
241
266
|
];
|
|
242
267
|
for (const cfNode of controlFlowNodes) {
|
|
243
268
|
// Only flag hooks inside components (capitalized) or custom hooks (use*)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
269
|
+
const enclosingFn = cfNode.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration) ||
|
|
270
|
+
cfNode.getFirstAncestorByKind(SyntaxKind.ArrowFunction) ||
|
|
271
|
+
cfNode.getFirstAncestorByKind(SyntaxKind.FunctionExpression);
|
|
247
272
|
if (!enclosingFn)
|
|
248
273
|
continue;
|
|
249
274
|
const fnName = enclosingFn.getName?.() || '';
|
|
@@ -263,7 +288,7 @@ function hookOrder(ctx) {
|
|
|
263
288
|
if (reported.has(hookName))
|
|
264
289
|
continue;
|
|
265
290
|
reported.add(hookName);
|
|
266
|
-
findings.push(finding('hook-order', 'error', 'bug', `Hook '${hookName}' called inside ${label} — violates Rules of Hooks`, ctx.filePath, cfNode.getStartLineNumber(), { suggestion: 'Move hook call to top level of component' }));
|
|
291
|
+
findings.push(finding('hook-order', 'error', 'bug', `Hook '${hookName}' called inside ${label} — violates Rules of Hooks`, ctx.filePath, cfNode.getStartLineNumber(), 1, { suggestion: 'Move hook call to top level of component' }));
|
|
267
292
|
}
|
|
268
293
|
}
|
|
269
294
|
return findings;
|
|
@@ -303,7 +328,7 @@ function effectSelfUpdateLoop(ctx) {
|
|
|
303
328
|
continue;
|
|
304
329
|
if (!Node.isArrayLiteralExpression(depsArg))
|
|
305
330
|
continue;
|
|
306
|
-
const deps = new Set(depsArg.getElements().map(el => el.getText()));
|
|
331
|
+
const deps = new Set(depsArg.getElements().map((el) => el.getText()));
|
|
307
332
|
// Find setter calls in the effect body
|
|
308
333
|
for (const innerCall of callbackArg.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
309
334
|
const expr = innerCall.getExpression();
|
|
@@ -325,7 +350,263 @@ function effectSelfUpdateLoop(ctx) {
|
|
|
325
350
|
}
|
|
326
351
|
if (isNested)
|
|
327
352
|
continue;
|
|
328
|
-
findings.push(finding('effect-self-update-loop', 'error', 'bug', `useEffect updates '${stateName}' via ${setterName}() while '${stateName}' is in deps — infinite re-render loop`, ctx.filePath, innerCall.getStartLineNumber(), { suggestion: `Move the write behind a guard or use a ref to break the cycle` }));
|
|
353
|
+
findings.push(finding('effect-self-update-loop', 'error', 'bug', `useEffect updates '${stateName}' via ${setterName}() while '${stateName}' is in deps — infinite re-render loop`, ctx.filePath, innerCall.getStartLineNumber(), 1, { suggestion: `Move the write behind a guard or use a ref to break the cycle` }));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return findings;
|
|
357
|
+
}
|
|
358
|
+
// ── Rule: missing-effect-cleanup ─────────────────────────────────────────
|
|
359
|
+
// useEffect with setInterval/addEventListener but no cleanup return function
|
|
360
|
+
function missingEffectCleanup(ctx) {
|
|
361
|
+
const findings = [];
|
|
362
|
+
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
363
|
+
const callee = call.getExpression().getText();
|
|
364
|
+
if (callee !== 'useEffect' && callee !== 'useLayoutEffect')
|
|
365
|
+
continue;
|
|
366
|
+
const args = call.getArguments();
|
|
367
|
+
if (args.length === 0)
|
|
368
|
+
continue;
|
|
369
|
+
const callback = args[0];
|
|
370
|
+
if (!Node.isArrowFunction(callback) && !Node.isFunctionExpression(callback))
|
|
371
|
+
continue;
|
|
372
|
+
const body = callback.getBody();
|
|
373
|
+
let hasCleanup = false;
|
|
374
|
+
if (Node.isBlock(body)) {
|
|
375
|
+
hasCleanup = body.getStatements().some((s) => {
|
|
376
|
+
if (!Node.isReturnStatement(s))
|
|
377
|
+
return false;
|
|
378
|
+
const expr = s.getExpression();
|
|
379
|
+
return (expr != null && (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr) || Node.isIdentifier(expr)));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (hasCleanup)
|
|
383
|
+
continue;
|
|
384
|
+
const leakyCalls = callback.getDescendantsOfKind(SyntaxKind.CallExpression).filter((c) => {
|
|
385
|
+
const name = c.getExpression().getText();
|
|
386
|
+
return (name === 'setInterval' || name === 'setTimeout' || name.endsWith('.addEventListener') || name.endsWith('.on'));
|
|
387
|
+
});
|
|
388
|
+
if (leakyCalls.length > 0) {
|
|
389
|
+
findings.push(finding('missing-effect-cleanup', 'warning', 'bug', `useEffect uses '${leakyCalls[0].getExpression().getText()}' but is missing a cleanup return function`, ctx.filePath, call.getStartLineNumber(), 1, { suggestion: 'Return a cleanup function: return () => clearInterval(id);' }));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return findings;
|
|
393
|
+
}
|
|
394
|
+
// ── Rule: inline-context-value ───────────────────────────────────────────
|
|
395
|
+
// <Context.Provider value={{...}}> causes re-renders on every parent render
|
|
396
|
+
function inlineContextValue(ctx) {
|
|
397
|
+
const findings = [];
|
|
398
|
+
for (const jsx of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) {
|
|
399
|
+
const name = jsx.getTagNameNode().getText();
|
|
400
|
+
if (!name.endsWith('.Provider'))
|
|
401
|
+
continue;
|
|
402
|
+
for (const attr of jsx.getAttributes()) {
|
|
403
|
+
if (!Node.isJsxAttribute(attr) || attr.getNameNode().getText() !== 'value')
|
|
404
|
+
continue;
|
|
405
|
+
const init = attr.getInitializer();
|
|
406
|
+
if (!init || !Node.isJsxExpression(init))
|
|
407
|
+
continue;
|
|
408
|
+
const expr = init.getExpression();
|
|
409
|
+
if (!expr)
|
|
410
|
+
continue;
|
|
411
|
+
if (Node.isObjectLiteralExpression(expr) || Node.isArrayLiteralExpression(expr)) {
|
|
412
|
+
findings.push(finding('inline-context-value', 'warning', 'pattern', 'Inline object/array passed to Context.Provider value — causes all consumers to re-render', ctx.filePath, jsx.getStartLineNumber(), 1, { suggestion: 'Memoize the value with useMemo' }));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return findings;
|
|
417
|
+
}
|
|
418
|
+
// ── Rule: ref-in-render ──────────────────────────────────────────────────
|
|
419
|
+
// Reading or writing ref.current during render — breaks React purity rules
|
|
420
|
+
// Source: react.dev/reference/react/useRef, eslint-plugin-react-hooks/refs
|
|
421
|
+
function refInRender(ctx) {
|
|
422
|
+
if (!isReactFile(ctx))
|
|
423
|
+
return [];
|
|
424
|
+
const findings = [];
|
|
425
|
+
// Collect useRef variable names: const myRef = useRef(...)
|
|
426
|
+
const refVars = new Set();
|
|
427
|
+
for (const decl of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
428
|
+
const init = decl.getInitializer();
|
|
429
|
+
if (!init || !Node.isCallExpression(init))
|
|
430
|
+
continue;
|
|
431
|
+
const callee = init.getExpression().getText();
|
|
432
|
+
if (callee === 'useRef' || callee === 'React.useRef') {
|
|
433
|
+
refVars.add(decl.getName());
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (refVars.size === 0)
|
|
437
|
+
return findings;
|
|
438
|
+
// Identify safe scopes: useEffect/useLayoutEffect/useCallback/event handler callbacks
|
|
439
|
+
const SAFE_CALLEE = new Set(['useEffect', 'useLayoutEffect', 'useCallback', 'useInsertionEffect']);
|
|
440
|
+
function isInSafeScope(node) {
|
|
441
|
+
let cur = node.getParent();
|
|
442
|
+
while (cur) {
|
|
443
|
+
// Inside a useEffect/useCallback callback
|
|
444
|
+
if ((Node.isArrowFunction(cur) || Node.isFunctionExpression(cur)) && cur.getParent()) {
|
|
445
|
+
const parent = cur.getParent();
|
|
446
|
+
if (Node.isCallExpression(parent)) {
|
|
447
|
+
const calleeName = parent.getExpression().getText();
|
|
448
|
+
if (SAFE_CALLEE.has(calleeName))
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Inside an event handler in JSX: onClick={() => ref.current = ...}
|
|
453
|
+
if ((Node.isArrowFunction(cur) || Node.isFunctionExpression(cur)) && cur.getParent()) {
|
|
454
|
+
const parent = cur.getParent();
|
|
455
|
+
if (Node.isJsxExpression(parent))
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
// Inside a cleanup return function
|
|
459
|
+
if ((Node.isArrowFunction(cur) || Node.isFunctionExpression(cur)) && cur.getParent()) {
|
|
460
|
+
const parent = cur.getParent();
|
|
461
|
+
if (Node.isReturnStatement(parent))
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
cur = cur.getParent();
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
// Find .current access on ref variables
|
|
469
|
+
for (const prop of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
470
|
+
if (prop.getName() !== 'current')
|
|
471
|
+
continue;
|
|
472
|
+
const obj = prop.getExpression();
|
|
473
|
+
if (!Node.isIdentifier(obj))
|
|
474
|
+
continue;
|
|
475
|
+
if (!refVars.has(obj.getText()))
|
|
476
|
+
continue;
|
|
477
|
+
// Skip if inside safe scope (effect, handler, callback)
|
|
478
|
+
if (isInSafeScope(prop))
|
|
479
|
+
continue;
|
|
480
|
+
// Skip lazy initialization pattern: if (ref.current === null) ref.current = x
|
|
481
|
+
// React explicitly allows this during render (react.dev/reference/react/useRef)
|
|
482
|
+
const ifAncestor = prop.getFirstAncestorByKind(SyntaxKind.IfStatement);
|
|
483
|
+
if (ifAncestor) {
|
|
484
|
+
const condText = ifAncestor.getExpression().getText();
|
|
485
|
+
const refName = obj.getText();
|
|
486
|
+
if (condText.includes(`${refName}.current`) &&
|
|
487
|
+
(condText.includes('null') || condText.includes('undefined') || condText.startsWith('!'))) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Check if this is a read or write
|
|
492
|
+
const parent = prop.getParent();
|
|
493
|
+
const isWrite = parent &&
|
|
494
|
+
Node.isBinaryExpression(parent) &&
|
|
495
|
+
parent.getLeft() === prop &&
|
|
496
|
+
parent.getOperatorToken().getKind() === SyntaxKind.EqualsToken;
|
|
497
|
+
const action = isWrite ? 'written to' : 'read';
|
|
498
|
+
findings.push(finding('ref-in-render', 'error', 'bug', `ref.current ${action} during render — refs are not tracked by React and may be stale`, ctx.filePath, prop.getStartLineNumber(), 1, {
|
|
499
|
+
suggestion: isWrite
|
|
500
|
+
? 'Move ref writes to useEffect or event handlers'
|
|
501
|
+
: 'Use useState instead if the value affects rendering',
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
return findings;
|
|
505
|
+
}
|
|
506
|
+
// ── Rule: missing-memo-deps ──────────────────────────────────────────────
|
|
507
|
+
// useMemo/useCallback called without dependency array — recomputes every render
|
|
508
|
+
// Source: react.dev/reference/react/useMemo, react.dev/reference/react/useCallback
|
|
509
|
+
const MEMO_HOOKS = new Set(['useMemo', 'useCallback', 'React.useMemo', 'React.useCallback']);
|
|
510
|
+
function missingMemoDeps(ctx) {
|
|
511
|
+
const findings = [];
|
|
512
|
+
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
513
|
+
const callee = call.getExpression().getText();
|
|
514
|
+
if (!MEMO_HOOKS.has(callee))
|
|
515
|
+
continue;
|
|
516
|
+
const args = call.getArguments();
|
|
517
|
+
if (args.length === 0)
|
|
518
|
+
continue;
|
|
519
|
+
// First arg should be the function, second should be deps array
|
|
520
|
+
if (args.length < 2) {
|
|
521
|
+
const hookName = callee.includes('.') ? callee.split('.')[1] : callee;
|
|
522
|
+
findings.push(finding('missing-memo-deps', 'warning', 'bug', `${hookName} called without dependency array — will recompute on every render, defeating memoization`, ctx.filePath, call.getStartLineNumber(), 1, { suggestion: `Add a dependency array as the second argument: ${hookName}(fn, [dep1, dep2])` }));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return findings;
|
|
526
|
+
}
|
|
527
|
+
// ── Rule: reducer-mutation ──────────────────────────────────────────────
|
|
528
|
+
// Direct state mutation inside useReducer reducer function
|
|
529
|
+
// Source: react.dev/reference/react/useReducer
|
|
530
|
+
function reducerMutation(ctx) {
|
|
531
|
+
const findings = [];
|
|
532
|
+
// Find useReducer calls and get the reducer function
|
|
533
|
+
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
534
|
+
const callee = call.getExpression().getText();
|
|
535
|
+
if (callee !== 'useReducer' && callee !== 'React.useReducer')
|
|
536
|
+
continue;
|
|
537
|
+
const args = call.getArguments();
|
|
538
|
+
if (args.length === 0)
|
|
539
|
+
continue;
|
|
540
|
+
const reducer = args[0];
|
|
541
|
+
// Reducer can be inline or a reference — handle both
|
|
542
|
+
let reducerBody;
|
|
543
|
+
let stateParam;
|
|
544
|
+
if (Node.isArrowFunction(reducer) || Node.isFunctionExpression(reducer)) {
|
|
545
|
+
reducerBody = reducer.getBody();
|
|
546
|
+
const params = reducer.getParameters();
|
|
547
|
+
if (params.length > 0)
|
|
548
|
+
stateParam = params[0].getName();
|
|
549
|
+
}
|
|
550
|
+
else if (Node.isIdentifier(reducer)) {
|
|
551
|
+
const name = reducer.getText();
|
|
552
|
+
const fn = ctx.sourceFile.getFunction(name);
|
|
553
|
+
if (fn) {
|
|
554
|
+
reducerBody = fn.getBody();
|
|
555
|
+
const params = fn.getParameters();
|
|
556
|
+
if (params.length > 0)
|
|
557
|
+
stateParam = params[0].getName();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!reducerBody || !stateParam)
|
|
561
|
+
continue;
|
|
562
|
+
// Look for direct mutations: state.prop = ..., state.prop++, state.push(...)
|
|
563
|
+
const mutationMethods = new Set(['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']);
|
|
564
|
+
for (const bin of reducerBody.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
565
|
+
const op = bin.getOperatorToken().getKind();
|
|
566
|
+
if (op !== SyntaxKind.EqualsToken && op !== SyntaxKind.PlusEqualsToken && op !== SyntaxKind.MinusEqualsToken)
|
|
567
|
+
continue;
|
|
568
|
+
const left = bin.getLeft();
|
|
569
|
+
if (!Node.isPropertyAccessExpression(left))
|
|
570
|
+
continue;
|
|
571
|
+
const root = left.getExpression();
|
|
572
|
+
if (Node.isIdentifier(root) && root.getText() === stateParam) {
|
|
573
|
+
findings.push(finding('reducer-mutation', 'error', 'bug', `Reducer mutates '${stateParam}.${left.getName()}' directly — return a new object instead`, ctx.filePath, bin.getStartLineNumber(), 1, { suggestion: `return { ...${stateParam}, ${left.getName()}: newValue }` }));
|
|
574
|
+
break; // One finding per reducer
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Check for state.method() mutations
|
|
578
|
+
for (const methodCall of reducerBody.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
579
|
+
const expr = methodCall.getExpression();
|
|
580
|
+
if (!Node.isPropertyAccessExpression(expr))
|
|
581
|
+
continue;
|
|
582
|
+
if (!mutationMethods.has(expr.getName()))
|
|
583
|
+
continue;
|
|
584
|
+
const obj = expr.getExpression();
|
|
585
|
+
// state.push() or state.items.push()
|
|
586
|
+
if (Node.isIdentifier(obj) && obj.getText() === stateParam) {
|
|
587
|
+
findings.push(finding('reducer-mutation', 'error', 'bug', `Reducer mutates '${stateParam}' via .${expr.getName()}() — return a new object instead`, ctx.filePath, methodCall.getStartLineNumber(), 1, { suggestion: `Return new state: return { ...${stateParam}, ... }` }));
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
if (Node.isPropertyAccessExpression(obj)) {
|
|
591
|
+
const root = obj.getExpression();
|
|
592
|
+
if (Node.isIdentifier(root) && root.getText() === stateParam) {
|
|
593
|
+
findings.push(finding('reducer-mutation', 'error', 'bug', `Reducer mutates '${stateParam}.${obj.getName()}' via .${expr.getName()}() — use immutable update`, ctx.filePath, methodCall.getStartLineNumber(), 1, {
|
|
594
|
+
suggestion: `return { ...${stateParam}, ${obj.getName()}: [...${stateParam}.${obj.getName()}, newItem] }`,
|
|
595
|
+
}));
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Check for state.prop++ / ++state.prop
|
|
601
|
+
for (const postfix of reducerBody.getDescendantsOfKind(SyntaxKind.PostfixUnaryExpression)) {
|
|
602
|
+
const operand = postfix.getOperand();
|
|
603
|
+
if (!Node.isPropertyAccessExpression(operand))
|
|
604
|
+
continue;
|
|
605
|
+
const root = operand.getExpression();
|
|
606
|
+
if (Node.isIdentifier(root) && root.getText() === stateParam) {
|
|
607
|
+
findings.push(finding('reducer-mutation', 'error', 'bug', `Reducer mutates '${stateParam}.${operand.getName()}' via ++ — return a new object instead`, ctx.filePath, postfix.getStartLineNumber(), 1, { suggestion: `return { ...${stateParam}, ${operand.getName()}: ${stateParam}.${operand.getName()} + 1 }` }));
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
329
610
|
}
|
|
330
611
|
}
|
|
331
612
|
return findings;
|
|
@@ -339,5 +620,10 @@ export const reactRules = [
|
|
|
339
620
|
stateExplosion,
|
|
340
621
|
hookOrder,
|
|
341
622
|
effectSelfUpdateLoop,
|
|
623
|
+
missingEffectCleanup,
|
|
624
|
+
inlineContextValue,
|
|
625
|
+
refInRender,
|
|
626
|
+
missingMemoDeps,
|
|
627
|
+
reducerMutation,
|
|
342
628
|
];
|
|
343
629
|
//# sourceMappingURL=react.js.map
|