feat(mocks): experimental compile-time internals access (#6514 Tier 2) - #6520
Conversation
Runtime auto-stubs (#6519) satisfy SDK-internal generic requests with functional defaults, but the test can never configure or verify such a type — it cannot write the type's name. This prototype removes that constraint: behind TUnitMocksExperimentalInternalsAccess, internal types of selected referenced assemblies become first-class mockable — nameable in test code, source-generated typed mocks, setups, matchers, verification — with zero InternalsVisibleTo required from the target assembly. Mechanism (the established publicizer pattern, wired for TUnit.Mocks): a Mono.Cecil MSBuild task rewrites the selected references so internals are public, preserving assembly identity, and swaps them into ReferencePathWithRefAssemblies only — the compiler's view. deps.json and copy-local keep the originals, so the runtime loads the real assembly, where the task-emitted IgnoresAccessChecksTo attribute makes the compiled IL (including generated mock classes implementing internal interfaces) valid. The mocks source generator needs no changes: through the publicized reference the types simply look public. Validated end to end against a fake-SDK library that grants no IVT to anyone (the exact case runtime stubs cannot reach): naming, mocking, typed setup/matcher/verification of an internal interface requested by SDK-internal code, and a hand-written implementation proving the runtime honors the attribute at type load. Incremental builds stay no-op; cold builds order correctly via project references. Experimental caveats documented in the project README: undocumented (but ecosystem-established) runtime attribute, inert on .NET Framework, AOT unverified, packaging not yet done.
Review: Tier 2 internals-access prototype (#6514)Solid prototype — the 1. No exception handling around the Cecil rewrite ( 2. Reimplementing an established pattern instead of depending on it 3. Duplicate-attribute collision risk 4. Silent first-match on simple-name resolution ( Nothing here blocks the prototype's purpose (proving the mechanism), and the test coverage (zero-IVT target lib + 4 tests covering naming/mocking/generic dispatch/matchers/manual impl) is a good validation story for the concept. These are mainly hardening items for the productionization pass already called out in the PR description. |
… task, tests, CI, docs Graduates the #6514 Tier 2 prototype into the TUnit.Mocks package: - The publicizer task multi-targets net472 (Visual Studio's .NET Framework MSBuild) and net8.0 (dotnet msbuild), selected via $(MSBuildRuntimeType); MSBuild references are compile-time only and pinned to a version shipping both assets. Task and Mono.Cecil ship under tasks/<tfm>/ in the nupkg (NU5100 suppressed — task assemblies are exactly the thing that must not live in lib/). - TUnit.Mocks.InternalsAccess.targets moved into src/TUnit.Mocks, packed to buildTransitive/<tfm>/ and build/, and imported by TUnit.Mocks.targets — consumers need only the opt-in property and item. Fully inert otherwise (verified: full TUnit.Mocks.Tests suite unaffected). Dual-path task resolution covers packaged and repo-local layouts. - Correctness fix surfaced by new coverage: publicize the IMPLEMENTATION assembly (%(OriginalPath)), not the reference assembly — Roslyn ref assemblies strip internal members when no IVT exists, so internal constructors vanished and internal classes emitted TM006. The compiler-facing item (the ref assembly) is still what gets swapped out. - Coverage expanded: internal class partial mocks (internal ctor), closed-generic internal interfaces, strong-named target assembly (identity preservation asserted down to the public key token), and unit tests of the task itself — publicizing, incrementality, the generated IgnoresAccessChecksTo source, and TUMIA001 for unresolved names. Build-order edges use SetTargetFramework per task TFM (SkipGetTargetFrameworkProperties alone leaks the referencing TargetFramework and breaks the outer build). - Verified against the produced nupkg: extracted-package buildTransitive import compiles and runs a consumer implementing an internal interface, including under PublishTrimmed (full trim mode). Native AOT remains unverified. - CI: projects added to TUnit.CI.slnx and a RunMockInternalsAccessTests pipeline module runs the suite; docs gain an "Internals Access (experimental)" section.
Greptile SummaryIntroduces experimental compile-time access to internal types for selected assemblies.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs | Implements identity-aware reference selection, content-hash incrementality, metadata-preserving publicization, and runtime-implementation resolution; the displayed prior defects are addressed. |
| src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets | Adds the opt-in MSBuild pipeline and limits rewritten references to the compiler-facing item group. |
| src/TUnit.Mocks/TUnit.Mocks.csproj | Packages the runtime-specific task binaries, dependencies, and transitive targets. |
| tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs | Covers duplicate requests, compiler metadata, multiple identities, implementation selection, strong names, and stale-cache invalidation. |
| tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs | Exercises typed mocking and runtime access to internal interfaces, generics, and classes. |
| docs/docs/writing-tests/mocking/advanced.md | Documents setup, mechanics, compatibility limitations, and experimental status. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Selected assembly reference] --> B[Resolve matching implementation identity]
B --> C[Create publicized compiler-only copy]
C --> D[Replace compiler reference]
B --> E[Keep original runtime asset]
F[Generated IgnoresAccessChecksTo attribute] --> G[Test assembly]
D --> G
G --> E
Reviews (9): Last reviewed commit: "fix(mocks): identity-first reference loo..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3b321588a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var originalPath = reference.GetMetadata("OriginalPath"); | ||
| var source = string.IsNullOrEmpty(originalPath) ? reference.ItemSpec : originalPath; |
There was a problem hiding this comment.
Resolve NuGet ref assets to implementation assemblies
When a PackageReference supplies ref/<tfm>/Foo.dll directly as its compile asset, OriginalPath is not populated because MSBuild did not substitute that reference in this target. This fallback therefore publicizes the reference assembly itself, where the internal members this feature needs have already been stripped, so opting into internals access still leaves those APIs unavailable. Resolve the corresponding runtime/implementation asset instead of treating a missing OriginalPath as proof that ItemSpec is an implementation assembly.
Useful? React with 👍 / 👎.
| Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date."); | ||
| } | ||
|
|
||
| var item = new TaskItem(destination); |
There was a problem hiding this comment.
Preserve compiler metadata on replacement references
When the selected reference has compiler-significant metadata such as Aliases or EmbedInteropTypes, constructing a fresh TaskItem discards it before the target replaces the original item. For example, an assembly referenced only through an extern alias becomes an ordinary global reference and the existing extern alias no longer resolves. Copy the original reference metadata onto the replacement before overriding the copy-local fields.
Useful? React with 👍 / 👎.
| var source = string.IsNullOrEmpty(originalPath) ? reference.ItemSpec : originalPath; | ||
| var destination = Path.Combine(OutputDirectory, Path.GetFileName(source)); | ||
|
|
||
| if (!File.Exists(destination) || File.GetLastWriteTimeUtc(destination) < File.GetLastWriteTimeUtc(source)) |
There was a problem hiding this comment.
Invalidate publicized copies by content rather than timestamp
When a dependency is downgraded or replaced by a different assembly whose timestamp is equal to or older than the existing output, this check reuses the previous publicized DLL. The compiler can then compile against the prior assembly's API while copy-local output and runtime resolution use the new original, causing missing-member or type-load failures until obj is cleaned. Track a content hash or assembly identity rather than assuming newer timestamps imply identical input.
Useful? React with 👍 / 👎.
- Preserve original reference metadata (Aliases, EmbedInteropTypes) on the swapped compiler reference via CopyMetadataTo - Content-hash (.sig) incrementality instead of timestamp comparison, so downgrades/equal-timestamp replacements re-publicize; sig files in FileWrites - Resolve implementation assembly from runtime/copy-local assets when the compile asset is a ref assembly with no OriginalPath (TUMIA003 when absent) - Warn TUMIA004 on ambiguous simple-name matches instead of silent first-pick - Wrap resolve/publicize in try/catch -> TUMIA005 error instead of unhandled Cecil exception crashing the build host - TUnitMocksInternalsAccessEmitAttributeDefinition=false suppresses the IgnoresAccessChecksToAttribute definition when another package injects it - README: diagnostics table + rationale for custom task vs Krafs.Publicizer
|
All review findings addressed in 5ea2559: Metadata drop on reference swap (Greptile P1 / Codex P2) — valid, fixed. The replacement item now carries the original reference's metadata via Timestamp-only cache (Greptile P2 / Codex P2) — valid, fixed. Incrementality is now content-based: a Ref assembly as compile asset with no OriginalPath (Codex P1) — valid, fixed. The task now takes the runtime/copy-local assets ( Cecil exception handling (Claude #1) — fixed. Resolve+publicize wrapped per assembly; failures surface as Duplicate attribute definition (Claude #3) — fixed. Silent first-match ambiguity (Claude #4) — fixed. Multiple distinct paths matching one simple name now warn Custom task vs Krafs.Publicizer dependency (Claude #2) — README now carries the rationale: those packages are consumer-facing MSBuild wiring, not library APIs; TUnit needs the step riding transparently inside its own package (single opt-in, buildTransitive, dual MSBuild hosts, RPWRA-only swap). Revisit if scope grows. Suite: 17/17 including e2e targets pipeline. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ea2559eae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| continue; | ||
| } | ||
|
|
||
| method.Attributes = (method.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public; |
There was a problem hiding this comment.
Keep private methods out of the publicized API
When a selected assembly contains a public type with a private overload, this unconditional rewrite exposes that overload too, even though the feature promises access to internals. For example, adding a private M(string) beside public M(object) causes an existing consumer call such as M(null) to bind to the formerly private overload whenever internals access is enabled, silently changing behavior in unrelated code. Promote only assembly-visible members (and the constructors/accessors required for internal types), while preserving genuinely private members.
Useful? React with 👍 / 👎.
Code reviewFollowed up on the prior review round (5ea2559) — verified each of the six previously-flagged fixes against the current code, plus a fresh pass for new issues and CLAUDE.md compliance. CLAUDE.md compliance: No violations found. This PR is isolated MSBuild tooling (no core engine metadata collection, no source-gen output changes, no VSTest, no blocking-on-async, tests/CI/docs updated alongside the source change). Previously-flagged issues: Confirmed fixed — metadata preservation ( One fix looks incomplete — ambiguous simple-name match ( When two resolved references share a simple name, the task now warns ( TUnit/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets Lines 63 to 65 in 5ea2559 So the second matching reference is never removed from Minor/lower-confidence: |
…ll ambiguous matches Review round 3: - TUMIA004 now supersedes ALL same-simple-name matches, not just the winner — a leftover duplicate shared the publicized copy's assembly identity (CS1703). New SupersededReferences task output feeds the targets Remove. - Publicize promotes only internal/protected internal/private protected; private and protected members are untouched, so a formerly-private overload can no longer capture existing calls in consuming code. - IgnoresAccessChecksTo is emitted with the assembly's real identity read from the resolved file, not the file-derived requested name.
|
Round 3 addressed in 55e5049: Ambiguous-match removal incomplete (CS1703) — valid, fixed. The task now outputs Publicizer promoted private members (Codex P2) — valid, fixed. Promotion is now scoped to assembly-visible surface only: IgnoresAccessChecksTo identity (minor) — fixed. The attribute is emitted with the assembly identity read from the resolved file ( Suite: 19/19 including e2e targets pipeline. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 55e5049ab0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var signaturePath = destination + ".sig"; | ||
| var signature = source + "\n" + HashFile(source); | ||
|
|
||
| if (!File.Exists(destination) || !File.Exists(signaturePath) || File.ReadAllText(signaturePath) != signature) |
There was a problem hiding this comment.
Include the publicizer version in the cache signature
When TUnit.Mocks is upgraded while the selected dependency remains unchanged, this signature still matches because it contains only the dependency path and content hash. Any fixes to the rewriting rules—such as changing which members are promoted—will therefore continue using the copy produced by the older task until the consumer cleans obj, so the compiler can see a stale publicized API. Include a transformation/task version in the signature to invalidate outputs when the publicizer changes.
Useful? React with 👍 / 👎.
Code reviewFollow-up on the prior two review rounds — checked whether the issues flagged after Previously-flagged issue: confirmed fixed, with solid test coverage The ambiguous simple-name match / CS1703 risk from the last round is now properly closed:
TUnit/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets Lines 58 to 69 in 55e5049 This closes the gap the previous review pointed out (the task-level test only asserted the task's own output, not the targets-file integration) — the new The "minor/lower-confidence" note from the last round (identity emitted from the requested name rather than the real assembly identity) is also fixed — Bonus hardening beyond what was asked: member promotion is now scoped to CLAUDE.md compliance: No violations — still isolated MSBuild tooling, no core engine/source-gen/VSTest/async-blocking concerns. Two non-blocking edge cases worth a look before this leaves experimental (both narrow enough that they don't meet the bar for a blocking issue, but touch the exact code path this round just changed, so flagging for visibility):
Neither blocks this PR; both are quick to close out in a follow-up if you want zero remaining rough edges before promoting past experimental. |
…etadata, version the cache signature - Duplicate TUnitMocksInternalsAccess items (same simple name, any case) now produce a single publicized reference instead of handing Csc the same rewritten assembly twice. - When multiple resolved references share the requested simple name, extern aliases are unioned and EmbedInteropTypes kept if any match set it, so compiler-significant metadata carried only by a non-selected match survives the swap. - The incrementality signature now includes the task version, so a TUnit.Mocks upgrade re-publicizes instead of reusing a copy produced by older rewrite rules.
|
Round 4 addressed in 05cdf1e: Duplicate opt-in items duplicate compiler references (Greptile P1) — valid, fixed. Requested names are now deduped (OrdinalIgnoreCase) at the top of Non-winning ambiguous match loses compiler metadata (Greptile P1 / Claude edge case 1) — valid, fixed. Since every same-simple-name match leaves Cache signature missing publicizer version (Codex P2) — valid, fixed. The Suite: 22/22 (task-level + e2e targets pipeline). |
Summary
The "Tier 2" follow-up to #6519's runtime auto-stubs for #6514, now fully productionized. Behind
TUnitMocksExperimentalInternalsAccess, internal types of selected referenced assemblies become first-class mockable — nameable in test code, source-generated typed mocks, setups, matchers, verification — with zeroInternalsVisibleTorequired from the target assembly. That exceeds runtime-proxy libraries: NSubstitute/Moq can auto-substitute such types (when the SDK grants IVT to Castle) but can never let the test configure one, because the test cannot write the type's name.Mechanism
The established publicizer pattern (Krafs.Publicizer / IgnoresAccessChecksToGenerator), wired into the TUnit.Mocks package:
PublicizeAssemblyReferences(Mono.Cecil MSBuild task) rewrites each selected reference so internals are public, preserving assembly identity (name/version/public key). The implementation assembly is the rewrite source — Roslyn ref assemblies strip internal members when no IVT exists (internal ctors vanish), which surfaced as TM006 on internal classes during test hardening.ReferencePathWithRefAssemblies— the compiler's view and nothing else.ReferencePath, copy-local, anddeps.jsonkeep the originals; the runtime loads the real assembly, where the task-emittedIgnoresAccessChecksTomakes the compiled IL (generated mock classes implementing internal interfaces included) valid.Productionization
$(MSBuildRuntimeType); ships with Mono.Cecil undertasks/<tfm>/;TUnit.Mocks.InternalsAccess.targetspacked tobuildTransitive/<tfm>/and imported byTUnit.Mocks.targets— consumers need only the opt-in. Fully inert otherwise (fullTUnit.Mocks.Testssuite unaffected: 1186/1186).TUMIA001for unresolved names.buildTransitivetargets from a standalone console implementing an internal interface — works, including underPublishTrimmed(full trim mode).TUnit.CI.slnx;RunMockInternalsAccessTestsModuleruns the suite.Remaining caveats
IgnoresAccessChecksToAttributeis CoreCLR-honored but not a documented contract (foundation of several long-lived OSS packages; low-but-nonzero risk).TUMIA002(the runtime doesn't honor the attribute).