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.
Summary
C#'s null-forgiving operator
b!scores zero ABC conditions where thebare
bscores one. It is the direct C# sibling of the Kotlina!!defectfixed on
fix/batch-2026-09-13, and the fifth instance of one class inthat batch.
Measured
bca dumpconfirmsb!parses aspostfix_unary_expression, which is inneither
csharp_bool_terminal_kinds!()nor peeled bycsharp_inspect_container.The class
A condition slot delegates to a per-language helper shaped:
The third outcome is the defect. Five constructs have now fallen through it
in one batch: Kotlin
is/in, bare parens, Kotlin infixand, Kotlinpostfix
!!/as, and now C#b!.Fix shape
Teach
csharp_inspect_containerto peel a postfix-unary wrapper, mirroringkotlin_wrapper_operandas landed inb2b78598. That helper is the modelworth copying, for two reasons beyond the peel itself:
unary_expressionis one kind for two spellings, and a positional read is correct for exactly
one of them;
kotlin_count_conditionderives its wrapper set from the peel ratherthan 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*/ aand!/*c*/adiscriminate differently between a field read and anindex 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 assumingzero.
Related
b2b78598fixes the Kotlin half.Resolution
Fixed on
fix/issue-1463(commit17ee482e), not yet merged.Measured, against a freshly built binary
abc.conditionsbeforecyclomaticif (b)(control)if (b!)if ((b!))if (!b!)if (b!!)if (arr[0]!)if (C()!)b! && b(chain slot)(b!) || b(chain slot)for (…; b!; …)if (b /*c*/ !)if (b++)(grammar-reachable, not valid C#)Cyclomatic does not move anywhere, which is what makes the
conditionsmove unambiguously ABC's.The change
csharp_wrapper_operandnow carries the peel, in theOption<(Node, bool)>shapekotlin_wrapper_operandandgroovy_wrapper_operanduse, andcsharp_count_conditionasks it whichwrappers 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_expressionis one kind id (370) at the pinned=0.23.5, with no numbered aliases and no hidden-rule sibling. It isone production for three operators; only
!is type-preserving, so thearm is gated on
is_child(BANG)and++/--decline — their tokensare already ABC assignments, so accepting them would have scored one
construct on two axes.
No double count for the
!itself: no C# arm counts aBANGtoken.Reuse decision
No shared cross-language peel helper. Kotlin, Groovy and C# share the
Option<(Node, bool)>shape and nothing else — a common functionwould 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'sdecision 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_expressionandpostfix_unary_expressionan emptyfieldsmap innode-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 inthe
ifslot's ownchild(2)read (if ( /*c*/ (b))scores zero too).The new arm adds no instance of it: a
postfix_unary_expressionstartsat its operand, so
child(0)is comment-immune by position. The twoexisting instances are now pinned as measured expectations in
csharp_null_forgiving_operand_survives_an_interposed_comment, with acomment telling #1455's fix to change those rows rather than delete
them.
Tests
csharp_null_forgiving_operand_scores_like_its_operand— eightmembers across both walker paths (§11): five through the
ifpredicate slot (
csharp_count_condition), three through the&&chain slot (csharp_count_unary_conditions), each paired withits 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— theb /*c*/ !row this fix moves, against the! /*c*/ band( /*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 therestated 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 itentirely fails none of the 3,429 library tests, because
if (i++)andi++ && bare type errors only the grammar's over-permissivenessreaches. Per grammar-dispatch §6 no fixture pins it; the measurement is
recorded on the helper instead.
Corpus
No snapshot moves. Every
postfix_unary_expressionin the six-file C#corpus and in the DeepSpeech
.csfiles is ani++or ann--—checked with
bca dumpon all 39 files, not assumed.Further gaps found while sweeping (not fixed here)
Walking the grammar's
non_lvalue_expression/lvalue_expression/_expression_statement_expressionalternatives against a conditionslot, 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 anassignment, the predicate counts as nothing.
if (new Wrapper())—object_creation_expressionreachingboolthrough a user-defined implicit conversion.
A fifth,
if (x switch { _ => true }), scores zero because the sole armis the excluded bare discard; a switch with any real arm is fine.
The
_ => nothingslot shape itself survives in nine more languages:ruby,go,rust,python,php,perl,cpp,luaandjavaall still restate their wrapper kind list in
<lang>_count_conditionrather 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.