JS-1462 Fix FP on S6582 when optional chaining would introduce unsafe undefined#6637
Conversation
Ruling ReportCode no longer flagged (3 issues)S6582 44759 | table.forEach(function (labelMarker, labelText) {
44760 | var statements = [];
> 44761 | if (!outerLoop || (outerLoop.labels && outerLoop.labels.get(labelText))) {
44762 | var label = ts.createIdentifier(labelText);
44763 | statements.push(isBreak ? ts.createBreak(label) : ts.createContinue(label)); 143 | // Chrome doesn't fire keypress events for command and alt keys,
144 | // so we need to handle this in a way that works across all OSes.
> 145 | if (key.length > 1 || agent && (agent.chrome && (event.altKey
146 | || agent.mac && event.metaKey
147 | || !agent.mac && event.ctrlKey))) {paper.js/src/path/PathItem.Boolean.js:442 440 | // This is a new curve, update clearHandles setting.
441 | clearHandles = !curve.hasHandles()
> 442 | || clearLookup && clearLookup[getId(curve)];
443 | // Keep track of locations for later curve-time
444 | // renormalization within the curve. |
Summary
Adds a type-safety interceptor to rule S6582 to eliminate false positives where optional chaining rewrites would introduce unsound The Problem: S6582 wraps TypeScript ESLint's The Solution: Before reporting, the interceptor uses TypeScript's type checker to:
The fix includes comprehensive tests verifying that upstream reports still fire in boolean contexts, that the interceptor's defensive fallbacks work, and that genuine violations still pass through. What reviewers should knowStart here:
Key design decisions:
What to look for:
|
|
Thanks for the summary — this accurately describes the implementation and its intent. No changes needed. |
|
Thank you for the thorough review! The latent crash path has been addressed — the guard is now |
|
Thank you for the review! |
|
Thanks for the LGTM! No changes needed. |
|
Thank you for the review! |
|
The latent crash path has been closed — the |
|
Negation patterns: Good catch — this was not intentional. Added a guard before the contextual type check that detects negation patterns ( Unrelated ruling file: The |
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
9cce503 to
4aaf440
Compare
Maven ITS reports line 768 as a valid S6582 issue. The Node.js bridge virtual TypeScript program infers a more specific type for the untyped lowercase() parameter, causing the fix to suppress it, but the Maven ITS (which is the authoritative ruling environment) still reports it. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Note: fix should be gated on
|
Without strictNullChecks, TypeScript treats null and undefined as implicit subtypes of every type. Any contextual-type reasoning about whether undefined is safe to introduce is meaningless in that mode, since undefined is silently assignable to string, number, etc. The fix now skips the suppression interceptor entirely when strictNullChecks is disabled, falling through to the upstream prefer-optional-chain rule instead. This makes the rule consistent across both the Node.js bridge ruling and the Maven ITS, which uses a virtual TypeScript program that does not set strictNullChecks for plain JavaScript projects. Ruling expected files updated to reflect new correctly-reported issues in projects without strictNullChecks (JS and TS alike). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
Review findings from current head (7a5a444):
In
The optional-chain rewrite here is The current tests lock in that false negative by marking this case as Please narrow the suppression so it only covers cases where the optional-chain rewrite would actually leak
The PR body still says the branch updates ~34 files and ~28 ruling baselines, but the current diff is 9 files with only 2 ruling baselines. It would be good to refresh the description before the next review pass, and the |
Boolean comparison expressions (e.g. a.prop !== b) always return boolean regardless of optional chaining, so the optional-chain rewrite never leaks undefined into the surrounding type. The previous contextual-type check suppressed !a || a.prop !== b because the contextual type is boolean (which excludes undefined), but the rewrite a?.prop !== b is also boolean — no type safety issue exists. Add a new guard: when the right side of the LogicalExpression is a BinaryExpression with a comparison operator, allow the report unconditionally and skip the contextual-type suppression. Move the !a || a.module !== b.module test case from valid to invalid (the rule should now report it), and add the expected autofix output. Also revert the unrelated README.md rule-count change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Both points addressed: 1. Narrowed the suppression for boolean-comparison patterns Added a new guard in The 2. Dropped the unrelated README.md change The rule-count update (479→480, 29→30) has been reverted. That change is unrelated to this FP fix and belonged in a separate PR. PR description refresh (file count / ruling baseline count) is a manual step — the numbers in the description are now stale from earlier iterations but the code changes are limited to 3 files (rule.ts, unit.test.ts, README.md revert) with 2 ruling baselines. |
…6582/rule.ts Comment: would be nice to check if node is present tho, in case the upstream rule changes at some point. so if node is there we pick it up, otherwise we fallback to this logic. which btw could be added to a helper `getNodeFromLocation`. Probably there is something already in the helpers, not sure
Comment: LGTM! ✅ \n\nSeaworthy fix overall, and the upstream sentinel test is a particularly clever piece of navigation — it'll warn the crew if the upstream rule ever stops raising on these patterns, making the interceptor removable without anyone running aground. One type-safety gap worth examining before you raise anchor.\n\nThe `undefinedAssignable` check in `rule.ts:123–128` tests for `Undefined`, `Any`, and `Unknown` — but not for `ts.TypeFlags.Void`. TypeScript's `void` type is contextually permissive for callback assignments: when you write `const fn: () => void = () => arr && arr.length`, the contextual type of `arr && arr.length` is `void`. Because `void` carries neither the `Undefined` nor `Any` nor `Unknown` flag, `undefinedAssignable` comes back false and the report is suppressed — but `arr?.length` is perfectly assignable to `void`, so the suggestion would have been type-safe. Adding `(t.flags & ts.TypeFlags.Void) !== 0` to the `constituents.some(...)` check would close this narrow false-negative. Worth a conscious decision either way — the PR description doesn't address it, savvy?" 🗣️ [Give feedback](https://sonarsource.enterprise.slack.com/archives/C0AJXQM9MPZ)
|
Good catch — added |
No implementation changes required. The contextual-type interceptor and comparison-pattern bypass are correct as-is. Two ruling entries in TypeScript:lib/tsc.js (lines 50184 and 50201) were flagged by the rule but missing from the expected baseline. Both follow the `a && a.prop === value` comparison pattern (specifically `node.parent.parent && node.parent.parent.kind === 163`), which is a valid optional-chain candidate identical in structure to other already- accepted patterns in the same file (e.g., lines 8599 and 37690). The expected ruling file for the TypeScript project is updated to include these two lines. Added one test case inspired by the ruling: `a && a.prop === b` (comparison pattern with `&&`) to complement the existing `!a || a.prop !== b` test. Both demonstrate that the comparison-pattern check correctly allows reports where the right-hand side is a comparison expression.
|
I found one regression in the new comparison bypass. The bypass added in ['===', '!==', '<', '>', '<=', '>=', 'instanceof', 'in']But the upstream Today they fall through to the contextual-type suppression and become false negatives in typed-boolean contexts. I confirmed it with a one-off interface Item { p: string }
function f(a: Item | null): boolean {
return a && a.p != null;
}Expected: 1 issue ( Please extend the comparison bypass to cover |
Extract node-resolution logic from the anonymous interceptReport callback into a named helper `findReportNode`, splitting the complexity across two focused functions each well within the threshold. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Comment: I found one regression in the new comparison bypass. The bypass added in `packages/analysis/src/jsts/rules/S6582/rule.ts` only forwards strict/relational comparison operators: ```ts ['===', '!==', '<', '>', '<=', '>=', 'instanceof', 'in'] ``` But the upstream `prefer-optional-chain` rule also reports binary comparison chains for `==` and `!=` (see `@typescript-eslint/eslint-plugin/dist/rules/prefer-optional-chain-utils/gatherLogicalOperands.js`, which maps both operators to `OperandValidity.Last`). Those comparisons are also always boolean after the rewrite, so they need the same bypass. Today they fall through to the contextual-type suppression and become false negatives in typed-boolean contexts. I confirmed it with a one-off `RuleTester` run on the current branch: ```ts interface Item { p: string } function f(a: Item | null): boolean { return a && a.p != null; } ``` Expected: 1 issue (`a?.p != null` is still a safe boolean rewrite) Actual on this branch: 0 issues Please extend the comparison bypass to cover `==` / `!=` as well, and add regression tests next to the existing `===` / `!==` comparison cases.
The upstream prefer-optional-chain rule does not raise on `a && a.p == null` patterns because the rewrite to `a?.p == null` changes semantics: when `a` is null, the original short-circuits to null (falsy), while `a?.p == null` evaluates to `undefined == null` which is `true` (truthy). The upstream rule correctly avoids suggesting this unsafe transformation, so S6582 receives no report to intercept, producing 0 errors instead of the expected 1.
|




Rule Profile
S6582— Optional chaining should be preferredGenuine violation example:
False Positive Pattern
The upstream
prefer-optional-chainrule suggests replacinga && a.prop(or its negated form!a || !a.prop) witha?.propwithout consulting the surrounding type context. When the expression is used in a typed assignment or return position that declaresT | null(notT | undefined), the replacement changes the result type fromT | nulltoT | undefined, breaking type assignability. Because the upstream rule does not callchecker.getContextualType(), it cannot distinguish safe rewrites (boolean/void contexts) from unsafe ones (narrowly typed assignments or returns).Pattern 1 — typed variable declaration (
T | null):Pattern 2 — typed return value (
ISnapshot | null):Pattern 3 — negated double-null guard in typed context:
False Negative Risk
The fix suppresses reports only when the contextual type explicitly excludes
undefined. Three classes of patterns always pass through unconditionally:if (a && a.prop),while (a && a.prop)) — boolean/void position imposes no contextual type, so the report is always emitted.a && a.prop !== b) — a comparison operator returnsbooleanregardless of optional chaining;undefinednever leaks.!a || !a.prop) —!always returnsboolean;!a?.propis semantically identical.Accepted known false negative:
function f(arr: string[] | null): boolean { return !arr || !arr.length; }. The!operator makes the replacement type-safe, but the negation-guard bypass fires before the contextual-type check, so the report is emitted correctly. However, if a negation pattern appears in a function explicitly typed as returningboolean, the contextual-type check would normally suppress it. The negation bypass pre-empts this, so no false negative arises there either.Conservative accepted false negative:
a && a.p == nullpatterns. The upstream rule does not report these (the loose== nullrewrite changes semantics whenaisnull), so the interceptor never sees them; no regression from this fix.Genuine violation that passes through (not suppressed):
The risk of over-suppression is bounded: suppression only fires when
strictNullChecksis enabled, a contextual type exists, and none of its union constituents includeUndefined,Any,Unknown, orVoid.Implementation
How the rule works today: S6582 wraps the upstream
@typescript-eslint/prefer-optional-chainrule. Before this fix, it passed reports through unchanged after a parser-services guard, offering no protection against type-unsafe suggestions in strictly-typed assignment contexts.What changed:
1.
strictNullChecksgate — skip the interceptor entirely whenstrictNullChecksis off; without it,undefinedis implicitly assignable to every type and contextual-type reasoning is meaningless:2.
findReportNodehelper — the upstream rule may report vialocinstead ofnode. This helper resolves the ESTreeLogicalExpressionspanning the reported range by walking up fromgetNodeByRangeIndex, and falls back toctx.report(descriptor)on any missing information so it is never silently lossy:3. Negation-pattern bypass —
!a || !a.propalways stays boolean, allow unconditionally. Both sides must be negated;!a || a.prop !== bfalls through to the contextual-type check:4. Comparison-pattern bypass —
a && a.prop !== b: comparison operators (==,!=,===,!==,<,>,<=,>=,instanceof,in) always yieldboolean; allow unconditionally:5.
allowsUndefinedcontextual-type check — suppress only when contextual type exists and none of its union constituents includeUndefined | Any | Unknown | Void:Technical Summary
Files changed:
packages/analysis/src/jsts/rules/S6582/rule.ts— core implementation: addedinterceptReportwrapper with negation/comparison bypasses, contextual-type suppression,strictNullChecksgate,findReportNodehelper, andallowsUndefinedhelper (~+165 lines)packages/analysis/src/jsts/rules/S6582/unit.test.ts— unit tests: upstream sentinel, defensive fallback tests, and main valid/invalid test suite covering all bypass paths and suppression cases (~+277 lines)packages/analysis/src/jsts/rules/S6582/fixtures/tsconfig.json— tsconfig for typed test fixtures enablingstrictNullChecks(+5 lines)packages/analysis/src/jsts/rules/S6582/fixtures/index.ts— typed fixture file for unit tests (+1 line)packages/analysis/src/jsts/rules/S6582/fixtures/no-strict-null-checks/index.ts— fixture for no-strict-null-checks test path (+1 line)packages/analysis/src/jsts/rules/S6582/fixtures/no-strict-null-checks/tsconfig.json— tsconfig withoutstrictNullChecksfor no-strict path tests (+3 lines)its/ruling/src/test/expected/TypeScript/javascript-S6582.json— ruling baseline: two new true positives added fora && a.prop === valuepatterns in TypeScript compiler source (-1 net line)its/ruling/src/test/expected/paper.js/javascript-S6582.json— ruling baseline updated for paper.js project (-/+ 4 lines)Net change: +447 insertions, −10 deletions across 8 files.