feat: compile-check compiles only the dependents that use what a public API change touched

Merged
HA
3d ago
+1083-28

Summary

  • When an assembly's public API changes, compile-check now compiles only the dependents that write one of the names the change touches. Every other dependent is left out, the way it is when the API did not change.
  • A change no name can tell (an operator, a base type, an abstract member added, a member C# binds by pattern, and the like) still compiles every dependent.
  • Builds on #3347, which supplies the names.

User Impact

Before: adding a public property to an assembly that 67 others reference compiled all 67, on a project with 561 assemblies. That took about 28 seconds, of which about 18 were the dependents.

After: only the dependents that write the changed type's or member's name compile. In measurements on that project:

  • a specific type or member name is written by 0 to 2 of the 67 dependents;
  • the expected time for such an edit is 12 to 15 seconds instead of 28.

Some edits are not faster, but nothing is missed:

  • short common names (written by about 50 of the 67);
  • constructors (target-typed new( is written by 59);
  • changes to a type's shape.

Changes

  • The identifiers of every unit that references another unit of the plan are read as the run starts:

    • cut by the characters C# allows (a letter, letter number or _, then also digits, combining and spacing marks, connectors and formatting characters, the last left out of the name), with comments and strings included;
    • plus new( for a target-typed new;
    • also the generator output the unit's last full compile saved. That output is read before any compile starts, because a unit's compile removes and rewrites it; only the sources, which nothing removes, are read in the background.
    • A unit whose sources cannot be read, or that has none, has no index and compiles on any change.
  • When a reference becomes final, each unit reading it learns whether the verdict touches it:

    • never when kept;
    • always when the change is unnamed, when nothing compared the reference, or when the unit has no index;
    • otherwise only when the unit writes one of the changed names.

    Only touching references count as changed inputs. So a dependent nothing touches is skipped (and counts as keeping its surface for the units below it). A unit that started early assuming the reference kept its surface keeps that compile.

  • Unchanged: which reference assembly a unit reads when it compiles (KeptSurfaceReferences, the input key), when a unit may start early, and when a stale compile is redone.

  • Each result lists the references whose change counted for it, with the first name it writes (changed_by in the VibeLog). The run reports the units left out by names (name_skips) and what reading the names took (name_index). The JSON response is unchanged.

  • docs/compile-check.md step 3 explains the narrowing; Limitations adds:

    • generators that write a name the dependent's sources never write. Leaving a dependent out removes its saved generator output, so in steady use the names come from hand-written sources alone and name_index.with_generated stays near 0. Closing that gap needs left-out dependents to keep their outputs and record;
    • every change to a type in System or Microsoft.CodeAnalysis (and namespaces under them) compiles every dependent;
    • \u-escaped identifiers.
  • docs/vibe-logs.md lists the new fields.

Verification

  • New tests:

    • a dependent that writes no changed name is skipped and reported;
    • one that writes the member's or only the type's name compiles and says which;
    • an unreadable dependent compiles;
    • an unnamed change compiles every dependent, even when names come with it;
    • an early compile is kept when the assumed reference changed only names the unit never writes (as a dependent and as a unit selected for its own sources), and is redone when it writes one;
    • a unit selected for its own sources still reads the produced reference assembly;
    • only the touching references are named;
    • the chain below a name skip is skipped;
    • a name only in saved generator output makes the dependent compile;
    • a unit that started early compiles again on a name only its saved generator output writes, although its own compile removes that output;
    • the generator output is read before start returns: a FIFO source holds the background read until the directory is gone (not run on Windows);
    • identifiers with a spacing mark, combining mark, connector, letter number and zero-width joiner; a target-typed new with and without white space;
    • identifier scanning, reading sources and generated code, and the background read being over once the run returns;
    • the VibeLog fields, and their absence when there is nothing to say.
  • Mutations, each tried and then restored:

    MutationFails
    A named change always touchesskip by names, early compile kept, chain skip, plus 4 more
    A named change never touchesdependent that writes a name, compiles again, generated code name
    An unreadable index does not touchunreadable dependent
    namedChange() ignores the unnamed reasonunnamed change with names, TestOnlyANamedVerdictIsANamedChange
    Saved generator output is not readgenerated code name, TestReadIdentifiersReadsSourcesAndGeneratedCode
    Read the generator output in the background after the sourcesTestNameIndexReadsGeneratedCodeBeforeStartReturns (3 of 3)
    Drop combining / spacing marks and formatting characters from identifier partsidentifier characters test
    Keep formatting characters in the nameidentifier characters test
    Letter numbers do not start an identifieridentifier characters test
    (#3347) A different answer without names reads as namedTestVerdictFromWorkerTreatsADifferenceWithoutNamesAsUnnamed
    (#3347) Record format version stays 1TestReusableSurfaceVerdictRejectsAVersionOneRecord
  • In cli/dispatcher:

    • go test -race -count=1 ./internal/compilecheck/... ./internal/dispatcher/... passes;
    • the new scheduler tests pass 10 times in a row under -race.
  • 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 in 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. The longest production Go file touched has 447 lines.

  • BenchmarkScanIdentifiers (a file of 40,000 new Thing() expressions, about 840 KB): 13 ms per scan, against 1.07 s with the previous new( check that copied the rest of the file.

  • Not verified here: the timing on a large project. That is measured after merge.