Skip to content

fix(useOptionalChain): fix negated expressions#9567

Merged
ematipico merged 1 commit intomainfrom
fix/negated-optional-chain
Mar 21, 2026
Merged

fix(useOptionalChain): fix negated expressions#9567
ematipico merged 1 commit intomainfrom
fix/negated-optional-chain

Conversation

@ematipico
Copy link
Member

Summary

Closes #7211

Code implemented with the help of a coding agent, but then modified to fit our coding style.

Test Plan

New tests added, mostly taken from typescript-eslint

Docs

Added a new test case

@changeset-bot
Copy link

changeset-bot bot commented Mar 21, 2026

🦋 Changeset detected

Latest commit: 967f340

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@biomejs/biome Patch
@biomejs/cli-win32-x64 Patch
@biomejs/cli-win32-arm64 Patch
@biomejs/cli-darwin-x64 Patch
@biomejs/cli-darwin-arm64 Patch
@biomejs/cli-linux-x64 Patch
@biomejs/cli-linux-arm64 Patch
@biomejs/cli-linux-x64-musl Patch
@biomejs/cli-linux-arm64-musl Patch
@biomejs/wasm-web Patch
@biomejs/wasm-bundler Patch
@biomejs/wasm-nodejs Patch
@biomejs/backend-jsonrpc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added A-Linter Area: linter L-JavaScript Language: JavaScript and super languages labels Mar 21, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

Walkthrough

This PR extends the useOptionalChain linter rule to detect negated logical-OR chains such as !foo || !foo.bar. Detection now recognises LogicalOr chains where every leaf is a negation, strips the ! from operands when building the optional-chain nodes, prevents conversion for nested compatible negated-OR chains, and adds a negated: bool field to LogicalAndChainNodes to preserve correct !/|| semantics during fix generation. Tests for valid and invalid negated-OR scenarios were added.

Possibly related PRs

Suggested reviewers

  • dyc3
  • denbezrukov
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(useOptionalChain): fix negated expressions' directly addresses the main change—extending the useOptionalChain rule to handle negated expressions, which is precisely what the PR accomplishes.
Description check ✅ Passed The description references issue #7211 and explains the fix involves handling negated expressions in useOptionalChain, with appropriate AI-assistance disclosure and mention of new tests.
Linked Issues check ✅ Passed The PR fully addresses issue #7211 by extending useOptionalChain to detect and handle negated logical-OR chains (e.g., !foo || !foo.bar), adding rule logic, public struct field, and comprehensive test cases.
Out of Scope Changes check ✅ Passed All changes directly support the objective of fixing useOptionalChain for negated expressions: rule implementation, public API update, changeset entry, and test fixtures.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/negated-optional-chain

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can use Trivy to scan for security misconfigurations and secrets in Infrastructure as Code files.

Add a .trivyignore file to your project to customize which findings Trivy reports.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs (1)

995-1102: Consider extracting shared logic to reduce duplication.

optional_chain_expression_nodes_from_negated_or is ~100 lines nearly identical to optional_chain_expression_nodes. The only differences are the traversal operator (|| vs &&), the expression extraction (strip_negation vs normalized_optional_chain_like), and the final negated flag.

A helper that accepts these as parameters would halve the code and simplify future maintenance. That said, it's not blocking for this bug fix.

♻️ Sketch of potential refactor
// Pseudocode outline
enum ChainMode {
    LogicalAnd,
    NegatedOr,
}

fn extract_chain_nodes(
    &mut self,
    logical: &JsLogicalExpression,
    model: &SemanticModel,
    mode: ChainMode,
) -> Option<LogicalAndChainNodes> {
    let (expected_op, extract_head, negated) = match mode {
        ChainMode::LogicalAnd => (JsLogicalOperator::LogicalAnd, normalize_fn, false),
        ChainMode::NegatedOr => (JsLogicalOperator::LogicalOr, strip_negation_fn, true),
    };
    // ... shared traversal and comparison logic ...
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs` around
lines 995 - 1102, optional_chain_expression_nodes_from_negated_or duplicates
almost all logic from optional_chain_expression_nodes; extract the shared
traversal/comparison code into a single helper (e.g., extract_chain_nodes) that
takes parameters for the expected JsLogicalOperator, a head-extraction function
(use strip_negation for negated || and normalized_optional_chain_like for &&),
and a negated flag; move the loop that walks next_chain_head, the cmp_chain
comparison, the parts_pop/tail logic, and final assembly into that helper and
have optional_chain_expression_nodes_from_negated_or and
optional_chain_expression_nodes call it, keeping references to symbols like
JsLogicalOperator::LogicalOr/LogicalAnd, strip_negation,
normalized_optional_chain_like, Self::from_expression, self.cmp_chain, and
LogicalAndChainNodes unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs`:
- Around line 995-1102: optional_chain_expression_nodes_from_negated_or
duplicates almost all logic from optional_chain_expression_nodes; extract the
shared traversal/comparison code into a single helper (e.g.,
extract_chain_nodes) that takes parameters for the expected JsLogicalOperator, a
head-extraction function (use strip_negation for negated || and
normalized_optional_chain_like for &&), and a negated flag; move the loop that
walks next_chain_head, the cmp_chain comparison, the parts_pop/tail logic, and
final assembly into that helper and have
optional_chain_expression_nodes_from_negated_or and
optional_chain_expression_nodes call it, keeping references to symbols like
JsLogicalOperator::LogicalOr/LogicalAnd, strip_negation,
normalized_optional_chain_like, Self::from_expression, self.cmp_chain, and
LogicalAndChainNodes unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e03c48de-e8fb-4cb3-870d-0cb795d4b552

📥 Commits

Reviewing files that changed from the base of the PR and between fd977ae and 2c7f647.

⛔ Files ignored due to path filters (2)
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/invalidNegatedOrChain.js.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/validNegatedOrChain.js.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (4)
  • .changeset/feat-use-optional-chain-negated-or.md
  • crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/invalidNegatedOrChain.js
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/validNegatedOrChain.js

@ematipico ematipico force-pushed the fix/negated-optional-chain branch from 2c7f647 to 967f340 Compare March 21, 2026 11:41
@codspeed-hq
Copy link

codspeed-hq bot commented Mar 21, 2026

Merging this PR will not alter performance

✅ 58 untouched benchmarks
⏩ 156 skipped benchmarks1


Comparing fix/negated-optional-chain (967f340) with main (f8660b8)2

Open in CodSpeed

Footnotes

  1. 156 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (fd977ae) during the generation of this report, so f8660b8 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs (1)

998-1102: Consider sharing the && and negated-|| chain walkers.

optional_chain_expression_nodes_from_negated_or() is now almost a line-for-line fork of optional_chain_expression_nodes(). Pulling the common traversal into one helper would stop the tail-splitting and existing-optional-token bookkeeping from drifting the next time this rule picks up another corner-case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs` around
lines 998 - 1102, The two functions
optional_chain_expression_nodes_from_negated_or and
optional_chain_expression_nodes duplicate the same traversal/collection logic;
extract the shared loop into a single helper (e.g., walk_logical_chain) that
takes parameters for: how to obtain the initial next_chain_head (a closure using
logical.left()/right() and whether to strip_negation), a negated boolean, and
callbacks to record prefix/parts; reuse existing helpers/symbols
(strip_negation, normalized_optional_chain_like, Self::from_expression,
cmp_chain, self.buf, LogicalAndChainNodes) inside the consolidated loop and keep
only the small differences (initial head selection and negated flag) in the
callers so all tail-splitting and optional-token bookkeeping is performed by the
shared helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs`:
- Around line 712-727: In is_inside_another_negated_chain, the code assumes the
stripped node sits directly under JsUnaryExpression, so change the parent
lookups to skip over any JsParenthesizedExpression wrappers: when locating the
unary around self.head (the unary variable from
self.head.parent::<JsUnaryExpression>()?) walk up through
JsParenthesizedExpression nodes until you find a JsUnaryExpression (or None),
and do the same when inspecting grand_parent.right() before calling
strip_negation/ Self::from_expression; update the parent traversal logic in
is_inside_another_negated_chain to transparently bypass
JsParenthesizedExpression nodes so nested negated chains like !(foo.bar) are
recognized correctly.

---

Nitpick comments:
In `@crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs`:
- Around line 998-1102: The two functions
optional_chain_expression_nodes_from_negated_or and
optional_chain_expression_nodes duplicate the same traversal/collection logic;
extract the shared loop into a single helper (e.g., walk_logical_chain) that
takes parameters for: how to obtain the initial next_chain_head (a closure using
logical.left()/right() and whether to strip_negation), a negated boolean, and
callbacks to record prefix/parts; reuse existing helpers/symbols
(strip_negation, normalized_optional_chain_like, Self::from_expression,
cmp_chain, self.buf, LogicalAndChainNodes) inside the consolidated loop and keep
only the small differences (initial head selection and negated flag) in the
callers so all tail-splitting and optional-token bookkeeping is performed by the
shared helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: edd8f2e9-b2ea-48cf-a72c-5ecaceecaf7a

📥 Commits

Reviewing files that changed from the base of the PR and between 2c7f647 and 967f340.

⛔ Files ignored due to path filters (2)
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/invalidNegatedOrChain.js.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/validNegatedOrChain.js.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (4)
  • .changeset/feat-use-optional-chain-negated-or.md
  • crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/invalidNegatedOrChain.js
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/validNegatedOrChain.js
✅ Files skipped from review due to trivial changes (3)
  • .changeset/feat-use-optional-chain-negated-or.md
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/validNegatedOrChain.js
  • crates/biome_js_analyze/tests/specs/complexity/useOptionalChain/invalidNegatedOrChain.js

Comment on lines +712 to +727
fn is_inside_another_negated_chain(&self) -> Option<bool> {
let unary = self.head.parent::<JsUnaryExpression>()?;
let parent = unary.parent::<JsLogicalExpression>()?;
let grand_parent = parent.parent::<JsLogicalExpression>()?;
if !matches!(grand_parent.operator().ok()?, JsLogicalOperator::LogicalOr) {
return Some(false);
}
if grand_parent
.left()
.ok()
.as_ref()
.and_then(|e| e.as_js_logical_expression())
== Some(&parent)
{
let stripped = strip_negation(&grand_parent.right().ok()?)?;
let gp_right_chain = Self::from_expression(stripped).ok()?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Skip transparent parentheses when suppressing nested negated chains.

strip_negation() already accepts !(foo.bar), but this helper assumes the stripped node sits directly under JsUnaryExpression. With !foo || !(foo.bar) || !(foo.bar.baz), the inner || chain then misses the nesting check and reports alongside the outer one. Please walk past JsParenthesizedExpression before looking for the unary parent.

🩹 Possible fix
-        let unary = self.head.parent::<JsUnaryExpression>()?;
+        let unary = iter::successors(self.head.parent::<AnyJsExpression>(), |expression| {
+            if matches!(expression, AnyJsExpression::JsParenthesizedExpression(_)) {
+                expression.parent::<AnyJsExpression>()
+            } else {
+                None
+            }
+        })
+        .last()?
+        .as_js_unary_expression()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_js_analyze/src/lint/complexity/use_optional_chain.rs` around
lines 712 - 727, In is_inside_another_negated_chain, the code assumes the
stripped node sits directly under JsUnaryExpression, so change the parent
lookups to skip over any JsParenthesizedExpression wrappers: when locating the
unary around self.head (the unary variable from
self.head.parent::<JsUnaryExpression>()?) walk up through
JsParenthesizedExpression nodes until you find a JsUnaryExpression (or None),
and do the same when inspecting grand_parent.right() before calling
strip_negation/ Self::from_expression; update the parent traversal logic in
is_inside_another_negated_chain to transparently bypass
JsParenthesizedExpression nodes so nested negated chains like !(foo.bar) are
recognized correctly.

@ematipico ematipico merged commit b7ab931 into main Mar 21, 2026
18 checks passed
@ematipico ematipico deleted the fix/negated-optional-chain branch March 21, 2026 16:00
@github-actions github-actions bot mentioned this pull request Mar 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Linter Area: linter L-JavaScript Language: JavaScript and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

💅 useOptionalChain doesn't work with negations

2 participants