test: render API approvals with PublicApiGenerator - #80
Conversation
The hand-rolled renderer in PublicApiSurfaceTests printed a type's kind, its name and its member signatures, and nothing else. Six kinds of breaking change therefore passed the approval test unchanged: sealing a type or dropping `abstract`, removing a base type or an implemented interface, changing or deleting a parameter default, flipping an `in`/`ref`/`out` modifier, adding or removing an attribute, and tightening a nullable annotation. All six break consumers, and the approval is the only thing in the repository watching for them. Hand-rolling the missing six is more reflection than a test should own, so the rendering now comes from PublicApiGenerator, which emits compilable C# declarations and covers all of them at once. It is a test-only PackageReference carrying PrivateAssets="all", so nothing reaches a shipped package. Two things are excluded deliberately, both documented at the options object: assembly-level attributes, which carry the version this build was handed from outside and would fail the approval on every CI run, and the compiler's nullable bookkeeping attributes, which the generator already renders as `?` on the signatures themselves. The approvals are regenerated. The diff is large because the rendering is much richer, not because the surface moved: the set of public types and member names is identical package by package, with three deliberate gains — operator overloads, which the old renderer skipped as SpecialName; protected constructors, which it never asked reflection for; and record-synthesized members, which collapse into the `record` keyword now that records print as records. Closes #50 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR SummaryLow Risk Overview
PublicApiGenerator 11.5.4 is added as a test-only central package ( Reviewed by Cursor Bugbot for commit 8f0e3d4. Bugbot is set up for automated code reviews on this repo. Configure here. |
Closes #50.
Which route, and why
The issue offered two: (1) render the approvals with
PublicApiGenerator, or (2) keep the hand-rolled renderer and addEnablePackageValidationagainst a 1.2.0 baseline. This PR takes route 1.Route 1 closes all six reported gaps in one move — base types and implemented interfaces,
sealed/abstract, parameter defaults,in/ref/out, attributes, nullability — because the generator emits compilable C# declarations rather than a summary of them. Hand-rolling those six onto the existing reflection walk would be more reflection code than a test should own, and each one is a place to get the corner cases subtly wrong.The dependency fits the repo's conventions: it goes into
Directory.Packages.propsunder the existingTestlabel (central package management is on), and thePackageReferenceintests/Directory.Build.propscarriesPrivateAssets="all", so it is a test-only tool and never reaches a shipped.nupkg. Nothing about the workflow changes: same nine approval files, same.received.txtdrop, same CI Show generated API approvals step and artifact upload.Route 2 was not taken here, but it is not wrong — the two are complementary, as the issue says. Left as a follow-up because:
netstandard2.0andnet10.0— genuinely uncovered here, since the test only ever loads thenet10.0build. That is worth its own issue rather than being folded in behind an approval-rendering change.What the approval diff shows
Large, as expected: +1087/−851 across the nine files, because the rendering is much richer. It is not a surface change. I compared the two renderings mechanically — extracting the set of public type names and member names from the old files and from the new ones, package by package — and the sets are identical apart from three differences, all of which are the new renderer seeing more than the old one:
J1939Name,IsoTpEndpointandPcigetoperator ==/operator !=. The old renderer filtered them out asIsSpecialName.IsoTpException,J1939NodeException,J1939TpExceptionandUdsExceptioneach have aprotectedctor taking aCanKitErrorCode. The old renderer's class doc claimed to cover "public/protected member signatures", but it passedBindingFlags.PublictoGetConstructors, so protected ctors were invisible to it.recordkeyword.J1939SpnDefinitionloses its printed<Clone>$,Deconstruct,Equals,GetHashCodeandToString, and prints aspublic sealed recordinstead.TxConfirmationlikewise losesEquals/GetHashCode/ToString. I setTreatRecordsAsClasses = falseon purpose for this: the generator defaults to printing a record as a class, and record-ness is part of the promise (value equality,with, deconstruction), so turning one back into a class must fail the approval.Everything else in the diff is the same members, rendered with the detail the issue asked for. A few examples of what is now visible and was not before:
That last one is worth calling out on its own:
TxConfirmation's properties areinit, notset. The old renderer reportedCanWriteand printed{get/set}, so aninitsilently relaxed to asetwould have passed. Same story for[System.Flags]onCanKit.Pro.CANopen's flags enum, which is now in the approval.No behaviour change, no release
Test-only.
test:maps to no release, and nothing insrc/is touched — the nine.approved.txtfiles are the record of the surface, not the surface.Verification
dotnet build CanKit.Pro.sln -c Release— clean, 0 warnings.dotnet test CanKit.Pro.sln -c Release— 403 passed, 0 failed.dotnet format --verify-no-changesreports nothing on the touched file (the pre-existing violations inUdsTransferTests.cs,UdsClientTests.csandNfr006ErrorArchitectureTests.csare untouched and predate this branch).Two things the richer rendering exposed, neither fixed here
Both look like genuine API observations rather than rendering artefacts, and both feel like separate issues:
ICanBusService.FindOverlappingFilterSubscriptions()returns a bare tuple pair. It renders asIReadOnlyList<ValueTuple<ISubscription, ISubscription>>plus a[return: TupleElementNames({"First", "Second"})]attribute — the source declares(ISubscription First, ISubscription Second). "First"/"Second" carry no meaning for an overlapping pair, the attribute is noise in the approval, and renaming tuple element names is a source-breaking change for callers who destructure by name. A small namedreadonly record structwould read better and be safe to evolve.Only the
net10.0build is approved. The test doesAssembly.Load, so it sees whichever TFM the test project resolved — nevernetstandard2.0. The packages ship both. Anetstandard2.0-only difference (a polyfilled overload, a#ifthat guards a member) would not be caught by this test at all. This is exactly the hole route 2'sEnablePackageValidationfills, and is the strongest argument for doing it as a follow-up.🤖 Generated with Claude Code