False-positive condition count on nullish coalescing `? ?` lines blocks Coverage on New Code gate

What language is this for?

TypeScript

Which rule?

Not a rule violation — this is a false-positive in coverage condition counting (the uncovered_conditions / conditions_to_cover metric). The “Partially covered” indicator on a line uses the same condition counter, and that
counter inflates branch counts on ?? (nullish coalescing) expressions.

Why I believe it’s a false-positive

A single line containing a nullish coalescing (??) expression is reported as having more conditions than actually exist. Istanbul/Jest coverage shows 100% branch coverage on the line (all branches the language defines are
hit), but SonarQube reports e.g. “4 of 6 conditions covered” — leaving 2 phantom uncovered conditions that no test can possibly reach. This blocks the “Coverage on New Code ≥ 90%” quality gate on otherwise fully-tested code, and
the only fix is to rewrite the ?? as an explicit if/return — semantically identical but cosmetically uglier.

Which Sonar product

  • SonarQube Server, version 2025.4.3 (build 113915)
  • Scanner CLI: SonarScanner CLI 8.0.1.6346 (running in CircleCI via sonar-scanner in pull_request_analysis mode)
  • SonarJS plugin: 10.25.0.33900
  • Not using SonarQube Cloud, not using SonarQube for IDE / Connected Mode

Toolchain producing the coverage report

  • TypeScript: 4.8.4
  • Jest: 28.1.3 with ts-jest 28.0.8 (Istanbul coverage reporter, lcov output)
  • Cypress: 15.16.0 with @jsdevtools/coverage-istanbul-loader (lcov output)
  • Both lcov files merged via sonar.javascript.lcov.reportPaths=**/jest/lcov.info,**/cypress/lcov.info

Self-contained reproducer

// resolve.ts
export interface Item {
  key: string;
}

export function resolveItem(items: Item[], key: string): Item {
  // SonarQube reports "4 of 6 conditions covered" on the line below,
  // even though both branches of `??` are exercised by the tests.
  return items.find(item => item.key === key) ?? { key };
}
// resolve.spec.ts
import { resolveItem } from './resolve';

describe('resolveItem', () => {
  it('returns the matching item when present', () => {
    const match = { key: 'a' };
    expect(resolveItem([match], 'a')).toBe(match);
  });

  it('returns a fallback when no item matches', () => {
    expect(resolveItem([{ key: 'a' }], 'b')).toEqual({ key: 'b' });
  });

  it('returns a fallback when the list is empty', () => {
    expect(resolveItem([], 'a')).toEqual({ key: 'a' });
  });
});

Run jest --coverage. Istanbul’s text report shows 100% branches:

File         | % Stmts | % Branch | % Funcs | % Lines
-------------|---------|----------|---------|--------
resolve.ts   |     100 |      100 |     100 |     100

Istanbul’s lcov.info for the ?? line lists 4 BRDA records (2 for the cond-expr, 2 for the binary-expr), all with non-zero hit counts:

DA:7,3
BRDA:7,2,0,1
BRDA:7,2,1,2
BRDA:7,3,0,2
BRDA:7,3,1,1
BRF:4
BRH:4
end_of_record

After ingestion, SonarQube reports the line as partially covered (4 of 6 conditions) — 2 conditions that have no corresponding source-level branch, and that no test is capable of hitting.

Workaround

Rewriting the same logic without ?? clears the gate:

export function resolveItem(items: Item[], key: string): Item {
  const match = items.find(item => item.key === key);
  if (match) {
    return match;
  }
  return { key };
}

The same three tests now produce 2 of 2 conditions covered in SonarQube, and the lcov BRDA records still show the same total coverage. The runtime semantics are identical.

Expected behavior

A line containing a ?? b should expose at most 2 conditions to SonarQube (matching the 2 control-flow branches the operator introduces — a is non-nullish vs a is nullish). The current behavior counts ~3 conditions per ??
operand, double-counting against istanbul’s BRDA records and reporting unreachable phantom conditions.

Hey Daniel, welcome to the community and thanks for your comprehensive description of the issue! It seems like both reports (jest and cypress) show 100% coverage for that line/condition, so probably the discrepancy is introduced by SonarQube when merging the lcov reports.

A similar issue was reported not long ago, and it was fixed in SonarJS 12.1, which is bundled with SonarQube 2026.2 onwards. Before flagging this for our developers, could you please try analyzing it on SonarQube 2026.2? If you have a testing instance, then you could try upgrading it and running the analysis against it. If the issue persists, I’ll be happy to ask devs to take a look.