@@ -26,10 +26,17 @@ import { generateMeta } from '../helpers/generate-meta.js';
2626import { getMainFunctionTokenLocation , report , toSecondaryLocation } from '../helpers/location.js' ;
2727import * as meta from './generated-meta.js' ;
2828
29+ interface BranchFrame {
30+ hasSideEffect : boolean ;
31+ hasReturn : boolean ;
32+ }
33+
2934interface FunctionContext {
3035 codePath : Rule . CodePath ;
3136 containsReturnWithoutValue : boolean ;
3237 returnStatements : estree . ReturnStatement [ ] ;
38+ branchStack : BranchFrame [ ] ;
39+ hasSideEffectOnlyBranch : boolean ;
3340}
3441
3542interface 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+ }
0 commit comments