@kernlang/review 3.2.3 → 3.3.5
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.js +140 -3
- package/dist/cache.js.map +1 -1
- package/dist/call-graph.d.ts +4 -1
- package/dist/call-graph.js +290 -25
- package/dist/call-graph.js.map +1 -1
- package/dist/concept-rules/contract-drift.d.ts +21 -0
- package/dist/concept-rules/contract-drift.js +66 -0
- package/dist/concept-rules/contract-drift.js.map +1 -0
- package/dist/concept-rules/cross-stack-utils.d.ts +50 -0
- package/dist/concept-rules/cross-stack-utils.js +98 -0
- package/dist/concept-rules/cross-stack-utils.js.map +1 -0
- package/dist/concept-rules/index.js +12 -1
- package/dist/concept-rules/index.js.map +1 -1
- package/dist/concept-rules/tainted-across-wire.d.ts +33 -0
- package/dist/concept-rules/tainted-across-wire.js +98 -0
- package/dist/concept-rules/tainted-across-wire.js.map +1 -0
- package/dist/concept-rules/untyped-api-response.d.ts +30 -0
- package/dist/concept-rules/untyped-api-response.js +71 -0
- package/dist/concept-rules/untyped-api-response.js.map +1 -0
- package/dist/external-tools.d.ts +36 -4
- package/dist/external-tools.js +79 -12
- package/dist/external-tools.js.map +1 -1
- package/dist/graph.js +149 -39
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +29 -4
- package/dist/index.js +329 -47
- package/dist/index.js.map +1 -1
- package/dist/inferrer.d.ts +5 -0
- package/dist/inferrer.js +1 -1
- package/dist/inferrer.js.map +1 -1
- package/dist/llm-bridge.d.ts +26 -1
- package/dist/llm-bridge.js +42 -6
- package/dist/llm-bridge.js.map +1 -1
- package/dist/llm-review.js +29 -11
- package/dist/llm-review.js.map +1 -1
- package/dist/mappers/ts-concepts.js +278 -7
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/dist/public-api.d.ts +73 -0
- package/dist/public-api.js +351 -0
- package/dist/public-api.js.map +1 -0
- package/dist/reporter.d.ts +5 -0
- package/dist/reporter.js +119 -84
- package/dist/reporter.js.map +1 -1
- package/dist/review-health.d.ts +38 -0
- package/dist/review-health.js +60 -0
- package/dist/review-health.js.map +1 -0
- package/dist/rules/async.js +4 -16
- package/dist/rules/async.js.map +1 -1
- package/dist/rules/base.js +112 -87
- package/dist/rules/base.js.map +1 -1
- package/dist/rules/confidence.d.ts +2 -2
- package/dist/rules/confidence.js +32 -15
- package/dist/rules/confidence.js.map +1 -1
- package/dist/rules/dead-code.d.ts +2 -1
- package/dist/rules/dead-code.js +49 -3
- package/dist/rules/dead-code.js.map +1 -1
- package/dist/rules/index.js +131 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/kern-source-cross-file.d.ts +2 -0
- package/dist/rules/kern-source-cross-file.js +102 -0
- package/dist/rules/kern-source-cross-file.js.map +1 -0
- package/dist/rules/kern-source.js +86 -9
- package/dist/rules/kern-source.js.map +1 -1
- package/dist/rules/nextjs-app-router.js +936 -31
- package/dist/rules/nextjs-app-router.js.map +1 -1
- package/dist/rules/nextjs.js +193 -10
- package/dist/rules/nextjs.js.map +1 -1
- package/dist/rules/react-composition.js +442 -61
- package/dist/rules/react-composition.js.map +1 -1
- package/dist/rules/react-hooks.js +51 -2
- package/dist/rules/react-hooks.js.map +1 -1
- package/dist/rules/react.js +265 -49
- package/dist/rules/react.js.map +1 -1
- package/dist/rules/utils.d.ts +37 -2
- package/dist/rules/utils.js +113 -0
- package/dist/rules/utils.js.map +1 -1
- package/dist/semantic-diff.js +1 -1
- package/dist/semantic-diff.js.map +1 -1
- package/dist/taint-ast.js +228 -4
- package/dist/taint-ast.js.map +1 -1
- package/dist/taint-crossfile.d.ts +30 -2
- package/dist/taint-crossfile.js +280 -59
- package/dist/taint-crossfile.js.map +1 -1
- package/dist/taint-types.d.ts +2 -1
- package/dist/taint-types.js +32 -2
- package/dist/taint-types.js.map +1 -1
- package/dist/taint.d.ts +1 -1
- package/dist/taint.js +1 -1
- package/dist/taint.js.map +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* These rules require import-graph awareness — they gracefully no-op when run
|
|
6
6
|
* in single-file mode (no ctx.fileContext).
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { basename, dirname, resolve } from 'path';
|
|
10
|
+
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
11
|
+
import { finding, span } from './utils.js';
|
|
11
12
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
12
13
|
const CLIENT_HOOKS = new Set([
|
|
13
14
|
'useState',
|
|
@@ -40,17 +41,32 @@ const CLIENT_EVENT_HANDLERS = new Set([
|
|
|
40
41
|
'onDrag',
|
|
41
42
|
]);
|
|
42
43
|
const BROWSER_GLOBALS = /\b(window|document|localStorage|sessionStorage|navigator|history|location)\b/;
|
|
44
|
+
const BROWSER_GLOBAL_NAMES = [
|
|
45
|
+
'window',
|
|
46
|
+
'document',
|
|
47
|
+
'localStorage',
|
|
48
|
+
'sessionStorage',
|
|
49
|
+
'navigator',
|
|
50
|
+
'history',
|
|
51
|
+
'location',
|
|
52
|
+
];
|
|
53
|
+
const ACTION_STATE_HOOKS = new Set(['useActionState']);
|
|
43
54
|
function hasClientDirective(fullText) {
|
|
44
55
|
return /^['"]use client['"];?\s*$/m.test(fullText.substring(0, 200));
|
|
45
56
|
}
|
|
46
57
|
function hasServerDirective(fullText) {
|
|
47
58
|
return /^['"]use server['"];?\s*$/m.test(fullText.substring(0, 200));
|
|
48
59
|
}
|
|
60
|
+
function isHookLikeName(name) {
|
|
61
|
+
return CLIENT_HOOKS.has(name) || /^use[A-Z0-9]/.test(name);
|
|
62
|
+
}
|
|
49
63
|
/** Does this file itself use any client-only API (hooks, browser globals, event handlers)? */
|
|
50
64
|
function fileUsesClientApi(ctx) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
for (const identifier of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
66
|
+
const name = identifier.getText();
|
|
67
|
+
if (BROWSER_GLOBAL_NAMES.includes(name) && isBrowserGlobalReference(identifier, name))
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
54
70
|
// JSX event handlers
|
|
55
71
|
for (const attr of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
56
72
|
const name = attr.getNameNode().getText();
|
|
@@ -61,17 +77,619 @@ function fileUsesClientApi(ctx) {
|
|
|
61
77
|
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
62
78
|
const expr = call.getExpression();
|
|
63
79
|
if (expr.getKind() === SyntaxKind.Identifier) {
|
|
64
|
-
if (
|
|
80
|
+
if (isHookLikeName(expr.getText()))
|
|
65
81
|
return true;
|
|
66
82
|
}
|
|
67
83
|
else if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
68
84
|
const prop = expr.asKind(SyntaxKind.PropertyAccessExpression);
|
|
69
|
-
if (prop &&
|
|
85
|
+
if (prop && isHookLikeName(prop.getName()))
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
function isClientBoundary(ctx, fullText) {
|
|
92
|
+
return (hasClientDirective(fullText) || ctx.fileContext?.isClientBoundary === true || ctx.fileContext?.boundary === 'client');
|
|
93
|
+
}
|
|
94
|
+
function unwrapParens(node) {
|
|
95
|
+
let current = node;
|
|
96
|
+
while (Node.isParenthesizedExpression(current)) {
|
|
97
|
+
current = current.getExpression();
|
|
98
|
+
}
|
|
99
|
+
return current;
|
|
100
|
+
}
|
|
101
|
+
function isNodeWithin(node, container) {
|
|
102
|
+
if (!container)
|
|
103
|
+
return false;
|
|
104
|
+
return node.getStart() >= container.getStart() && node.getEnd() <= container.getEnd();
|
|
105
|
+
}
|
|
106
|
+
function getTypeofGuardState(node, globalName) {
|
|
107
|
+
const expr = unwrapParens(node);
|
|
108
|
+
if (!Node.isBinaryExpression(expr))
|
|
109
|
+
return undefined;
|
|
110
|
+
const operator = expr.getOperatorToken().getText();
|
|
111
|
+
if (operator !== '===' && operator !== '==' && operator !== '!==' && operator !== '!=')
|
|
112
|
+
return undefined;
|
|
113
|
+
const left = unwrapParens(expr.getLeft());
|
|
114
|
+
const right = unwrapParens(expr.getRight());
|
|
115
|
+
const isTypeofGlobal = (candidate) => Node.isTypeOfExpression(candidate) &&
|
|
116
|
+
Node.isIdentifier(candidate.getExpression()) &&
|
|
117
|
+
candidate.getExpression().getText() === globalName;
|
|
118
|
+
const isUndefinedLiteral = (candidate) => Node.isStringLiteral(candidate) && candidate.getLiteralText() === 'undefined';
|
|
119
|
+
if (!((isTypeofGlobal(left) && isUndefinedLiteral(right)) || (isUndefinedLiteral(left) && isTypeofGlobal(right)))) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return operator === '!==' || operator === '!=' ? 'defined' : 'undefined';
|
|
123
|
+
}
|
|
124
|
+
function conditionGuaranteesBrowserGlobal(node, globalName, branch) {
|
|
125
|
+
const expr = unwrapParens(node);
|
|
126
|
+
const state = getTypeofGuardState(expr, globalName);
|
|
127
|
+
if (state)
|
|
128
|
+
return branch === 'true' ? state === 'defined' : state === 'undefined';
|
|
129
|
+
if (Node.isPrefixUnaryExpression(expr) && expr.getOperatorToken() === SyntaxKind.ExclamationToken) {
|
|
130
|
+
return conditionGuaranteesBrowserGlobal(expr.getOperand(), globalName, branch === 'true' ? 'false' : 'true');
|
|
131
|
+
}
|
|
132
|
+
if (!Node.isBinaryExpression(expr))
|
|
133
|
+
return false;
|
|
134
|
+
const operator = expr.getOperatorToken().getText();
|
|
135
|
+
if (branch === 'true' && operator === '&&') {
|
|
136
|
+
return (conditionGuaranteesBrowserGlobal(expr.getLeft(), globalName, 'true') ||
|
|
137
|
+
conditionGuaranteesBrowserGlobal(expr.getRight(), globalName, 'true'));
|
|
138
|
+
}
|
|
139
|
+
if (branch === 'false' && operator === '||') {
|
|
140
|
+
return (conditionGuaranteesBrowserGlobal(expr.getLeft(), globalName, 'false') ||
|
|
141
|
+
conditionGuaranteesBrowserGlobal(expr.getRight(), globalName, 'false'));
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
function isBrowserGlobalReference(node, globalName) {
|
|
146
|
+
if (!Node.isIdentifier(node) || node.getText() !== globalName)
|
|
147
|
+
return false;
|
|
148
|
+
const parent = node.getParent();
|
|
149
|
+
if (!parent)
|
|
150
|
+
return false;
|
|
151
|
+
if (parent.getKind() === SyntaxKind.TypeOfExpression)
|
|
152
|
+
return false;
|
|
153
|
+
if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node)
|
|
154
|
+
return false;
|
|
155
|
+
if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node)
|
|
156
|
+
return false;
|
|
157
|
+
if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node)
|
|
158
|
+
return false;
|
|
159
|
+
if (Node.isPropertySignature(parent) && parent.getNameNode() === node)
|
|
160
|
+
return false;
|
|
161
|
+
if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node)
|
|
162
|
+
return false;
|
|
163
|
+
if (Node.isShorthandPropertyAssignment(parent) && parent.getNameNode() === node) {
|
|
164
|
+
const decls = node.getSymbol()?.getDeclarations() ?? [];
|
|
165
|
+
return decls.every((decl) => decl.getSourceFile() !== node.getSourceFile());
|
|
166
|
+
}
|
|
167
|
+
if (Node.isImportSpecifier(parent) || Node.isBindingElement(parent) || Node.isParameterDeclaration(parent))
|
|
168
|
+
return false;
|
|
169
|
+
if (Node.isVariableDeclaration(parent) && parent.getNameNode() === node)
|
|
170
|
+
return false;
|
|
171
|
+
if (Node.isFunctionDeclaration(parent) && parent.getNameNode() === node)
|
|
172
|
+
return false;
|
|
173
|
+
if (Node.isClassDeclaration(parent) && parent.getNameNode() === node)
|
|
174
|
+
return false;
|
|
175
|
+
if (Node.isTypeReference(parent) || Node.isQualifiedName(parent) || Node.isTypeAliasDeclaration(parent))
|
|
176
|
+
return false;
|
|
177
|
+
const declarations = node.getSymbol()?.getDeclarations() ?? [];
|
|
178
|
+
if (declarations.some((decl) => decl.getSourceFile() === node.getSourceFile()))
|
|
179
|
+
return false;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
function isGuardedBrowserGlobalUse(node, globalName) {
|
|
183
|
+
let current = node;
|
|
184
|
+
while ((current = current.getParent())) {
|
|
185
|
+
if (Node.isIfStatement(current)) {
|
|
186
|
+
if (isNodeWithin(node, current.getThenStatement()) &&
|
|
187
|
+
conditionGuaranteesBrowserGlobal(current.getExpression(), globalName, 'true')) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (isNodeWithin(node, current.getElseStatement()) &&
|
|
191
|
+
conditionGuaranteesBrowserGlobal(current.getExpression(), globalName, 'false')) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (Node.isConditionalExpression(current)) {
|
|
196
|
+
if (isNodeWithin(node, current.getWhenTrue()) &&
|
|
197
|
+
conditionGuaranteesBrowserGlobal(current.getCondition(), globalName, 'true')) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
if (isNodeWithin(node, current.getWhenFalse()) &&
|
|
201
|
+
conditionGuaranteesBrowserGlobal(current.getCondition(), globalName, 'false')) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (Node.isBinaryExpression(current) && isNodeWithin(node, current.getRight())) {
|
|
206
|
+
const operator = current.getOperatorToken().getText();
|
|
207
|
+
if (operator === '&&' && conditionGuaranteesBrowserGlobal(current.getLeft(), globalName, 'true'))
|
|
70
208
|
return true;
|
|
209
|
+
if (operator === '||' && conditionGuaranteesBrowserGlobal(current.getLeft(), globalName, 'false'))
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
function getReactActionStateBindings(ctx) {
|
|
216
|
+
const reactImports = ctx.sourceFile
|
|
217
|
+
.getImportDeclarations()
|
|
218
|
+
.filter((decl) => decl.getModuleSpecifierValue() === 'react');
|
|
219
|
+
if (reactImports.length === 0)
|
|
220
|
+
return [];
|
|
221
|
+
const importedHookNames = new Set();
|
|
222
|
+
const namespaceImports = new Set();
|
|
223
|
+
for (const decl of reactImports) {
|
|
224
|
+
for (const named of decl.getNamedImports()) {
|
|
225
|
+
if (ACTION_STATE_HOOKS.has(named.getName())) {
|
|
226
|
+
importedHookNames.add(named.getAliasNode()?.getText() ?? named.getName());
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const namespace = decl.getNamespaceImport();
|
|
230
|
+
if (namespace)
|
|
231
|
+
namespaceImports.add(namespace.getText());
|
|
232
|
+
}
|
|
233
|
+
if (importedHookNames.size === 0 && namespaceImports.size === 0)
|
|
234
|
+
return [];
|
|
235
|
+
const bindings = [];
|
|
236
|
+
for (const decl of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
237
|
+
const nameNode = decl.getNameNode();
|
|
238
|
+
const init = decl.getInitializer();
|
|
239
|
+
if (!Node.isArrayBindingPattern(nameNode) || !init || !Node.isCallExpression(init))
|
|
240
|
+
continue;
|
|
241
|
+
const expr = init.getExpression();
|
|
242
|
+
let isActionStateCall = false;
|
|
243
|
+
if (Node.isIdentifier(expr)) {
|
|
244
|
+
isActionStateCall = importedHookNames.has(expr.getText());
|
|
245
|
+
}
|
|
246
|
+
else if (Node.isPropertyAccessExpression(expr)) {
|
|
247
|
+
isActionStateCall =
|
|
248
|
+
namespaceImports.has(expr.getExpression().getText()) && ACTION_STATE_HOOKS.has(expr.getName());
|
|
249
|
+
}
|
|
250
|
+
if (!isActionStateCall)
|
|
251
|
+
continue;
|
|
252
|
+
const elements = nameNode.getElements();
|
|
253
|
+
if (elements.length < 2)
|
|
254
|
+
continue;
|
|
255
|
+
const actionElement = elements[1];
|
|
256
|
+
if (!Node.isBindingElement(actionElement))
|
|
257
|
+
continue;
|
|
258
|
+
const actionNameNode = actionElement.getNameNode();
|
|
259
|
+
if (!Node.isIdentifier(actionNameNode))
|
|
260
|
+
continue;
|
|
261
|
+
const pendingElement = elements[2];
|
|
262
|
+
const hasPendingBinding = pendingElement !== undefined &&
|
|
263
|
+
Node.isBindingElement(pendingElement) &&
|
|
264
|
+
Node.isIdentifier(pendingElement.getNameNode()) &&
|
|
265
|
+
pendingElement.getNameNode().getText().trim().length > 0;
|
|
266
|
+
const stateElement = elements[0];
|
|
267
|
+
const stateNameNode = stateElement !== undefined &&
|
|
268
|
+
Node.isBindingElement(stateElement) &&
|
|
269
|
+
Node.isIdentifier(stateElement.getNameNode()) &&
|
|
270
|
+
stateElement.getNameNode().getText().trim().length > 0
|
|
271
|
+
? stateElement.getNameNode()
|
|
272
|
+
: undefined;
|
|
273
|
+
bindings.push({
|
|
274
|
+
decl,
|
|
275
|
+
stateNameNode,
|
|
276
|
+
actionName: actionNameNode.getText(),
|
|
277
|
+
hasPendingBinding,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return bindings;
|
|
281
|
+
}
|
|
282
|
+
function isActionBoundInJsx(ctx, actionName) {
|
|
283
|
+
return ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute).some((attr) => {
|
|
284
|
+
const attrName = attr.getNameNode().getText();
|
|
285
|
+
if (attrName !== 'action' && attrName !== 'formAction')
|
|
286
|
+
return false;
|
|
287
|
+
const initNode = attr.getInitializer();
|
|
288
|
+
if (!initNode || !Node.isJsxExpression(initNode))
|
|
289
|
+
return false;
|
|
290
|
+
const expression = initNode.getExpression();
|
|
291
|
+
return expression?.getText() === actionName;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
function hasNonDeclarationReferenceInFile(ctx, identifier) {
|
|
295
|
+
if (!Node.isIdentifier(identifier))
|
|
296
|
+
return false;
|
|
297
|
+
const declarations = identifier.getSymbol()?.getDeclarations() ?? [];
|
|
298
|
+
if (declarations.length === 0)
|
|
299
|
+
return false;
|
|
300
|
+
for (const candidate of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
301
|
+
if (candidate === identifier)
|
|
302
|
+
continue;
|
|
303
|
+
if (candidate.getText() !== identifier.getText())
|
|
304
|
+
continue;
|
|
305
|
+
const candidateDeclarations = candidate.getSymbol()?.getDeclarations() ?? [];
|
|
306
|
+
if (candidateDeclarations.length === 0)
|
|
307
|
+
continue;
|
|
308
|
+
const sameBinding = candidateDeclarations.some((decl) => declarations.includes(decl));
|
|
309
|
+
if (!sameBinding)
|
|
310
|
+
continue;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
function getJsxTagName(node) {
|
|
316
|
+
return node.getTagNameNode().getText();
|
|
317
|
+
}
|
|
318
|
+
function getJsxAttributes(node) {
|
|
319
|
+
return node.getAttributes();
|
|
320
|
+
}
|
|
321
|
+
function getJsxExpressionAttribute(node, attrName) {
|
|
322
|
+
for (const attr of getJsxAttributes(node)) {
|
|
323
|
+
if (!Node.isJsxAttribute(attr) || attr.getNameNode().getText() !== attrName)
|
|
324
|
+
continue;
|
|
325
|
+
const init = attr.getInitializer();
|
|
326
|
+
if (!init || !Node.isJsxExpression(init))
|
|
327
|
+
return undefined;
|
|
328
|
+
return init.getExpression() ?? undefined;
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
function getStringAttribute(node, attrName) {
|
|
333
|
+
for (const attr of getJsxAttributes(node)) {
|
|
334
|
+
if (!Node.isJsxAttribute(attr) || attr.getNameNode().getText() !== attrName)
|
|
335
|
+
continue;
|
|
336
|
+
const init = attr.getInitializer();
|
|
337
|
+
if (!init || !Node.isStringLiteral(init))
|
|
338
|
+
return undefined;
|
|
339
|
+
return init.getLiteralText();
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
function isSubmitControl(node) {
|
|
344
|
+
const tagName = getJsxTagName(node);
|
|
345
|
+
if (tagName === 'button') {
|
|
346
|
+
const typeAttr = getStringAttribute(node, 'type');
|
|
347
|
+
return typeAttr === undefined || typeAttr === 'submit';
|
|
348
|
+
}
|
|
349
|
+
if (tagName === 'input') {
|
|
350
|
+
const typeAttr = getStringAttribute(node, 'type');
|
|
351
|
+
return typeAttr === 'submit' || typeAttr === 'image';
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
function fileUsesUseFormStatus(ctx) {
|
|
356
|
+
const imports = ctx.sourceFile
|
|
357
|
+
.getImportDeclarations()
|
|
358
|
+
.filter((decl) => decl.getModuleSpecifierValue() === 'react-dom');
|
|
359
|
+
if (imports.length === 0)
|
|
360
|
+
return false;
|
|
361
|
+
const importedHookNames = new Set();
|
|
362
|
+
const namespaceImports = new Set();
|
|
363
|
+
for (const decl of imports) {
|
|
364
|
+
for (const named of decl.getNamedImports()) {
|
|
365
|
+
if (named.getName() === 'useFormStatus')
|
|
366
|
+
importedHookNames.add(named.getAliasNode()?.getText() ?? named.getName());
|
|
367
|
+
}
|
|
368
|
+
const namespace = decl.getNamespaceImport();
|
|
369
|
+
if (namespace)
|
|
370
|
+
namespaceImports.add(namespace.getText());
|
|
371
|
+
}
|
|
372
|
+
for (const call of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
373
|
+
const expr = call.getExpression();
|
|
374
|
+
if (Node.isIdentifier(expr) && importedHookNames.has(expr.getText()))
|
|
375
|
+
return true;
|
|
376
|
+
if (Node.isPropertyAccessExpression(expr) &&
|
|
377
|
+
namespaceImports.has(expr.getExpression().getText()) &&
|
|
378
|
+
expr.getName() === 'useFormStatus') {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
function functionLikeHasUseServerDirective(node) {
|
|
385
|
+
if (!Node.isFunctionDeclaration(node) && !Node.isFunctionExpression(node) && !Node.isArrowFunction(node))
|
|
386
|
+
return false;
|
|
387
|
+
const body = node.getBody();
|
|
388
|
+
if (!body)
|
|
389
|
+
return false;
|
|
390
|
+
return /['"]use server['"]/.test(body.getText().substring(0, 100));
|
|
391
|
+
}
|
|
392
|
+
function functionLikeIsAsync(node) {
|
|
393
|
+
if (Node.isFunctionDeclaration(node) || Node.isFunctionExpression(node) || Node.isArrowFunction(node)) {
|
|
394
|
+
return node.isAsync();
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
function resolveImportSourceFile(ctx, importDecl) {
|
|
399
|
+
let resolvedSourceFile;
|
|
400
|
+
try {
|
|
401
|
+
resolvedSourceFile = importDecl.getModuleSpecifierSourceFile() ?? undefined;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
if (resolvedSourceFile) {
|
|
407
|
+
// The shared fsProject caches resolved source files across reviewFile calls; refresh so edits
|
|
408
|
+
// in the imported file (watch mode, repeated reviewFile invocations) are picked up.
|
|
409
|
+
try {
|
|
410
|
+
resolvedSourceFile.refreshFromFileSystemSync();
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// File may have been deleted — leave the (now-stale) reference; caller decides what to do.
|
|
414
|
+
}
|
|
415
|
+
return resolvedSourceFile;
|
|
416
|
+
}
|
|
417
|
+
const specifier = importDecl.getModuleSpecifierValue();
|
|
418
|
+
if (!specifier.startsWith('.'))
|
|
419
|
+
return undefined;
|
|
420
|
+
const project = ctx.sourceFile.getProject();
|
|
421
|
+
const fromDir = dirname(ctx.sourceFile.getFilePath());
|
|
422
|
+
const fallbackCandidates = [specifier];
|
|
423
|
+
if (specifier.endsWith('.js')) {
|
|
424
|
+
fallbackCandidates.push(`${specifier.slice(0, -3)}.ts`, `${specifier.slice(0, -3)}.tsx`);
|
|
425
|
+
}
|
|
426
|
+
else if (specifier.endsWith('.jsx')) {
|
|
427
|
+
fallbackCandidates.push(`${specifier.slice(0, -4)}.tsx`);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
fallbackCandidates.push(`${specifier}.ts`, `${specifier}.tsx`, `${specifier}/index.ts`, `${specifier}/index.tsx`);
|
|
431
|
+
}
|
|
432
|
+
for (const candidate of fallbackCandidates) {
|
|
433
|
+
const fullPath = resolve(fromDir, candidate);
|
|
434
|
+
if (!existsSync(fullPath))
|
|
435
|
+
continue;
|
|
436
|
+
const sourceFile = project.getSourceFile(fullPath);
|
|
437
|
+
if (sourceFile)
|
|
438
|
+
return sourceFile;
|
|
439
|
+
try {
|
|
440
|
+
return project.addSourceFileAtPath(fullPath);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
try {
|
|
444
|
+
return new Project({
|
|
445
|
+
compilerOptions: {
|
|
446
|
+
strict: true,
|
|
447
|
+
target: 99,
|
|
448
|
+
module: 99,
|
|
449
|
+
moduleResolution: 100,
|
|
450
|
+
jsx: 4 /* Preserve */,
|
|
451
|
+
allowJs: true,
|
|
452
|
+
esModuleInterop: true,
|
|
453
|
+
allowSyntheticDefaultImports: true,
|
|
454
|
+
},
|
|
455
|
+
}).addSourceFileAtPath(fullPath);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Keep trying other extension fallbacks.
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
function resolveExportedServerActionFunctions(sourceFile, exportName) {
|
|
465
|
+
const resolved = [];
|
|
466
|
+
if (!sourceFile)
|
|
467
|
+
return resolved;
|
|
468
|
+
const fileHasUseServer = hasServerDirective(sourceFile.getFullText());
|
|
469
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
470
|
+
if (!fn.isExported() || fn.getName() !== exportName || !fn.isAsync())
|
|
471
|
+
continue;
|
|
472
|
+
if (functionLikeHasUseServerDirective(fn) || (fileHasUseServer && fn.isExported()))
|
|
473
|
+
resolved.push(fn);
|
|
474
|
+
}
|
|
475
|
+
for (const stmt of sourceFile.getVariableStatements()) {
|
|
476
|
+
if (!stmt.isExported())
|
|
477
|
+
continue;
|
|
478
|
+
for (const decl of stmt.getDeclarations()) {
|
|
479
|
+
if (decl.getName() !== exportName)
|
|
480
|
+
continue;
|
|
481
|
+
const init = decl.getInitializer();
|
|
482
|
+
if (!init || (!Node.isArrowFunction(init) && !Node.isFunctionExpression(init)) || !init.isAsync())
|
|
483
|
+
continue;
|
|
484
|
+
if (functionLikeHasUseServerDirective(init) || fileHasUseServer)
|
|
485
|
+
resolved.push(init);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return resolved;
|
|
489
|
+
}
|
|
490
|
+
function resolveImportedServerActionFunctions(ctx, decl) {
|
|
491
|
+
if (Node.isImportSpecifier(decl)) {
|
|
492
|
+
return resolveExportedServerActionFunctions(resolveImportSourceFile(ctx, decl.getImportDeclaration()), decl.getName());
|
|
493
|
+
}
|
|
494
|
+
if (Node.isNamespaceImport(decl))
|
|
495
|
+
return [];
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
function resolveDirectImportedServerActionFunctions(ctx, expr) {
|
|
499
|
+
if (Node.isIdentifier(expr)) {
|
|
500
|
+
for (const decl of ctx.sourceFile.getImportDeclarations()) {
|
|
501
|
+
for (const named of decl.getNamedImports()) {
|
|
502
|
+
const localName = named.getAliasNode()?.getText() ?? named.getName();
|
|
503
|
+
if (localName !== expr.getText())
|
|
504
|
+
continue;
|
|
505
|
+
return resolveExportedServerActionFunctions(resolveImportSourceFile(ctx, decl), named.getName());
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
if (Node.isPropertyAccessExpression(expr) && Node.isIdentifier(expr.getExpression())) {
|
|
511
|
+
const namespaceName = expr.getExpression().getText();
|
|
512
|
+
for (const decl of ctx.sourceFile.getImportDeclarations()) {
|
|
513
|
+
const namespaceImport = decl.getNamespaceImport();
|
|
514
|
+
if (!namespaceImport || namespaceImport.getText() !== namespaceName)
|
|
515
|
+
continue;
|
|
516
|
+
return resolveExportedServerActionFunctions(resolveImportSourceFile(ctx, decl), expr.getName());
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
function resolveServerActionFunctions(ctx, expr) {
|
|
522
|
+
const resolved = [];
|
|
523
|
+
if (!expr)
|
|
524
|
+
return resolved;
|
|
525
|
+
const candidate = unwrapParens(expr);
|
|
526
|
+
if ((Node.isFunctionExpression(candidate) || Node.isArrowFunction(candidate)) && functionLikeIsAsync(candidate)) {
|
|
527
|
+
if (functionLikeHasUseServerDirective(candidate))
|
|
528
|
+
resolved.push(candidate);
|
|
529
|
+
return resolved;
|
|
530
|
+
}
|
|
531
|
+
const directImportedActions = resolveDirectImportedServerActionFunctions(ctx, candidate);
|
|
532
|
+
if (directImportedActions.length > 0)
|
|
533
|
+
return directImportedActions;
|
|
534
|
+
if (Node.isPropertyAccessExpression(candidate)) {
|
|
535
|
+
const objectExpr = candidate.getExpression();
|
|
536
|
+
if (!Node.isIdentifier(objectExpr))
|
|
537
|
+
return resolved;
|
|
538
|
+
const declarations = objectExpr.getSymbol()?.getDeclarations() ?? [];
|
|
539
|
+
for (const decl of declarations) {
|
|
540
|
+
if (!Node.isNamespaceImport(decl))
|
|
541
|
+
continue;
|
|
542
|
+
const importDecl = decl.getFirstAncestorByKind(SyntaxKind.ImportDeclaration);
|
|
543
|
+
const sourceFile = importDecl ? resolveImportSourceFile(ctx, importDecl) : undefined;
|
|
544
|
+
resolved.push(...resolveExportedServerActionFunctions(sourceFile, candidate.getName()));
|
|
545
|
+
}
|
|
546
|
+
return resolved;
|
|
547
|
+
}
|
|
548
|
+
if (!Node.isIdentifier(candidate))
|
|
549
|
+
return resolved;
|
|
550
|
+
const declarations = candidate.getSymbol()?.getDeclarations() ?? [];
|
|
551
|
+
for (const decl of declarations) {
|
|
552
|
+
if (Node.isImportSpecifier(decl)) {
|
|
553
|
+
resolved.push(...resolveImportedServerActionFunctions(ctx, decl));
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (decl.getSourceFile() !== ctx.sourceFile) {
|
|
557
|
+
resolved.push(...resolveImportedServerActionFunctions(ctx, decl));
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (Node.isFunctionDeclaration(decl) && decl.isAsync()) {
|
|
561
|
+
const fileHasUseServer = hasServerDirective(ctx.sourceFile.getFullText());
|
|
562
|
+
if (functionLikeHasUseServerDirective(decl) || (fileHasUseServer && decl.isExported()))
|
|
563
|
+
resolved.push(decl);
|
|
564
|
+
}
|
|
565
|
+
if (Node.isVariableDeclaration(decl)) {
|
|
566
|
+
const init = decl.getInitializer();
|
|
567
|
+
if (!init || (!Node.isArrowFunction(init) && !Node.isFunctionExpression(init)) || !init.isAsync())
|
|
568
|
+
continue;
|
|
569
|
+
const fileHasUseServer = hasServerDirective(ctx.sourceFile.getFullText());
|
|
570
|
+
const variableStatement = decl.getVariableStatement();
|
|
571
|
+
if (functionLikeHasUseServerDirective(init) || (fileHasUseServer && variableStatement?.isExported())) {
|
|
572
|
+
resolved.push(init);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return resolved;
|
|
577
|
+
}
|
|
578
|
+
function isServerActionReference(ctx, expr) {
|
|
579
|
+
return resolveServerActionFunctions(ctx, expr).length > 0;
|
|
580
|
+
}
|
|
581
|
+
function hasNativeSubmitDescendant(form) {
|
|
582
|
+
const opening = form.getOpeningElement();
|
|
583
|
+
if (isSubmitControl(opening))
|
|
584
|
+
return true;
|
|
585
|
+
for (const child of form.getDescendants()) {
|
|
586
|
+
if (Node.isJsxOpeningElement(child) && isSubmitControl(child))
|
|
587
|
+
return true;
|
|
588
|
+
if (Node.isJsxSelfClosingElement(child) && isSubmitControl(child))
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
function functionReturnsValue(node) {
|
|
594
|
+
const body = node.getBody();
|
|
595
|
+
if (!body || !Node.isBlock(body))
|
|
596
|
+
return false;
|
|
597
|
+
for (const stmt of body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
|
|
598
|
+
const expr = stmt.getExpression();
|
|
599
|
+
if (!expr)
|
|
600
|
+
continue;
|
|
601
|
+
if (Node.isIdentifier(expr) && expr.getText() === 'undefined')
|
|
602
|
+
continue;
|
|
603
|
+
if (Node.isVoidExpression(expr))
|
|
604
|
+
continue;
|
|
605
|
+
if (Node.isCallExpression(expr) &&
|
|
606
|
+
Node.isIdentifier(expr.getExpression()) &&
|
|
607
|
+
['redirect', 'permanentRedirect', 'notFound'].includes(expr.getExpression().getText())) {
|
|
608
|
+
continue;
|
|
71
609
|
}
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const POST_SUBMIT_CLOSURE_CALLS = new Set([
|
|
615
|
+
'revalidatePath',
|
|
616
|
+
'revalidateTag',
|
|
617
|
+
'updateTag',
|
|
618
|
+
'refresh',
|
|
619
|
+
'redirect',
|
|
620
|
+
'permanentRedirect',
|
|
621
|
+
'notFound',
|
|
622
|
+
]);
|
|
623
|
+
const MUTATION_CALL_RE = /\b(create|update|delete|remove|insert|upsert|save|write|publish|archive|destroy|replace)\b/i;
|
|
624
|
+
const MUTATION_FETCH_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
625
|
+
function functionCallsPostSubmitClosure(node) {
|
|
626
|
+
const body = node.getBody();
|
|
627
|
+
if (!body)
|
|
628
|
+
return false;
|
|
629
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
630
|
+
const expr = call.getExpression();
|
|
631
|
+
if (Node.isIdentifier(expr) && POST_SUBMIT_CLOSURE_CALLS.has(expr.getText()))
|
|
632
|
+
return true;
|
|
633
|
+
if (Node.isPropertyAccessExpression(expr) && POST_SUBMIT_CLOSURE_CALLS.has(expr.getName()))
|
|
634
|
+
return true;
|
|
72
635
|
}
|
|
73
636
|
return false;
|
|
74
637
|
}
|
|
638
|
+
function isKnownInputCarrier(node) {
|
|
639
|
+
if (!Node.isIdentifier(node))
|
|
640
|
+
return false;
|
|
641
|
+
const typeText = node.getType().getText(node);
|
|
642
|
+
return (typeText.includes('FormData') ||
|
|
643
|
+
typeText.includes('URLSearchParams') ||
|
|
644
|
+
typeText.includes('Headers') ||
|
|
645
|
+
['formData', 'searchParams', 'headers'].includes(node.getText()));
|
|
646
|
+
}
|
|
647
|
+
function getMutationCall(node) {
|
|
648
|
+
const body = node.getBody();
|
|
649
|
+
if (!body)
|
|
650
|
+
return undefined;
|
|
651
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
652
|
+
const expr = call.getExpression();
|
|
653
|
+
if (Node.isIdentifier(expr)) {
|
|
654
|
+
if (expr.getText() === 'fetch') {
|
|
655
|
+
const options = call.getArguments()[1];
|
|
656
|
+
if (!options || !Node.isObjectLiteralExpression(options))
|
|
657
|
+
continue;
|
|
658
|
+
const methodProp = options
|
|
659
|
+
.getProperties()
|
|
660
|
+
.find((prop) => Node.isPropertyAssignment(prop) &&
|
|
661
|
+
prop.getName() === 'method' &&
|
|
662
|
+
Node.isStringLiteral(prop.getInitializer() ?? undefined));
|
|
663
|
+
if (!methodProp || !Node.isPropertyAssignment(methodProp))
|
|
664
|
+
continue;
|
|
665
|
+
const methodInit = methodProp.getInitializer();
|
|
666
|
+
if (!methodInit || !Node.isStringLiteral(methodInit))
|
|
667
|
+
continue;
|
|
668
|
+
if (MUTATION_FETCH_METHODS.has(methodInit.getLiteralText().toUpperCase()))
|
|
669
|
+
return call;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (MUTATION_CALL_RE.test(expr.getText()))
|
|
673
|
+
return call;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (!Node.isPropertyAccessExpression(expr))
|
|
677
|
+
continue;
|
|
678
|
+
if (isKnownInputCarrier(expr.getExpression()))
|
|
679
|
+
continue;
|
|
680
|
+
if (MUTATION_CALL_RE.test(expr.getName()))
|
|
681
|
+
return call;
|
|
682
|
+
}
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
function getFunctionLikeName(node) {
|
|
686
|
+
if (Node.isFunctionDeclaration(node))
|
|
687
|
+
return node.getName() ?? '<anon>';
|
|
688
|
+
const parent = node.getParent();
|
|
689
|
+
if (Node.isVariableDeclaration(parent))
|
|
690
|
+
return parent.getName();
|
|
691
|
+
return '<anon>';
|
|
692
|
+
}
|
|
75
693
|
// ── Rule: use-client-drilled-too-high ────────────────────────────────────
|
|
76
694
|
// File has 'use client' but doesn't actually use any client API itself.
|
|
77
695
|
// Its children do. Moving the directive down would preserve RSC benefits.
|
|
@@ -88,28 +706,69 @@ function useClientDrilledTooHigh(ctx) {
|
|
|
88
706
|
// when the file has child imports that DO use client APIs — but we can't
|
|
89
707
|
// cheaply check that without the full fileContextMap. Fire as a warning
|
|
90
708
|
// either way; severity bumps to error when we can prove a child needs it.
|
|
91
|
-
|
|
709
|
+
const severity = 'warning';
|
|
92
710
|
let detail = 'File has "use client" but uses no hooks, event handlers, or browser APIs itself.';
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
711
|
+
let importedChildren = [];
|
|
712
|
+
const graphFile = ctx.config?.graphFileMap?.get(ctx.filePath);
|
|
713
|
+
if (graphFile && graphFile.imports.length > 0) {
|
|
714
|
+
importedChildren = graphFile.imports;
|
|
715
|
+
detail += ` Imported children: ${importedChildren
|
|
716
|
+
.slice(0, 3)
|
|
717
|
+
.map((p) => basename(p))
|
|
718
|
+
.join(', ')}${importedChildren.length > 3 ? '…' : ''}.`;
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
const fileContextMap = ctx.config?.fileContextMap;
|
|
722
|
+
if (fileContextMap) {
|
|
723
|
+
// Fallback for older graph-aware callers that only provide fileContextMap.
|
|
724
|
+
importedChildren = [...fileContextMap.entries()]
|
|
725
|
+
.filter(([, v]) => v.importedBy.includes(ctx.filePath))
|
|
726
|
+
.map(([k]) => k);
|
|
727
|
+
if (importedChildren.length > 0) {
|
|
728
|
+
detail += ` Imported children: ${importedChildren
|
|
729
|
+
.slice(0, 3)
|
|
730
|
+
.map((p) => basename(p))
|
|
731
|
+
.join(', ')}${importedChildren.length > 3 ? '…' : ''}.`;
|
|
732
|
+
}
|
|
105
733
|
}
|
|
106
734
|
}
|
|
107
735
|
const line = 1;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
736
|
+
const result = finding('use-client-drilled-too-high', severity, 'pattern', `'use client' directive is drilled too high — ${detail} Move it to the leaf component that actually uses client APIs to preserve Server Component benefits.`, ctx.filePath, line, 1, {
|
|
737
|
+
suggestion: 'Remove the top-level "use client" and add it to only the child component(s) that use hooks or browser APIs',
|
|
738
|
+
});
|
|
739
|
+
result.relatedSpans = importedChildren.slice(0, 3).map((child) => ({
|
|
740
|
+
file: child,
|
|
741
|
+
startLine: 1,
|
|
742
|
+
startCol: 1,
|
|
743
|
+
endLine: 1,
|
|
744
|
+
endCol: 1,
|
|
745
|
+
}));
|
|
746
|
+
result.provenance = {
|
|
747
|
+
summary: importedChildren.length > 0
|
|
748
|
+
? `"use client" sits above ${importedChildren.length} imported child${importedChildren.length === 1 ? '' : 'ren'} while this file uses no client-only APIs itself.`
|
|
749
|
+
: '"use client" is present, but the file itself has no local client-only API usage.',
|
|
750
|
+
steps: [
|
|
751
|
+
{
|
|
752
|
+
kind: 'boundary',
|
|
753
|
+
location: { file: ctx.filePath, startLine: 1, startCol: 1, endLine: 1, endCol: 1 },
|
|
754
|
+
label: `'use client'`,
|
|
755
|
+
detail: 'Client boundary is declared at the top of this file.',
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
kind: 'boundary',
|
|
759
|
+
location: { file: ctx.filePath, startLine: 1, startCol: 1, endLine: 1, endCol: 1 },
|
|
760
|
+
label: 'no local client API usage',
|
|
761
|
+
detail: 'This file does not use hooks, event handlers, or browser globals directly.',
|
|
762
|
+
},
|
|
763
|
+
...importedChildren.slice(0, 3).map((child) => ({
|
|
764
|
+
kind: 'import',
|
|
765
|
+
location: { file: child, startLine: 1, startCol: 1, endLine: 1, endCol: 1 },
|
|
766
|
+
label: basename(child),
|
|
767
|
+
detail: 'Imported child under the same declared client boundary.',
|
|
768
|
+
})),
|
|
769
|
+
],
|
|
770
|
+
};
|
|
771
|
+
return [result];
|
|
113
772
|
}
|
|
114
773
|
// ── Rule: server-api-in-client ───────────────────────────────────────────
|
|
115
774
|
// Client Component imports or calls server-only APIs:
|
|
@@ -121,7 +780,7 @@ function serverApiInClient(ctx) {
|
|
|
121
780
|
if (ctx.fileRole !== 'runtime')
|
|
122
781
|
return [];
|
|
123
782
|
const fullText = ctx.sourceFile.getFullText();
|
|
124
|
-
const isClient =
|
|
783
|
+
const isClient = isClientBoundary(ctx, fullText);
|
|
125
784
|
if (!isClient)
|
|
126
785
|
return [];
|
|
127
786
|
const findings = [];
|
|
@@ -129,9 +788,33 @@ function serverApiInClient(ctx) {
|
|
|
129
788
|
for (const imp of ctx.sourceFile.getImportDeclarations()) {
|
|
130
789
|
const mod = imp.getModuleSpecifierValue();
|
|
131
790
|
if (mod === 'next/headers' || mod === 'server-only') {
|
|
132
|
-
|
|
791
|
+
const hit = finding('server-api-in-client', 'error', 'bug', `Client Component imports '${mod}' — this will fail at build time. Server-only APIs cannot run in a client boundary.`, ctx.filePath, imp.getStartLineNumber(), 1, {
|
|
133
792
|
suggestion: `Move this logic to a Server Component or a server action, or drop the 'use client' directive if this file does not need it`,
|
|
134
|
-
})
|
|
793
|
+
});
|
|
794
|
+
hit.provenance = {
|
|
795
|
+
summary: `Client boundary imports server-only module '${mod}'.`,
|
|
796
|
+
steps: [
|
|
797
|
+
{
|
|
798
|
+
kind: 'boundary',
|
|
799
|
+
location: { file: ctx.filePath, startLine: 1, startCol: 1, endLine: 1, endCol: 1 },
|
|
800
|
+
label: `'use client'`,
|
|
801
|
+
detail: 'This file is treated as a Client Component.',
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
kind: 'import',
|
|
805
|
+
location: {
|
|
806
|
+
file: ctx.filePath,
|
|
807
|
+
startLine: imp.getStartLineNumber(),
|
|
808
|
+
startCol: 1,
|
|
809
|
+
endLine: imp.getStartLineNumber(),
|
|
810
|
+
endCol: 1,
|
|
811
|
+
},
|
|
812
|
+
label: mod,
|
|
813
|
+
detail: 'Server-only module imported into a client boundary.',
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
};
|
|
817
|
+
findings.push(hit);
|
|
135
818
|
}
|
|
136
819
|
}
|
|
137
820
|
// Call check: cookies()/headers()/draftMode() invocation in client code
|
|
@@ -150,7 +833,219 @@ function serverApiInClient(ctx) {
|
|
|
150
833
|
.some((imp) => imp.getModuleSpecifierValue() === 'next/headers' && imp.getNamedImports().some((ni) => ni.getName() === name));
|
|
151
834
|
if (!fromNextHeaders)
|
|
152
835
|
continue;
|
|
153
|
-
|
|
836
|
+
const hit = finding('server-api-in-client', 'error', 'bug', `'${name}()' called in Client Component — next/headers APIs are server-only and will throw at runtime`, ctx.filePath, call.getStartLineNumber(), 1, {
|
|
837
|
+
suggestion: `Call '${name}()' in a Server Component or server action, then pass the result as a prop`,
|
|
838
|
+
});
|
|
839
|
+
hit.provenance = {
|
|
840
|
+
summary: `Client boundary calls server-only API ${name}().`,
|
|
841
|
+
steps: [
|
|
842
|
+
{
|
|
843
|
+
kind: 'boundary',
|
|
844
|
+
location: { file: ctx.filePath, startLine: 1, startCol: 1, endLine: 1, endCol: 1 },
|
|
845
|
+
label: `'use client'`,
|
|
846
|
+
detail: 'This file is treated as a Client Component.',
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
kind: 'call',
|
|
850
|
+
location: {
|
|
851
|
+
file: ctx.filePath,
|
|
852
|
+
startLine: call.getStartLineNumber(),
|
|
853
|
+
startCol: 1,
|
|
854
|
+
endLine: call.getStartLineNumber(),
|
|
855
|
+
endCol: 1,
|
|
856
|
+
},
|
|
857
|
+
label: `${name}()`,
|
|
858
|
+
detail: `Call is only valid from 'next/headers' on the server.`,
|
|
859
|
+
},
|
|
860
|
+
],
|
|
861
|
+
};
|
|
862
|
+
findings.push(hit);
|
|
863
|
+
}
|
|
864
|
+
return findings;
|
|
865
|
+
}
|
|
866
|
+
// ── Rule: browser-api-in-server ──────────────────────────────────────────
|
|
867
|
+
// Browser globals used directly in a Server Component / server boundary.
|
|
868
|
+
function browserApiInServer(ctx) {
|
|
869
|
+
if (ctx.fileRole !== 'runtime')
|
|
870
|
+
return [];
|
|
871
|
+
const fullText = ctx.sourceFile.getFullText();
|
|
872
|
+
if (isClientBoundary(ctx, fullText))
|
|
873
|
+
return [];
|
|
874
|
+
if (!BROWSER_GLOBALS.test(fullText))
|
|
875
|
+
return [];
|
|
876
|
+
const findings = [];
|
|
877
|
+
const reported = new Set();
|
|
878
|
+
for (const identifier of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
879
|
+
const globalName = identifier.getText();
|
|
880
|
+
if (!BROWSER_GLOBAL_NAMES.includes(globalName))
|
|
881
|
+
continue;
|
|
882
|
+
if (reported.has(globalName))
|
|
883
|
+
continue;
|
|
884
|
+
if (!isBrowserGlobalReference(identifier, globalName))
|
|
885
|
+
continue;
|
|
886
|
+
if (isGuardedBrowserGlobalUse(identifier, globalName))
|
|
887
|
+
continue;
|
|
888
|
+
reported.add(globalName);
|
|
889
|
+
findings.push(finding('browser-api-in-server', 'error', 'bug', `'${globalName}' is used in a Server Component/server boundary — browser APIs require 'use client' or a Client Component`, ctx.filePath, identifier.getStartLineNumber(), 1, {
|
|
890
|
+
suggestion: 'Move this logic into a Client Component, or pass a server-safe value down as a prop instead of reading browser globals here',
|
|
891
|
+
}));
|
|
892
|
+
}
|
|
893
|
+
return findings;
|
|
894
|
+
}
|
|
895
|
+
// ── Rule: use-action-state-missing-pending ───────────────────────────────
|
|
896
|
+
// useActionState bound to form action but pending tuple value is not captured.
|
|
897
|
+
function useActionStateMissingPending(ctx) {
|
|
898
|
+
if (ctx.fileRole !== 'runtime')
|
|
899
|
+
return [];
|
|
900
|
+
const fullText = ctx.sourceFile.getFullText();
|
|
901
|
+
if (!isClientBoundary(ctx, fullText))
|
|
902
|
+
return [];
|
|
903
|
+
const findings = [];
|
|
904
|
+
for (const binding of getReactActionStateBindings(ctx)) {
|
|
905
|
+
if (binding.hasPendingBinding)
|
|
906
|
+
continue;
|
|
907
|
+
if (!isActionBoundInJsx(ctx, binding.actionName))
|
|
908
|
+
continue;
|
|
909
|
+
findings.push(finding('use-action-state-missing-pending', 'warning', 'pattern', `useActionState is bound to '${binding.actionName}' without capturing the pending tuple value — server action submits have no in-flight UI state`, ctx.filePath, binding.decl.getStartLineNumber(), 1, {
|
|
910
|
+
suggestion: 'Capture the third tuple value from useActionState, e.g. const [state, formAction, pending] = useActionState(...), then disable the submit button or show loading UI while pending',
|
|
911
|
+
}));
|
|
912
|
+
}
|
|
913
|
+
return findings;
|
|
914
|
+
}
|
|
915
|
+
// ── Rule: use-action-state-missing-feedback ──────────────────────────────
|
|
916
|
+
// useActionState bound to form action but returned state is never read.
|
|
917
|
+
function useActionStateMissingFeedback(ctx) {
|
|
918
|
+
if (ctx.fileRole !== 'runtime')
|
|
919
|
+
return [];
|
|
920
|
+
const fullText = ctx.sourceFile.getFullText();
|
|
921
|
+
if (!isClientBoundary(ctx, fullText))
|
|
922
|
+
return [];
|
|
923
|
+
const findings = [];
|
|
924
|
+
for (const binding of getReactActionStateBindings(ctx)) {
|
|
925
|
+
if (!isActionBoundInJsx(ctx, binding.actionName))
|
|
926
|
+
continue;
|
|
927
|
+
if (binding.stateNameNode && hasNonDeclarationReferenceInFile(ctx, binding.stateNameNode))
|
|
928
|
+
continue;
|
|
929
|
+
findings.push(finding('use-action-state-missing-feedback', 'warning', 'pattern', `useActionState is bound to '${binding.actionName}' but its state value is never read — server action success/error feedback is not surfaced`, ctx.filePath, binding.decl.getStartLineNumber(), 1, {
|
|
930
|
+
suggestion: 'Read the first tuple value from useActionState and surface result state in the UI or a side effect (for example an error message, success state, toast, or redirect)',
|
|
931
|
+
}));
|
|
932
|
+
}
|
|
933
|
+
return findings;
|
|
934
|
+
}
|
|
935
|
+
// ── Rule: server-action-form-missing-pending ─────────────────────────────
|
|
936
|
+
// Native submit control is wired directly to a Server Action but no pending
|
|
937
|
+
// state substrate (useActionState / useFormStatus) is present in the file.
|
|
938
|
+
function serverActionFormMissingPending(ctx) {
|
|
939
|
+
if (ctx.fileRole !== 'runtime')
|
|
940
|
+
return [];
|
|
941
|
+
const actionStateBindings = getReactActionStateBindings(ctx);
|
|
942
|
+
const actionStateNames = new Set(actionStateBindings.map((binding) => binding.actionName));
|
|
943
|
+
if (fileUsesUseFormStatus(ctx))
|
|
944
|
+
return [];
|
|
945
|
+
const findings = [];
|
|
946
|
+
for (const form of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
|
947
|
+
if (getJsxTagName(form.getOpeningElement()) !== 'form')
|
|
948
|
+
continue;
|
|
949
|
+
const actionExpr = getJsxExpressionAttribute(form.getOpeningElement(), 'action');
|
|
950
|
+
if (!actionExpr)
|
|
951
|
+
continue;
|
|
952
|
+
if (Node.isIdentifier(actionExpr) && actionStateNames.has(actionExpr.getText()))
|
|
953
|
+
continue;
|
|
954
|
+
if (!isServerActionReference(ctx, actionExpr))
|
|
955
|
+
continue;
|
|
956
|
+
if (!hasNativeSubmitDescendant(form))
|
|
957
|
+
continue;
|
|
958
|
+
findings.push(finding('server-action-form-missing-pending', 'warning', 'pattern', 'Form is wired directly to a Server Action with a native submit control but no pending-state UX was detected — users can resubmit while the action is in flight', ctx.filePath, form.getStartLineNumber(), 1, {
|
|
959
|
+
suggestion: 'Render the submit button from a Client Component that uses useFormStatus(), then disable it or show loading text while pending. If you need result state too, consider useActionState().',
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
return findings;
|
|
963
|
+
}
|
|
964
|
+
// ── Rule: server-action-form-return-value-ignored ────────────────────────
|
|
965
|
+
// Direct form actions do not surface returned values. If a same-file Server
|
|
966
|
+
// Action returns data, it should usually be wired through useActionState.
|
|
967
|
+
function serverActionFormReturnValueIgnored(ctx) {
|
|
968
|
+
if (ctx.fileRole !== 'runtime')
|
|
969
|
+
return [];
|
|
970
|
+
const actionStateBindings = getReactActionStateBindings(ctx);
|
|
971
|
+
const actionStateNames = new Set(actionStateBindings.map((binding) => binding.actionName));
|
|
972
|
+
const findings = [];
|
|
973
|
+
for (const form of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
|
974
|
+
if (getJsxTagName(form.getOpeningElement()) !== 'form')
|
|
975
|
+
continue;
|
|
976
|
+
const actionExpr = getJsxExpressionAttribute(form.getOpeningElement(), 'action');
|
|
977
|
+
if (!actionExpr)
|
|
978
|
+
continue;
|
|
979
|
+
if (Node.isIdentifier(actionExpr) && actionStateNames.has(actionExpr.getText()))
|
|
980
|
+
continue;
|
|
981
|
+
const serverActions = resolveServerActionFunctions(ctx, actionExpr);
|
|
982
|
+
if (serverActions.length === 0)
|
|
983
|
+
continue;
|
|
984
|
+
if (!serverActions.some((fn) => functionReturnsValue(fn)))
|
|
985
|
+
continue;
|
|
986
|
+
findings.push(finding('server-action-form-return-value-ignored', 'warning', 'bug', 'Form posts directly to a Server Action that returns a value, but plain form actions do not surface returned state — the result is ignored unless you use useActionState()', ctx.filePath, form.getStartLineNumber(), 1, {
|
|
987
|
+
suggestion: 'If the action result drives success/error UI, wrap it in useActionState() and render the returned state. Otherwise remove the unused return value and redirect/revalidate explicitly.',
|
|
988
|
+
}));
|
|
989
|
+
}
|
|
990
|
+
return findings;
|
|
991
|
+
}
|
|
992
|
+
// ── Rule: server-action-form-mutation-missing-invalidation ───────────────
|
|
993
|
+
// Direct form action posts to a mutating Server Action that neither revalidates
|
|
994
|
+
// cache nor redirects, so the submit likely completes with stale UI.
|
|
995
|
+
function serverActionFormMutationMissingInvalidation(ctx) {
|
|
996
|
+
if (ctx.fileRole !== 'runtime')
|
|
997
|
+
return [];
|
|
998
|
+
const actionStateBindings = getReactActionStateBindings(ctx);
|
|
999
|
+
const actionStateNames = new Set(actionStateBindings.map((binding) => binding.actionName));
|
|
1000
|
+
const findings = [];
|
|
1001
|
+
for (const form of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
|
1002
|
+
if (getJsxTagName(form.getOpeningElement()) !== 'form')
|
|
1003
|
+
continue;
|
|
1004
|
+
const actionExpr = getJsxExpressionAttribute(form.getOpeningElement(), 'action');
|
|
1005
|
+
if (!actionExpr)
|
|
1006
|
+
continue;
|
|
1007
|
+
if (Node.isIdentifier(actionExpr) && actionStateNames.has(actionExpr.getText()))
|
|
1008
|
+
continue;
|
|
1009
|
+
const candidate = resolveServerActionFunctions(ctx, actionExpr).find((fn) => {
|
|
1010
|
+
if (functionReturnsValue(fn))
|
|
1011
|
+
return false;
|
|
1012
|
+
if (functionCallsPostSubmitClosure(fn))
|
|
1013
|
+
return false;
|
|
1014
|
+
return !!getMutationCall(fn);
|
|
1015
|
+
});
|
|
1016
|
+
if (!candidate)
|
|
1017
|
+
continue;
|
|
1018
|
+
const actionFile = candidate.getSourceFile().getFilePath();
|
|
1019
|
+
const actionName = getFunctionLikeName(candidate);
|
|
1020
|
+
const mutationCall = getMutationCall(candidate);
|
|
1021
|
+
const actionLine = candidate.getStartLineNumber();
|
|
1022
|
+
const mutationLine = mutationCall?.getStartLineNumber();
|
|
1023
|
+
const hit = finding('server-action-form-mutation-missing-invalidation', 'warning', 'bug', `Form posts directly to mutating Server Action '${actionName}' but no redirect or cache invalidation was detected — the submit can complete with stale UI`, ctx.filePath, form.getStartLineNumber(), 1, {
|
|
1024
|
+
suggestion: 'After a successful mutation, call revalidatePath(), revalidateTag(), updateTag(), redirect(), or return state through useActionState() so the UI closes the loop.',
|
|
1025
|
+
});
|
|
1026
|
+
hit.relatedSpans = [span(actionFile, actionLine), ...(mutationLine ? [span(actionFile, mutationLine)] : [])];
|
|
1027
|
+
hit.provenance = {
|
|
1028
|
+
summary: `Form action resolves to ${actionName}(), which performs a likely mutation but does not redirect or refresh server data.`,
|
|
1029
|
+
steps: [
|
|
1030
|
+
{
|
|
1031
|
+
kind: 'call',
|
|
1032
|
+
location: span(actionFile, actionLine),
|
|
1033
|
+
label: `${actionName}()`,
|
|
1034
|
+
detail: 'Resolved Server Action bound to the form action.',
|
|
1035
|
+
},
|
|
1036
|
+
...(mutationLine
|
|
1037
|
+
? [
|
|
1038
|
+
{
|
|
1039
|
+
kind: 'call',
|
|
1040
|
+
location: span(actionFile, mutationLine),
|
|
1041
|
+
label: mutationCall?.getExpression().getText() ?? 'mutation call',
|
|
1042
|
+
detail: 'Likely mutating operation inside the Server Action body.',
|
|
1043
|
+
},
|
|
1044
|
+
]
|
|
1045
|
+
: []),
|
|
1046
|
+
],
|
|
1047
|
+
};
|
|
1048
|
+
findings.push(hit);
|
|
154
1049
|
}
|
|
155
1050
|
return findings;
|
|
156
1051
|
}
|
|
@@ -273,5 +1168,15 @@ function serverActionUnvalidatedInput(ctx) {
|
|
|
273
1168
|
return findings;
|
|
274
1169
|
}
|
|
275
1170
|
// ── Exported App Router Rules ────────────────────────────────────────────
|
|
276
|
-
export const nextjsAppRouterRules = [
|
|
1171
|
+
export const nextjsAppRouterRules = [
|
|
1172
|
+
useClientDrilledTooHigh,
|
|
1173
|
+
serverApiInClient,
|
|
1174
|
+
browserApiInServer,
|
|
1175
|
+
useActionStateMissingPending,
|
|
1176
|
+
useActionStateMissingFeedback,
|
|
1177
|
+
serverActionFormMissingPending,
|
|
1178
|
+
serverActionFormReturnValueIgnored,
|
|
1179
|
+
serverActionFormMutationMissingInvalidation,
|
|
1180
|
+
serverActionUnvalidatedInput,
|
|
1181
|
+
];
|
|
277
1182
|
//# sourceMappingURL=nextjs-app-router.js.map
|