feat: compile-check learns which names a public API change touches

Merged
HA
3d ago
+1773-129

Summary

  • When an assembly's public API changes, compile-check now learns which names the change touches (type and member names), or that no name can tell it. This is the groundwork for compiling only the dependents that write one of those names.
  • No scheduling change; the next PR consults the names. Which dependents compile is the same as before.

User Impact

None yet. The VibeLog (ULOOP_DEBUG) surface_checks entries now show changed_name_count, the first 10 changed_names, and unnamed (why no name tells the change), so the next PR's effect can be measured on a real project.

Changes

  • Worker (C#):
    • Every row of a surface description now carries the type and member it belongs to and a row kind. The same / different answer still comes from comparing the texts in order, so it does not change.
    • On "different", the worker collects names from the rows whose text one side holds more often than the other (rows that only moved are not a named change).
    • A change a dependent can feel without writing any name is unnamed and carries no names:
      • a manifest row;
      • for a type both sides have: its flags or attributes, base type, interfaces, type parameters, an instance field of a value type, a parameterless constructor, an indexer;
      • an abstract member added;
      • an operator, a compiler-generated member, or a member C# binds by pattern (GetEnumerator, GetAwaiter, Add, Deconstruct, SetResult, …);
      • every row of an interpolated string handler or collection builder type;
      • every row of a type in System, System.*, Microsoft.CodeAnalysis or Microsoft.CodeAnalysis.*, on one side or both. The compiler looks these up by name (IsExternalInit for init, Index for ^1), so a dependent uses them without writing their names. Only polyfills live there in a project. A project that puts its own extension classes in System.* gets every dependent compiled when one of their members changes; the docs and the ADR in the follow-up PRs say so;
      • more than 2000 names.
    • A type added or removed is named by its type name (and its members). Namespace segments are named only when one side lacks that namespace.
    • A member whose name starts with an accessor prefix names itself and the name without the prefix (get_P and P, also for an explicit implementation N.IFoo.get_P). Only its metadata tells an accessor from a method declared as get_Value, which a dependent writes with the prefix.
    • The base type moves from the type row to a row of its own, so the description version goes from sv1 to sv2. No other row's text changes.
  • Go:
    • The response, the surface record (format version 1 → 2, so an old record is asked again once), and SurfaceVerdict carry ChangedNames / UnnamedReason.
    • A different answer that names nothing reads as unnamed, both in the client and where an answer becomes a verdict.
    • Only a verdict that is not kept, has names, and has no unnamed reason is a named change. Every other verdict, including the zero verdict of a failed unit, still affects every dependent.
  • docs/vibe-logs.md documents the new surface_checks fields.

Verification

  • C# (tests/CompileCheckWorker.UnitTests): 113 tests pass.

    • AssertDifferent now also checks the answer against the dependent it compiles: the change is unnamed, or the dependent writes one of its names. All existing different-surface cases pass this check.
    • New tests use dependents that never write the changed type's name (foreach, await, operators, new() and unmanaged constraints, casts, boxing, a renamed indexer, an extension GetAwaiter).
    • One limitation: adding an interface member with a default body cannot be emitted against the fixture's .NET Standard 2.0 (CS8701). That rule is tested by building the description rows directly.
  • Mutations, each tried on the worker and then restored:

    MutationFails
    Empty the pattern-bound member listforeach / await on an unnamed type, extension GetAwaiter, GetEnumerator added, SetResult changed
    Treat type-shape rows as membersbase type changed, interface added
    Treat every type as one-sided21 tests, including abstract / sealed / ref struct
    Always name namespace segmentsnested generic member, new namespace
    Value-type instance field as memberstruct field added
    Parameterless constructor as memberparameterless constructor removed
    Indexer as memberindexer added, renamed indexer changed
    Drop the well-known namespace checkwell-known type removed (init), well-known type member changed (^1)
    Do not keep the name before the accessor prefixmethod named like an accessor, explicit accessor implementation, and 4 tests whose expected names include accessors
    Go: a different answer without names reads as namedTestVerdictFromWorkerTreatsADifferenceWithoutNamesAsUnnamed
    Go: record format version stays 1TestReusableSurfaceVerdictRejectsAVersionOneRecord
  • In cli/dispatcher:

    • go test -race -count=1 ./internal/compilecheck/... ./internal/dispatcher/... passes.
  • In each of the four Go modules:

    • gofmt -l is empty;
    • go vet passes;
    • golangci-lint run reports 0 issues.
  • go test ./... passes in cli/dispatcher and cli/release-automation, including ./internal/architecture/.... In cli/common and cli/project-runner, one test each fails only because the local sandbox refuses a directory under /tmp and a Unix socket bind; neither module is touched here.

  • scripts/check-code-complexity.sh and scripts/check-file-length.sh pass.