Skip to content

Commit 690d8a1

Browse files
JS-1462 Fix S6582 over-suppression by suppressing only known false positives (#6830)
1 parent 670bef8 commit 690d8a1

6 files changed

Lines changed: 168 additions & 81 deletions

File tree

its/ruling/src/test/expected/TypeScript/javascript-S6582.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@
183183
43934,
184184
44204,
185185
44570,
186+
44761,
186187
45278,
187188
46088,
188189
46102,

its/ruling/src/test/expected/desktop/typescript-S6582.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@
256256
314
257257
],
258258
"desktop:app/src/ui/toolbar/branch-dropdown.tsx": [
259-
329
259+
329,
260+
330
260261
],
261262
"desktop:script/changelog/parser.ts": [
262263
21,

its/ruling/src/test/expected/knockout/javascript-S6582.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"knockout:src/binding/defaultBindings/value.js": [
2323
38
2424
],
25+
"knockout:src/binding/editDetection/arrayToDomNodeChildren.js": [
26+
89
27+
],
2528
"knockout:src/binding/expressionRewriting.js": [
2629
119,
2730
184

its/ruling/src/test/expected/paper.js/javascript-S6582.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
71,
6161
110,
6262
111,
63-
140
63+
140,
64+
145
6465
],
6566
"paper.js:src/item/Item.js": [
6667
921,
@@ -157,6 +158,7 @@
157158
200,
158159
310,
159160
418,
161+
442,
160162
919,
161163
946,
162164
959,

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

Lines changed: 149 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,6 @@ import * as meta from './generated-meta.js';
2828
*/
2929
const preferOptionalChainRule = tsEslintRules['prefer-optional-chain'];
3030

31-
/**
32-
* Matches negated access guards that stay boolean after optional chaining.
33-
*
34-
* Pseudo code:
35-
* if (!value || !value.property) {
36-
* // `value?.property` remains wrapped by `!`
37-
* }
38-
*/
39-
function isNegatedOptionalChainGuard(node: Rule.Node) {
40-
return (
41-
node.type === 'LogicalExpression' &&
42-
node.operator === '||' &&
43-
node.left.type === 'UnaryExpression' &&
44-
node.left.operator === '!' &&
45-
node.right.type === 'UnaryExpression' &&
46-
node.right.operator === '!'
47-
);
48-
}
49-
5031
/**
5132
* Resolves the AST node associated with a report descriptor.
5233
*
@@ -108,29 +89,6 @@ function allowsUndefined(type: ts.Type): boolean {
10889
);
10990
}
11091

111-
/**
112-
* Returns the type imposed by the surrounding context on the reported expression.
113-
*
114-
* Pseudo code:
115-
* let result: string | null;
116-
* result = value && value.property;
117-
*
118-
* Here, `node` is `value && value.property`, and its contextual type is `string | null`
119-
* because that is the type expected by the assignment target `result`.
120-
*/
121-
function getContextualTypeOfNode(
122-
services: Rule.RuleContext['sourceCode']['parserServices'],
123-
checker: ts.TypeChecker,
124-
node: Rule.Node,
125-
): ts.Type | null {
126-
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
127-
if (!tsNode) {
128-
return null;
129-
}
130-
131-
return checker.getContextualType(tsNode as ts.Expression) ?? null;
132-
}
133-
13492
/**
13593
* Sanitized rule 'prefer-optional-chain' from TypeScript ESLint.
13694
*
@@ -171,40 +129,163 @@ export const rule: Rule.RuleModule = {
171129
}
172130

173131
const checker = services.program.getTypeChecker();
174-
return interceptReport(preferOptionalChainRule, (ctx, descriptor) => {
175-
const node = findReportNode(ctx, descriptor);
176-
if (!node) {
177-
return;
132+
133+
/**
134+
* Returns the type imposed by the surrounding context on the reported expression.
135+
*
136+
* Pseudo code:
137+
* let result: string | null;
138+
* result = value && value.property;
139+
*
140+
* Here, `node` is `value && value.property`, and its contextual type is `string | null`
141+
* because that is the type expected by the assignment target `result`.
142+
*/
143+
function getContextualTypeOfNode(node: Rule.Node): ts.Type | null {
144+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
145+
if (!tsNode) {
146+
return null;
178147
}
179148

180-
// Negation patterns (!a || !a.prop): the ! operator always returns boolean,
181-
// so optional chaining (!a?.prop) is always type-safe regardless of context.
182-
// Both sides must be negated — !a || (comparison) is not a negation pattern
183-
// and should fall through to the contextual type check.
184-
if (isNegatedOptionalChainGuard(node)) {
185-
ctx.report(descriptor);
186-
return;
149+
return checker.getContextualType(tsNode as ts.Expression) ?? null;
150+
}
151+
152+
function getTypeOfNode(node: Rule.Node): ts.Type | null {
153+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
154+
if (!tsNode) {
155+
return null;
187156
}
188157

189-
// Comparison patterns (e.g. !a || a.prop !== b): the comparison operator always
190-
// returns boolean, so the optional-chain rewrite (a?.prop !== b) is also boolean —
191-
// no undefined leaks into the surrounding type regardless of context.
192-
if (
158+
return checker.getTypeAtLocation(tsNode);
159+
}
160+
161+
function getContextualTypeSubject(node: Rule.Node) {
162+
let current = node;
163+
while (
164+
current.parent?.type === 'LogicalExpression' &&
165+
(current.parent.left === current || current.parent.right === current)
166+
) {
167+
current = current.parent;
168+
}
169+
return current;
170+
}
171+
172+
function hasTypeUnsafeContextualType(node: Rule.Node) {
173+
const contextualType = getContextualTypeOfNode(getContextualTypeSubject(node));
174+
return contextualType != null && !allowsUndefined(contextualType);
175+
}
176+
177+
function matchesFunctionReturnFalsePositive(node: Rule.Node) {
178+
return (
179+
node.type === 'LogicalExpression' &&
180+
node.operator === '&&' &&
181+
node.parent?.type === 'ReturnStatement' &&
182+
node.right.type !== 'BinaryExpression' &&
183+
hasTypeUnsafeContextualType(node)
184+
);
185+
}
186+
187+
function matchesTypedVariableInitializerFalsePositive(node: Rule.Node) {
188+
return (
189+
node.type === 'LogicalExpression' &&
190+
node.operator === '&&' &&
191+
node.parent?.type === 'VariableDeclarator' &&
192+
node.parent.init === node &&
193+
node.right.type !== 'BinaryExpression' &&
194+
hasTypeUnsafeContextualType(node)
195+
);
196+
}
197+
198+
function matchesObjectLiteralPropertyFalsePositive(node: Rule.Node) {
199+
return (
193200
node.type === 'LogicalExpression' &&
194-
node.right.type === 'BinaryExpression' &&
195-
['==', '!=', '===', '!==', '<', '>', '<=', '>=', 'instanceof', 'in'].includes(
196-
node.right.operator,
197-
)
201+
node.operator === '&&' &&
202+
node.parent?.type === 'Property' &&
203+
node.parent.value === node &&
204+
node.right.type !== 'BinaryExpression' &&
205+
hasTypeUnsafeContextualType(node)
206+
);
207+
}
208+
209+
function matchesCallArgumentFalsePositive(node: Rule.Node) {
210+
const subject = getContextualTypeSubject(node);
211+
const parent = subject.parent;
212+
if (
213+
node.type !== 'LogicalExpression' ||
214+
node.operator !== '&&' ||
215+
node.right.type === 'BinaryExpression' ||
216+
parent?.type !== 'CallExpression' ||
217+
!parent.arguments.includes(subject as never)
218+
) {
219+
return false;
220+
}
221+
222+
const contextualType = getContextualTypeOfNode(subject);
223+
return contextualType != null && !allowsUndefined(contextualType);
224+
}
225+
226+
function matchesAssignmentFalsePositive(node: Rule.Node) {
227+
if (
228+
node.type !== 'LogicalExpression' ||
229+
node.operator !== '&&' ||
230+
node.right.type === 'BinaryExpression' ||
231+
node.parent?.type !== 'AssignmentExpression' ||
232+
node.parent.right !== node ||
233+
node.parent.operator !== '='
198234
) {
199-
ctx.report(descriptor);
235+
return false;
236+
}
237+
238+
const parent = node.parent;
239+
if (parent.left.type !== 'Identifier' && parent.left.type !== 'MemberExpression') {
240+
// Keep the matcher narrowly scoped to ordinary typed assignment targets.
241+
// Broader target coverage can be added once we have concrete FP examples.
242+
return false;
243+
}
244+
245+
const targetType = getTypeOfNode(parent.left as Rule.Node);
246+
return targetType != null && !allowsUndefined(targetType);
247+
}
248+
249+
/**
250+
* Returns true when the upstream report is a known false positive that should be suppressed.
251+
*
252+
* We suppress only when the optional-chain rewrite would leak a type-unsafe `undefined`
253+
* into the surrounding context. All other cases are reported by default.
254+
*/
255+
function isKnownFalsePositive(node: Rule.Node): boolean {
256+
if (matchesFunctionReturnFalsePositive(node)) {
257+
return true;
258+
}
259+
260+
if (matchesTypedVariableInitializerFalsePositive(node)) {
261+
return true;
262+
}
263+
264+
if (matchesObjectLiteralPropertyFalsePositive(node)) {
265+
return true;
266+
}
267+
268+
if (matchesCallArgumentFalsePositive(node)) {
269+
return true;
270+
}
271+
272+
if (matchesAssignmentFalsePositive(node)) {
273+
return true;
274+
}
275+
276+
return false;
277+
}
278+
279+
return interceptReport(preferOptionalChainRule, (ctx, descriptor) => {
280+
const node = findReportNode(ctx, descriptor);
281+
if (!node) {
282+
return;
283+
}
284+
285+
if (isKnownFalsePositive(node)) {
200286
return;
201287
}
202288

203-
// Left-operand-of-|| or left-operand-of-?? pattern:
204-
// When (a && a.prop) is the left operand of || or ??, the enclosing operator
205-
// absorbs the undefined introduced by optional chaining, making the rewrite
206-
// type-safe. E.g. (repo && repo.name) || fallback → repo?.name || fallback
207-
// preserves the overall type because || and ?? provide a fallback for undefined.
208289
const parent = node.parent;
209290
if (
210291
parent?.type === 'LogicalExpression' &&
@@ -225,18 +306,7 @@ export const rule: Rule.RuleModule = {
225306
return;
226307
}
227308

228-
const contextualType = getContextualTypeOfNode(services, checker, node);
229-
if (!contextualType) {
230-
// No contextual type (e.g. if/while boolean context) — replacement is safe
231-
ctx.report(descriptor);
232-
return;
233-
}
234-
235-
if (allowsUndefined(contextualType)) {
236-
// undefined is assignable to the contextual type — replacement is type-safe
237-
ctx.report(descriptor);
238-
}
239-
// undefined is NOT assignable to the contextual type — suppress the report
309+
ctx.report(descriptor);
240310
}).create(context);
241311
},
242312
};

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,16 @@ describe('S6582', () => {
267267
code: `interface Commit { parentSHAs: string[] } function hasMultipleCommits(commit: Commit | undefined): boolean { return commit !== undefined && commit.parentSHAs.some(x => x.length > 0) }`,
268268
filename: path.join(import.meta.dirname, 'fixtures/index.ts'),
269269
},
270+
{
271+
// FP: assignment after declaration to an identifier typed as 'string | null' excludes undefined
272+
code: `interface Item { name: string } function f(item: Item | null) { let value: string | null = null; value = item && item.name; }`,
273+
filename: path.join(import.meta.dirname, 'fixtures/index.ts'),
274+
},
275+
{
276+
// FP: assignment after declaration to a typed property excludes undefined
277+
code: `interface Item { name: string } function f(item: Item | null) { const holder: { value: string | null } = { value: null }; holder.value = item && item.name; }`,
278+
filename: path.join(import.meta.dirname, 'fixtures/index.ts'),
279+
},
270280
],
271281
invalid: [
272282
{

0 commit comments

Comments
 (0)