feat: compile-check's worker can tell whether two reference assemblies show dependents the same API

Merged
HA
19h ago
+2177-12

Summary

  • compile-check's changed-files check worker gets a compare-surface command. 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 unmanaged constraint 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.Metadata only (no Roslyn), and only identical texts are same:
    • 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.
  • Program dispatches commands through one internal switch (fingerprint, check, compare-surface); an unknown command throws instead of running check.
  • 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 same case asserts that the bytes differ, the dependent's diagnostics are identical, and the worker answers same.
    • A different case asserts that the dependent's diagnostics differ and the worker answers different.
    • 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-surface did not exist. After it, all 74 tests pass.
  • Mutations, each restored afterwards:

    MutationFails
    Skip the nullability description9 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 embeddednint
    Drop every return-value row[return: MaybeNull]
    Arrays have no positionarray element
    Every type has a positionprivate 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 positiongeneric value type argument
    Keep unreferenced private nested typeslambda, iterator, async, private nested type
    Drop every nested typenested type made private, private protected
    Drop referenced private nested types toothe interface case and both struct-field cases
    Drop private protected nested typesprivate protected
    Keep state machine attributesiterator, async
    Skip the manifest (assembly attributes, references, forwarded types, resources)InternalsVisibleTo target changed
    Drop parameter namesparameter renamed
    A type without its own context does not inherit its outer type'snested type when the outer type's context moves
  • go test ./internal/compilecheck/... in cli/dispatcher passes (the worker sources are embedded there).

  • Repository CI does not run on pull requests into this branch; the checks above were run locally.