Skip to content

fix(abc/csharp): the null-forgiving b! scores no condition #1463

Description

@dekobon

Summary

C#'s null-forgiving operator b! scores zero ABC conditions where the
bare b scores one. It is the direct C# sibling of the Kotlin a!! defect
fixed on fix/batch-2026-09-13, and the fifth instance of one class in
that batch.

Measured

if (b)      ->  abc.conditions 1,  cyclomatic 2
if (b!)     ->  abc.conditions 0,  cyclomatic 2     <- wrong
if ((b!))   ->  abc.conditions 0
if (!b!)    ->  abc.conditions 0

bca dump confirms b! parses as postfix_unary_expression, which is in
neither csharp_bool_terminal_kinds!() nor peeled by
csharp_inspect_container.

The class

A condition slot delegates to a per-language helper shaped:

if matches!(kind, <lang>_bool_terminal_kinds!()) { +1 }
else if matches!(kind, Paren | Unary) { recurse }
// anything else -> silently nothing

The third outcome is the defect. Five constructs have now fallen through it
in one batch: Kotlin is / in, bare parens, Kotlin infix and, Kotlin
postfix !! / as, and now C# b!.

Fix shape

Teach csharp_inspect_container to peel a postfix-unary wrapper, mirroring
kotlin_wrapper_operand as landed in b2b78598. That helper is the model
worth copying, for two reasons beyond the peel itself:

  • it reads its operand by grammar field, not by index — unary_expression
    is one kind for two spellings, and a positional read is correct for exactly
    one of them;
  • kotlin_count_condition derives its wrapper set from the peel rather
    than restating it, so the caller's "is this mine" predicate and the helper
    cannot drift. Those two lists disagreeing is what produced the Kotlin bug.

Check for a §5 double count first: is the ! token counted by any C# arm?
Kotlin's as? had exactly that collision and had to stay excluded.

Tests

A postfix member alongside a bare control and a parenthesised spelling, per
§11. Note from the Kotlin fix: fixture spacing can be load-bearing
! /*c*/ a and !/*c*/a discriminate differently between a field read and an
index read. If a perturbation fails nothing, vary the fixture before
concluding the property is untestable.

Corpus

Unmeasured. The C# corpus is 6 files; check for ! postfix before assuming
zero.

Related

Resolution

Fixed on fix/issue-1463 (commit 17ee482e), not yet merged.

Measured, against a freshly built binary

predicate abc.conditions before after cyclomatic
if (b) (control) 1 1 2
if (b!) 0 1 2
if ((b!)) 0 1 2
if (!b!) 0 1 2
if (b!!) 0 1 2
if (arr[0]!) 0 1 2
if (C()!) 0 1 2
b! && b (chain slot) 1 2 3
(b!) || b (chain slot) 1 2 3
for (…; b!; …) 0 1 2
if (b /*c*/ !) 0 1 2
if (b++) (grammar-reachable, not valid C#) 0 0 2

Cyclomatic does not move anywhere, which is what makes the
conditions move unambiguously ABC's.

The change

csharp_wrapper_operand now carries the peel, in the
Option<(Node, bool)> shape kotlin_wrapper_operand and
groovy_wrapper_operand use, and csharp_count_condition asks it which
wrappers it unwraps instead of restating csharp_paren_expr_kinds!() /
csharp_prefix_unary_expr_kinds!() independently — the §7 divergence
#1459 and #1466 removed in those two languages.

postfix_unary_expression is one kind id (370) at the pinned
=0.23.5, with no numbered aliases and no hidden-rule sibling. It is
one production for three operators; only ! is type-preserving, so the
arm is gated on is_child(BANG) and ++ / -- decline — their tokens
are already ABC assignments, so accepting them would have scored one
construct on two axes.

No double count for the ! itself: no C# arm counts a BANG token.

Reuse decision

No shared cross-language peel helper. Kotlin, Groovy and C# share the
Option<(Node, bool)> shape and nothing else — a common function
would need the wrapper kind set, a per-wrapper operand accessor, a
per-wrapper proves-boolean flag and the parent seed as parameters,
which is every line of a five-line while let. This re-confirms #1466's
decision after reading all three.

Field vs index (#1455)

Not addressed, and it does not fall out here. The C# grammar gives
parenthesized_expression, prefix_unary_expression and
postfix_unary_expression an empty fields map in node-types.json,
so the field read Kotlin and Groovy use is unavailable. if (! /*c*/ b)
and if (( /*c*/ b)) still score zero, and a third instance exists in
the if slot's own child(2) read (if ( /*c*/ (b)) scores zero too).

The new arm adds no instance of it: a postfix_unary_expression starts
at its operand, so child(0) is comment-immune by position. The two
existing instances are now pinned as measured expectations in
csharp_null_forgiving_operand_survives_an_interposed_comment, with a
comment telling #1455's fix to change those rows rather than delete
them.

Tests

  • csharp_null_forgiving_operand_scores_like_its_operand — eight
    members across both walker paths (§11): five through the if
    predicate slot (csharp_count_condition), three through the
    && chain slot (csharp_count_unary_conditions), each paired with
    its unwrapped control, both metric axes asserted per member, and the
    fixture anchored by kind-id counts via assert_csharp_fixture_spells.
  • csharp_null_forgiving_operand_survives_an_interposed_comment — the
    b /*c*/ ! row this fix moves, against the ! /*c*/ b and
    ( /*c*/ b) rows it does not.

Both fail against the unfixed code. Verified by three perturbations
(dropping the arm, reading the operand at child(1), and restoring the
restated wrapper list in the slot); each fails both tests, and the
restated-list perturbation fails the predicate-slot rows specifically.

The !-vs-++ guard has no observable perturbation: dropping it
entirely fails none of the 3,429 library tests, because if (i++) and
i++ && b are type errors only the grammar's over-permissiveness
reaches. Per grammar-dispatch §6 no fixture pins it; the measurement is
recorded on the helper instead.

Corpus

No snapshot moves. Every postfix_unary_expression in the six-file C#
corpus and in the DeepSpeech .cs files is an i++ or an n--
checked with bca dump on all 39 files, not assumed.

Further gaps found while sweeping (not fixed here)

Walking the grammar's non_lvalue_expression / lvalue_expression /
_expression_statement_expression alternatives against a condition
slot, four more spellings score zero against a cyclomatic decision of
one. Each is a separate judgement call and none is this issue's:

  • if (default(bool))default_expression, a genuine bool terminal.
  • if (checked(b))checked_expression, a transparent wrapper.
  • if (b = C())assignment_expression; the = counts as an
    assignment, the predicate counts as nothing.
  • if (new Wrapper())object_creation_expression reaching bool
    through a user-defined implicit conversion.

A fifth, if (x switch { _ => true }), scores zero because the sole arm
is the excluded bare discard; a switch with any real arm is fine.

The _ => nothing slot shape itself survives in nine more languages:
ruby, go, rust, python, php, perl, cpp, lua and java
all still restate their wrapper kind list in <lang>_count_condition
rather than asking their peel. No present defect was measured in those
— the two lists currently agree — but it is the same drift hazard
#1459, #1466 and #1463 each landed on.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions