fix(mocks): make generated identifier sanitization injective - #6507
Conversation
SanitizeIdentifier mapped every separator to '_' and collapsed runs, so `A_B.IFoo` and `A.B.IFoo` both produced `A_B_IFoo`. That string is the AddSource hint name, and a duplicate hint name makes Roslyn drop the generated sources for both types with no diagnostic — the user only sees the downstream CS1061/CS0117 from the missing setup surface. Double literal underscores before mapping separators, so a single '_' in the result always came from a separator. The two copies of the routine (MockImplBuilder and TypeSymbolExtensions) now share one implementation in IdentifierEscaping. Only names that already contain an underscore change. The three refreshed snapshots are multi-interface mocks whose composite hint name gains a doubled underscore at the join; their contents are unchanged, only the hint-name ordering of the emitted files. Fixes #6505
| if (c == '_') | ||
| { | ||
| sb.Append("__"); | ||
| lastWasSeparator = false; | ||
| } | ||
| else if (char.IsLetterOrDigit(c)) | ||
| { | ||
| sb.Append(c); | ||
| lastWasSeparator = false; | ||
| } | ||
| else if (!lastWasSeparator) | ||
| { | ||
| // Runs of separators still collapse to one '_' ("IFoo<T>" -> "IFoo_T_"); only the |
There was a problem hiding this comment.
When a compilation mocks types such as A_.B.IFoo and A._B.IFoo, the literal underscore and namespace separator are emitted in opposite orders but both sanitize to A___B_IFoo, causing generated identifiers and AddSource hint names to collide and the mock sources to be dropped.
Greptile SummaryThe PR centralizes generated-identifier sanitization, preserves literal underscores by doubling them, and adds collision diagnostics that skip conflicting mock models while allowing unrelated mocks to generate. Confidence Score: 3/5The PR is not yet safe to merge because combining Mock.Of() and Mock.Wrap(...) for one constructable class can still emit duplicate hint names and lose generated mock sources. The new detector deliberately equates same-target models without considering their generation mode, while the wrap and ordinary branches both emit identical implementation-factory and member hint names. Files Needing Attention: src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs, src/TUnit.Mocks.SourceGenerator/MockGenerator.cs
|
| Filename | Overview |
|---|---|
| src/TUnit.Mocks.SourceGenerator/IdentifierEscaping.cs | Centralizes sanitization and distinguishes literal underscores from separators by doubling underscores. |
| src/TUnit.Mocks.SourceGenerator/Discovery/GeneratedNameCollisionDetector.cs | Detects remaining sanitized-name collisions, but its mode-agnostic identity lets same-target models emit duplicate hint names. |
| src/TUnit.Mocks.SourceGenerator/MockGenerator.cs | Integrates collision annotation and TM008 reporting before generation, while retaining overlapping emission paths for distinct same-target modes. |
| tests/TUnit.Mocks.SourceGenerator.Tests/Issue6505Tests.cs | Covers underscore/separator collisions, diagnostics, and preservation of unrelated output. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Collect mock models] --> B[Deduplicate models]
B --> C[Annotate generated-name collisions]
C --> D{CollidesWith set?}
D -- Yes --> E[Report TM008 and skip model]
D -- No --> F[Generate mock sources]
Reviews (2): Last reviewed commit: "fix(mocks): report TM008 when two mocked..." | Re-trigger Greptile
Code reviewNice, focused fix — good catch tracing the silent However, I traced the new algorithm's state machine by hand (and verified with a brute-force search over a small alphabet) and the mapping is still not injective, which is the exact property this PR sets out to guarantee. The residual collisionIn
Both are valid, real fully-qualified type names, and both collapse to The doc comment's claim — "a single underscore in the result always came from a separator" — is true but insufficient: a run of 3+ underscores is still ambiguous about where the separator/underscore boundary falls, so the output can't be uniquely decoded. Doubling only fixes collisions where the underscore and separator aren't adjacent (which happens to cover the Why this matters architecturally: any escaping scheme that relies on "run length distinguishes X from Y" needs to handle the boundary between runs, not just within a single run. A more robust fix that sidesteps this whole class of problem: append a short stable hash (e.g. 8 hex chars of FNV-1a or similar) of the original fully-qualified name to the sanitized identifier used for the It'd be worth adding a regression test for the Related, lower-severity (pre-existing, not introduced by this diff)
Neither of these two is something this PR needs to fix, but they're close enough to the change (same bug family, one directly downstream of No CLAUDE.md violations found — snapshot files are correctly committed as |
Review follow-up: doubling literal underscores is not enough on its own. No mapping onto [A-Za-z0-9_] can be injective while both a separator and an underscore render as runs of '_' — a run of three cannot say which order it came in, so `A_.B.IFoo` and `A._B.IFoo` still meet at `A___B_IFoo`. Detect that before anything is written: the dedupe step already sees every model, so group them by generated name and flag the ones that share. Colliding types are skipped with TM008 naming both culprits and the name they share, instead of duplicate hint names aborting the generator and taking every unrelated mock in the compilation with them. Grouping keys on the secondary-surface flag (a multi-interface combo and its pair surface share a composite name by design) and ignores models that are the same target mocked in different modes.
|
Confirmed — That case can't be fixed by a better escape, though: the output alphabet is So instead the residual case is now detected and reported rather than dropped (pushed in f44a8fa):
Grouping keys on the secondary-surface flag (a multi-interface combo and its pair setup surface deliberately share a composite name and are told apart by the hint-name suffix) and ignores models that are the same target mocked in different modes, so neither is a false positive. Three new tests cover it: the sanitizer's separator/underscore distinction, TM008 on the adjacent-token case, and an unrelated mock surviving alongside a collision. Full suites still green — SourceGenerator 90, Mocks 1182, Http 54, Logging 31, Analyzers 56. |
| private static string Identity(MockTypeModel model) | ||
| => model.AdditionalInterfaceNames.Length == 0 | ||
| ? model.FullyQualifiedName | ||
| : model.FullyQualifiedName + "|" + string.Join("|", model.AdditionalInterfaceNames); |
There was a problem hiding this comment.
Same-target modes still collide
When a constructable class is used with both Mock.Of<T>() and Mock.Wrap<T>(...), the models survive deduplication but this identity treats them as the same target and suppresses collision annotation. Both generation branches then emit the same _MockImplFactory.g.cs and _MockMembers.g.cs hint names, causing the generator to lose those mock sources instead of reporting TM008.
Code reviewRe-reviewed after commit f44a8fa, which responds to the non-injectivity concern raised in the earlier review on this PR. The core concern is resolved, via a design pivot rather than a direct fix — and it's the right call. The original approach tried to make Rather than keep chasing an unreachable "fully injective" encoding, the new That exhaustiveness also happens to close a second gap from the earlier review: Verified in the diff/tests:
No blocking issues. Nice use of a compile-time diagnostic to convert a silent, whole-compilation generator failure into a scoped, actionable error. |
Fixes #6505
Problem
SanitizeIdentifiermapped every non-alphanumeric character to_and collapsed runs, so it was not injective:A_B.IFooandA.B.IFooboth sanitized toA_B_IFoo.That string is the
AddSourcehint name (viaGetSafeFileName→GetCompositeSafeName), so mocking both types in one compilation produced duplicate hint names and Roslyn dropped the generated sources for both — with no diagnostic. The user only saw the downstream symptom (CS1061 on every setup, or CS0117 onT.Mock()), with nothing pointing at the cause.Fix
Double literal underscores before mapping separators (
_→__, separators →_), so a single_in the result always came from a separator:A.B.IFoo→A_B_IFooA_B.IFoo→A__B_IFooRuns of separators still collapse (
IFoo<T>→IFoo_T_); only the separator/underscore distinction has to survive.The two byte-identical copies of the routine (
MockImplBuilderandTypeSymbolExtensions) now delegate to one implementation inIdentifierEscaping, so they can't drift.Snapshot churn
Only names that already contain an underscore change. Three snapshots were refreshed — all multi-interface mocks, whose composite hint name is built by joining with
_before sanitizing and so now gains a doubled underscore at the join. The file contents are identical; only the hint-name sort order of the emitted files moved. (Keeping the join pre-sanitize is deliberate: joining already-sanitized parts with a plain_would letA.B+ additional interfaceCcollide with the typeA.B.C.)Tests
New
Issue6505Tests— verified to fail onmainand pass here:Full runs, all green:
TUnit.Mocks.SourceGenerator.Tests(87),TUnit.Mocks.Tests(1182),TUnit.Mocks.Http.Tests(54),TUnit.Mocks.Logging.Tests(31),TUnit.Mocks.Analyzers.Tests(56).