perf(mocks): memoize generator discovery per compilation and decouple emitted source from call-site locations - #6913
Conversation
…e independent of call-site location - Cache single-type, transitive and multi-type models per Compilation so a type mocked at many call sites is modelled once. - Drop request locations before emitting; TM009 pairs failures with their request location in a separate step. - Shallow MockTypeModel hash and reference-equality fast paths for dedup. - Cheaper Task/ValueTask, framework-namespace and TUnit.Mocks namespace checks; visited check before the static-abstract scan. - Only bind T.Mock() invocations when a *_MockStaticExtension is visible in the compilation. - Tracking names plus incrementality tests.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Warning Review limit reachedNext included review available in 26 seconds. View limit detailsLimit details: You’ve used all 8 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (9)
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughThe mock source generator now caches discovery models per compilation and uses symbol-based namespace checks. Its incremental pipeline tracks equatable requests, models, and emission results. A source sink collects generated files and retains partial output when generation encounters a non-cancellation exception. New tests check cache reuse and regeneration. ChangesMock generator pipeline
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Refactor Sequence Diagram(s)sequenceDiagram
participant MockGenerator
participant MockSourceSink
participant CompilationOutput
participant TM009Reporter
MockGenerator->>MockSourceSink: Collect generated source files
MockSourceSink-->>MockGenerator: Return accumulated sources and failure details
MockGenerator->>CompilationOutput: Add collected sources
MockGenerator->>TM009Reporter: Report failed result with request location
Merge Risk: ⚪ Minimal · up to No actionable merge-blocking issue is established by the supplied evidence; the change is mergeable after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The changes affect how mock sources are reused and emitted, but the reviewed paths keep cached state within a compilation and leave source output under the generator’s control. No new privilege or cross-service access was identified. Cancellation behavior and some security coverage remain uncertain. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit checks each mock cache, Comment |
ReviewI read the diff and found no blocking issues. Memoization (
Pipeline restructuring
Micro-optimisations
Tests: the PR says existing snapshots pass unchanged, and the tracking names make incrementality tests possible. I'd like to see a test that asserts Overall this is a good, well-reasoned change. |
|
…es in any namespace The T.Mock() binding gate only scanned the TUnit.Mocks namespace, so an existing extension declared elsewhere no longer suppressed a duplicate. Check source declarations via the declaration table and walk every namespace of referenced assemblies that reference TUnit.Mocks.
Review of #6913I read the diff and did not run the tests, so this covers the design and logic only. I found no blocking issues. What looks good
Minor observations (non-blocking)
The change is sound and the existing snapshots are unchanged, which supports the claim that output is identical. Approving from my side. |
…overy cache races Add tests that check MayReferenceGeneratedStaticExtensions detects *_MockStaticExtension types declared in source or in a referenced assembly outside TUnit.Mocks, and skips assemblies that do not reference TUnit.Mocks. Document that concurrent cache misses may build a model twice without changing the result, and parenthesize the Task/ValueTask name pattern for readability.
Review of #6913I reviewed the diff by reading it. I did not build the PR or run its tests. The Overall the design is sound. Per-compilation memoization keyed on the Notes, none blocking:
The PR reports that the existing snapshots pass unchanged. Together with the new tracking names ( LGTM. |
|
Thanks for the review. Replies by point:
Call-site move test. I ran the TUnit.Mocks.SourceGenerator tests locally (snapshot and incrementality) at 865f3a1. All passed: 170/170 on net10.0 and 163/163 on net8.0. No new commit was needed for this round. |
Summary
Makes
TUnit.Mocks.SourceGeneratorcheaper per edit. Mocks are now modelled once per compilation rather than once per call site. Moving a call site (for example, adding a line above it) no longer regenerates that type's source. Generated output is unchanged: all existing snapshots pass without updates.Changes
Per-compilation memoization (
Discovery/MockDiscoveryCache.cs,MockTypeDiscovery.cs). AConditionalWeakTable<Compilation, ...>holds threeConcurrentDictionarymemos:BuildSingleTypeModel, keyed by (symbol withSymbolEqualityComparer.IncludeNullability,isPartialMock,isWrapMock)BuildModelWithTransitiveDependencies, keyed by (symbol,isPartialMock)Mock.Of<T1, T2, ...>()result, keyed by the type-argument listEverything that depends on the consumer (accessibility and InternalsVisibleTo through
compilation.Assembly,MockNamespaceConflictDetector,InterfaceImplementability) is a function of the compilation, which is the outer key. Every site and every transitive walk that reaches the same type shares one model instance. A cancelled computation throws before anything is cached.Generated source no longer depends on location (
MockGenerator.cs,Models/MockEmitResult.cs). Distinct requests are projected to their models (DistinctModels) before emitting. The emit step is now aSelect, and its equatableMockEmitResultholds the generated files plus any failure. ARegisterSourceOutputadds those files and reports TM008. TM009 is reported in a separate output that combines the failed results with the location-bearing distinct requests, so it still points at the same call site or attribute as before. TM006 for attributes was already its own output and is unchanged. The internal test hook now takes aMockSourceSinkinstead of aSourceProductionContext. Files added before a failure are still emitted, as the existing diagnostic test expects. Trade-off: the pipeline now holds the generated text for each model.Dedup hashing (
MockTypeModel.cs,EquatableArray.cs).MockTypeModel.GetHashCodeis now shallow: identity fields, flags,AdditionalInterfaceNamesand array lengths, all of whichEqualsalso compares. It no longer walks every member and parameter.Equalsgets aReferenceEqualsfast path, andEquatableArray.Equalsgets a same-backing-array fast path. With memoization, duplicate models are usually the same instance. I did not cache the hash lazily:withexpressions copy fields, so a cached hash would carry over to modified copies (CollidesWith,EmitsSharedMemberSurface).Cheaper transform checks (
MockTypeDiscovery.cs):UnwrapAsyncTypenow matchesSystem.Threading.Tasks.Task<TResult>/ValueTask<TResult>by name, arity and namespace chain instead of callingConstructedFrom.ToDisplayString(). The match is equivalent, including theTResultparameter name and not being nested.TUnit.Mocksnamespace checks for the invocation and the attribute compare namespace segments.visitedcheck now runs beforeHasStaticAbstractMembers. A type rejected by that scan is rejected every time, so marking it visited first never changes the result.T.Mock()binding check (TransformMockExtensionInvocation). A generator never sees its own output, so a.Mock()can only already bind to a*_MockStaticExtensionthat comes from a referenced assembly or hand-written source. Once per compilation, the generator looks for such a type in any namespace. For source it asks the declaration table (ContainsSymbolsWithName). For references it walks every namespace, but only in assemblies that are or referenceTUnit.Mocks, because an extension that returns TUnit mocks must reference it.GetSymbolInfo(invocation)now runs only when one exists; otherwise the old check could never have matched, so behaviour is unchanged.Tracking names and incrementality tests. Added
MockTrackingNamesandWithTrackingNameon the pipeline steps. The newMockGeneratorIncrementalityTestslive intests/TUnit.Mocks.SourceGenerator.Tests, which already has the Mocks references and test infrastructure.TUnit.SourceGenerator.IncrementalTestsis an xunit project wired to the Core/Assertions generators. The tests cover:Skipped
UseFallbackNamespace(MockNamespaceConflictDetector) looks at the consumer's own source declarations in the target's namespace, so it changes with ordinary edits. Member and constructor accessibility and auto-mock factory resolution also depend on the consuming compilation. Keying on MetadataReference plus assembly identity would miss those changes. Within one compilation the memo already removes the per-site repetition.T.Mock()already bound by a referenced assembly's extension". The test compilations use Roslyn 4.12, which cannot compileextension(...)blocks, so that referenced assembly cannot be built in-process. The gate only skips a check that could not match anyway (see item 5).Tests run
tests/TUnit.Mocks.SourceGenerator.Testson net8.0, net9.0 and net10.0: all pass (159 / 161 / 166), snapshots unchanged, no.received.txttests/TUnit.Mocks.Tests(net10.0): 1336 passedtests/TUnit.Mocks.Http.Tests(net10.0): 58 passedtests/TUnit.Mocks.Logging.Tests(net10.0): 31 passedtests/TUnit.Mocks.InternalsAccess.Tests(net10.0): 33 passedSummary by CodeRabbit
Performance
Bug Fixes