Skip to content

feat(lint): extend useNullishCoalescing to detect ternary nullish checks#9240

Closed
hammadxcm wants to merge 3 commits intobiomejs:mainfrom
hammadxcm:feat/use-nullish-coalescing-ternary-checks
Closed

feat(lint): extend useNullishCoalescing to detect ternary nullish checks#9240
hammadxcm wants to merge 3 commits intobiomejs:mainfrom
hammadxcm:feat/use-nullish-coalescing-ternary-checks

Conversation

@hammadxcm
Copy link

@hammadxcm hammadxcm commented Feb 25, 2026

AI Disclosure: This PR was developed with AI assistance (Claude Code).

Summary

  • Extended the useNullishCoalescing nursery rule to detect ternary expressions that perform explicit nullish checks and suggest rewriting them with ??.
  • Added the ignoreTernaryTests option (default: false) to allow disabling ternary detection independently from the existing logical OR detection.
  • Added comprehensive test coverage (21 invalid cases, 20 valid cases, 5 option-toggle cases).

Detected patterns

Simple checks:

a !== null ? a : 'default'       // → a ?? 'default'
a === undefined ? 'default' : a  // → a ?? 'default'
a != null ? a : 'default'        // → a ?? 'default' (loose)
null === a ? 'default' : a       // → a ?? 'default' (reversed)

Compound checks:

a === null || a === undefined ? 'default' : a  // → a ?? 'default'
a !== null && a !== undefined ? a : 'default'  // → a ?? 'default'

Also handles member expressions, computed members, parenthesized subjects, and function calls.

Test plan

  • All 21 invalid ternary patterns correctly produce diagnostics and safe fixes
  • All 20 valid ternary patterns produce no diagnostics
  • ignoreTernaryTests: true suppresses all ternary diagnostics
  • Existing logical OR detection tests unchanged and passing
  • Full spec test suite passes (2183/2183)
  • just f, just l, just gen-analyzer all pass

Closes #9229

Extend the useNullishCoalescing nursery rule to detect ternary
expressions that perform explicit nullish checks and suggest
rewriting them with the ?? operator.

Detected patterns:
- a !== null ? a : b  ->  a ?? b
- a !== undefined ? a : b  ->  a ?? b
- a != null ? a : b  ->  a ?? b
- a === null ? b : a  ->  a ?? b (inverted)
- a === null || a === undefined ? b : a  ->  a ?? b (compound)
- a !== null && a !== undefined ? a : b  ->  a ?? b (compound)

Also handles null/undefined on either side of the comparison,
member expressions, computed members, and parenthesized subjects.

Adds the ignoreTernaryTests option (default: false) to allow
skipping ternary expressions entirely when set to true.

Closes biomejs#9229
@changeset-bot
Copy link

changeset-bot bot commented Feb 25, 2026

🦋 Changeset detected

Latest commit: b7c7c5f

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 Feb 25, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Walkthrough

Extends the nursery rule useNullishCoalescing to also detect ternary expressions that perform explicit nullish checks and suggest rewriting them with ?? (examples: a !== null ? a : b, a == null ? b : a, and compound null/undefined checks). Adds JsConditionalExpression to the rule query via a new AnyNullishCoalescingQuery union and a Ternary state variant carrying subject and fallback expressions. Implements extraction/matching helpers, safety checks (type/context, call-expression avoidance, structural equivalence), diagnostics for ternary cases, and automatic fixes that preserve trivia with appropriate applicability. Introduces an ignoreTernaryTests option (default: false) and adds tests for valid/invalid ternaries and the new option.

Possibly related PRs

Suggested labels

A-Diagnostic

Suggested reviewers

  • ematipico
  • dyc3
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarises the main change—extending useNullishCoalescing to detect ternary nullish checks—and is concise and clear.
Description check ✅ Passed The description comprehensively explains the changes, detected patterns, test coverage, and links to the related issue, clearly relating to the changeset.
Linked Issues check ✅ Passed All requirements from #9229 are met: ternary nullish checks detected, strict/loose comparisons supported, compound checks handled, ignoreTernaryTests option added, and safe fixes provided.
Out of Scope Changes check ✅ Passed All changes are directly scoped to extending useNullishCoalescing for ternary detection; no unrelated modifications present.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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: 3

🧹 Nitpick comments (2)
crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs (1)

454-462: undefined shadowing is not checked.

is_undefined_identifier does a raw text comparison against "undefined". In non-strict-mode JS, undefined can be shadowed. This is a known limitation shared with ESLint's equivalent rule, so likely acceptable — but worth a brief note in the function's doc comment.

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

In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs` around
lines 454 - 462, Add a short doc comment above the is_undefined_identifier
function explaining that it performs a raw text comparison on the identifier
token and does not perform symbol resolution, so it will not detect cases where
the global undefined has been shadowed (e.g., in non-strict-mode code or when a
local variable named "undefined" exists); reference the function name
is_undefined_identifier in the comment so future readers know this limitation
applies to that helper.
.changeset/extend-nullish-coalescing-ternary.md (1)

18-18: Tiny grammar nit: "independently from" → "independently of" is more standard English.

-Added a new `ignoreTernaryTests` option (default: `false`) to allow disabling this check independently from the existing logical OR detection.
+Added a new `ignoreTernaryTests` option (default: `false`) to allow disabling this check independently of the existing logical OR detection.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/extend-nullish-coalescing-ternary.md at line 18, Tiny grammar
nit: change "independently from" to "independently of" in the changeset text;
update the sentence that describes the new option ignoreTernaryTests (default:
false) so it reads "independently of the existing logical OR detection" instead
of "independently from".
🤖 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/nursery/use_nullish_coalescing.rs`:
- Around line 53-59: The rule currently treats strict single-null/undefined
ternaries as safe to auto-fix to `??`, but that's semantically unsafe; update
the Ternary handling in use_nullish_coalescing.rs so fixes are only emitted when
the ternary is actually loose or compound: either change the emitted fix kind
for Ternary from FixKind::Safe to FixKind::Unsafe, or add an
`is_loose_or_compound` flag on the Ternary variant and make the `action` return
None unless that flag is true (mirroring the `can_fix` guard used by the
LogicalOr path); adjust any related logic that sets the Ternary variant so it
detects `!=`/`==` or both null/undefined checks (`a !== null && a !==
undefined`) before allowing a safe fix.
- Around line 464-470: expressions_are_equivalent currently uses text comparison
and will treat call expressions like foo() as equivalent even though replacing a
ternary with the nullish-coalescing operator changes call count; add a check for
call expressions inside the subject expression (e.g. traverse the subject
AnyJsExpression and detect AnyJsCallExpression or call-like nodes) and use that
in extract_ternary_nullish_check (or in the action arm where the fix is
produced) to either skip emitting the automatic fix or mark the fix as unsafe
when a call expression is present; update the logic that produces the fix to
only apply the transformation when the subject contains no call expressions.

In
`@crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryInvalid.js`:
- Around line 64-65: The test currently uses foo() twice in the ternary for v19
which changes semantics if foo() has side effects; update the test so foo() is
only invoked once (e.g. assign its result to a temporary/subject variable and
use that in the conditional and result) or move this case into a separate
category/excluded list for side-effect-unsafe subjects so it does not assert a
transformation that calls foo() only once; key symbols: v19 and foo().

---

Nitpick comments:
In @.changeset/extend-nullish-coalescing-ternary.md:
- Line 18: Tiny grammar nit: change "independently from" to "independently of"
in the changeset text; update the sentence that describes the new option
ignoreTernaryTests (default: false) so it reads "independently of the existing
logical OR detection" instead of "independently from".

In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs`:
- Around line 454-462: Add a short doc comment above the is_undefined_identifier
function explaining that it performs a raw text comparison on the identifier
token and does not perform symbol resolution, so it will not detect cases where
the global undefined has been shadowed (e.g., in non-strict-mode code or when a
local variable named "undefined" exists); reference the function name
is_undefined_identifier in the comment so future readers know this limitation
applies to that helper.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1d2ca15 and 2a83383.

⛔ Files ignored due to path filters (5)
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ignoreTernaryTestsEnabled.js.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryInvalid.js.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryValid.js.snap is excluded by !**/*.snap and included by **
  • packages/@biomejs/backend-jsonrpc/src/workspace.ts is excluded by !**/backend-jsonrpc/src/workspace.ts and included by **
  • packages/@biomejs/biome/configuration_schema.json is excluded by !**/configuration_schema.json and included by **
📒 Files selected for processing (7)
  • .changeset/extend-nullish-coalescing-ternary.md
  • crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ignoreTernaryTestsEnabled.js
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ignoreTernaryTestsEnabled.options.json
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryInvalid.js
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryValid.js
  • crates/biome_rule_options/src/use_nullish_coalescing.rs

Comment on lines +64 to +65
// === In variable declaration ===
const v19 = foo() !== undefined ? foo() : 'default';
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

foo() subject — see earlier comment about side-effect safety.

This test case will produce a safe fix that changes foo() from being called twice to once. Depending on how the main issue is resolved, this case may need to move to a separate category or be excluded.

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

In
`@crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryInvalid.js`
around lines 64 - 65, The test currently uses foo() twice in the ternary for v19
which changes semantics if foo() has side effects; update the test so foo() is
only invoked once (e.g. assign its result to a temporary/subject variable and
use that in the conditional and result) or move this case into a separate
category/excluded list for side-effect-unsafe subjects so it does not assert a
transformation that calls foo() only once; key symbols: v19 and foo().

Distinguish fix applicability for ternary nullish checks:

- Loose equality (!=, ==) and compound strict checks: safe fix
  (covers both null and undefined, matching ?? semantics).
- Strict single checks (===, !==): unsafe fix
  (only checks one of null/undefined, ?? checks both).
- Call expression subjects: no fix offered
  (ternary evaluates subject twice, ?? evaluates once).
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs (1)

90-90: ⚠️ Potential issue | 🟠 Major

fix_kind: FixKind::Safe conflicts with the MaybeIncorrect fixes emitted for strict ternary checks.

The rule advertises FixKind::Safe, but the Ternary action arm explicitly emits Applicability::MaybeIncorrect for strict single checks (===/!==). The rule-level declaration should be FixKind::Unsafe to truthfully describe the broadest fix severity the rule can produce.

🛠️ Proposed fix
-        fix_kind: FixKind::Safe,
+        fix_kind: FixKind::Unsafe,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs` at line
90, The rule currently sets fix_kind: FixKind::Safe but the Ternary action emits
fixes with Applicability::MaybeIncorrect for strict ternary checks (===/!==), so
update the rule-level declaration to use fix_kind: FixKind::Unsafe to reflect
the broadest severity; locate the fix_kind assignment and replace FixKind::Safe
with FixKind::Unsafe and keep the existing Ternary action that emits
Applicability::MaybeIncorrect unchanged.
🧹 Nitpick comments (1)
crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs (1)

200-200: extract_ternary_nullish_check is called twice — cache results in state.

run_ternary (line 285) and action (line 200) both call extract_ternary_nullish_check, traversing the same AST subtree twice. The subject and fallback expressions could be stored directly in UseNullishCoalescingState::Ternary so action only reads them without redoing the work.

♻️ Sketch of the refactor
 Ternary {
     range: TextRange,
     can_fix: bool,
     is_safe_fix: bool,
+    subject: AnyJsExpression,
+    fallback: AnyJsExpression,
 },

Then in run_ternary:

+    let subject = info.subject.clone();
+    let fallback = info.fallback.clone();
     Some(UseNullishCoalescingState::Ternary {
         range: conditional.syntax().text_trimmed_range(),
         can_fix,
         is_safe_fix,
+        subject,
+        fallback,
     })

And in action, destructure subject/fallback from state instead of calling extract_ternary_nullish_check again.

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

In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs` at line
200, The code calls extract_ternary_nullish_check twice (from run_ternary and
action); change the state to cache its results by adding subject and fallback
into UseNullishCoalescingState::Ternary, have run_ternary call
extract_ternary_nullish_check once and populate those fields on the state
variant, and update action to destructure/read subject and fallback from
UseNullishCoalescingState::Ternary instead of calling
extract_ternary_nullish_check again so the AST subtree is only traversed once.
🤖 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/nursery/use_nullish_coalescing.rs`:
- Around line 100-112: Move the helper enum UseNullishCoalescingState so it is
declared after the impl Rule for UseNullishCoalescing block and make it
non-public (remove the pub), since helper types must live below the impl and
State is an internal detail; locate the current UseNullishCoalescingState
declaration and cut it, then paste it immediately after the closing brace of
impl Rule for UseNullishCoalescing and drop the pub keyword (keep the same
variants and fields).

---

Outside diff comments:
In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs`:
- Line 90: The rule currently sets fix_kind: FixKind::Safe but the Ternary
action emits fixes with Applicability::MaybeIncorrect for strict ternary checks
(===/!==), so update the rule-level declaration to use fix_kind: FixKind::Unsafe
to reflect the broadest severity; locate the fix_kind assignment and replace
FixKind::Safe with FixKind::Unsafe and keep the existing Ternary action that
emits Applicability::MaybeIncorrect unchanged.

---

Nitpick comments:
In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs`:
- Line 200: The code calls extract_ternary_nullish_check twice (from run_ternary
and action); change the state to cache its results by adding subject and
fallback into UseNullishCoalescingState::Ternary, have run_ternary call
extract_ternary_nullish_check once and populate those fields on the state
variant, and update action to destructure/read subject and fallback from
UseNullishCoalescingState::Ternary instead of calling
extract_ternary_nullish_check again so the AST subtree is only traversed once.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a83383 and 2d2882f.

⛔ Files ignored due to path filters (1)
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryInvalid.js.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (1)
  • crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs

Comment on lines +19 to +20
/// Enforce using nullish coalescing operator (`??`) instead of logical or (`||`)
/// or ternary expressions that perform explicit nullish checks.
Copy link
Member

Choose a reason for hiding this comment

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

This will break docs. Please fix it as per contribution guidelines

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — all doc examples now use expect_diagnostic and file= for invalid cases, and file= for valid cases, following the contribution guidelines for RuleDomain::Types rules.

Copy link
Member

Choose a reason for hiding this comment

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

No, that's not what a meant. The first line can't contain newlines, it will break the codegen

Comment on lines 129 to 153
match state {
UseNullishCoalescingState::LogicalOr { operator_range, .. } => Some(
RuleDiagnostic::new(
rule_category!(),
*operator_range,
markup! {
"Use "<Emphasis>"??"</Emphasis>" instead of "<Emphasis>"||"</Emphasis>"."
},
)
.note(markup! {
"The "<Emphasis>"||"</Emphasis>" operator checks for all falsy values (including 0, '', and false), while "<Emphasis>"??"</Emphasis>" only checks for null and undefined."
}),
),
UseNullishCoalescingState::Ternary { range, .. } => Some(
RuleDiagnostic::new(
rule_category!(),
*range,
markup! {
"Prefer "<Emphasis>"??"</Emphasis>" instead of a ternary expression with an explicit nullish check."
},
)
.note(markup! {
"This ternary can be simplified to the nullish coalescing operator."
}),
),
Copy link
Member

Choose a reason for hiding this comment

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

I don't have much context about how the previous implementation worked, but here's the issue now: the third pillar of the rule is shown twice if we can fix the code. We should offer a solution once

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — removed the redundant .note() from the Ternary diagnostic. The fix action already communicates the solution, so the diagnostic now only shows the main message.

Comment on lines +347 to +360
}

Some(TernaryNullishInfo {
subject: non_nullish_branch.clone(),
fallback: fallback_branch,
covers_both_nullish,
})
}

struct SimpleNullishCheck {
/// The non-nullish side of the comparison.
subject: AnyJsExpression,
/// Whether this is an equality check (`===` / `==`).
is_equality: bool,
Copy link
Member

Choose a reason for hiding this comment

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

The documentation of the function doesn't mention how the check is determined. Here you check right and then left, but you don't explain why, which is critical to the understanding of the business logic

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — added a comprehensive doc comment explaining that the right operand is checked first because the common convention is expr === null (subject on the left, literal on the right), and we fall back to checking the left operand for the reversed form null === expr.

let (is_equality, is_loose) = match operator {
JsBinaryOperator::StrictEquality => (true, false),
JsBinaryOperator::StrictInequality => (false, false),
JsBinaryOperator::Equality => (true, true),
Copy link
Member

Choose a reason for hiding this comment

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

"Compound" doesn't have any meaning unless it's contextualised. What's a "compound nullish check"? Please provide docs that simplify things

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — added a doc comment that explains a "compound nullish check" combines two strict comparisons against null and undefined with a logical operator. Two forms are recognized:

  • Equality form: a === null || a === undefined (logical OR)
  • Inequality form: a !== null && a !== undefined (logical AND)

Both sides must test the same subject, use strict equality/inequality (not loose), and check different nullish values.

Comment on lines +464 to +471
return Some(NullishKind::Null);
}
if is_undefined_identifier(expr) {
return Some(NullishKind::Undefined);
}
}
None
}
Copy link
Member

Choose a reason for hiding this comment

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

This is plain wrong. An expression can fold on multiple lines because there are multiple tokens, and text_trimmed() only removes trivia at the edges, the ones in the middle are kept.

Remove this check, it's full of false positives

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — replaced the text_trimmed() node-level comparison with a proper token-by-token comparison using descendants_with_tokens(). The new implementation iterates all tokens in both expressions and compares each token's kind() and text_trimmed() individually, which correctly ignores trivia between tokens (e.g. a . b and a.b now compare as equivalent).

// both null and undefined (compound or loose equality).
let (check_subject, is_equality, covers_both_nullish) =
if let AnyJsExpression::JsLogicalExpression(logical) = &test {
// Compound: `a === null || a === undefined` or `a !== null && a !== undefined`
Copy link
Member

Choose a reason for hiding this comment

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

Please document. Especially what defines "simple null check"

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — added a doc comment explaining that a "simple nullish check" is a single binary expression that compares an expression against null or undefined using ===, !==, ==, or !=. Examples: a !== null, undefined === x, null != value.

}

struct TernaryNullishInfo {
/// The expression to use as the left side of `??` (e.g. `a` in `a ?? b`).
Copy link
Member

Choose a reason for hiding this comment

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

What "test"?

Copy link
Author

Choose a reason for hiding this comment

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

Fixed — added a doc comment to run_ternary clarifying that this detects ternary expressions (condition ? consequent : alternate) where the condition is a nullish check. Also renamed the local variable from test to condition inside extract_ternary_nullish_check for clarity.

…hecks

- Fix doc examples to use expect_diagnostic and file= per contribution guidelines
- Remove redundant diagnostic note from ternary variant
- Replace text_trimmed() expression comparison with token-by-token comparison
- Improve documentation for all ternary helper functions
- Change fix_kind to Unsafe to match MaybeIncorrect applicability
- Cache subject/fallback in state to avoid double AST traversal
- Move UseNullishCoalescingState below impl Rule block
- Fix changeset grammar
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: 2

♻️ Duplicate comments (1)
crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs (1)

226-226: 🛠️ Refactor suggestion | 🟠 Major

UseNullishCoalescingState should not be pub.

The placement below impl Rule is now correct, but the pub visibility remains. State is an internal implementation detail and should be private.

♻️ Proposed fix
-pub enum UseNullishCoalescingState {
+enum UseNullishCoalescingState {

Based on learnings: "all helper functions, structs, and enums must be placed below the impl Rule block."

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

In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs` at line
226, The enum UseNullishCoalescingState is declared public but is an internal
helper and should be private; change its visibility by removing the pub modifier
from UseNullishCoalescingState and ensure the enum remains placed below the impl
Rule block as an internal helper so only the module can access it (locate the
enum declaration named UseNullishCoalescingState and drop the pub keyword).
🤖 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/nursery/use_nullish_coalescing.rs`:
- Around line 566-573: contains_call_expression currently only looks for
JsSyntaxKind::JS_CALL_EXPRESSION and therefore misses `new` expressions which
are also double-evaluated by the ternary; update contains_call_expression to
return true for both call and new expressions (e.g. check node.kind() ==
JsSyntaxKind::JS_CALL_EXPRESSION || node.kind() ==
JsSyntaxKind::JS_NEW_EXPRESSION) so that `new Foo()` is treated the same as
`foo()` when deciding can_fix.
- Line 89: The metadata declares fix_kind: FixKind::Unsafe but the Ternary
action returns Applicability::Always when is_safe_fix is true, so make them
consistent: either change the rule metadata to fix_kind: FixKind::Safe if every
ternary rewrite performed by the Ternary action is truly safe, or keep
FixKind::Unsafe and modify the Ternary action to return
Applicability::MaybeIncorrect (instead of Applicability::Always) when
is_safe_fix is true; update the return logic in the Ternary action so it uses
Applicability::MaybeIncorrect whenever the rule metadata remains
FixKind::Unsafe.

---

Duplicate comments:
In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs`:
- Line 226: The enum UseNullishCoalescingState is declared public but is an
internal helper and should be private; change its visibility by removing the pub
modifier from UseNullishCoalescingState and ensure the enum remains placed below
the impl Rule block as an internal helper so only the module can access it
(locate the enum declaration named UseNullishCoalescingState and drop the pub
keyword).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d2882f and b7c7c5f.

⛔ Files ignored due to path filters (4)
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/commentTrivia.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ignoreConditionalTestsDisabled.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/invalid.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/useNullishCoalescing/ternaryInvalid.js.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (2)
  • .changeset/extend-nullish-coalescing-ternary.md
  • crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/extend-nullish-coalescing-ternary.md

recommended: false,
severity: Severity::Information,
fix_kind: FixKind::Safe,
fix_kind: FixKind::Unsafe,
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs | head -100

Repository: biomejs/biome

Length of output: 4548


🏁 Script executed:

cat -n crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs | sed -n '85,95p'

Repository: biomejs/biome

Length of output: 457


🏁 Script executed:

cat -n crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs | sed -n '200,220p'

Repository: biomejs/biome

Length of output: 1086


🏁 Script executed:

# Search for action function and Applicability returns in this rule
rg -A 5 "fn action" crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs

Repository: biomejs/biome

Length of output: 361


🏁 Script executed:

# Look for other rules that use FixKind::Unsafe with Applicability
rg -B 5 "FixKind::Unsafe" crates/biome_js_analyze/src/lint/ | head -50

Repository: biomejs/biome

Length of output: 4585


🏁 Script executed:

# Search for rules that return Applicability::Always with FixKind::Unsafe
rg "Applicability::Always" crates/biome_js_analyze/src/lint/nursery/ | head -20

Repository: biomejs/biome

Length of output: 165


🏁 Script executed:

# Look at the LogicalOr action path to see what applicability it returns
rg -A 20 "UseNullishCoalescingState::LogicalOr" crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs

Repository: biomejs/biome

Length of output: 2784


🏁 Script executed:

# Check the metadata applicability function - what does it return?
rg -B 5 -A 10 "fn applicability" crates/biome_analyze/src/

Repository: biomejs/biome

Length of output: 2041


🏁 Script executed:

# Look at how other rules handle multiple applicability levels with a single fix_kind
rg -B 5 "Applicability::Always" crates/biome_js_analyze/src/lint/ | grep -A 10 "FixKind"

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Check if there's documentation about fix_kind vs Applicability relationship
find crates/biome_analyze -name "*.md" -exec grep -l "fix_kind\|Applicability" {} \;

Repository: biomejs/biome

Length of output: 93


🏁 Script executed:

# Check the FixKind to Applicability conversion
rg -B 3 -A 10 "impl.*From.*FixKind\|impl.*Into.*FixKind" crates/biome_analyze/src/

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Check the biome_diagnostics crate for FixKind definition
find . -path "*/biome_diagnostics/*" -name "*.rs" -exec rg -l "FixKind" {} \;

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Look for enum FixKind definition
rg -B 2 -A 10 "enum FixKind" crates/

Repository: biomejs/biome

Length of output: 993


🏁 Script executed:

# Find the TryInto/From implementation for FixKind to Applicability
rg -B 5 -A 15 "impl.*TryInto.*Applicability\|impl.*From.*FixKind.*Applicability" crates/

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Search more broadly for FixKind conversion
rg -B 3 -A 8 "try_into\(\)" crates/biome_analyze/src/rule.rs | head -40

Repository: biomejs/biome

Length of output: 488


🏁 Script executed:

# Check biome_diagnostics Applicability enum
rg -B 2 -A 15 "^pub enum Applicability" crates/

Repository: biomejs/biome

Length of output: 1621


🏁 Script executed:

# Find the From/TryInto implementation for FixKind
rg "FixKind" crates/biome_diagnostics/src/suggestion.rs

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Look for impl blocks in rule.rs that handle FixKind conversion
rg -B 5 -A 10 "impl.*FixKind" crates/biome_analyze/src/rule.rs

Repository: biomejs/biome

Length of output: 928


🏁 Script executed:

# Search for From/Into implementations with FixKind
rg "From<FixKind>\|impl.*TryFrom" crates/

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Check if FixKind is re-exported from biome_diagnostics
rg "pub.*FixKind\|use.*FixKind" crates/biome_analyze/src/lib.rs

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Check the Ternary action path more carefully - what determines is_safe_fix?
rg -B 30 "let applicability = if \*is_safe_fix" crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs

Repository: biomejs/biome

Length of output: 1518


🏁 Script executed:

# Look for similar patterns where action() returns different Applicability than fix_kind in other rules
rg -B 5 "Applicability::(Always|MaybeIncorrect)" crates/biome_js_analyze/src/lint/ | grep -A 3 "FixKind::" | head -40

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Check contributing guidelines for how fix_kind and Applicability should relate
cat crates/biome_analyze/CONTRIBUTING.md

Repository: biomejs/biome

Length of output: 50371


Ternary action returns Applicability::Always despite fix_kind: FixKind::Unsafe metadata.

Line 89 declares fix_kind: FixKind::Unsafe, which maps to Applicability::MaybeIncorrect by default. However, the Ternary action (lines 208–212) returns Applicability::Always when is_safe_fix is true. This breaks the contract where metadata describes the rule's safety level. Either declare fix_kind: FixKind::Safe (if all ternary rewrites are safe), or keep Unsafe and return MaybeIncorrect consistently for all actions.

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

In `@crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs` at line
89, The metadata declares fix_kind: FixKind::Unsafe but the Ternary action
returns Applicability::Always when is_safe_fix is true, so make them consistent:
either change the rule metadata to fix_kind: FixKind::Safe if every ternary
rewrite performed by the Ternary action is truly safe, or keep FixKind::Unsafe
and modify the Ternary action to return Applicability::MaybeIncorrect (instead
of Applicability::Always) when is_safe_fix is true; update the return logic in
the Ternary action so it uses Applicability::MaybeIncorrect whenever the rule
metadata remains FixKind::Unsafe.

Comment on lines +566 to +573
/// Check whether an expression contains a call expression anywhere in its subtree.
/// When the subject contains calls, the ternary evaluates them twice (test + branch)
/// while `??` would only evaluate once, changing side-effect semantics.
fn contains_call_expression(expr: &AnyJsExpression) -> bool {
expr.syntax()
.descendants()
.any(|node| node.kind() == JsSyntaxKind::JS_CALL_EXPRESSION)
}
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

contains_call_expression misses new expressions.

new Foo() has the same double-evaluation problem as foo(). If the subject is a new expression — e.g. new Foo() !== null ? new Foo() : b — the constructor is called twice in the ternary but only once after ?? rewriting, yet can_fix is set to true.

🐛 Proposed fix
 fn contains_call_expression(expr: &AnyJsExpression) -> bool {
     expr.syntax()
         .descendants()
-        .any(|node| node.kind() == JsSyntaxKind::JS_CALL_EXPRESSION)
+        .any(|node| {
+            matches!(
+                node.kind(),
+                JsSyntaxKind::JS_CALL_EXPRESSION | JsSyntaxKind::JS_NEW_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/nursery/use_nullish_coalescing.rs` around
lines 566 - 573, contains_call_expression currently only looks for
JsSyntaxKind::JS_CALL_EXPRESSION and therefore misses `new` expressions which
are also double-evaluated by the ternary; update contains_call_expression to
return true for both call and new expressions (e.g. check node.kind() ==
JsSyntaxKind::JS_CALL_EXPRESSION || node.kind() ==
JsSyntaxKind::JS_NEW_EXPRESSION) so that `new Foo()` is treated the same as
`foo()` when deciding can_fix.

@hammadxcm hammadxcm requested a review from ematipico February 25, 2026 09:49
@ematipico
Copy link
Member

Closing. This is a bot and it can't get things right.

@ematipico ematipico closed this Feb 25, 2026
@pkallos
Copy link
Contributor

pkallos commented Feb 25, 2026

Closing. This is a bot and it can't get things right.

ah that’s frustrating, sorry about that.

i’m working on an implementation of this also, made a lot of progress last night; i’ll try to learn/apply what i can from your initial feedback on this PR

@ematipico
Copy link
Member

take your time @pkallos, no need to rush things. Thank you!

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.

📎 useNullishCoalescing: suggest ?? for ternary nullish checks

3 participants