Conformance
Curb states compatibility with dotnet format as a measurement, gated in CI on every push
against a 1,196-file corpus:
- With reflow off, Curb's output is byte-identical to
dotnet format whitespace— 100%, enforced as a build gate. - With reflow on, also 100% — deterministic layout has no arrangement inherited from the source for
dotnet formatto disagree with, so it is the cleaner of the two. - With reflow on and
csharp_keep_existing_linebreaks = true, 99.9%. One file falls short: a property pattern that reflow breaks, and whose bracedotnet formatthen moves. Measured and held rather than quietly rounded up. - Zero failed or unparsable files across the corpus, also gated.
What is measured is that Curb's output is a fixed point of dotnet format: run dotnet format
over a Curb-formatted file and nothing changes. That is what decides whether Format Document in
your IDE will fight your formatter.
The same measurement over twelve real repositories and 41,000 files is in the benchmarks.
For a reference tool T — dotnet format whitespace, dotnet format style, or jb cleanupcode — and an
input x formatted under some .editorconfig, call Curb's output Z = curb(x). Conformance with
T means:
T(Z) == Z
Curb's output is a fixed point of T. This is a weaker, and more useful, claim than raw agreement.
It does not require Z to equal X = T(x) — what T would have produced from the same raw input on
its own. Curb is free to pick a shape T would never have chosen by itself, as long as T accepts
that shape once it exists and does not move it again. That is what makes opinionated and deterministic
layout admissible at all: dotnet format declines to decide almost everything about line breaks, so
anything it declines to decide Curb may decide while remaining a fixed point.
X != Z while T(Z) == Z still holds is the common, expected case, not something each instance needs to
justify on its own: most of the ReSharper-derived wrapping, blank-line and reflow keys have no dotnet format opinion to agree with at all, so hundreds of hand-written cases legitimately differ this way.
./build.sh verifyexpectations reports how many, the same way ./build.sh churn reports adoption cost —
published rather than asserted, because it is worth knowing, but gating on it case by case would mean
writing an entry for every one of Curb's opinions individually, which drowns the exceptions this
page exists to surface in paperwork nobody would read.
The rarer and more serious case is T(Z) != Z itself failing — Curb's own chosen output is not even
a fixed point, and no choice of Z would make it one. This is what ./build.sh verifyexpectations actually
gates, on every case, with zero tolerance for an undocumented one. It happens where T cannot recognise
what Curb is doing at all, so it undoes the opinion however it is expressed:
- A ReSharper-only key set alongside an IDE0055 key
Tdoes enforce, where the two are in direct conflict —csharp_empty_block_style = togethernext tocsharp_preserve_single_line_blocks = falseis the current example:Talways re-expands the single-line block the ReSharper key just collapsed. - A language-level suppression
Thas no concept of, because it is not diagnostic-driven — a#pragma warning disableregion Curb leaves untouched is always reformatted bydotnet format whitespaceregardless of the pragma. - An unrecognised option value, where Curb falls back to the option's documented default and
T's own fallback for the same invalid value does not match its documented default either.
Every one of these is recorded in conformance divergences, by the test case
that demonstrates it. An undocumented one fails the build outright — the two-step "reported, then gated
once a number exists" pattern churn uses does not apply here, because the whole point of this check is
that a new one is never accepted silently. Each entry needs to be either a real, permanent incompatibility
(documented and accepted) or a bug to fix — never a silent gap in a floor.
The same fixed-point property, and the same documentation discipline for a T(Z) != Z case, is what
Curb holds itself to against dotnet format style (for the semantic cleanup rules curb cleanup
applies) — gated in CI by ./build.sh verifycleanupexpectations, the same way verifyExpectations gates
the whitespace side.
jb cleanupcode is where the ReSharper-derived wrapping and blank-line keys would ideally be checked —
they have no dotnet format opinion to compare against at all. ./build.sh verifyexpectationsjb runs the
same cases through it and reports the count, but does not gate on it and is not wired into CI —
though for a narrower reason than it first looked like.
An early version gave each case its own throwaway project batched into one solution, on the theory that
jb's cost is per invocation rather than per project — wrong on both counts it was measured against. Speed:
going from one project to five in the same invocation cost nothing extra (jb pays a large, ~8 second fixed
platform-startup cost regardless), but the full 838-project run took ~5 minutes, so real per-project MSBuild
evaluation overhead was the dominant cost. Determinism: that same shape also measured non-deterministic —
three otherwise-identical runs against the same 838-case dump landed on 330, 377 and 404 disagreements, with
no code change between them.
One project holding every case as its own uniquely-namespaced file, rather than one project per case, fixes
both: the run takes ~30 seconds now, and two runs landed on the exact same 307-case set — name for name, not
just the same count. Many separate project/compilation contexts, not jb's cleanup logic itself, was almost
certainly the source of both problems.
What still blocks gating is scale, not reliability. dotnet format whitespace disagrees with Curb on 5 of
842 cases, each root-caused to a real, documented incompatibility. jb started at roughly 307 of 842 —
dotnet format mostly declines to decide and so rarely fights Curb's choices, where jb is a full
opinionated formatter with its own stance on almost everything. Gating "every non-fixed-point named
individually" the way verifyExpectations does would mean triaging hundreds of cases into registry entries
in one sitting — the same reason the whitespace side's X != Z shape-divergence check is reported in
aggregate rather than itemised. The fix is the one used there: find the root causes behind the disagreeing
cases and gate on those being documented, the way the whitespace side gates on 5 categories rather than
hundreds of cases.
That categorisation is under way, not finished. Two harness bugs (unconditional namespace insertion into
files with no type declaration; two top-level-statement files merged into the same compilation, which C#
only allows one of, regardless of namespace handling — now detected and excluded) and eight root causes
found and fixed so far, each an injected .editorconfig key added into a case's own section when its shape
needs it, or — where Curb's own behaviour makes the other direction impossible — unconditionally. See the
code comments next to caseSection in Targets.fs for which is which and why, including one case
(csharp_prefer_braces) where "unconditional" turned out to be wrong and had to be made conditional after
it broke PreferBracesTests' own default-behaviour cases in the other direction:
csharp_empty_block_style (both spellings), csharp_style_namespace_declarations, csharp_prefer_braces,
dotnet_style_require_accessibility_modifiers, csharp_style_expression_bodied_{methods,constructors, operators,local_functions} (as one group, detected by a parameter list directly before => — the feature
that distinguishes these four from the three accessor-shaped ones below), both trailing-comma keys, and
csharp_space_between_attribute_sections (not a Curb-implemented key at all, but safe unconditionally since
Curb has no option that ever produces the alternative). That took the count from 307 to roughly 198 of 842
(819 checked, 23 excluded).
Not every remaining disagreement turned out to need a key at all. Prompted by a direct challenge — was
this actually finding Curb bugs, or just adding editorconfig until jb stopped complaining? — the empty
try { } case was re-investigated with dotnet format whitespace as an independent, neutral check rather
than trusting Curb's own test comments alone: it confirmed dotnet format treats an empty try exactly
as laxly as it treats catch/finally, which #39 had already special-cased to always collapse — try
itself had simply been left out by oversight. Fixed at the printer (Printers.Statements.cs,
PrintTryStatement), not routed around with an injected key, which is the right fix whenever the
disagreement turns out to be a genuine Curb gap rather than a legitimate difference of opinion: it also
improved the whitespace side's own conformance for free. Two harness regex bugs were fixed in the same
pass, both false-positive/false-negative shape detection caused by unhandled nested parentheses:
bracelessControlFlowBody's naive [^)]* stopped at a nested call's own closing paren (e.g. is Point(1, 2) inside an if), leaving braces unprotected; parameterizedExpressionBody's unanchored
)\s*=> matched a lambda argument's arrow nested inside a call on a block-bodied method's only statement,
wrongly treating the method itself as expression-bodied. Both now use a one-level-balanced-parens pattern,
and the expression-body regex is additionally anchored to the start of a line so a nested lambda can no
longer be mistaken for a member's own arrow.
That anchored version still missed a tuple return type (public (int First, int Second) M() => (1, 2);),
since a parenthesized return type isn't a plain word run — fixed by allowing the prefix at most one
parenthesized group in addition to its word tokens. The first attempt at that generalisation allowed the
word tokens and the parenthesized group to repeat interchangeably with only optional spacing between
them — the classic (\w+)*-style catastrophic-backtracking shape — and measurably hung the harness at
100% CPU for minutes on the real dump before it was caught and reworked to keep the word-token run
separated by a required space and the parenthesized group limited to a single, non-repeated occurrence,
which is what keeps the match linear; verified against a 300+ character deliberately non-matching stress
input at 0ms before trusting it again. Net effect of the try fix plus all three regex fixes: 198 → 185
of 820 checked.
Also confirmed, not fixed: csharp_style_namespace_declarations = block_scoped is a genuine no-op in Curb
when the source is already file-scoped — Printers.cs's FileScopedNamespace never reads
context.Options.NamespaceStyle at all, so nothing implements the block_scoped direction (IDE0161's
converse). NamespaceStyleTests.Block_scoped_is_accepted_and_changes_nothing documents this by name and
is expected to keep appearing in jb's disagreement list until that direction is implemented — it is not
a candidate for an injected key, since the disagreement is real.
What's left splits into several more distinct categories, each its own investigation the size of one of the
above: expression-body direction for the three accessor-shaped constructs (accessors, properties, indexers —
found empirically that getting the direction right isn't enough on its own; the accessor body's own line-
breaking still disagreed after four different keys were tried, so this needs more than one fix); attribute
lists with multiple attributes in one bracket section being split into one section each; redundant-
parentheses-around-operators and qualified-name-shortening (semantic style preferences Curb never applies at
all, and never will — see AGENTS.md's scope boundary: both require a resolved compilation, which Curb
never loads); and two wrapping-algorithm mismatches (query-clause continuation indentation, chain/binary-
operator continuation position) where jb's own algorithm disagrees with Curb's bespoke one. Each of these
was cross-checked against dotnet format whitespace where it has an opinion (attribute-section spacing and
attribute-list splitting both independently match Curb, not jb) — that check is the discipline going
forward: confirm which side is actually right before reaching for another injected key.