Skip to content

Commit 39064fb

Browse files
sonar-nigel[bot]Vibe Botclaudefrancois-mora-sonarsource
authored
JS-1307 Fix FP in S3516 for functions with intentional invariant non-literal returns (#6511)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Francois Mora <francois.mora@sonarsource.com>
1 parent ac4b1be commit 39064fb

4 files changed

Lines changed: 212 additions & 6 deletions

File tree

its/ruling/src/test/expected/eigen/typescript-S3516.json

Lines changed: 0 additions & 5 deletions
This file was deleted.

packages/jsts/src/rules/S3516/rule.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,17 @@ import { generateMeta } from '../helpers/generate-meta.js';
2626
import { getMainFunctionTokenLocation, report, toSecondaryLocation } from '../helpers/location.js';
2727
import * as meta from './generated-meta.js';
2828

29+
interface BranchFrame {
30+
hasSideEffect: boolean;
31+
hasReturn: boolean;
32+
}
33+
2934
interface FunctionContext {
3035
codePath: Rule.CodePath;
3136
containsReturnWithoutValue: boolean;
3237
returnStatements: estree.ReturnStatement[];
38+
branchStack: BranchFrame[];
39+
hasSideEffectOnlyBranch: boolean;
3340
}
3441

3542
interface SingleWriteVariable {
@@ -59,6 +66,12 @@ export const rule: Rule.RuleModule = {
5966
returnStatement => returnStatement.argument as estree.Node,
6067
);
6168
if (areAllSameValue(returnedValues, context.sourceCode.getScope(node))) {
69+
// Only suppress when the returned value is a non-literal (e.g., a variable used for chaining).
70+
// Functions returning literals (false, null, 0, etc.) have no chaining rationale and are always flagged.
71+
const firstValue = getLiteralValue(returnedValues[0], context.sourceCode.getScope(node));
72+
if (firstValue === undefined && functionContext.hasSideEffectOnlyBranch) {
73+
return;
74+
}
6275
report(
6376
context,
6477
{
@@ -75,12 +88,45 @@ export const rule: Rule.RuleModule = {
7588
}
7689
}
7790

91+
function pushBranchFrame() {
92+
functionContextStack.at(-1)?.branchStack.push({ hasSideEffect: false, hasReturn: false });
93+
}
94+
95+
function popBranchFrame() {
96+
const ctx = functionContextStack.at(-1);
97+
if (ctx) {
98+
const frame = ctx.branchStack.pop();
99+
if (frame?.hasSideEffect && !frame.hasReturn) {
100+
ctx.hasSideEffectOnlyBranch = true;
101+
}
102+
}
103+
}
104+
105+
function recordSideEffect(node: estree.CallExpression | estree.AssignmentExpression) {
106+
const ctx = functionContextStack.at(-1);
107+
if (!ctx) {
108+
return;
109+
}
110+
const frame = ctx.branchStack.at(-1);
111+
if (frame) {
112+
frame.hasSideEffect = true;
113+
}
114+
// Also handle bare statement branches where ExpressionStatement IS the branch body
115+
// (e.g. `if (x) doSomething()` — no BlockStatement is pushed for such a branch)
116+
const exprStmt = (node as TSESTree.Node).parent as TSESTree.Node | undefined;
117+
if (exprStmt && isBranchBody(exprStmt)) {
118+
ctx.hasSideEffectOnlyBranch = true;
119+
}
120+
}
121+
78122
return {
79123
onCodePathStart(codePath: Rule.CodePath) {
80124
functionContextStack.push({
81125
codePath,
82126
containsReturnWithoutValue: false,
83127
returnStatements: [],
128+
branchStack: [],
129+
hasSideEffectOnlyBranch: false,
84130
});
85131
codePathSegments.push(currentCodePathSegments);
86132
currentCodePathSegments = [];
@@ -102,8 +148,34 @@ export const rule: Rule.RuleModule = {
102148
currentContext.containsReturnWithoutValue =
103149
currentContext.containsReturnWithoutValue || !returnStatement.argument;
104150
currentContext.returnStatements.push(returnStatement);
151+
const frame = currentContext.branchStack.at(-1);
152+
if (frame) {
153+
frame.hasReturn = true;
154+
}
105155
}
106156
},
157+
BlockStatement(node: estree.Node) {
158+
if (isBranchBody(node as TSESTree.Node)) {
159+
pushBranchFrame();
160+
}
161+
},
162+
'BlockStatement:exit'(node: estree.Node) {
163+
if (isBranchBody(node as TSESTree.Node)) {
164+
popBranchFrame();
165+
}
166+
},
167+
SwitchCase() {
168+
pushBranchFrame();
169+
},
170+
'SwitchCase:exit'() {
171+
popBranchFrame();
172+
},
173+
'ExpressionStatement > CallExpression'(node: estree.CallExpression) {
174+
recordSideEffect(node);
175+
},
176+
'ExpressionStatement > AssignmentExpression'(node: estree.AssignmentExpression) {
177+
recordSideEffect(node);
178+
},
107179
'FunctionDeclaration:exit': checkOnFunctionExit,
108180
'FunctionExpression:exit': checkOnFunctionExit,
109181
'ArrowFunctionExpression:exit': checkOnFunctionExit,
@@ -215,3 +287,28 @@ function evaluateUnaryLiteralExpression(operator: string, innerReturnedValue: Li
215287
return undefined;
216288
}
217289
}
290+
291+
/**
292+
* Returns true if the node is a direct branch body of a conditional or loop statement —
293+
* i.e., the consequent/alternate of an IfStatement or the body of a loop.
294+
* Used to push and pop branch frames on the stack when entering and exiting branches.
295+
*/
296+
function isBranchBody(node: TSESTree.Node): boolean {
297+
const { parent } = node;
298+
if (!parent) {
299+
return false;
300+
}
301+
if (parent.type === 'IfStatement') {
302+
return node === parent.consequent || node === parent.alternate;
303+
}
304+
if (
305+
parent.type === 'WhileStatement' ||
306+
parent.type === 'DoWhileStatement' ||
307+
parent.type === 'ForStatement' ||
308+
parent.type === 'ForInStatement' ||
309+
parent.type === 'ForOfStatement'
310+
) {
311+
return node === parent.body;
312+
}
313+
return false;
314+
}

packages/jsts/src/rules/S3516/unit.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ describe('S3516', () => {
205205
}
206206
207207
var arrowWithExpressionBody = p => p ? 1 : 1;
208-
208+
209209
function sameSymbolicValueSameConstraint(p) { // FN - not using SE
210210
var num = foo() - bar();
211211
var num2 = num;
@@ -216,6 +216,94 @@ describe('S3516', () => {
216216
}
217217
}`,
218218
},
219+
{
220+
// chaining pattern: while-loop mutates obj in-place, invariant return enables chaining
221+
code: `
222+
function deepFreezeExample<T>(obj: T): T {
223+
if (!obj || typeof obj !== 'object') {
224+
return obj;
225+
}
226+
const stack: any[] = [obj];
227+
while (stack.length > 0) {
228+
const current = stack.shift();
229+
Object.freeze(current);
230+
for (const key of Object.keys(current)) {
231+
const val = current[key];
232+
if (val && typeof val === 'object' && !Object.isFrozen(val)) {
233+
stack.push(val);
234+
}
235+
}
236+
}
237+
return obj;
238+
}`,
239+
},
240+
{
241+
// chaining pattern: if/else mutates linked-list pointers, invariant return enables chaining
242+
code: `
243+
interface MoveRecord<V> {
244+
value: V;
245+
previousIndex: number;
246+
_nextMoved: MoveRecord<V> | null;
247+
}
248+
function addToMovesExample<V>(
249+
record: MoveRecord<V>,
250+
movesTail: MoveRecord<V> | null,
251+
toIndex: number,
252+
): MoveRecord<V> {
253+
if (record.previousIndex === toIndex) {
254+
return record;
255+
}
256+
if (movesTail === null) {
257+
movesTail = record;
258+
} else {
259+
movesTail._nextMoved = record;
260+
}
261+
return record;
262+
}`,
263+
},
264+
{
265+
// signaling pattern: conditional callback invocation, invariant return of non-literal
266+
code: `
267+
interface SelectionState { empty: boolean; from: number; }
268+
interface PluginState {
269+
decorations: string[];
270+
selection: SelectionState;
271+
getAnnotationsAt(pos: number): string[] | null;
272+
}
273+
function getDecorationsExample(
274+
state: PluginState,
275+
onUpdate: (annotations: string[]) => void,
276+
): string[] {
277+
const decorations = state.decorations ?? [];
278+
if (!state.selection.empty) {
279+
return decorations;
280+
}
281+
const annotations = state.getAnnotationsAt(state.selection.from);
282+
if (annotations) {
283+
onUpdate(annotations);
284+
}
285+
return decorations;
286+
}`,
287+
},
288+
{
289+
// middleware pattern: async function returns non-literal for chaining while a nested
290+
// conditional performs side effects (e.g. signout, alert) without returning.
291+
// Two returns of the same non-literal value but with an inner side-effect-only branch.
292+
code: `
293+
async function middlewareExample(req, next) {
294+
const res = await next(req)
295+
if (res.errors && res.errors.length) {
296+
if (alreadyHandled(req)) {
297+
return res
298+
}
299+
const result = await fetch('/api/check')
300+
if (result.status === 401) {
301+
performSignOut()
302+
}
303+
}
304+
return res
305+
}`,
306+
},
219307
],
220308
invalid: [
221309
{
@@ -479,6 +567,31 @@ describe('S3516', () => {
479567
var arrowEquivalent6 = (p) => { if (p) { return ~4; } return -5; };`,
480568
errors: 8,
481569
},
570+
{
571+
// literal return with side-effect conditional: literal guard prevents exemption
572+
code: `
573+
function f(a, g) {
574+
if (a < 0) { return false; }
575+
if (a > 10) { g(a); }
576+
return false;
577+
}`,
578+
errors: 1,
579+
},
580+
{
581+
// switch where the case with side effect ALSO has a return: not side-effect-only branch,
582+
// so the exemption does not apply and the issue is correctly raised
583+
code: `
584+
function dispatchInTheMiddleOfReducer(state = [], action) {
585+
switch (action.type) {
586+
case 'DISPATCH_IN_MIDDLE':
587+
sideEffect()
588+
return state
589+
default:
590+
return state
591+
}
592+
}`,
593+
errors: 1,
594+
},
482595
],
483596
});
484597
});

packages/jsts/src/rules/helpers/ast.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,4 @@ export function hasTypePredicateReturn(node: estree.Node): boolean {
722722
}
723723
return false;
724724
}
725+

0 commit comments

Comments
 (0)