Skip to content

Commit b69c6ca

Browse files
sonar-nigel[bot]Vibe Bot
andauthored
JS-1422 Fix FP on S2234: suppress intentional parameter swap in paired ternary calls (#6685)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com>
1 parent eef2fb7 commit b69c6ca

File tree

2 files changed

+168
-2
lines changed

2 files changed

+168
-2
lines changed

packages/analysis/src/jsts/rules/S2234/rule.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const rule: Rule.RuleModule = {
4242
create(context: Rule.RuleContext) {
4343
const services = context.sourceCode.parserServices;
4444
const canResolveType = isRequiredParserServices(services);
45+
const COMPARISON_OPERATORS = ['==', '!=', '===', '!==', '<', '<=', '>', '>='] as const;
4546

4647
function checkArguments(functionCall: estree.CallExpression) {
4748
// Extract argument names first (cheap operation)
@@ -84,7 +85,8 @@ export const rule: Rule.RuleModule = {
8485
swappedArgumentName &&
8586
!areComparedArguments([argumentName, swappedArgumentName], functionCall) &&
8687
!isIntentionalComparatorReversal(functionCall, argumentName, swappedArgumentName) &&
87-
!isInDirectionalContext(functionCall)
88+
!isInDirectionalContext(functionCall) &&
89+
!isIntentionalTernarySwap(functionCall, argumentName, swappedArgumentName)
8890
) {
8991
raiseIssue(argumentName, swappedArgumentName, functionDeclaration, functionCall);
9092
return;
@@ -121,7 +123,7 @@ export const rule: Rule.RuleModule = {
121123
switch (test.type) {
122124
case 'BinaryExpression': {
123125
const binExpr = test;
124-
if (['==', '!=', '===', '!==', '<', '<=', '>', '>='].includes(binExpr.operator)) {
126+
if ((COMPARISON_OPERATORS as readonly string[]).includes(binExpr.operator)) {
125127
const { left: lhs, right: rhs } = binExpr;
126128
return checkComparedArguments(lhs, rhs);
127129
}
@@ -226,6 +228,100 @@ export const rule: Rule.RuleModule = {
226228
return false;
227229
}
228230

231+
/**
232+
* Returns true when the detected argument swap is in one branch of a ConditionalExpression,
233+
* the other branch calls the same function with those arguments in the opposite (normal) order,
234+
* AND the ternary condition itself is a comparison that involves both of those same arguments.
235+
*
236+
* This ensures the condition is actually selecting the correct ordering rather than being an
237+
* unrelated boolean. For example:
238+
* `start < stop ? fn(start, stop) : fn(stop, start)` — suppressed (condition compares the pair)
239+
* `legacy ? fn(stop, start) : fn(start, stop)` — reported (condition unrelated to arg order)
240+
*/
241+
function isIntentionalTernarySwap(
242+
functionCall: estree.CallExpression,
243+
arg1Name: string,
244+
arg2Name: string,
245+
): boolean {
246+
const ancestors = context.sourceCode.getAncestors(functionCall);
247+
const parent = ancestors.at(-1);
248+
249+
if (parent?.type !== 'ConditionalExpression') {
250+
return false;
251+
}
252+
253+
const conditional = parent;
254+
255+
// Determine the "other" branch of the ternary
256+
let otherBranch: estree.Node | null = null;
257+
if (conditional.consequent === functionCall) {
258+
otherBranch = conditional.alternate;
259+
} else if (conditional.alternate === functionCall) {
260+
otherBranch = conditional.consequent;
261+
}
262+
263+
if (otherBranch?.type !== 'CallExpression') {
264+
return false;
265+
}
266+
267+
const otherCall = otherBranch as estree.CallExpression;
268+
269+
// Both calls must target the same callee (by source text)
270+
if (
271+
context.sourceCode.getText(functionCall.callee) !==
272+
context.sourceCode.getText(otherCall.callee)
273+
) {
274+
return false;
275+
}
276+
277+
if (otherCall.arguments.length !== functionCall.arguments.length) {
278+
return false;
279+
}
280+
281+
// Find positions of arg1 and arg2 in the flagged call
282+
const args = functionCall.arguments;
283+
const idx1 = args.findIndex(a => a.type === 'Identifier' && a.name === arg1Name);
284+
const idx2 = args.findIndex(a => a.type === 'Identifier' && a.name === arg2Name);
285+
286+
if (idx1 < 0 || idx2 < 0) {
287+
return false;
288+
}
289+
290+
// In the other branch, those same positions must carry arg2 and arg1 (reversed)
291+
const otherArgs = otherCall.arguments;
292+
const otherAtIdx1 = otherArgs[idx1];
293+
const otherAtIdx2 = otherArgs[idx2];
294+
295+
if (
296+
!(
297+
otherAtIdx1?.type === 'Identifier' &&
298+
otherAtIdx1.name === arg2Name &&
299+
otherAtIdx2?.type === 'Identifier' &&
300+
otherAtIdx2.name === arg1Name
301+
)
302+
) {
303+
return false;
304+
}
305+
306+
// The ternary condition must itself compare the same argument pair. This ties the
307+
// suppression to condition-controlled ordering (e.g. `a < b ? fn(a, b) : fn(b, a)`)
308+
// rather than an arbitrary boolean selector (e.g. `flag ? fn(b, a) : fn(a, b)`).
309+
const test = conditional.test;
310+
if (test.type !== 'BinaryExpression') {
311+
return false;
312+
}
313+
if (!(COMPARISON_OPERATORS as readonly string[]).includes(test.operator)) {
314+
return false;
315+
}
316+
const leftName = test.left.type === 'Identifier' ? test.left.name : undefined;
317+
const rightName = test.right.type === 'Identifier' ? test.right.name : undefined;
318+
if (!leftName || !rightName) {
319+
return false;
320+
}
321+
const conditionNames = new Set([leftName, rightName]);
322+
return conditionNames.has(arg1Name) && conditionNames.has(arg2Name);
323+
}
324+
229325
function resolveFunctionDeclaration(node: estree.CallExpression): FunctionSignature | null {
230326
if (canResolveType) {
231327
return resolveFromTSSignature(node);

packages/analysis/src/jsts/rules/S2234/unit.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,30 @@ describe('S2234', () => {
195195
};
196196
`,
197197
},
198+
{
199+
// False positive: swap in alternate branch paired with normal-order call in consequent (phaser canonical-ordering pattern)
200+
code: `
201+
function getTrigger(index1, index2) { return { from: index1, to: index2 }; }
202+
function getSortedTrigger(index1, index2) {
203+
const event = index1 < index2
204+
? getTrigger(index1, index2)
205+
: getTrigger(index2, index1);
206+
return event;
207+
}
208+
`,
209+
},
210+
{
211+
// False positive: swap in consequent branch paired with normal-order call in alternate (d3-array inline-comparison style)
212+
code: `
213+
function tickIncrement(start, stop, count) { return Math.ceil((stop - start) / count); }
214+
function computeTick(start, stop, count) {
215+
const inc = stop < start
216+
? tickIncrement(stop, start, count)
217+
: tickIncrement(start, stop, count);
218+
return inc;
219+
}
220+
`,
221+
},
198222
],
199223
invalid: [
200224
{
@@ -299,6 +323,52 @@ describe('S2234', () => {
299323
const wrongWrapper = (year, month) => formatDate(month, year);`,
300324
errors: 1,
301325
},
326+
{
327+
// Ternary where the condition is an unrelated boolean — condition must compare the swapped pair
328+
code: `
329+
function formatDate(year, month) {}
330+
var year = 2024, month = 6, legacy = true;
331+
const d = legacy ? formatDate(month, year) : formatDate(year, month);`,
332+
errors: 1,
333+
},
334+
{
335+
// Ternary where the condition is a boolean variable derived from a comparison, not a direct comparison
336+
code: `
337+
function tickIncrement(start, stop, count) { return Math.ceil((stop - start) / count); }
338+
function computeTick(start, stop, count) {
339+
const reverse = stop < start;
340+
const inc = reverse
341+
? tickIncrement(stop, start, count)
342+
: tickIncrement(start, stop, count);
343+
return inc;
344+
}`,
345+
errors: 1,
346+
},
347+
{
348+
// Ternary where the other branch is not a CallExpression — ternary suppression does not apply
349+
code: `
350+
function f(start, stop) {}
351+
var start = 0, stop = 10, x = null;
352+
const r = x ? f(stop, start) : null;`,
353+
errors: 1,
354+
},
355+
{
356+
// Ternary where the other branch calls a different function — ternary suppression does not apply
357+
code: `
358+
function f(start, stop) {}
359+
function g(start, stop) {}
360+
var start = 0, stop = 10, x = null;
361+
const r = x ? f(stop, start) : g(start, stop);`,
362+
errors: 1,
363+
},
364+
{
365+
// Ternary where both branches swap arguments — no normal-order branch, ternary suppression does not apply
366+
code: `
367+
function f(start, stop) {}
368+
var start = 0, stop = 10, x = null;
369+
const r = x ? f(stop, start) : f(stop, start);`,
370+
errors: 2,
371+
},
302372
],
303373
});
304374

0 commit comments

Comments
 (0)