fix(mocks): report TM006 instead of CS1729 for unsubclassable classes - #6501
Conversation
Greptile SummaryThis PR replaces generated-code constructor failures with a call-site diagnostic for classes that cannot be subclassed.
Confidence Score: 3/5The PR is not yet safe to merge because generated Mock entry-point matching can still apply TM006 to unrelated consumer code. The analyzer continues to identify generated entry points through a spoofable namespace and containing-type suffix, then treats the invocation receiver as a mock target and can emit a compilation-blocking diagnostic. Files Needing Attention: src/TUnit.Mocks.Analyzers/InaccessibleConstructorMockAnalyzer.cs
|
| Filename | Overview |
|---|---|
| src/TUnit.Mocks.Analyzers/InaccessibleConstructorMockAnalyzer.cs | Adds TM006 target resolution and constructor eligibility checks; the previously reported generated-entry identity issue remains unresolved. |
| src/TUnit.Mocks.Analyzers/Rules.cs | Defines the TM006 error descriptor and guidance. |
| src/TUnit.Mocks.SourceGenerator/MockGenerator.cs | Emits only the static mock entry-point stub for classes lacking an accessible constructor. |
| src/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs | Adds the model predicate used to identify class targets without discovered constructors. |
| tests/TUnit.Mocks.Analyzers.Tests/InaccessibleConstructorMockAnalyzerTests.cs | Covers inaccessible and chainable constructor cases across the supported mock entry points. |
| tests/TUnit.Mocks.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs | Clarifies cross-assembly accessibility coverage in the verifier documentation. |
Reviews (4): Last reviewed commit: "fix(mocks): report TM006 for the multi-t..." | Re-trigger Greptile
|
To use Codex here, create an environment for this repo. |
|
Claude finished @thomhurst's task in 1m 58s —— View job Tasks
Answer:
|
There was a problem hiding this comment.
Review
Good fix overall — replacing a confusing generated-code CS1729 with a targeted diagnostic at the call site is the right shape for this problem, and the entry-point coverage (Mock.Of<T>(), Mock.Wrap<T>(), generated T.Mock()) plus the sealed/struct double-report guard is well thought through.
One correctness gap and one coverage gap worth addressing before merge:
1. Analyzer's chainability check diverges from the generator's — reintroduces the same failure mode for a narrower case
InaccessibleConstructorMockAnalyzer.IsChainable (src/TUnit.Mocks.Analyzers/InaccessibleConstructorMockAnalyzer.cs:124-139) only checks a constructor's own DeclaredAccessibility + assembly visibility. But the generator's real notion of "usable constructor" — MemberDiscovery.DiscoverConstructors → IsMemberAccessible → AreMemberSignatureTypesAccessible / IsTypeAccessible (src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs:848-876, 553-634) — also requires every constructor parameter type to be accessible from the compilation assembly.
Concretely: a class with a single public constructor whose parameter type is internal in another assembly (a real pattern — some libraries use an internal "capability" parameter to gate public-looking constructors) will:
- Generator side:
DiscoverConstructorsfilters that ctor out (param type inaccessible) →Constructors.Length == 0→LacksAccessibleConstructor == true→ only the static stub is emitted, which forwards toMock.Of<T>(). - Analyzer side:
IsChainablesees apublicctor and returnstrue→ no TM006 is reported. - Runtime:
Mock.Of<T>()finds no registered factory (MockRegistry.TryGetFactoryfails, src/TUnit.Mocks/Mock.cs:70-77) and throwsInvalidOperationException: No mock factory registered for type ....
So for this case the user gets exactly the kind of unclear, disconnected-from-the-call-site failure this PR sets out to eliminate — just moved from a compile-time CS1729 to a runtime exception, with no analyzer warning in between.
This is a structural risk, not just a missed edge case: the analyzer and generator now each hand-maintain their own copy of "is this constructor chainable," and they've already drifted apart on day one. Rather than porting AreMemberSignatureTypesAccessible/IsTypeAccessible into the analyzer as a second copy, it'd be worth extracting the shared predicate (constructor accessibility + parameter-type accessibility) into one place both projects reference — e.g. a small shared source file/project linked into both TUnit.Mocks.Analyzers and TUnit.Mocks.SourceGenerator (both are already netstandard2.0 Roslyn components, so linking a shared .cs file via <Compile Include> costs nothing). That guarantees the two can't silently diverge again the next time either side's logic changes.
2. The primary target scenario (T.Mock()) isn't actually tested against the real generated shape
Generated_Static_Mock_Entry_Point_Reports_TM006 (tests/TUnit.Mocks.Analyzers.Tests/InaccessibleConstructorMockAnalyzerTests.cs:274-306) says in its own comment that the real generated entry point is a C# 14 extension(...) block (confirmed in MockStaticExtensionBuilder.BuildCore, src/TUnit.Mocks.SourceGenerator/Builders/MockStaticExtensionBuilder.cs:100-106), but the test harness's Roslyn version doesn't support extension blocks yet, so the test substitutes a plain static method with a matching name/namespace/staticness instead.
That's a reasonable stopgap, but it means ResolveMockTarget's namespace-based matching (chosen specifically to cope with the "compiler-synthesised nested extension type" shape, per its own comment) is unverified against the actual symbol shape Roslyn produces for extension members — which can differ from a plain static method (synthesized containing/marker types, ReducedFrom, etc.). Since this is the exact path from the original bug report (T.Mock() → CS1729), it'd be worth locking it in with real coverage — e.g. an end-to-end test that runs the actual source generator output through the analyzer, or bumping/adding a second test harness on a newer Roslyn/LangVersion that supports extension syntax — rather than relying on manual verification (per the PR description) staying correct across future refactors.
Nothing else stood out — Rules.cs addition, the sealed/value-type exclusion to avoid double-reporting with TM001/TM002, and the LacksAccessibleConstructor model property all look correct and well-documented.
|
Thanks — finding 1 is a real divergence and is fixed in 2985b13. One correction on the worked example, and a push-back on the extraction. 1. Divergence fixed; the specific repro isn't constructible though. I did try to pin it with the scenario you described — a public constructor whose parameter type is internal in another assembly — and it can't exist: the declaring assembly won't compile it. C# requires a constructor to be no more accessible than its parameter types, so for constructors the parameter-type gate can only fire where the constructor itself is already gated ( 2. On the real What I do have is a real-SDK verification: with the branch built into That's also what drove the namespace-based matching in the first place: matching on Analyzer tests 41 passed, |
I tested both halves of this against the real Azure package. Short answer: it can't unlock mocking, but it can unlock a genuinely useful stub/value-builder — and yes, it's AOT-safe. It doesn't help mocking, for two independent reasons. 1. The blocker is subclassing, not construction. TUnit.Mocks intercepts by emitting a subclass, and every derived constructor must chain to an accessible base constructor. 2. Even with a subclass there'd be nothing to intercept. Reading the metadata for Zero virtual members. A subclass could override nothing, so Where it does pay off. What people actually want from these types is a value to hand back from a mocked [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
static extern QueueRuntimeProperties NewProps(string name);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_ActiveMessageCount")]
static extern void SetActive(QueueRuntimeProperties props, long value);
var p = NewProps("my-queue");
SetActive(p, 42);
// prints: OK Name=my-queue ActiveMessageCount=42That ran clean against Caveats worth pricing in before committing to it: it needs So I'd frame it as a separate feature — something like |
ReviewRe-reviewed at c5b3c2c (latest commit). Both findings from the previous review round are properly addressed: the analyzer's One new gap worth fixing before merge: TM006 doesn't cover the multi-type
|
A class whose constructors are all private, or internal in another assembly, cannot be subclassed at all — every subclass constructor must chain to a base constructor. The generator emitted the impl regardless, so mocking e.g. Azure.Messaging.ServiceBus.Administration.QueueRuntimeProperties failed with a bare CS1729 pointing into generated code, with nothing to tie it back to the offending call site. Skip generation for such targets (keeping only the static Mock() entry point so the call site still binds) and add TM006, reported at the call site for Mock.Of<T>(), Mock.Wrap<T>() and the generated T.Mock() form. Protected and protected-internal constructors stay chainable, so abstract classes are unaffected. Fixes #6493
… rule Review finding: the analyzer only inspected a constructor's own accessibility, while MemberDiscovery.DiscoverConstructors also requires every parameter type to be reachable. The analyzer now applies the same signature-type rule (arrays, generic arguments and pointers included), so the two cannot report different answers for the same target. Also adds cross-assembly coverage — an internal constructor in a referenced assembly is the actual shape from the report, and the previous tests could only express the same-assembly private case.
Review finding: matching every static Mock() in namespace TUnit.Mocks let a consumer-declared method of that shape draw a compilation-blocking diagnostic. Probed the real symbol shape against Roslyn 4.14 — the generated member's containing type is the synthesised, unnamed extension type, whose own containing type is the *_MockStaticExtension class — and the match now walks out to that class. Verified against a real-SDK build that both entry points still report: Issue6493Repro.cs(16,17): error TM006: ... // NoAccessibleCtorProbe.Mock() Issue6493Repro.cs(17,17): error TM006: ... // Mock.Of<NoAccessibleCtorProbe>() The analyzer-harness test for that path now asserts the negative case it can express; the previous one stood in a plain static method and was pinning the overbroad behaviour.
Of<T1, T2>() through Of<T1, T2, T3, T4>() return Mock<T1>, and MockTypeDiscovery reuses T1's constructors for the multi-type model, so the generator skips generation for the same LacksAccessibleConstructor case. The analyzer only resolved a target for exactly one type argument, so those calls reported nothing and failed at runtime with "No mock factory registered" instead of at the call site.
c5b3c2c to
9c17356
Compare
|
Fixed in 9c17356. The multi-type gap is real — verified end to end rather than taken on the review's word:
On the marker-attribute suggestion for Also rebased onto main to clear the conflict ( |
ReviewRe-reviewed at 9c17356 (latest commit). This addresses the only outstanding gap from the previous round. Multi-type Ran the new test ( No new issues found. The remaining architectural note from the previous round (naming-convention-based detection in Good to merge from my side. |
Fixes #6493
Problem
T.Mock()on a class whose constructors are all inaccessible produced:MemberDiscovery.DiscoverConstructorsonly collects accessible constructors, so the model ended up with an emptyConstructorsarray andMockFactoryBuilder.GenerateConstructorDispatchfell through tonew XMockImpl(engine). The impl derives from the target, and its implicitbase()call has nothing to bind to. The error pointed into generated code with no link back to the call site.Such a type genuinely cannot be mocked: every subclass constructor must chain to a base constructor, so with all of them private (or internal in another assembly) no subclass can be declared at all. The right outcome is a clear diagnostic, not generated code that cannot compile.
Fix
MockTypeModel.LacksAccessibleConstructor— true for a class target with no chainable constructor.MockGeneratorskips impl/factory/members for those targets and emits only the staticMock()entry point, so the call site still binds and the diagnostic below is the single error the user sees.TM006+InaccessibleConstructorMockAnalyzer, reported at the call site forMock.Of<T>(),Mock.Wrap<T>()and the generatedT.Mock()form. The message points at the library's own factory (e.g. Azure'sServiceBusModelFactory) as the way to build such a value.Protected and protected-internal constructors remain chainable — the generated impl derives from the target — so abstract classes are unaffected. Sealed and value types are left to TM001/TM002 rather than double-reported.
Tests
tests/TUnit.Mocks.Analyzers.Tests/InaccessibleConstructorMockAnalyzerTests.cs— 9 cases: private-only ctor via all three entry points, plus non-reporting cases for public / implicit parameterless / protected / same-assembly internal constructors, interfaces, and sealed types.Analyzer tests: 39 passed.
TUnit.Mocks.Tests: 1160 passed. Generator snapshots: 80 passed, unchanged.