@@ -28,25 +28,6 @@ import * as meta from './generated-meta.js';
2828 */
2929const 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} ;
0 commit comments