feat: compile-check's worker can tell whether two reference assemblies show dependents the same API
Summary
- compile-check's changed-files check worker gets a
compare-surfacecommand. It answers whether two reference assemblies of one assembly show every dependent the same API. Nothing calls it yet; a follow-up uses it to stop recompiling dependents when an edit only moved compiler bookkeeping in the reference assembly.
User Impact
No behavior changes in this pull request. Adding worker sources changes the worker's bootstrap key, so the first compile-check that tries the changed-files check after updating builds the worker once more (a few seconds).
Why the command is needed: in a project with 561 assemblies, a reference assembly often changes in bytes without changing the API its dependents see, and every assembly that references it then compiles again (10 to 20 seconds for an assembly 67 others reference directly). Three common causes:
- A private reference-type field moves the nullable context attributes of its type.
- A private generic method with an
unmanagedconstraint adds an embedded attribute type. - Any private member added before a lambda, an async method or an iterator renumbers the compiler's private closure and state machine types.
Changes
- Each reference assembly is written out as text with
System.Reflection.Metadataonly (no Roslyn), and only identical texts aresame:- Structure: assembly identity, references, forwarded types, resources, and every type, member, signature, constant, layout, security and attribute a dependent can see. Rows are named instead of numbered, because an extra embedded type renumbers the tables. Attributes, interfaces, constraints and explicit implementations of one row are sorted; types and members keep table order.
- Left out of the structure: types carrying
[Microsoft.CodeAnalysis.Embedded](a dependent cannot see them, even through InternalsVisibleTo), private nested types that no kept row refers to (iterated to a fixpoint), the async/iterator state machine attributes that name such a type, the module version id, and method bodies. Return-value rows are all kept: a method's nullable context comes from its own signature, so such a row never appears on one side only. - Nullability: each row that can hold an annotation is resolved to one effective value — its own
NullableAttribute, else the context of its method, type and outer types. Rows whose type has no nullable position (void, primitives, non-generic value types and enums,Nullable<T>of those) are not written, since their context value means nothing.
- Known limit: a dependent that already fails because it names a private nested type left out of the description (for example
class D : Outer { P x; }) may report the error under another ID (CS0246 or CS0426 becomes CS0122). Whether it compiles, where the error is, and its warnings do not change. - Fail closed: an unreadable file, a nullable attribute on an unexpected kind of row, or an attribute value of an unexpected shape answers
different. Programdispatches commands through one internal switch (fingerprint,check,compare-surface); an unknown command throws instead of runningcheck.- The description starts with a version (
sv1), to be raised whenever what it writes changes.
Verification
-
tests/CompileCheckWorker.UnitTests: 34 new tests. Each builds two versions of a library into reference assemblies with Roslyn 4.3.1 (deterministic, nullable enabled) and compiles a dependent against each. They compile against .NET Standard 2.0, which, like the .NET Standard 2.1 and .NET Framework 4.8 profiles Unity compiles against, defines none of the nullable, unmanaged or native-integer attributes, so the compiler embeds them the same way.- A
samecase asserts that the bytes differ, the dependent's diagnostics are identical, and the worker answerssame. - A
differentcase asserts that the dependent's diagnostics differ and the worker answersdifferent. - The lambda and private-nested-type cases also assert that the private nested types are what differs.
- Before the implementation, the project failed to build because
compare-surfacedid not exist. After it, all 74 tests pass.
- A
-
Mutations, each restored afterwards:
Mutation Fails Skip the nullability description 9 annotation-change cases (property, oblivious, constraint, base type argument, internal under IVT, containing type argument, unconstrained type parameter, array element, generic value type argument) Drop every attribute [Obsolete],nint,[return: MaybeNull]Drop attributes whose type is embedded nintDrop every return-value row [return: MaybeNull]Arrays have no position array element Every type has a position private reference fields, position-free members, base type keeps its annotation Nullable<int>has a position / enums have a positionposition-free members Generic value types have no position generic value type argument Keep unreferenced private nested types lambda, iterator, async, private nested type Drop every nested type nested type made private, private protected Drop referenced private nested types too the interface case and both struct-field cases Drop private protected nested types private protected Keep state machine attributes iterator, async Skip the manifest (assembly attributes, references, forwarded types, resources) InternalsVisibleTo target changed Drop parameter names parameter renamed A type without its own context does not inherit its outer type's nested type when the outer type's context moves -
go test ./internal/compilecheck/...incli/dispatcherpasses (the worker sources are embedded there). -
Repository CI does not run on pull requests into this branch; the checks above were run locally.