Replace Location with value-equatable LocationInfo — fix incremental cache invalidation on Clone#216
Conversation
Skymly
left a comment
There was a problem hiding this comment.
Review: PR #216 — Replace Location with value-equatable LocationInfo
This is a well-executed fix for a subtle but important incremental pipeline bug. The root cause analysis (Location reference equality + List reference equality breaking cache on Clone) is correct, and the fix is comprehensive. A few observations:
Issues
1. GetHashCode uses non-ordinal string hashing
Equals uses StringComparison.Ordinal for FilePath, but GetHashCode uses FilePath?.GetHashCode() which uses the current culture's hash. On .NET 5+ this is typically the same, but on netstandard2.0 the default string.GetHashCode() is culture-sensitive and can differ from ordinal. If two LocationInfo instances have the same ordinal-equal FilePath but different culture-based hash codes, they'd be Equals but have different hashes — violating the hash contract.
Fix: use StringComparer.Ordinal.GetHashCode(FilePath ?? "") instead of FilePath?.GetHashCode().
2. No test for ToLocation() round-trip
LocationInfo_Equals_AfterClone verifies value equality survives Clone, but there's no test that ToLocation() produces a Location with the correct FilePath, SourceSpan, and LineSpan. Since ToLocation() creates an ExternalFileLocation (not a syntax-tree-backed location), it's worth a unit test to confirm the round-trip preserves the diagnostic coordinates. This matters because if ToLocation() returns wrong coordinates, diagnostics will point to the wrong location in the IDE.
3. DiagnosticInfo.MessageArgsEqual uses EqualityComparer<object?>.Default
This is pre-existing (not introduced by this PR), but worth noting: MessageArgs are object?[] and compared with default equality. If a message arg is a struct (e.g. int order value), EqualityComparer<object?>.Default boxes and uses value equality — this works. But if an arg is a string, it uses string equality — also fine. No action needed, just documenting for future awareness.
Observations (not blocking)
4. LocationInfo constructor calls GetLineSpan() twice
FilePath = location.SourceTree?.FilePath ?? location.GetLineSpan().Path;
TextSpan = location.SourceSpan;
LineSpan = location.GetLineSpan().Span;GetLineSpan() is called twice when SourceTree is non-null (once for the fallback, once for LineSpan). Minor perf nit — could cache var lineSpan = location.GetLineSpan(); and reuse. Not critical since this runs in transform, not hot path.
5. ResolvedTransition is still a class (reference equality)
ResolvedTransition was updated to use LocationInfo for its Location property, but it remains a sealed class with reference equality. This is fine because ResolvedTransition is only used inside Execute (validation phase), not in the incremental pipeline — the pipeline carries Result<StateMachineModel> where StateMachineModel is a record. No action needed, just confirming the analysis.
6. EquatableArray<T> constraint is where T : IEquatable<T>
All model records implement IEquatable<T> via record's auto-generated equality. LocationInfo implements IEquatable<LocationInfo>. DiagnosticInfo implements IEquatable<DiagnosticInfo>. This is correct.
What's good
- Root cause analysis is thorough: both
Locationreference equality ANDList<T>reference equality were identified as causes. LocationInfois areadonly structwithIEquatable<T>— optimal for incremental pipeline value equality.ToLocation()correctly createsExternalFileLocationviaLocation.Create(filePath, textSpan, lineSpan)— sufficient forDiagnostic.Create.- All 9 model records updated consistently.
AssertCacheHitupgraded to useClone()— this is a stronger test that would have caught the original bug.LocationInfo_Equals_AfterClonetest directly validates the core fix.- Transform return types changed from
List<T>toEquatableArray<T>inRegistrationGeneratorHelper.TransformandHandlerOrderGenerator.Transform— this was the other half of the fix. - XML doc on
LocationInfoclearly explains why it exists and the Clone scenario.
Summary
The fix is correct and comprehensive. Issue #1 (hash code) is a real correctness bug on netstandard2.0 that should be fixed. Issue #2 (missing round-trip test) is a test gap worth filling. The rest are observations.
- Remove stale <param name='unchangedSources'> from RunIncrementalEdit XML doc comment (the parameter does not exist in the method signature). - Remove dead code GetAllTrackedStepNames (defined but never called). - Add Cached/Unchanged assertions to GenerateSingleton and StateTransition edit tests: the unmodified file's transform should be Cached or Unchanged, verifying the core incremental guarantee. - Rename RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached to RegisterStrategy_EditOneFile_CombineReflectsChange: the unmodified file's transform is Modified (not Cached) because Transform returns List<KeyedRegistration> (reference equality). This will be fixed by PR #216 (LocationInfo + EquatableArray), but until then the test correctly verifies only the Combine stage behavior. Added XML doc explaining the limitation.
Review issues fixed in latest commit
All 133 tests pass (was 131, +2 new tests). |
… affected contracts (#214) * Add incremental edit tests for RegisterStrategy, GenerateSingleton, StateTransition Add RunIncrementalEdit helper to SourceGeneratorTestContext that runs a generator on an initial compilation, replaces one syntax tree with a modified version, and re-runs. This enables testing the core incremental value: editing one file should only regenerate affected contracts. Three new test methods in IncrementalCacheTests: - RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached: two strategy contracts in separate files, add a strategy to one file, assert Combine is Modified and transform reflects the new strategy. - GenerateSingleton_EditOneFile_TransformReflectsChange: two singleton targets in separate files, add a singleton to one file, assert transform has at least one Modified/New output. - StateTransition_EditOneFile_CollectIsModified: two state machines in separate files, add a transition to one file, assert Combine is Modified and transform reflects the change. Also add GetTrackedStepReasons and GetAllTrackedStepNames helpers for inspecting tracked step reasons from a GeneratorDriverRunResult. Closes #213 * Fix review issues: stale doc, dead code, caching assertions - Remove stale <param name='unchangedSources'> from RunIncrementalEdit XML doc comment (the parameter does not exist in the method signature). - Remove dead code GetAllTrackedStepNames (defined but never called). - Add Cached/Unchanged assertions to GenerateSingleton and StateTransition edit tests: the unmodified file's transform should be Cached or Unchanged, verifying the core incremental guarantee. - Rename RegisterStrategy_EditOneFile_UnmodifiedContractStaysCached to RegisterStrategy_EditOneFile_CombineReflectsChange: the unmodified file's transform is Modified (not Cached) because Transform returns List<KeyedRegistration> (reference equality). This will be fixed by PR #216 (LocationInfo + EquatableArray), but until then the test correctly verifies only the Combine stage behavior. Added XML doc explaining the limitation.
Location uses reference equality, which breaks model value equality when Compilation.Clone() creates new Location objects. This forced AssertCacheHit to use the same compilation (not Clone), weakening the cache test. Fix: introduce LocationInfo, a value-equatable struct storing FilePath, TextSpan, and LineSpan. Replace Location with LocationInfo in all 9 record model types and DiagnosticInfo. Convert Location to LocationInfo in transform methods, and LocationInfo to Location in diagnostic reporting. Also fix List<T> return types in KeyedRegistration and HandlerRegistration transforms to EquatableArray<T> — List<T> uses reference equality, which broke caching even with LocationInfo. EquatableArray<T> provides value equality for correct incremental caching. AssertCacheHit now uses Compilation.Clone() for the second run, verifying that LocationInfo value equality holds across cloned compilations. All 7 cache tests pass with Clone, plus a new LocationInfo_Equals_AfterClone test. Closes #215
- LocationInfo.GetHashCode now uses StringComparer.Ordinal for FilePath, matching Equals which uses StringComparison.Ordinal. The previous FilePath?.GetHashCode() could return culture-sensitive hashes on netstandard2.0, violating the hash/equals contract. - Cache GetLineSpan() result in constructor (was called twice). - Add LocationInfo_ToLocation_RoundTripsCoordinates test verifying FilePath, SourceSpan, and LineSpan survive the ToLocation round-trip. - Add LocationInfo_NullLocation_ToLocationReturnsNull test for the null-location edge case.
64f4e0f to
f5250e5
Compare
Summary
LocationInfo, a value-equatable struct that replacesLocationin all incremental generator models.Locationuses reference equality, which breaks model value equality whenCompilation.Clone()creates newLocationobjects — this forcedAssertCacheHitto use the same compilation (notClone), weakening the cache test.LocationwithLocationInfoin 9 record model types:KeyedRegistration,HandlerRegistration,CompositeRegistration,DecoratorRegistration,EventHandlerRegistration,SingletonTargetInfo,StateMachineModel,TransitionModel,StateParentModel.DiagnosticInfoto useLocationInfo?with value equality instead ofReferenceEquals.List<T>return types inKeyedRegistrationandHandlerRegistrationtransforms toEquatableArray<T>—List<T>uses reference equality, which broke caching even withLocationInfo.EquatableArray<T>provides value equality for correct incremental caching.AssertCacheHitnow usesCompilation.Clone()for the second run, verifying thatLocationInfovalue equality holds across cloned compilations.LocationInfo_Equals_AfterClonetest verifyingLocationInfoequality afterClone.What this fixes
Before:
AssertCacheHithad this comment:After: All 7 cache tests pass with
Clone(), proving thatLocationInfovalue equality holds across cloned compilations. This is a stronger test that verifies the incremental pipeline works correctly even when Roslyn internally clones compilations.Design
LocationInfostoresFilePath(string),TextSpan(int Start + Length), andLineSpan(LinePositionSpan) — all value types with structural equality. TheToLocation()method reconstructs aLocationfor diagnostic reporting viaLocation.Create(filePath, textSpan, lineSpan).Related Issue
Closes #215
Solution module
Type of change
Test plan
dotnet build DesignPatterns.slnx -c Release— 0 errors, 0 warningsdotnet test DesignPatterns.slnx -c Release --no-build— all 642 tests passCachesOnSecondRuntests pass withCompilation.Clone()(previously used same compilation)LocationInfo_Equals_AfterClonetest verifies LocationInfo equality after CloneBreaking changes