Skip to content

Commit 32ae309

Browse files
zgliczclaudegithub-actions[bot]
authored
JS-106 Fix S3801: Detect calls to never-returning functions (#6155)
Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 77ecdaa commit 32ae309

File tree

4 files changed

+87
-13
lines changed

4 files changed

+87
-13
lines changed

its/ruling/src/test/expected/jsts/ace/javascript-S3801.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
2747,
6565
2769,
6666
2955,
67-
5100,
6867
5333,
6968
5923,
7069
5970,
@@ -103,13 +102,6 @@
103102
"ace:lib/ace/mode/javascript_worker.js": [
104103
105
105104
],
106-
"ace:lib/ace/mode/json/json_parse.js": [
107-
106,
108-
147,
109-
199,
110-
229,
111-
256
112-
],
113105
"ace:lib/ace/mode/jsoniq.js": [
114106
26,
115107
51

its/ruling/src/test/expected/jsts/desktop/typescript-S3801.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,6 @@
128128
"desktop:app/src/ui/lib/list/list.tsx": [
129129
590
130130
],
131-
"desktop:app/src/ui/local-changes-overwritten/local-changes-overwritten-dialog.tsx": [
132-
157
133-
],
134131
"desktop:app/src/ui/multi-commit-operation/base-multi-commit-operation.tsx": [
135132
75,
136133
103,

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
generateMeta,
2525
getMainFunctionTokenLocation,
2626
getParent,
27+
getSignatureFromCallee,
2728
getTypeFromTreeNode,
2829
isRequiredParserServices,
2930
report,
@@ -42,6 +43,8 @@ interface FunctionContext {
4243
switchStatements: estree.SwitchStatement[];
4344
/** Tracks whether last case of each switch has reachable exit (doesn't return/throw) */
4445
switchLastCaseReachable: Map<estree.SwitchStatement, boolean>;
46+
/** The last call expression in the function body (may return never) */
47+
lastCallExpression: estree.CallExpression | null;
4548
}
4649

4750
interface FunctionLikeDeclaration {
@@ -122,7 +125,14 @@ export const rule: Rule.RuleModule = {
122125
isExhaustiveSwitch(switchStmt, services) &&
123126
!functionContext.switchLastCaseReachable.get(switchStmt),
124127
);
125-
functionContext.containsImplicitReturn = !hasExhaustiveSwitch;
128+
129+
// Check if the last call expression returns 'never' (e.g., a throwing function)
130+
// If so, the implicit return is unreachable
131+
const lastCallReturnsNever =
132+
functionContext.lastCallExpression &&
133+
isNeverReturningCall(functionContext.lastCallExpression, services);
134+
135+
functionContext.containsImplicitReturn = !hasExhaustiveSwitch && !lastCallReturnsNever;
126136
} else {
127137
functionContext.containsImplicitReturn = hasReachableSegment;
128138
}
@@ -160,6 +170,7 @@ export const rule: Rule.RuleModule = {
160170
returnStatements: [],
161171
switchStatements: [],
162172
switchLastCaseReachable: new Map(),
173+
lastCallExpression: null,
163174
});
164175
allCurrentSegments.push(currentSegments);
165176
currentSegments = new Set();
@@ -217,6 +228,16 @@ export const rule: Rule.RuleModule = {
217228
currentContext.switchLastCaseReachable.set(switchStmt, hasReachableExit);
218229
}
219230
},
231+
ExpressionStatement(node: estree.Node) {
232+
const currentContext = functionContextStack.at(-1);
233+
if (currentContext) {
234+
const expr = (node as estree.ExpressionStatement).expression;
235+
if (expr.type === 'CallExpression') {
236+
// Track any call expression - we'll check if it returns 'never' later
237+
currentContext.lastCallExpression = expr;
238+
}
239+
}
240+
},
220241
'FunctionDeclaration:exit': checkOnFunctionExit,
221242
'FunctionExpression:exit': checkOnFunctionExit,
222243
'ArrowFunctionExpression:exit': checkOnFunctionExit,
@@ -301,3 +322,21 @@ function isEnumType(type: ts.Type): boolean {
301322
type.symbol?.flags === ts.SymbolFlags.EnumMember
302323
);
303324
}
325+
326+
/**
327+
* Checks if a call expression returns 'never' (e.g., a function that always throws).
328+
* This helps detect when an implicit return is actually unreachable because the
329+
* last statement calls a function that never returns.
330+
*/
331+
function isNeverReturningCall(
332+
callExpr: estree.CallExpression,
333+
services: RequiredParserServices,
334+
): boolean {
335+
const signature = getSignatureFromCallee(callExpr, services);
336+
if (!signature) {
337+
return false;
338+
}
339+
340+
const returnType = signature.getReturnType();
341+
return (returnType.flags & ts.TypeFlags.Never) !== 0;
342+
}

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,8 @@ describe('S3801', () => {
294294
}`,
295295
errors: 1,
296296
},
297-
// possible FP, see https://github.com/SonarSource/SonarJS/issues/2579
297+
// FP without type information - we can't detect that throwError returns 'never'
298+
// See JS-106. With type information, this is now fixed (see type-aware tests below)
298299
{
299300
code: `
300301
function throwError(message: string): never {
@@ -448,8 +449,53 @@ describe('S3801', () => {
448449
}
449450
`,
450451
},
452+
{
453+
// Issue JS-106: Calling a function that returns 'never' - should not raise
454+
// The function terminates via the never-returning call, so no implicit return
455+
code: `
456+
function throwError(message: string): never {
457+
throw new Error(message);
458+
}
459+
function formatDateOrThrow(value: unknown): string {
460+
if (value instanceof Date) {
461+
return value.toISOString();
462+
}
463+
throwError('Invalid date');
464+
}
465+
`,
466+
},
467+
{
468+
// Calling a never-returning function without explicit return type on caller
469+
code: `
470+
function throwError(message: string): never {
471+
throw new Error(message);
472+
}
473+
function withNeverType(a) {
474+
if (a === 1) {
475+
return true;
476+
}
477+
throwError('False');
478+
}
479+
`,
480+
},
451481
],
452482
invalid: [
483+
{
484+
// Calling a function without explicit 'never' return type - should raise
485+
// TypeScript infers 'void' not 'never' for functions that only throw
486+
code: `
487+
function throwErrorNoAnnotation() {
488+
throw new Error('error');
489+
}
490+
function test(a) {
491+
if (a === 1) {
492+
return true;
493+
}
494+
throwErrorNoAnnotation();
495+
}
496+
`,
497+
errors: 1,
498+
},
453499
{
454500
// Non-exhaustive switch - should raise
455501
code: `

0 commit comments

Comments
 (0)